Skip to main content

graph_explorer_style/
scene.rs

1//! Scene model + animator: a target `Scene` (dense, index-keyed) is handed to
2//! an `Animator`, which eases the displayed scene toward it each frame and
3//! fills a caller-owned render-ready `Frame`. Pure — no GPU, no wasm — so it's
4//! host-unit-testable, benchmarkable, and allocation-auditable.
5//!
6//! Everything here is keyed by `NodeIndex`/`LabelId` from `crate::interner`;
7//! all element types are Copy and all per-frame containers are refilled in
8//! place, so the steady state (graph at rest OR mid-transition) allocates
9//! nothing once warm. `tests/zero_alloc.rs` enforces that with a counting
10//! allocator — if you add an allocation to `set_target` (warm path),
11//! `advance`, `interpolate_into`, `frame_into`, `tick_into`, or
12//! `tick_positioned_into`, that test fails.
13
14use crate::anim::{Easing, HaloEmphasis, lerp, lerp2, lerp4};
15use crate::interner::{LabelId, NodeIndex};
16
17#[derive(Debug, Clone, Copy, PartialEq)]
18pub struct SceneNode {
19    pub pos: [f32; 2],
20    pub radius: f32,
21    pub color: [f32; 4],
22    pub opacity: f32,
23    pub shape: u32,
24    pub label: LabelId,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub struct SceneEdge {
29    pub a: NodeIndex,
30    pub b: NodeIndex,
31    pub color: [f32; 4],
32    pub width: f32,
33    pub opacity: f32,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq)]
37pub struct SceneHalo {
38    pub node: NodeIndex,
39    pub emphasis: HaloEmphasis,
40}
41
42/// A fully-built target scene. `nodes` is dense over the interner's index
43/// space; `None` means "not in this scene". Determinism comes from index
44/// order (intern order), which is stable per session. (The old BTreeMap gave
45/// lexicographic-id order; nothing may depend on that.)
46#[derive(Debug, Clone, Default, PartialEq)]
47pub struct Scene {
48    pub nodes: Vec<Option<SceneNode>>,
49    pub edges: Vec<SceneEdge>,
50    pub halos: Vec<SceneHalo>,
51}
52
53impl Scene {
54    /// Place `node` at `ix`, growing the dense vec as the interner grows.
55    pub fn set(&mut self, ix: NodeIndex, node: SceneNode) {
56        let i = ix as usize;
57        if i >= self.nodes.len() { self.nodes.resize(i + 1, None); }
58        self.nodes[i] = Some(node);
59    }
60    pub fn get(&self, ix: NodeIndex) -> Option<&SceneNode> {
61        self.nodes.get(ix as usize).and_then(|s| s.as_ref())
62    }
63    pub fn clear(&mut self) {
64        self.nodes.clear();
65        self.edges.clear();
66        self.halos.clear();
67    }
68}
69
70// Render-ready outputs. `FrameNode.index` carries the node identity so the
71// single wasm upload pass can fill hit targets and screen positions without
72// a resolve; Strings appear nowhere in a Frame.
73#[derive(Debug, Clone, Copy, PartialEq)]
74pub struct FrameNode {
75    pub index: NodeIndex,
76    pub pos: [f32; 2],
77    pub radius: f32,
78    pub color: [f32; 4],
79    pub opacity: f32,
80    pub shape: u32,
81    pub label: LabelId,
82}
83#[derive(Debug, Clone, Copy, PartialEq)]
84pub struct FrameEdge { pub a: [f32; 2], pub b: [f32; 2], pub color: [f32; 4], pub width: f32, pub opacity: f32 }
85#[derive(Debug, Clone, Copy, PartialEq)]
86pub struct FrameHalo { pub node: NodeIndex, pub pos: [f32; 2], pub radius: f32, pub emphasis: HaloEmphasis }
87
88/// Caller-owned, refilled in place every tick. Compact (only present nodes).
89#[derive(Debug, Clone, Default, PartialEq)]
90pub struct Frame {
91    pub nodes: Vec<FrameNode>,
92    pub edges: Vec<FrameEdge>,
93    pub halos: Vec<FrameHalo>,
94}
95
96/// Eases a displayed `Scene` toward a target `Scene`, filling `Frame`s.
97pub struct Animator {
98    displayed: Scene,
99    /// Snapshot of `displayed` at transition start. Kept allocated across
100    /// transitions (`clone_from`) so retargeting doesn't allocate once warm.
101    from_buf: Scene,
102    to: Scene,
103    start_ms: f64,
104    dur_ms: f32,
105    easing: Easing,
106    animating: bool,
107    /// Scratch for `interpolate_into` (nodes only; edges/halos come from
108    /// `to`). Persistent for the same reason as `from_buf`.
109    scratch: Vec<Option<SceneNode>>,
110}
111
112impl Default for Animator { fn default() -> Self { Self::new() } }
113
114impl Animator {
115    pub fn new() -> Self {
116        Self {
117            displayed: Scene::default(),
118            from_buf: Scene::default(),
119            to: Scene::default(),
120            start_ms: 0.0,
121            dur_ms: 0.0,
122            easing: Easing::Linear,
123            animating: false,
124            scratch: Vec::new(),
125        }
126    }
127
128    /// Point the animator at a new target. Tweens from the currently-displayed
129    /// scene when `tween` is on, `dur_ms > 0`, and there's something to move
130    /// from; otherwise snaps. (Same contract as before the index rewrite.)
131    pub fn set_target(&mut self, target: Scene, now_ms: f64, dur_ms: f32, easing: Easing, tween: bool) {
132        let has_displayed = self.displayed.nodes.iter().any(|n| n.is_some());
133        if tween && dur_ms > 0.0 && has_displayed {
134            self.from_buf.nodes.clone_from(&self.displayed.nodes);
135            // from_buf carries nodes only; edges/halos always come from `to`.
136            self.from_buf.edges.clear();
137            self.from_buf.halos.clear();
138            // Edges/halos switch to the new target's immediately (one-time
139            // copy here, not per animating tick in `advance`: `to` is
140            // immutable for the transition's duration).
141            self.displayed.edges.clone_from(&target.edges);
142            self.displayed.halos.clone_from(&target.halos);
143            self.to = target;
144            self.start_ms = now_ms;
145            self.dur_ms = dur_ms;
146            self.easing = easing;
147            self.animating = true;
148        } else {
149            self.displayed.nodes.clone_from(&target.nodes);
150            self.displayed.edges.clone_from(&target.edges);
151            self.displayed.halos.clone_from(&target.halos);
152            self.to = target;
153            self.animating = false;
154        }
155    }
156
157    /// Advance to `now_ms` and fill `out`. Commits the target when the
158    /// transition completes.
159    pub fn tick_into(&mut self, now_ms: f64, out: &mut Frame) {
160        self.advance(now_ms);
161        frame_into(&self.displayed, out);
162    }
163
164    /// Advance, then override node positions from `pos` (dense, indexed by
165    /// `NodeIndex`; `None` = animator keeps its interpolated position) before
166    /// filling `out`.
167    ///
168    /// This is the boundary between the two things that want to move a node:
169    /// the **simulation owns position**, the **animator owns appearance**.
170    /// The override is written into `self.displayed` IN PLACE — not into a
171    /// throwaway copy — so `displayed` stays truthful about what's on screen;
172    /// the next `set_target` snapshots `displayed` as the transition's `from`,
173    /// and a stale snapshot makes any node that later leaves the simulation
174    /// teleport (the Slice A bug; its regression test lives below).
175    ///
176    /// Caller contract (unchanged from Slice A): `pos` must cover every node
177    /// the simulation owns, on every call — an owned-but-omitted index falls
178    /// back to plain position-tweening for that call, which reads as the node
179    /// fighting the simulation.
180    pub fn tick_positioned_into(&mut self, now_ms: f64, pos: &[Option<[f32; 2]>], out: &mut Frame) {
181        self.advance(now_ms);
182        for (i, slot) in self.displayed.nodes.iter_mut().enumerate() {
183            if let (Some(n), Some(Some(p))) = (slot.as_mut(), pos.get(i)) {
184                n.pos = *p;
185            }
186        }
187        frame_into(&self.displayed, out);
188    }
189
190    /// Shared transition bookkeeping for both tick entry points.
191    fn advance(&mut self, now_ms: f64) {
192        if self.animating {
193            let raw = ((now_ms - self.start_ms) as f32 / self.dur_ms).clamp(0.0, 1.0);
194            let t = self.easing.apply(raw);
195            interpolate_into(&self.from_buf, &self.to, t, &mut self.scratch);
196            // keep visible node state current for a mid-flight retarget
197            // (edges/halos were copied from `to` once, in `set_target`)
198            self.displayed.nodes.clone_from(&self.scratch);
199            if raw >= 1.0 {
200                self.displayed.nodes.clone_from(&self.to.nodes);
201                self.animating = false;
202            }
203        }
204    }
205
206    /// Positions of the current *target* scene's nodes (post-transition).
207    pub fn target_positions(&self) -> impl Iterator<Item = [f32; 2]> + '_ {
208        self.to.nodes.iter().flatten().map(|n| n.pos)
209    }
210
211    /// Target position of one node (post-transition), if present.
212    pub fn target_position(&self, ix: NodeIndex) -> Option<[f32; 2]> {
213        self.to.get(ix).map(|n| n.pos)
214    }
215}
216
217/// Interpolate two node sets at eased `t` into `out` (cleared and refilled).
218/// Nodes present in both lerp; entering (to-only) fade in; exiting
219/// (from-only) fade out and are kept at reduced alpha until `t == 1`.
220fn interpolate_into(from: &Scene, to: &Scene, t: f32, out: &mut Vec<Option<SceneNode>>) {
221    let len = from.nodes.len().max(to.nodes.len());
222    out.clear();
223    out.resize(len, None);
224    for (i, slot) in out.iter_mut().enumerate() {
225        let f = from.nodes.get(i).and_then(|s| s.as_ref());
226        let g = to.nodes.get(i).and_then(|s| s.as_ref());
227        *slot = match (f, g) {
228            (Some(fnode), Some(tn)) => Some(SceneNode {
229                pos: lerp2(fnode.pos, tn.pos, t),
230                radius: lerp(fnode.radius, tn.radius, t),
231                color: lerp4(fnode.color, tn.color, t),
232                opacity: lerp(fnode.opacity, tn.opacity, t),
233                shape: tn.shape,
234                label: tn.label,
235            }),
236            (None, Some(tn)) => Some(SceneNode { opacity: tn.opacity * t, ..*tn }),
237            (Some(fnode), None) if t < 1.0 => {
238                Some(SceneNode { opacity: fnode.opacity * (1.0 - t), ..*fnode })
239            }
240            _ => None,
241        };
242    }
243}
244
245/// Resolve a scene into `out` (cleared and refilled): edge endpoints and halo
246/// positions/radii looked up by index; references to an absent node are
247/// skipped. O(1) per lookup, no hashing, no Strings.
248fn frame_into(s: &Scene, out: &mut Frame) {
249    out.nodes.clear();
250    out.edges.clear();
251    out.halos.clear();
252    for (i, n) in s.nodes.iter().enumerate() {
253        if let Some(n) = n {
254            out.nodes.push(FrameNode {
255                index: i as NodeIndex,
256                pos: n.pos, radius: n.radius, color: n.color,
257                opacity: n.opacity, shape: n.shape, label: n.label,
258            });
259        }
260    }
261    for e in &s.edges {
262        let (Some(a), Some(b)) = (s.get(e.a), s.get(e.b)) else { continue };
263        out.edges.push(FrameEdge { a: a.pos, b: b.pos, color: e.color, width: e.width, opacity: e.opacity });
264    }
265    for h in &s.halos {
266        let Some(n) = s.get(h.node) else { continue };
267        out.halos.push(FrameHalo { node: h.node, pos: n.pos, radius: n.radius, emphasis: h.emphasis });
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    fn node(pos: [f32; 2], radius: f32, opacity: f32) -> SceneNode {
276        SceneNode { pos, radius, color: [1.0, 0.0, 0.0, 1.0], opacity, shape: 0, label: 0 }
277    }
278
279    fn scene_with(nodes: &[(NodeIndex, SceneNode)]) -> Scene {
280        let mut s = Scene::default();
281        for (ix, n) in nodes { s.set(*ix, *n); }
282        s
283    }
284
285    #[test]
286    fn no_transition_emits_target_immediately() {
287        let mut a = Animator::new();
288        let s = scene_with(&[(0, node([1.0, 2.0], 10.0, 1.0))]);
289        a.set_target(s, 0.0, 200.0, Easing::Linear, true); // empty displayed => snaps
290        let mut f = Frame::default();
291        a.tick_into(0.0, &mut f);
292        assert_eq!(f.nodes.len(), 1);
293        assert_eq!(f.nodes[0].pos, [1.0, 2.0]);
294    }
295
296    #[test]
297    fn moved_node_interpolates_position() {
298        let mut b = Animator::new();
299        let mut f = Frame::default();
300        b.set_target(scene_with(&[(0, node([0.0, 0.0], 10.0, 1.0))]), 0.0, 200.0, Easing::Linear, true);
301        b.tick_into(0.0, &mut f); // commits displayed = from (empty before => snaps to this)
302        b.set_target(scene_with(&[(0, node([10.0, 0.0], 10.0, 1.0))]), 0.0, 200.0, Easing::Linear, true);
303        b.tick_into(0.0, &mut f);
304        assert!((f.nodes[0].pos[0] - 0.0).abs() < 1e-4);
305        b.tick_into(100.0, &mut f);
306        assert!((f.nodes[0].pos[0] - 5.0).abs() < 1e-4);
307        b.tick_into(200.0, &mut f);
308        assert!((f.nodes[0].pos[0] - 10.0).abs() < 1e-4);
309    }
310
311    #[test]
312    fn entering_node_fades_in_exiting_fades_out() {
313        let mut a = Animator::new();
314        let mut f = Frame::default();
315        a.set_target(scene_with(&[(0, node([0.0, 0.0], 10.0, 1.0))]), 0.0, 200.0, Easing::Linear, true);
316        a.tick_into(0.0, &mut f);
317        a.set_target(scene_with(&[(1, node([5.0, 5.0], 10.0, 1.0))]), 0.0, 200.0, Easing::Linear, true);
318        a.tick_into(100.0, &mut f); // midpoint
319        let get = |f: &Frame, ix: NodeIndex| f.nodes.iter().find(|n| n.index == ix).map(|n| n.opacity);
320        assert!((get(&f, 0).unwrap() - 0.5).abs() < 1e-4, "exiting alpha");
321        assert!((get(&f, 1).unwrap() - 0.5).abs() < 1e-4, "entering alpha");
322        a.tick_into(200.0, &mut f);
323        assert_eq!(f.nodes.len(), 1);
324        assert_eq!(f.nodes[0].index, 1);
325    }
326
327    #[test]
328    fn edges_resolve_endpoints_from_interpolated_positions() {
329        let mut a = Animator::new();
330        let mut f = Frame::default();
331        let mut s0 = scene_with(&[(0, node([0.0, 0.0], 10.0, 1.0)), (1, node([0.0, 0.0], 10.0, 1.0))]);
332        s0.edges.push(SceneEdge { a: 0, b: 1, color: [0.0; 4], width: 1.0, opacity: 1.0 });
333        a.set_target(s0, 0.0, 200.0, Easing::Linear, true);
334        a.tick_into(0.0, &mut f);
335        let mut s1 = scene_with(&[(0, node([0.0, 0.0], 10.0, 1.0)), (1, node([10.0, 0.0], 10.0, 1.0))]);
336        s1.edges.push(SceneEdge { a: 0, b: 1, color: [0.0; 4], width: 1.0, opacity: 1.0 });
337        a.set_target(s1, 0.0, 200.0, Easing::Linear, true);
338        a.tick_into(100.0, &mut f);
339        assert_eq!(f.edges.len(), 1);
340        assert!((f.edges[0].b[0] - 5.0).abs() < 1e-4); // b endpoint mid-glide
341    }
342
343    #[test]
344    fn halos_resolve_position_and_radius_from_their_node() {
345        let mut a = Animator::new();
346        let mut f = Frame::default();
347        let mut s = scene_with(&[(0, node([3.0, 4.0], 12.0, 1.0))]);
348        s.halos.push(SceneHalo { node: 0, emphasis: HaloEmphasis::Primary });
349        a.set_target(s, 0.0, 0.0, Easing::Linear, false); // snap
350        a.tick_into(0.0, &mut f);
351        assert_eq!(f.halos.len(), 1);
352        assert_eq!(f.halos[0].pos, [3.0, 4.0]);
353        assert_eq!(f.halos[0].radius, 12.0);
354        assert_eq!(f.halos[0].node, 0);
355    }
356
357    #[test]
358    fn edges_and_halos_referencing_absent_nodes_are_skipped() {
359        let mut a = Animator::new();
360        let mut f = Frame::default();
361        let mut s = scene_with(&[(0, node([0.0, 0.0], 10.0, 1.0))]);
362        // index 7 is absent from the scene: the edge and halo must be dropped,
363        // not emitted with garbage endpoints.
364        s.edges.push(SceneEdge { a: 0, b: 7, color: [0.0; 4], width: 1.0, opacity: 1.0 });
365        s.halos.push(SceneHalo { node: 7, emphasis: HaloEmphasis::Primary });
366        a.set_target(s, 0.0, 0.0, Easing::Linear, false);
367        a.tick_into(0.0, &mut f);
368        assert_eq!(f.nodes.len(), 1);
369        assert!(f.edges.is_empty());
370        assert!(f.halos.is_empty());
371    }
372
373    #[test]
374    fn target_position_looks_up_one_node_by_index() {
375        let mut a = Animator::new();
376        a.set_target(scene_with(&[(0, node([3.0, 4.0], 10.0, 1.0))]), 0.0, 0.0, Easing::Linear, false);
377        assert_eq!(a.target_position(0), Some([3.0, 4.0]));
378        assert_eq!(a.target_position(9), None); // missing index
379    }
380
381    #[test]
382    fn target_positions_reflect_destination_even_mid_transition() {
383        let mut a = Animator::new();
384        let mut f = Frame::default();
385        a.set_target(scene_with(&[(0, node([0.0, 0.0], 10.0, 1.0))]), 0.0, 200.0, Easing::Linear, true);
386        a.tick_into(0.0, &mut f);
387        a.set_target(scene_with(&[(0, node([100.0, 0.0], 10.0, 1.0))]), 0.0, 200.0, Easing::Linear, true);
388        // the emitted frame at t=0 still shows the OLD position...
389        a.tick_into(0.0, &mut f);
390        assert!((f.nodes[0].pos[0] - 0.0).abs() < 1e-4);
391        // ...but target_positions already reports the destination.
392        let tgt: Vec<[f32; 2]> = a.target_positions().collect();
393        assert_eq!(tgt, vec![[100.0, 0.0]]);
394    }
395
396    #[test]
397    fn tween_disabled_snaps_positions() {
398        let mut a = Animator::new();
399        let mut f = Frame::default();
400        a.set_target(scene_with(&[(0, node([0.0, 0.0], 10.0, 1.0))]), 0.0, 200.0, Easing::Linear, false);
401        a.tick_into(0.0, &mut f);
402        a.set_target(scene_with(&[(0, node([10.0, 0.0], 10.0, 1.0))]), 0.0, 200.0, Easing::Linear, false);
403        a.tick_into(0.0, &mut f);
404        assert_eq!(f.nodes[0].pos[0], 10.0); // no interpolation
405    }
406
407    #[test]
408    fn tick_positioned_overrides_node_positions() {
409        let mut a = Animator::new();
410        let mut f = Frame::default();
411        let s = scene_with(&[(0, node([0.0, 0.0], 5.0, 1.0))]);
412        a.set_target(s, 0.0, 0.0, Easing::Linear, false);
413
414        let pos = vec![Some([77.0, -3.0])];
415        a.tick_positioned_into(0.0, &pos, &mut f);
416        assert_eq!(f.nodes[0].pos, [77.0, -3.0], "the simulation owns position");
417        assert_eq!(f.nodes[0].radius, 5.0, "the animator still owns appearance");
418    }
419
420    #[test]
421    fn edges_and_halos_follow_the_overridden_positions() {
422        // This is the whole reason the override happens before frame assembly:
423        // edges and halos resolve endpoints out of the node set, so they track
424        // the simulation for free instead of needing their own path.
425        let mut sc = scene_with(&[(0, node([0.0, 0.0], 5.0, 1.0)), (1, node([1.0, 1.0], 5.0, 1.0))]);
426        sc.edges.push(SceneEdge { a: 0, b: 1, color: [1.0; 4], width: 1.0, opacity: 1.0 });
427        sc.halos.push(SceneHalo { node: 0, emphasis: HaloEmphasis::Primary });
428
429        let mut an = Animator::new();
430        let mut f = Frame::default();
431        an.set_target(sc, 0.0, 0.0, Easing::Linear, false);
432
433        let pos = vec![Some([100.0, 0.0]), Some([200.0, 50.0])];
434        an.tick_positioned_into(0.0, &pos, &mut f);
435
436        assert_eq!(f.edges[0].a, [100.0, 0.0]);
437        assert_eq!(f.edges[0].b, [200.0, 50.0]);
438        assert_eq!(f.halos[0].pos, [100.0, 0.0]);
439    }
440
441    #[test]
442    fn a_node_absent_from_the_overrides_keeps_its_interpolated_position() {
443        // Synthetic radial affordances (`__more`, `__agg:…`) are not in the
444        // simulation, so they must keep tweening normally. Both an empty
445        // slice and an explicit None slot mean "animator keeps its position".
446        let mut a = Animator::new();
447        let mut f = Frame::default();
448        a.set_target(scene_with(&[(0, node([9.0, 9.0], 5.0, 1.0))]), 0.0, 0.0, Easing::Linear, false);
449        let empty: Vec<Option<[f32; 2]>> = Vec::new();
450        a.tick_positioned_into(0.0, &empty, &mut f);
451        assert_eq!(f.nodes[0].pos, [9.0, 9.0]);
452        a.tick_positioned_into(0.0, &[None], &mut f);
453        assert_eq!(f.nodes[0].pos, [9.0, 9.0]);
454    }
455
456    #[test]
457    fn tick_positioned_still_advances_the_transition() {
458        // The override must not bypass the tween bookkeeping, or a mid-flight
459        // colour transition would freeze whenever the sim is running.
460        let mut a = Animator::new();
461        let mut f = Frame::default();
462        a.set_target(scene_with(&[(0, node([0.0, 0.0], 10.0, 1.0))]), 0.0, 0.0, Easing::Linear, false);
463        a.set_target(scene_with(&[(0, node([0.0, 0.0], 20.0, 1.0))]), 0.0, 100.0, Easing::Linear, true);
464
465        let pos: Vec<Option<[f32; 2]>> = Vec::new();
466        a.tick_positioned_into(50.0, &pos, &mut f);
467        assert!((f.nodes[0].radius - 15.0).abs() < 0.01, "radius should be halfway");
468        a.tick_positioned_into(100.0, &pos, &mut f);
469        assert_eq!(f.nodes[0].radius, 20.0);
470    }
471
472    #[test]
473    fn exiting_node_keeps_its_simulated_position_while_it_fades() {
474        // The more common trigger for the clone-and-discard bug: a node
475        // removed from the graph is also removed from the simulation's
476        // position set. `interpolate_into` keeps a source-only ("exiting")
477        // node at `from`'s position while it fades out. If the override were
478        // written into a throwaway copy instead of into `self.displayed`,
479        // `displayed` would still hold the pre-simulation, scene-authored
480        // position when `set_target` snapshots it as the new `from` — so the
481        // exiting node would teleport back to that stale position and only
482        // then fade out.
483        let mut a = Animator::new();
484        let mut f = Frame::default();
485        let s0 = scene_with(&[(0, node([0.0, 0.0], 10.0, 1.0)), (1, node([0.0, 0.0], 10.0, 1.0))]);
486        a.set_target(s0, 0.0, 0.0, Easing::Linear, false); // snap
487
488        let pos = vec![Some([500.0, 0.0]), Some([10.0, 10.0])];
489        a.tick_positioned_into(0.0, &pos, &mut f); // displayed now holds simulated positions
490
491        // node 1 drops out of the graph (and out of the simulation); 0 remains.
492        let s1 = scene_with(&[(0, node([1.0, 1.0], 10.0, 1.0))]);
493        a.set_target(s1, 0.0, 200.0, Easing::Linear, true); // node 1 becomes exiting
494
495        let pos2 = vec![Some([501.0, 0.0]), None]; // node 1 no longer simulated
496        a.tick_positioned_into(0.0, &pos2, &mut f); // t=0 of the new transition
497
498        let b = f.nodes.iter().find(|n| n.index == 1).expect("node 1 still fading");
499        assert_eq!(b.pos, [10.0, 10.0], "exiting node should stay at its last simulated position while fading, not teleport to a stale scene-authored one");
500    }
501
502    #[test]
503    fn mid_flight_retarget_starts_from_the_displayed_position_not_the_old_target() {
504        // Retargeting while a transition is in flight must start the new
505        // transition from wherever the node is currently displayed (the
506        // in-flight interpolated position), not from the previous target
507        // (`to`) it was heading toward but had not yet reached.
508        let mut a = Animator::new();
509        let mut f = Frame::default();
510        a.set_target(scene_with(&[(0, node([0.0, 0.0], 10.0, 1.0))]), 0.0, 200.0, Easing::Linear, true); // snaps (displayed was empty)
511        a.set_target(scene_with(&[(0, node([100.0, 0.0], 10.0, 1.0))]), 0.0, 200.0, Easing::Linear, true);
512        a.tick_into(100.0, &mut f); // halfway: displayed pos[0] == 50.0, still mid-flight
513
514        a.set_target(scene_with(&[(0, node([300.0, 0.0], 10.0, 1.0))]), 100.0, 200.0, Easing::Linear, true);
515        a.tick_into(100.0, &mut f); // t=0 of the new transition
516        assert!((f.nodes[0].pos[0] - 50.0).abs() < 1e-4, "new transition should start from the displayed position (50), not the old target (100)");
517    }
518
519    #[test]
520    fn frame_buffers_do_not_reallocate_when_warm() {
521        let mut a = Animator::new();
522        let mut s = Scene::default();
523        for i in 0..100u32 { s.set(i, node([i as f32, 0.0], 5.0, 1.0)); }
524        for i in 0..99u32 {
525            s.edges.push(SceneEdge { a: i, b: i + 1, color: [1.0; 4], width: 1.0, opacity: 1.0 });
526        }
527        // First target snaps (displayed is empty); warm up one frame.
528        a.set_target(s.clone(), 0.0, 100.0, Easing::Linear, true);
529        let mut f = Frame::default();
530        a.tick_into(0.0, &mut f);
531        // Second target actually animates; duration long enough that every
532        // tick below stays mid-transition, so `advance`'s animating body
533        // (interpolate_into + scratch + node clone) runs each frame.
534        let mut s2 = s.clone();
535        for n in s2.nodes.iter_mut().flatten() { n.pos[0] += 50.0; }
536        a.set_target(s2, 0.0, 1e9, Easing::Linear, true);
537        a.tick_into(1.0, &mut f); // capture capacities only after an animating tick
538        let caps = (f.nodes.capacity(), f.edges.capacity(), f.halos.capacity());
539        for t in 2..150 { a.tick_into(t as f64, &mut f); }
540        // Mid-loop retarget exercises the warm `from_buf.clone_from` path.
541        a.set_target(s, 150.0, 1e9, Easing::Linear, true);
542        for t in 151..300 { a.tick_into(t as f64, &mut f); }
543        assert_eq!(caps, (f.nodes.capacity(), f.edges.capacity(), f.halos.capacity()));
544    }
545
546    #[test]
547    fn dense_gaps_are_skipped_not_emitted() {
548        let mut s = Scene::default();
549        s.set(5, node([1.0, 2.0], 5.0, 1.0)); // indices 0..5 are None
550        let mut a = Animator::new();
551        a.set_target(s, 0.0, 0.0, Easing::Linear, false);
552        let mut f = Frame::default();
553        a.tick_into(0.0, &mut f);
554        assert_eq!(f.nodes.len(), 1);
555        assert_eq!(f.nodes[0].index, 5);
556    }
557}