Skip to content

Commit ce44276

Browse files
authored
Add live visualization for behavior trees (#58)
1. **Live web visualizer**: Attach via ```BT::with_telemetry(port)``` (default 127.0.0.1) or ```BT::with_telemetry_at(addr, port)``` — opens an embedded HTML page at ```http://{addr}:{port}/``` that renders the tree over a WebSocket. Rationale for choosing websockets, along with advantages I wrote here: #55 (comment) 2. **Zero-overhead when off**: Whole feature is hidden behind ```visualize``` To see a live demo: 1. ```cargo run --bin visualizer_smoke``` 2. On your local web browser, open ```http://127.0.0.1:8910/``` I also added a whole bunch of tests for prevent regression. Also, a huge chunk of this PR is me adding a tracer to all bt nodes. While there is graphviz and json serialization support, without an actual tracer it would be impossible to know the live state of each node (running/pass/fail/idle) at every tick. Existing code only gave static structure of the tree, not the live state of the entire tree along with every single node's status at each tick. Disclaimer: Used claude code to generate documentation and the unit tests. https://github.com/user-attachments/assets/aadbb7be-9fbe-4833-b5db-8da4cf8ecfbe
1 parent 2c4901b commit ce44276

21 files changed

Lines changed: 2493 additions & 165 deletions

bonsai/Cargo.toml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ name = "bonsai-bt"
1212
readme = "../README.md"
1313
repository = "https://github.com/sollimann/bonsai.git"
1414
rust-version = "1.80.0"
15-
version = "0.11.0"
15+
version = "0.12.0"
1616

1717
[lib]
1818
name = "bonsai_bt"
@@ -21,13 +21,16 @@ path = "src/lib.rs"
2121
[dependencies]
2222
petgraph = { version = "0.6.2", optional = true }
2323
serde = { version = "1.0.137", features = ["derive"], optional = true }
24+
serde_json = { version = "1.0.81", optional = true }
25+
tungstenite = { version = "0.21", optional = true }
2426

2527
[features]
26-
visualize = ["dep:petgraph"]
28+
visualize = ["dep:petgraph", "serde", "serde_json", "tungstenite"]
2729
f32 = []
2830

2931
[dev-dependencies]
3032
serde_json = { version = "1.0.81" }
33+
tungstenite = { version = "0.21" }
3134

3235
[[test]]
3336
name = "tests"

bonsai/src/bt.rs

Lines changed: 101 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,63 @@
1-
use std::fmt::Debug;
2-
31
use crate::{state::State, ActionArgs, Behavior, Float, Status, UpdateEvent};
42

53
#[cfg(feature = "serde")]
64
use serde::{Deserialize, Serialize};
75

6+
/// Result of [`BT::try_route_recording`]: whether the recording helper consumed
7+
/// the tick. `Handled` carries the value `tick` should return; `NotHandled`
8+
/// tells the caller to continue with the no-op path. Under
9+
/// `not(feature = "visualize")` the helper unconditionally returns
10+
/// `NotHandled`, so the `Handled` variant is unconstructed.
11+
#[allow(dead_code)]
12+
enum TickRoute {
13+
NotHandled,
14+
Handled(Option<(Status, Float)>),
15+
}
16+
817
/// The execution state of a behavior tree, along with a "blackboard" (state
918
/// shared between all nodes in the tree).
1019
#[derive(Clone, Debug)]
1120
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1221
pub struct BT<A, B> {
1322
/// constructed behavior tree
14-
state: State<A>,
23+
pub(crate) state: State<A>,
1524
/// keep the initial state
16-
initial_behavior: Behavior<A>,
25+
pub(crate) initial_behavior: Behavior<A>,
1726
/// The data storage shared by all nodes in the tree. This is generally
1827
/// referred to as a "blackboard". State is written to and read from a
1928
/// blackboard, allowing nodes to share state and communicate each other.
20-
bb: B,
29+
pub(crate) bb: B,
2130
/// Whether the tree has been finished before.
22-
finished: bool,
31+
pub(crate) finished: bool,
32+
/// Monotonically increasing per-tick counter. Starts at 0; first completed
33+
/// `tick`/`tick_recording` call increments to 1. Survives `reset_bt`
34+
/// (the counter is global to the BT instance, not the current run).
35+
pub(crate) tick_count: u64,
36+
/// Bundle of visualize-only state: preorder node metadata, telemetry
37+
/// channel sender, dropped-trace counter, and the per-tick recording
38+
/// buffer. See [`crate::telemetry_state::TelemetryState`].
39+
#[cfg(feature = "visualize")]
40+
#[cfg_attr(feature = "serde", serde(skip))]
41+
pub(crate) telemetry: crate::telemetry_state::TelemetryState,
2342
}
2443

2544
impl<A: Clone, B> BT<A, B> {
2645
pub fn new(behavior: Behavior<A>, blackboard: B) -> Self {
2746
let backup_behavior = behavior.clone();
2847
let bt = State::new(behavior);
2948

49+
#[cfg(feature = "visualize")]
50+
let telemetry =
51+
crate::telemetry_state::TelemetryState::new(crate::telemetry::build_node_metas(&backup_behavior));
52+
3053
Self {
3154
state: bt,
3255
initial_behavior: backup_behavior,
3356
bb: blackboard,
3457
finished: false,
58+
tick_count: 0,
59+
#[cfg(feature = "visualize")]
60+
telemetry,
3561
}
3662
}
3763

@@ -58,13 +84,64 @@ impl<A: Clone, B> BT<A, B> {
5884
if self.finished {
5985
return None;
6086
}
61-
match self.state.tick(e, &mut self.bb, f) {
62-
result @ (Status::Success | Status::Failure, _) => {
63-
self.finished = true;
64-
Some(result)
65-
}
66-
result => Some(result),
87+
if let TickRoute::Handled(out) = self.try_route_recording(e, f) {
88+
return out;
89+
}
90+
self.tick_count += 1;
91+
let result = self.dispatch_noop_tick(e, f);
92+
if matches!(result, (Status::Success | Status::Failure, _)) {
93+
self.finished = true;
6794
}
95+
Some(result)
96+
}
97+
98+
/// Run `State::tick` with a [`NoopTracer`](crate::tracer::NoopTracer) (the
99+
/// non-recording path). The cfg-gated `metas` binding lives inside this
100+
/// helper so `tick`'s body can stay free of `#[cfg]` directives.
101+
///
102+
/// Disjoint-field borrows: `&self.telemetry.node_metas` (immutable) and
103+
/// `&mut self.state` / `&mut self.bb` (mutable) target distinct fields,
104+
/// so the borrow checker accepts the simultaneous borrows.
105+
///
106+
/// `#[inline(always)]` ensures the cfg branches constant-fold at the
107+
/// monomorphization site, leaving identical generated code to the prior
108+
/// inlined-in-`tick` version.
109+
#[inline(always)]
110+
fn dispatch_noop_tick<E, F>(&mut self, e: &E, f: &mut F) -> (Status, Float)
111+
where
112+
E: UpdateEvent,
113+
F: FnMut(ActionArgs<E, A>, &mut B) -> (Status, Float),
114+
{
115+
let mut tracer = crate::tracer::NoopTracer;
116+
#[cfg(feature = "visualize")]
117+
let metas: &[crate::tracer::NodeMeta] = &self.telemetry.node_metas;
118+
#[cfg(not(feature = "visualize"))]
119+
let metas: &[crate::tracer::NodeMeta] = &[];
120+
self.state.tick(0, metas, e, &mut self.bb, f, &mut tracer)
121+
}
122+
123+
/// If telemetry is attached, dispatch to `tick_recording` and return its
124+
/// result as [`TickRoute::Handled`]. Returns [`TickRoute::NotHandled`]
125+
/// otherwise — the caller (`tick`) should proceed with the no-op path.
126+
///
127+
/// `#[inline(always)]` lets the optimizer constant-fold the no-op path:
128+
/// under `not(feature = "visualize")` the body unconditionally returns
129+
/// `TickRoute::NotHandled`, so the `if let TickRoute::Handled(_) = ...`
130+
/// branch in `tick` becomes unreachable and disappears.
131+
#[inline(always)]
132+
fn try_route_recording<E, F>(&mut self, e: &E, f: &mut F) -> TickRoute
133+
where
134+
E: UpdateEvent,
135+
F: FnMut(ActionArgs<E, A>, &mut B) -> (Status, Float),
136+
{
137+
#[cfg(feature = "visualize")]
138+
if self.telemetry.sender.is_some() {
139+
return TickRoute::Handled(self.tick_recording(e, f).map(|(result, _)| result));
140+
}
141+
// Suppress unused-variable warnings on the no-op path (visualize off,
142+
// or visualize on but no sender attached).
143+
let _ = (e, f);
144+
TickRoute::NotHandled
68145
}
69146

70147
/// Retrieve an immutable reference to the blackboard for
@@ -94,6 +171,18 @@ impl<A: Clone, B> BT<A, B> {
94171
let initial_behavior = self.initial_behavior.to_owned();
95172
self.state = State::new(initial_behavior);
96173
self.finished = false;
174+
// tick_count is intentionally NOT reset — it identifies tick events
175+
// across the BT's lifetime, including across reset_bt boundaries.
176+
// dropped_traces resets per-run: it's a diagnostic for the current session.
177+
#[cfg(feature = "visualize")]
178+
{
179+
self.telemetry.dropped_traces = 0;
180+
}
181+
}
182+
183+
/// Returns the total number of ticks this BT has completed (across resets).
184+
pub fn tick_count(&self) -> u64 {
185+
self.tick_count
97186
}
98187

99188
/// Whether this behavior tree is in a completed state (the last tick returned
@@ -102,57 +191,3 @@ impl<A: Clone, B> BT<A, B> {
102191
self.finished
103192
}
104193
}
105-
106-
#[cfg(feature = "visualize")]
107-
impl<A: Clone + Debug, B: Debug> BT<A, B> {
108-
/// Compile the behavior tree into a [graphviz](https://graphviz.org/) compatible [DiGraph](https://docs.rs/petgraph/latest/petgraph/graph/type.DiGraph.html).
109-
///
110-
/// ```rust
111-
/// use std::collections::HashMap;
112-
/// use bonsai_bt::{
113-
/// Behavior::{Action, Sequence, Wait, WaitForever, While},
114-
/// BT
115-
/// };
116-
///
117-
/// #[derive(Clone, Debug, Copy)]
118-
/// pub enum Counter {
119-
/// // Increment accumulator.
120-
/// Inc,
121-
/// // Decrement accumulator.
122-
/// Dec,
123-
/// }
124-
///
125-
///
126-
/// // create the behavior
127-
/// let behavior = While(Box::new(WaitForever), vec![Wait(0.5), Action(Counter::Inc), WaitForever]);
128-
///
129-
/// let h: HashMap<String, i32> = HashMap::new();
130-
/// let mut bt = BT::new(behavior, h);
131-
///
132-
/// // produce a string DiGraph compatible with graphviz
133-
/// // paste the contents in graphviz, e.g: https://dreampuf.github.io/GraphvizOnline/#
134-
/// let g = bt.get_graphviz();
135-
/// println!("{}", g);
136-
/// ```
137-
pub fn get_graphviz(&mut self) -> String {
138-
self.get_graphviz_with_graph_instance().0
139-
}
140-
141-
pub(crate) fn get_graphviz_with_graph_instance(
142-
&mut self,
143-
) -> (String, petgraph::Graph<crate::visualizer::NodeType<A>, u32>) {
144-
use crate::visualizer::NodeType;
145-
use petgraph::dot::{Config, Dot};
146-
use petgraph::Graph;
147-
148-
let behavior = self.initial_behavior.to_owned();
149-
150-
let mut graph = Graph::<NodeType<A>, u32, petgraph::Directed>::new();
151-
let root_id = graph.add_node(NodeType::Root);
152-
153-
Self::dfs_recursive(&mut graph, behavior, root_id);
154-
155-
let digraph = Dot::with_config(&graph, &[Config::EdgeNoLabel]);
156-
(format!("{:?}", digraph), graph)
157-
}
158-
}

0 commit comments

Comments
 (0)