Skip to main content

viewport_lib/runtime/
mod.rs

1//! Scene runtime: per-frame orchestration layer between [`Scene`] and [`ViewportRenderer`].
2//!
3//! [`ViewportRuntime`] runs registered plugins in a defined priority order each frame,
4//! drives a fixed timestep accumulator for physics, and flushes accumulated transform
5//! writes back to the scene. It does not own the scene, selection, or GPU resources.
6//!
7//! # Usage
8//!
9//! ```rust,ignore
10//! use viewport_lib::runtime::{
11//!     FixedTimestep, RuntimeFrameContext, RuntimePhase, RuntimePlugin,
12//!     RuntimeStepContext, SceneRuntimeMode, ViewportRuntime,
13//! };
14//!
15//! // Build the runtime once at startup.
16//! let mut runtime = ViewportRuntime::new()
17//!     .with_mode(SceneRuntimeMode::Simulation)
18//!     .with_fixed_timestep(FixedTimestep::new(60.0))
19//!     .with_plugin(MyPhysicsPlugin::new());
20//!
21//! // Each frame:
22//! let mut frame_ctx = RuntimeFrameContext::default();
23//! frame_ctx.dt = wall_dt;
24//! frame_ctx.camera = camera.clone();
25//! frame_ctx.viewport_size = glam::Vec2::new(width, height);
26//! frame_ctx.input = action_frame.clone();
27//! let output = runtime.step(&mut scene, &mut selection, &frame_ctx);
28//!
29//! // Handle contact events in game logic:
30//! for event in output.events.read::<viewport_lib::plugins::physics_lite::ContactEvent>() { /* ... */ }
31//!
32//! // Render with interpolated transforms between fixed steps:
33//! let alpha = runtime.alpha();
34//! if let Some(t) = runtime.snapshots().interpolated(node_id, alpha) {
35//!     // use t instead of the node's scene transform
36//! }
37//! ```
38//!
39//! Existing `prepare` / `paint_to` call sites need no changes. `ViewportRuntime`
40//! is purely additive and does not affect [`ViewportRenderer`](crate::ViewportRenderer).
41
42pub mod camera_follow;
43pub mod context;
44/// Debug draw accumulator for runtime plugins.
45pub mod debug_draw;
46pub mod events;
47/// GPU plugin trait and lifecycle hooks.
48pub mod gpu_plugin;
49/// Plugin authoring guide.
50pub mod guide;
51/// Async job handoff types for runtime plugins.
52pub mod jobs;
53pub mod mode;
54pub mod output;
55pub mod plugin;
56/// Built-in animation, constraint, and physics plugins.
57pub mod resources;
58pub mod snapshot;
59/// Built-in interaction systems: SelectionSystem and ManipulationSystem.
60pub mod systems;
61pub mod timestep;
62
63pub use camera_follow::CameraFollow;
64pub use context::{RuntimeFrameContext, RuntimeStepContext, SimulationStepContext};
65pub use debug_draw::{DebugDraw, DebugLayer, DebugPrim};
66pub use events::RuntimeEventBus;
67pub use gpu_plugin::{GpuFrameContext, GpuPlugin, PostPaintTargets, gpu_phase};
68pub use jobs::{JobPoll, JobSender, JobSlot};
69pub use mode::SceneRuntimeMode;
70pub use output::{
71    CameraCommand, CameraFollowTarget, NodeTransformOp, RuntimeOutput, SelectionOp,
72    TransformWriteback, apply_camera_commands,
73};
74pub use plugin::{RuntimeEvent, RuntimePhase, RuntimePlugin, phase};
75pub use resources::RuntimeResources;
76pub use snapshot::{TransformSnapshot, TransformSnapshotTable};
77pub use systems::{ManipulationSystem, SelectionSystem};
78pub use timestep::{FixedStepIter, FixedTimestep};
79
80use crate::interaction::select::selection::Selection;
81use crate::scene::scene::Scene;
82
83// ---- free function ----------------------------------------------------------
84
85/// Run all plugins whose priority is in `[min, max)`, in the order they appear
86/// in the slice.
87#[allow(clippy::too_many_arguments)]
88fn run_range(
89    plugins: &mut Vec<Box<dyn RuntimePlugin>>,
90    min: i32,
91    max: i32,
92    dt: f32,
93    frame: &RuntimeFrameContext,
94    scene: &Scene,
95    writeback: &mut TransformWriteback,
96    output: &mut RuntimeOutput,
97    resources: &mut RuntimeResources,
98    timings: &mut std::collections::HashMap<&'static str, f32>,
99) {
100    for plugin in plugins.iter_mut() {
101        let p = plugin.priority();
102        if p >= min && p < max {
103            let mut ctx = RuntimeStepContext {
104                priority: p,
105                dt,
106                scene,
107                writeback,
108                output,
109                pick_hit: frame.pick_hit,
110                resources,
111            };
112            let t = std::time::Instant::now();
113            plugin.step(&mut ctx);
114            *timings.entry(plugin.type_name()).or_insert(0.0) += t.elapsed().as_secs_f32() * 1000.0;
115        }
116    }
117}
118
119/// Per-plugin timing for one runtime cycle, in milliseconds.
120///
121/// Each map is keyed by [`RuntimePlugin::type_name`] /
122/// [`GpuPlugin::type_name`] and holds the wall-clock CPU time that plugin spent
123/// in the corresponding seam this cycle. `step_ms` sums a plugin's `submit`,
124/// `step` (across every fixed sub-step), and `collect` calls; `pre_prepare_ms`
125/// and `post_paint_ms` time the GPU-plugin seams. These are the only places the
126/// renderer cannot see plugin cost, so reading them is how a host or benchmark
127/// attributes frame time to a named plugin without the renderer's `FrameStats`.
128#[derive(Debug, Default, Clone)]
129pub struct RuntimeStats {
130    /// CPU ms spent in each plugin's `submit` + `step` + `collect` during the
131    /// last [`ViewportRuntime::step`].
132    pub step_ms: std::collections::HashMap<&'static str, f32>,
133    /// CPU ms spent in each GPU plugin's `pre_prepare` during the last
134    /// [`ViewportRuntime::pre_prepare`].
135    pub pre_prepare_ms: std::collections::HashMap<&'static str, f32>,
136    /// CPU ms spent in each GPU plugin's `post_paint` during the last
137    /// [`ViewportRuntime::post_paint`].
138    pub post_paint_ms: std::collections::HashMap<&'static str, f32>,
139}
140
141impl RuntimeStats {
142    /// Total CPU ms across all plugins' `step` work last cycle.
143    pub fn total_step_ms(&self) -> f32 {
144        self.step_ms.values().sum()
145    }
146
147    /// Total CPU ms across all plugins' `pre_prepare` work last cycle.
148    pub fn total_pre_prepare_ms(&self) -> f32 {
149        self.pre_prepare_ms.values().sum()
150    }
151
152    /// Total CPU ms across all plugins' `post_paint` work last cycle.
153    pub fn total_post_paint_ms(&self) -> f32 {
154        self.post_paint_ms.values().sum()
155    }
156}
157
158// ---- tests ------------------------------------------------------------------
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::camera::camera::Camera;
164    use crate::interaction::input::ActionFrame;
165    use crate::interaction::select::selection::{NodeId, Selection};
166    use crate::scene::material::Material;
167    use crate::scene::scene::Scene;
168    use std::sync::{Arc, Mutex};
169
170    fn make_frame(camera: &Camera, input: &ActionFrame, dt: f32) -> RuntimeFrameContext {
171        RuntimeFrameContext {
172            dt,
173            camera: camera.clone(),
174            viewport_size: glam::Vec2::new(800.0, 600.0),
175            input: input.clone(),
176            pick_hit: None,
177            clicked: false,
178            drag_started: false,
179            dragging: false,
180            pointer_delta: glam::Vec2::ZERO,
181            cursor_viewport: None,
182            shift_held: false,
183        }
184    }
185
186    // Records (priority, id) each time step() is called.
187    struct OrderTracker {
188        priority: i32,
189        id: u32,
190        log: Arc<Mutex<Vec<(i32, u32)>>>,
191    }
192
193    impl RuntimePlugin for OrderTracker {
194        fn priority(&self) -> i32 {
195            self.priority
196        }
197        fn step(&mut self, _ctx: &mut RuntimeStepContext<'_>) {
198            self.log.lock().unwrap().push((self.priority, self.id));
199        }
200    }
201
202    // Counts total step() calls.
203    struct CallCounter {
204        priority: i32,
205        count: Arc<Mutex<u32>>,
206    }
207
208    impl RuntimePlugin for CallCounter {
209        fn priority(&self) -> i32 {
210            self.priority
211        }
212        fn step(&mut self, _ctx: &mut RuntimeStepContext<'_>) {
213            *self.count.lock().unwrap() += 1;
214        }
215    }
216
217    // Writes a fixed transform for one node each step.
218    struct WritebackPlugin {
219        node_id: NodeId,
220        transform: glam::Affine3A,
221    }
222
223    impl RuntimePlugin for WritebackPlugin {
224        fn priority(&self) -> i32 {
225            phase::SIMULATE
226        }
227        fn step(&mut self, ctx: &mut RuntimeStepContext<'_>) {
228            ctx.writeback.set(self.node_id, self.transform);
229        }
230    }
231
232    // Records the dt value passed to each Simulate step.
233    struct DtRecorder {
234        dts: Arc<Mutex<Vec<f32>>>,
235    }
236
237    impl RuntimePlugin for DtRecorder {
238        fn priority(&self) -> i32 {
239            phase::SIMULATE
240        }
241        fn step(&mut self, ctx: &mut RuntimeStepContext<'_>) {
242            self.dts.lock().unwrap().push(ctx.dt);
243        }
244    }
245
246    #[test]
247    fn test_phase_execution_order() {
248        // Register plugins in a scrambled order; verify they execute in priority order.
249        let log = Arc::new(Mutex::new(Vec::<(i32, u32)>::new()));
250        let mut runtime = ViewportRuntime::new()
251            .with_plugin(OrderTracker {
252                priority: phase::WRITEBACK,
253                id: 3,
254                log: log.clone(),
255            })
256            .with_plugin(OrderTracker {
257                priority: phase::SIMULATE,
258                id: 2,
259                log: log.clone(),
260            })
261            .with_plugin(OrderTracker {
262                priority: phase::ANIMATE,
263                id: 1,
264                log: log.clone(),
265            })
266            .with_plugin(OrderTracker {
267                priority: phase::PREPARE,
268                id: 0,
269                log: log.clone(),
270            });
271
272        let camera = Camera::default();
273        let input = ActionFrame::default();
274        let mut scene = Scene::new();
275        let mut sel = Selection::new();
276        runtime.step(
277            &mut scene,
278            &mut sel,
279            &make_frame(&camera, &input, 1.0 / 60.0),
280        );
281
282        let calls = log.lock().unwrap();
283        let priorities: Vec<i32> = calls.iter().map(|(p, _)| *p).collect();
284        // Verify priorities are in ascending order.
285        for w in priorities.windows(2) {
286            assert!(
287                w[0] <= w[1],
288                "expected ascending order, got {:?}",
289                priorities
290            );
291        }
292        assert_eq!(priorities.len(), 4);
293    }
294
295    #[test]
296    fn test_registration_order_within_phase() {
297        // Two plugins at the same priority must execute in registration order.
298        let log = Arc::new(Mutex::new(Vec::<(i32, u32)>::new()));
299        let mut runtime = ViewportRuntime::new()
300            .with_plugin(OrderTracker {
301                priority: phase::SIMULATE,
302                id: 0,
303                log: log.clone(),
304            })
305            .with_plugin(OrderTracker {
306                priority: phase::SIMULATE,
307                id: 1,
308                log: log.clone(),
309            });
310
311        let camera = Camera::default();
312        let input = ActionFrame::default();
313        let mut scene = Scene::new();
314        let mut sel = Selection::new();
315        runtime.step(
316            &mut scene,
317            &mut sel,
318            &make_frame(&camera, &input, 1.0 / 60.0),
319        );
320
321        let calls = log.lock().unwrap();
322        let ids: Vec<u32> = calls.iter().map(|(_, id)| *id).collect();
323        assert_eq!(ids, vec![0, 1]);
324    }
325
326    #[test]
327    fn test_simulate_runs_once_without_fixed_timestep() {
328        let count = Arc::new(Mutex::new(0u32));
329        let mut runtime = ViewportRuntime::new().with_plugin(CallCounter {
330            priority: phase::SIMULATE,
331            count: count.clone(),
332        });
333
334        let camera = Camera::default();
335        let input = ActionFrame::default();
336        let mut scene = Scene::new();
337        let mut sel = Selection::new();
338        runtime.step(
339            &mut scene,
340            &mut sel,
341            &make_frame(&camera, &input, 1.0 / 60.0),
342        );
343
344        assert_eq!(*count.lock().unwrap(), 1);
345    }
346
347    #[test]
348    fn test_simulate_runs_n_times_with_fixed_timestep() {
349        let count = Arc::new(Mutex::new(0u32));
350        let hz = 60.0_f32;
351        let step_dt = 1.0 / hz;
352        let mut runtime = ViewportRuntime::new()
353            .with_fixed_timestep(FixedTimestep::new(hz))
354            .with_plugin(CallCounter {
355                priority: phase::SIMULATE,
356                count: count.clone(),
357            });
358
359        let camera = Camera::default();
360        let input = ActionFrame::default();
361        let mut scene = Scene::new();
362        let mut sel = Selection::new();
363
364        // A frame spanning exactly 3 steps plus a tiny remainder yields 3 steps.
365        let dt = step_dt * 3.0 + 0.0001;
366        runtime.step(&mut scene, &mut sel, &make_frame(&camera, &input, dt));
367
368        assert_eq!(*count.lock().unwrap(), 3);
369    }
370
371    #[test]
372    fn test_simulate_dt_equals_step_dt_with_fixed_timestep() {
373        let dts = Arc::new(Mutex::new(Vec::<f32>::new()));
374        let hz = 60.0_f32;
375        let step_dt = 1.0 / hz;
376        let mut runtime = ViewportRuntime::new()
377            .with_fixed_timestep(FixedTimestep::new(hz))
378            .with_plugin(DtRecorder { dts: dts.clone() });
379
380        let camera = Camera::default();
381        let input = ActionFrame::default();
382        let mut scene = Scene::new();
383        let mut sel = Selection::new();
384
385        // Frame spanning 2 steps.
386        let dt = step_dt * 2.0 + 0.0001;
387        runtime.step(&mut scene, &mut sel, &make_frame(&camera, &input, dt));
388
389        let recorded = dts.lock().unwrap();
390        assert_eq!(recorded.len(), 2);
391        for &d in recorded.iter() {
392            assert!((d - step_dt).abs() < 1e-6, "expected {step_dt}, got {d}");
393        }
394    }
395
396    #[test]
397    fn test_writeback_flushes_to_scene() {
398        let target = glam::Affine3A::from_translation(glam::Vec3::new(3.0, 4.0, 5.0));
399        let mut scene = Scene::new();
400        let node_id = scene.add(None, glam::Mat4::IDENTITY, Material::default());
401
402        let mut runtime = ViewportRuntime::new().with_plugin(WritebackPlugin {
403            node_id,
404            transform: target,
405        });
406
407        let camera = Camera::default();
408        let input = ActionFrame::default();
409        let mut sel = Selection::new();
410        runtime.step(
411            &mut scene,
412            &mut sel,
413            &make_frame(&camera, &input, 1.0 / 60.0),
414        );
415
416        let node = scene.node(node_id).expect("node not found");
417        let pos = node.world_transform().col(3).truncate();
418        assert!(
419            (pos - glam::Vec3::new(3.0, 4.0, 5.0)).length() < 1e-5,
420            "pos was {pos:?}"
421        );
422    }
423
424    #[test]
425    fn test_writeback_ops_in_output() {
426        let target = glam::Affine3A::from_translation(glam::Vec3::new(1.0, 0.0, 0.0));
427        let mut scene = Scene::new();
428        let node_id = scene.add(None, glam::Mat4::IDENTITY, Material::default());
429
430        let mut runtime = ViewportRuntime::new().with_plugin(WritebackPlugin {
431            node_id,
432            transform: target,
433        });
434
435        let camera = Camera::default();
436        let input = ActionFrame::default();
437        let mut sel = Selection::new();
438        let output = runtime.step(
439            &mut scene,
440            &mut sel,
441            &make_frame(&camera, &input, 1.0 / 60.0),
442        );
443
444        assert_eq!(output.node_transform_ops.len(), 1);
445        assert_eq!(output.node_transform_ops[0].id, node_id);
446    }
447
448    #[test]
449    fn test_step_index_increments_each_simulate() {
450        let mut runtime = ViewportRuntime::new();
451        let camera = Camera::default();
452        let input = ActionFrame::default();
453        let mut scene = Scene::new();
454        let mut sel = Selection::new();
455
456        assert_eq!(runtime.step_index(), 0);
457        runtime.step(
458            &mut scene,
459            &mut sel,
460            &make_frame(&camera, &input, 1.0 / 60.0),
461        );
462        assert_eq!(runtime.step_index(), 1);
463        runtime.step(
464            &mut scene,
465            &mut sel,
466            &make_frame(&camera, &input, 1.0 / 60.0),
467        );
468        assert_eq!(runtime.step_index(), 2);
469    }
470
471    #[test]
472    fn test_step_index_increments_n_times_with_fixed_timestep() {
473        let hz = 60.0_f32;
474        let step_dt = 1.0 / hz;
475        let mut runtime = ViewportRuntime::new().with_fixed_timestep(FixedTimestep::new(hz));
476
477        let camera = Camera::default();
478        let input = ActionFrame::default();
479        let mut scene = Scene::new();
480        let mut sel = Selection::new();
481
482        // 3 fixed steps in one frame.
483        let dt = step_dt * 3.0 + 0.0001;
484        runtime.step(&mut scene, &mut sel, &make_frame(&camera, &input, dt));
485        assert_eq!(runtime.step_index(), 3);
486    }
487
488    #[test]
489    fn test_snapshot_updated_after_writeback() {
490        let target = glam::Affine3A::from_translation(glam::Vec3::new(7.0, 0.0, 0.0));
491        let mut scene = Scene::new();
492        let node_id = scene.add(None, glam::Mat4::IDENTITY, Material::default());
493
494        let mut runtime = ViewportRuntime::new().with_plugin(WritebackPlugin {
495            node_id,
496            transform: target,
497        });
498
499        let camera = Camera::default();
500        let input = ActionFrame::default();
501        let mut sel = Selection::new();
502        runtime.step(
503            &mut scene,
504            &mut sel,
505            &make_frame(&camera, &input, 1.0 / 60.0),
506        );
507
508        let snap = runtime.snapshots().get(node_id).expect("snapshot missing");
509        assert!((snap.curr.translation.x - 7.0).abs() < 1e-5);
510    }
511
512    // ---- new tests ----------------------------------------------------------
513
514    #[test]
515    fn test_post_sim_runs_after_simulate() {
516        let log = Arc::new(Mutex::new(Vec::<(i32, u32)>::new()));
517        let mut runtime = ViewportRuntime::new()
518            .with_plugin(OrderTracker {
519                priority: phase::POST_SIM,
520                id: 1,
521                log: log.clone(),
522            })
523            .with_plugin(OrderTracker {
524                priority: phase::SIMULATE,
525                id: 0,
526                log: log.clone(),
527            });
528
529        let camera = Camera::default();
530        let input = ActionFrame::default();
531        let mut scene = Scene::new();
532        let mut sel = Selection::new();
533        runtime.step(
534            &mut scene,
535            &mut sel,
536            &make_frame(&camera, &input, 1.0 / 60.0),
537        );
538
539        let calls = log.lock().unwrap();
540        let ids: Vec<u32> = calls.iter().map(|(_, id)| *id).collect();
541        assert_eq!(ids, vec![0, 1], "simulate must run before post_sim");
542    }
543
544    #[test]
545    fn test_plugin_between_bands() {
546        let log = Arc::new(Mutex::new(Vec::<(i32, u32)>::new()));
547        let mid = phase::ANIMATE + 50;
548        let mut runtime = ViewportRuntime::new()
549            .with_plugin(OrderTracker {
550                priority: phase::SIMULATE,
551                id: 2,
552                log: log.clone(),
553            })
554            .with_plugin(OrderTracker {
555                priority: mid,
556                id: 1,
557                log: log.clone(),
558            })
559            .with_plugin(OrderTracker {
560                priority: phase::ANIMATE,
561                id: 0,
562                log: log.clone(),
563            });
564
565        let camera = Camera::default();
566        let input = ActionFrame::default();
567        let mut scene = Scene::new();
568        let mut sel = Selection::new();
569        runtime.step(
570            &mut scene,
571            &mut sel,
572            &make_frame(&camera, &input, 1.0 / 60.0),
573        );
574
575        let calls = log.lock().unwrap();
576        let ids: Vec<u32> = calls.iter().map(|(_, id)| *id).collect();
577        assert_eq!(ids, vec![0, 1, 2], "plugins must execute in priority order");
578    }
579
580    // Plugin that records RuntimeEvent::NodeAdded / NodeRemoved calls.
581    struct EventRecorder {
582        added: Arc<Mutex<Vec<NodeId>>>,
583        removed: Arc<Mutex<Vec<NodeId>>>,
584    }
585
586    impl RuntimePlugin for EventRecorder {
587        fn priority(&self) -> i32 {
588            phase::PREPARE
589        }
590        fn on_event(&mut self, event: &RuntimeEvent, _ctx: &mut RuntimeStepContext<'_>) {
591            match event {
592                RuntimeEvent::NodeAdded(id) => self.added.lock().unwrap().push(*id),
593                RuntimeEvent::NodeRemoved(id) => self.removed.lock().unwrap().push(*id),
594            }
595        }
596        fn step(&mut self, _ctx: &mut RuntimeStepContext<'_>) {}
597    }
598
599    #[test]
600    fn test_lifecycle_events_node_added() {
601        let added = Arc::new(Mutex::new(Vec::<NodeId>::new()));
602        let removed = Arc::new(Mutex::new(Vec::<NodeId>::new()));
603        let mut runtime = ViewportRuntime::new().with_plugin(EventRecorder {
604            added: added.clone(),
605            removed: removed.clone(),
606        });
607
608        let camera = Camera::default();
609        let input = ActionFrame::default();
610        let mut scene = Scene::new();
611        let mut sel = Selection::new();
612
613        // First step: initializes snapshot, no events.
614        runtime.step(
615            &mut scene,
616            &mut sel,
617            &make_frame(&camera, &input, 1.0 / 60.0),
618        );
619        assert!(added.lock().unwrap().is_empty(), "no events on first step");
620
621        // Add a node, then step.
622        let node_id = scene.add(None, glam::Mat4::IDENTITY, Material::default());
623        runtime.step(
624            &mut scene,
625            &mut sel,
626            &make_frame(&camera, &input, 1.0 / 60.0),
627        );
628
629        let a = added.lock().unwrap();
630        assert!(
631            a.contains(&node_id),
632            "expected NodeAdded event for {:?}",
633            node_id
634        );
635        assert!(removed.lock().unwrap().is_empty());
636    }
637
638    #[test]
639    fn test_lifecycle_events_node_removed() {
640        let added = Arc::new(Mutex::new(Vec::<NodeId>::new()));
641        let removed = Arc::new(Mutex::new(Vec::<NodeId>::new()));
642        let mut runtime = ViewportRuntime::new().with_plugin(EventRecorder {
643            added: added.clone(),
644            removed: removed.clone(),
645        });
646
647        let camera = Camera::default();
648        let input = ActionFrame::default();
649        let mut scene = Scene::new();
650        let mut sel = Selection::new();
651
652        let node_id = scene.add(None, glam::Mat4::IDENTITY, Material::default());
653
654        // First step with the node present.
655        runtime.step(
656            &mut scene,
657            &mut sel,
658            &make_frame(&camera, &input, 1.0 / 60.0),
659        );
660        added.lock().unwrap().clear();
661
662        // Remove the node, then step.
663        scene.remove(node_id);
664        runtime.step(
665            &mut scene,
666            &mut sel,
667            &make_frame(&camera, &input, 1.0 / 60.0),
668        );
669
670        let r = removed.lock().unwrap();
671        assert!(
672            r.contains(&node_id),
673            "expected NodeRemoved event for {:?}",
674            node_id
675        );
676    }
677
678    // Plugin that records which of submit, step, collect were called each frame.
679    #[derive(Default)]
680    struct LifecycleRecorder {
681        log: Arc<Mutex<Vec<&'static str>>>,
682    }
683
684    impl RuntimePlugin for LifecycleRecorder {
685        fn priority(&self) -> i32 {
686            phase::PREPARE
687        }
688        fn submit(&mut self, _ctx: &RuntimeStepContext<'_>) {
689            self.log.lock().unwrap().push("submit");
690        }
691        fn step(&mut self, _ctx: &mut RuntimeStepContext<'_>) {
692            self.log.lock().unwrap().push("step");
693        }
694        fn collect(&mut self, _ctx: &mut RuntimeStepContext<'_>) {
695            self.log.lock().unwrap().push("collect");
696        }
697    }
698
699    #[test]
700    fn test_submit_collect_called() {
701        let log = Arc::new(Mutex::new(Vec::<&'static str>::new()));
702        let mut runtime =
703            ViewportRuntime::new().with_plugin(LifecycleRecorder { log: log.clone() });
704
705        let camera = Camera::default();
706        let input = ActionFrame::default();
707        let mut scene = Scene::new();
708        let mut sel = Selection::new();
709
710        runtime.step(
711            &mut scene,
712            &mut sel,
713            &make_frame(&camera, &input, 1.0 / 60.0),
714        );
715
716        let calls = log.lock().unwrap();
717        assert!(calls.contains(&"submit"), "submit must be called");
718        assert!(calls.contains(&"step"), "step must be called");
719        assert!(calls.contains(&"collect"), "collect must be called");
720        // submit before step before collect.
721        let si = calls.iter().position(|&s| s == "submit").unwrap();
722        let st = calls.iter().position(|&s| s == "step").unwrap();
723        let co = calls.iter().position(|&s| s == "collect").unwrap();
724        assert!(si < st, "submit must come before step");
725        assert!(st < co, "step must come before collect");
726    }
727
728    // ---- resource tests -----------------------------------------------------
729
730    #[test]
731    fn test_resource_insert_get() {
732        let mut res = RuntimeResources::new();
733        res.insert(42u32);
734        assert_eq!(res.get::<u32>(), Some(&42));
735        assert!(res.get::<u64>().is_none());
736    }
737
738    #[test]
739    fn test_resource_insert_overwrites() {
740        let mut res = RuntimeResources::new();
741        res.insert(1u32);
742        res.insert(2u32);
743        assert_eq!(res.get::<u32>(), Some(&2));
744    }
745
746    #[test]
747    fn test_resource_get_mut() {
748        let mut res = RuntimeResources::new();
749        res.insert(0u32);
750        *res.get_mut::<u32>().unwrap() = 99;
751        assert_eq!(res.get::<u32>(), Some(&99));
752    }
753
754    #[test]
755    fn test_resource_remove() {
756        let mut res = RuntimeResources::new();
757        res.insert(7u32);
758        assert!(res.contains::<u32>());
759        let val = res.remove::<u32>();
760        assert_eq!(val, Some(7));
761        assert!(!res.contains::<u32>());
762        // remove again returns None
763        assert!(res.remove::<u32>().is_none());
764    }
765
766    #[test]
767    fn test_resource_missing_returns_none() {
768        let res = RuntimeResources::new();
769        assert!(res.get::<u32>().is_none());
770        assert!(!res.contains::<u32>());
771    }
772
773    // Plugin that inserts a counter resource in step().
774    struct CounterWriter {
775        value: u32,
776    }
777
778    impl RuntimePlugin for CounterWriter {
779        fn priority(&self) -> i32 {
780            phase::ANIMATE
781        }
782        fn step(&mut self, ctx: &mut RuntimeStepContext<'_>) {
783            ctx.resources.insert(self.value);
784        }
785    }
786
787    // Plugin that reads the counter resource and records it.
788    struct CounterReader {
789        recorded: Arc<Mutex<Vec<u32>>>,
790    }
791
792    impl RuntimePlugin for CounterReader {
793        fn priority(&self) -> i32 {
794            phase::POST_SIM
795        }
796        fn step(&mut self, ctx: &mut RuntimeStepContext<'_>) {
797            if let Some(&v) = ctx.resources.get::<u32>() {
798                self.recorded.lock().unwrap().push(v);
799            }
800        }
801    }
802
803    #[test]
804    fn test_resource_shared_across_plugins_same_frame() {
805        // CounterWriter runs at ANIMATE, CounterReader at POST_SIM.
806        // The value written by CounterWriter must be visible to CounterReader in the same frame.
807        let recorded = Arc::new(Mutex::new(Vec::<u32>::new()));
808        let mut runtime = ViewportRuntime::new()
809            .with_plugin(CounterWriter { value: 42 })
810            .with_plugin(CounterReader {
811                recorded: recorded.clone(),
812            });
813
814        let camera = Camera::default();
815        let input = ActionFrame::default();
816        let mut scene = Scene::new();
817        let mut sel = Selection::new();
818        runtime.step(
819            &mut scene,
820            &mut sel,
821            &make_frame(&camera, &input, 1.0 / 60.0),
822        );
823
824        let vals = recorded.lock().unwrap();
825        assert_eq!(
826            vals.as_slice(),
827            &[42],
828            "reader must see value written by writer in the same frame"
829        );
830    }
831
832    #[test]
833    fn test_resource_persists_across_frames() {
834        // A resource inserted in frame 1 must still be present in frame 2 if not removed.
835        let mut runtime = ViewportRuntime::new().with_plugin(CounterWriter { value: 10 });
836
837        let camera = Camera::default();
838        let input = ActionFrame::default();
839        let mut scene = Scene::new();
840        let mut sel = Selection::new();
841
842        runtime.step(
843            &mut scene,
844            &mut sel,
845            &make_frame(&camera, &input, 1.0 / 60.0),
846        );
847        assert!(
848            runtime.resources().contains::<u32>(),
849            "resource must persist after step"
850        );
851    }
852
853    #[test]
854    fn test_runtime_works_without_resources() {
855        // A runtime with no plugins and no resource usage must compile and step correctly.
856        let mut runtime = ViewportRuntime::new();
857        let camera = Camera::default();
858        let input = ActionFrame::default();
859        let mut scene = Scene::new();
860        let mut sel = Selection::new();
861        let output = runtime.step(
862            &mut scene,
863            &mut sel,
864            &make_frame(&camera, &input, 1.0 / 60.0),
865        );
866        assert_eq!(
867            output
868                .events
869                .read::<crate::plugins::physics_lite::ContactEvent>()
870                .count(),
871            0
872        );
873        assert!(output.node_transform_ops.is_empty());
874    }
875
876    // ---- event bus tests ----------------------------------------------------
877
878    // Two distinct event types to verify typed routing.
879    #[derive(Debug, PartialEq)]
880    struct GameplayEvent {
881        id: u32,
882    }
883
884    #[derive(Debug, PartialEq)]
885    struct DiagnosticsEvent {
886        frame_ms: f32,
887    }
888
889    // Plugin that emits GameplayEvents.
890    struct GameplayEmitter {
891        count: u32,
892    }
893
894    impl RuntimePlugin for GameplayEmitter {
895        fn priority(&self) -> i32 {
896            phase::SIMULATE
897        }
898        fn step(&mut self, ctx: &mut RuntimeStepContext<'_>) {
899            for i in 0..self.count {
900                ctx.output.events.emit(GameplayEvent { id: i });
901            }
902        }
903    }
904
905    // Plugin that emits DiagnosticsEvents.
906    struct DiagnosticsEmitter;
907
908    impl RuntimePlugin for DiagnosticsEmitter {
909        fn priority(&self) -> i32 {
910            phase::POST_SIM
911        }
912        fn step(&mut self, ctx: &mut RuntimeStepContext<'_>) {
913            ctx.output.events.emit(DiagnosticsEvent {
914                frame_ms: ctx.dt * 1000.0,
915            });
916        }
917    }
918
919    // Plugin that reads GameplayEvents from the bus during its own step.
920    struct GameplayReader {
921        seen: Arc<Mutex<Vec<u32>>>,
922    }
923
924    impl RuntimePlugin for GameplayReader {
925        fn priority(&self) -> i32 {
926            phase::WRITEBACK
927        }
928        fn step(&mut self, ctx: &mut RuntimeStepContext<'_>) {
929            for ev in ctx.output.events.read::<GameplayEvent>() {
930                self.seen.lock().unwrap().push(ev.id);
931            }
932        }
933    }
934
935    fn run_one_frame(runtime: &mut ViewportRuntime) -> RuntimeOutput {
936        let camera = Camera::default();
937        let input = ActionFrame::default();
938        let mut scene = Scene::new();
939        let mut sel = Selection::new();
940        runtime.step(
941            &mut scene,
942            &mut sel,
943            &make_frame(&camera, &input, 1.0 / 60.0),
944        )
945    }
946
947    #[test]
948    fn test_event_bus_emit_and_read() {
949        let mut bus = RuntimeEventBus::new();
950        bus.emit(GameplayEvent { id: 1 });
951        bus.emit(GameplayEvent { id: 2 });
952        let ids: Vec<u32> = bus.read::<GameplayEvent>().map(|e| e.id).collect();
953        assert_eq!(ids, vec![1, 2]);
954    }
955
956    #[test]
957    fn test_event_bus_typed_isolation() {
958        // Reading a different type returns nothing even when other types have events.
959        let mut bus = RuntimeEventBus::new();
960        bus.emit(GameplayEvent { id: 42 });
961        assert!(!bus.has::<DiagnosticsEvent>());
962        assert_eq!(bus.count::<DiagnosticsEvent>(), 0);
963        let diag: Vec<_> = bus.read::<DiagnosticsEvent>().collect();
964        assert!(diag.is_empty());
965    }
966
967    #[test]
968    fn test_event_bus_drain() {
969        let mut bus = RuntimeEventBus::new();
970        bus.emit(GameplayEvent { id: 7 });
971        bus.emit(GameplayEvent { id: 8 });
972        let drained = bus.drain::<GameplayEvent>();
973        assert_eq!(
974            drained,
975            vec![GameplayEvent { id: 7 }, GameplayEvent { id: 8 }]
976        );
977        // Second drain returns empty.
978        assert!(bus.drain::<GameplayEvent>().is_empty());
979        assert!(!bus.has::<GameplayEvent>());
980    }
981
982    #[test]
983    fn test_event_bus_has_and_count() {
984        let mut bus = RuntimeEventBus::new();
985        assert!(!bus.has::<GameplayEvent>());
986        assert_eq!(bus.count::<GameplayEvent>(), 0);
987        bus.emit(GameplayEvent { id: 1 });
988        bus.emit(GameplayEvent { id: 2 });
989        assert!(bus.has::<GameplayEvent>());
990        assert_eq!(bus.count::<GameplayEvent>(), 2);
991    }
992
993    #[test]
994    fn test_event_bus_is_empty() {
995        let mut bus = RuntimeEventBus::new();
996        assert!(bus.is_empty());
997        bus.emit(GameplayEvent { id: 0 });
998        assert!(!bus.is_empty());
999    }
1000
1001    #[test]
1002    fn test_events_emitted_by_plugin_visible_in_output() {
1003        let mut runtime = ViewportRuntime::new().with_plugin(GameplayEmitter { count: 3 });
1004        let output = run_one_frame(&mut runtime);
1005        assert_eq!(output.events.count::<GameplayEvent>(), 3);
1006        let ids: Vec<u32> = output
1007            .events
1008            .read::<GameplayEvent>()
1009            .map(|e| e.id)
1010            .collect();
1011        assert_eq!(ids, vec![0, 1, 2]);
1012    }
1013
1014    #[test]
1015    fn test_events_typed_routing_across_plugins() {
1016        // GameplayEmitter (SIMULATE) emits GameplayEvents; GameplayReader (WRITEBACK)
1017        // reads them in the same frame via ctx.output.events.
1018        let seen = Arc::new(Mutex::new(Vec::<u32>::new()));
1019        let mut runtime = ViewportRuntime::new()
1020            .with_plugin(GameplayEmitter { count: 2 })
1021            .with_plugin(GameplayReader { seen: seen.clone() });
1022        run_one_frame(&mut runtime);
1023        let ids = seen.lock().unwrap();
1024        assert_eq!(ids.as_slice(), &[0, 1]);
1025    }
1026
1027    #[test]
1028    fn test_multiple_event_types_do_not_interfere() {
1029        // Both GameplayEmitter and DiagnosticsEmitter emit; each type is independent.
1030        let mut runtime = ViewportRuntime::new()
1031            .with_plugin(GameplayEmitter { count: 2 })
1032            .with_plugin(DiagnosticsEmitter);
1033        let output = run_one_frame(&mut runtime);
1034        assert_eq!(output.events.count::<GameplayEvent>(), 2);
1035        assert_eq!(output.events.count::<DiagnosticsEvent>(), 1);
1036    }
1037
1038    #[test]
1039    fn test_events_cleared_each_frame() {
1040        // Events from frame N must not appear in frame N+1.
1041        let mut runtime = ViewportRuntime::new().with_plugin(GameplayEmitter { count: 1 });
1042        run_one_frame(&mut runtime);
1043        let output2 = run_one_frame(&mut runtime);
1044        // The second frame also emits 1 event, but it must be exactly 1, not 2.
1045        assert_eq!(output2.events.count::<GameplayEvent>(), 1);
1046    }
1047
1048    #[test]
1049    fn test_default_output_is_empty() {
1050        let mut runtime = ViewportRuntime::new();
1051        let output = run_one_frame(&mut runtime);
1052        assert!(output.selection_ops.is_empty());
1053        assert!(output.node_transform_ops.is_empty());
1054        assert!(output.events.is_empty());
1055    }
1056
1057    // ---- job handoff tests --------------------------------------------------
1058
1059    use crate::runtime::jobs::{JobPoll, JobSlot};
1060
1061    #[test]
1062    fn test_job_slot_empty_by_default() {
1063        let slot: JobSlot<u32> = JobSlot::empty();
1064        assert!(slot.is_empty());
1065        assert!(!slot.is_pending());
1066        assert!(!slot.is_ready());
1067    }
1068
1069    #[test]
1070    fn test_job_slot_pending_after_new() {
1071        let (slot, _sender) = JobSlot::<u32>::new();
1072        assert!(slot.is_pending());
1073        assert!(!slot.is_empty());
1074        assert!(!slot.is_ready());
1075    }
1076
1077    #[test]
1078    fn test_job_handoff_complete() {
1079        let (mut slot, sender) = JobSlot::<u32>::new();
1080        assert!(slot.is_pending());
1081        sender.complete(42);
1082        assert!(slot.is_ready());
1083        match slot.take() {
1084            JobPoll::Ready(v) => assert_eq!(v, 42),
1085            _ => panic!("expected Ready, got other variant"),
1086        }
1087        // Slot resets to empty after taking the result.
1088        assert!(slot.is_empty());
1089    }
1090
1091    #[test]
1092    fn test_job_handoff_fail() {
1093        let (mut slot, sender) = JobSlot::<u32>::new();
1094        sender.fail("disk error");
1095        match slot.take() {
1096            JobPoll::Failed(msg) => assert_eq!(msg, "disk error"),
1097            _ => panic!("expected Failed"),
1098        }
1099        assert!(slot.is_empty());
1100    }
1101
1102    #[test]
1103    fn test_job_handoff_cancel() {
1104        let (mut slot, sender) = JobSlot::<u32>::new();
1105        sender.cancel();
1106        match slot.take() {
1107            JobPoll::Cancelled => {}
1108            _ => panic!("expected Cancelled"),
1109        }
1110        assert!(slot.is_empty());
1111    }
1112
1113    #[test]
1114    fn test_job_sender_drop_cancels() {
1115        let (mut slot, sender) = JobSlot::<u32>::new();
1116        drop(sender);
1117        match slot.take() {
1118            JobPoll::Cancelled => {}
1119            _ => panic!("expected Cancelled after sender drop"),
1120        }
1121        assert!(slot.is_empty());
1122    }
1123
1124    #[test]
1125    fn test_job_take_on_empty_returns_empty() {
1126        let mut slot: JobSlot<u32> = JobSlot::empty();
1127        match slot.take() {
1128            JobPoll::Empty => {}
1129            _ => panic!("expected Empty"),
1130        }
1131    }
1132
1133    #[test]
1134    fn test_job_take_while_pending_returns_pending() {
1135        let (mut slot, _sender) = JobSlot::<u32>::new();
1136        match slot.take() {
1137            JobPoll::Pending => {}
1138            _ => panic!("expected Pending"),
1139        }
1140        // Still pending after a non-consuming poll.
1141        assert!(slot.is_pending());
1142    }
1143
1144    #[test]
1145    fn test_job_async_handoff_across_frames() {
1146        // Plugin holds a JobSlot. Frame 1: start job. Frame 2+: result arrives.
1147        struct LoaderPlugin {
1148            slot: JobSlot<u32>,
1149            result: Arc<Mutex<Option<u32>>>,
1150        }
1151
1152        impl RuntimePlugin for LoaderPlugin {
1153            fn priority(&self) -> i32 {
1154                phase::PREPARE
1155            }
1156
1157            fn submit(&mut self, _ctx: &RuntimeStepContext<'_>) {
1158                if self.slot.is_empty() {
1159                    let (new_slot, sender) = JobSlot::new();
1160                    self.slot = new_slot;
1161                    // Inline completion simulating a fast background thread.
1162                    sender.complete(99);
1163                }
1164            }
1165
1166            fn collect(&mut self, _ctx: &mut RuntimeStepContext<'_>) {
1167                if let JobPoll::Ready(v) = self.slot.take() {
1168                    *self.result.lock().unwrap() = Some(v);
1169                }
1170            }
1171
1172            fn step(&mut self, _ctx: &mut RuntimeStepContext<'_>) {}
1173        }
1174
1175        let result = Arc::new(Mutex::new(None::<u32>));
1176        let mut runtime = ViewportRuntime::new().with_plugin(LoaderPlugin {
1177            slot: JobSlot::empty(),
1178            result: result.clone(),
1179        });
1180
1181        run_one_frame(&mut runtime);
1182        assert_eq!(*result.lock().unwrap(), Some(99));
1183    }
1184
1185    #[test]
1186    fn test_job_staged_resource_update_ordering() {
1187        // A low-priority plugin integrates a job result into resources in collect.
1188        // A high-priority plugin's collect must NOT see it (runs first due to sort order).
1189        // A later-running plugin's collect DOES see it.
1190
1191        struct Integrator {
1192            slot: JobSlot<u32>,
1193        }
1194
1195        impl RuntimePlugin for Integrator {
1196            fn priority(&self) -> i32 {
1197                phase::PREPARE
1198            } // low number = runs early in collect
1199
1200            fn submit(&mut self, _ctx: &RuntimeStepContext<'_>) {
1201                if self.slot.is_empty() {
1202                    let (s, sender) = JobSlot::new();
1203                    self.slot = s;
1204                    sender.complete(77);
1205                }
1206            }
1207
1208            fn collect(&mut self, ctx: &mut RuntimeStepContext<'_>) {
1209                if let JobPoll::Ready(v) = self.slot.take() {
1210                    ctx.resources.insert(v);
1211                }
1212            }
1213
1214            fn step(&mut self, _ctx: &mut RuntimeStepContext<'_>) {}
1215        }
1216
1217        struct Reader {
1218            seen: Arc<Mutex<Option<u32>>>,
1219        }
1220
1221        impl RuntimePlugin for Reader {
1222            fn priority(&self) -> i32 {
1223                phase::POST_SIM
1224            } // higher number = runs later in collect
1225
1226            fn collect(&mut self, ctx: &mut RuntimeStepContext<'_>) {
1227                *self.seen.lock().unwrap() = ctx.resources.get::<u32>().copied();
1228            }
1229
1230            fn step(&mut self, _ctx: &mut RuntimeStepContext<'_>) {}
1231        }
1232
1233        let seen = Arc::new(Mutex::new(None::<u32>));
1234        let mut runtime = ViewportRuntime::new()
1235            .with_plugin(Integrator {
1236                slot: JobSlot::empty(),
1237            })
1238            .with_plugin(Reader { seen: seen.clone() });
1239
1240        run_one_frame(&mut runtime);
1241        // Integrator (PREPARE) integrates into resources in collect before Reader (POST_SIM) collect.
1242        assert_eq!(*seen.lock().unwrap(), Some(77));
1243    }
1244
1245    #[test]
1246    fn test_job_slot_in_resources() {
1247        // A JobSlot stored in RuntimeResources is accessible to other plugins.
1248        let (slot, sender) = JobSlot::<u32>::new();
1249        let mut res = RuntimeResources::new();
1250        res.insert(slot);
1251        sender.complete(55);
1252        let found = res.get_mut::<JobSlot<u32>>().unwrap();
1253        match found.take() {
1254            JobPoll::Ready(v) => assert_eq!(v, 55),
1255            _ => panic!("expected Ready"),
1256        }
1257    }
1258
1259    // ---- skeleton / skinning tests ------------------------------------------
1260
1261    use crate::plugins::skeleton::{
1262        Joint, JointMatrices, Pose, Skeleton, SkeletonPlugin, apply_skin,
1263    };
1264    use crate::plugins::skinning::{SkinWeights, SkinnedMeshUpdate};
1265
1266    fn two_joint_skeleton() -> Skeleton {
1267        Skeleton::new(vec![
1268            Joint {
1269                name: "root".into(),
1270                parent: None,
1271                inverse_bind: glam::Affine3A::IDENTITY,
1272            },
1273            Joint {
1274                name: "child".into(),
1275                parent: Some(0),
1276                inverse_bind: glam::Affine3A::from_translation(-glam::Vec3::Y),
1277            },
1278        ])
1279    }
1280
1281    fn single_vertex_weights(joint: u8, weight: f32) -> SkinWeights {
1282        SkinWeights {
1283            joint_indices: vec![[joint, 0, 0, 0]],
1284            joint_weights: vec![[weight, 0.0, 0.0, 0.0]],
1285        }
1286    }
1287
1288    #[test]
1289    fn test_skeleton_joint_count() {
1290        let sk = two_joint_skeleton();
1291        assert_eq!(sk.joint_count(), 2);
1292    }
1293
1294    #[test]
1295    fn test_skeleton_find_joint() {
1296        let sk = two_joint_skeleton();
1297        assert_eq!(sk.find_joint("root"), Some(0));
1298        assert_eq!(sk.find_joint("child"), Some(1));
1299        assert_eq!(sk.find_joint("missing"), None);
1300    }
1301
1302    #[test]
1303    fn test_pose_identity_transforms() {
1304        let pose = Pose::identity(3);
1305        assert_eq!(pose.joint_count(), 3);
1306        for t in &pose.local_transforms {
1307            assert_eq!(*t, glam::Affine3A::IDENTITY);
1308        }
1309    }
1310
1311    #[test]
1312    fn test_joint_matrices_single_root_identity() {
1313        let sk = Skeleton::new(vec![Joint {
1314            name: "root".into(),
1315            parent: None,
1316            inverse_bind: glam::Affine3A::IDENTITY,
1317        }]);
1318        let pose = Pose::identity(1);
1319        let mats = JointMatrices::compute(&sk, &pose);
1320        let m = mats.as_slice()[0];
1321        // identity local * identity inverse_bind = identity
1322        assert!(m.matrix3.col(0).abs_diff_eq(glam::Vec3A::X, 1e-5));
1323        assert!(m.translation.abs_diff_eq(glam::Vec3A::ZERO, 1e-5));
1324    }
1325
1326    #[test]
1327    fn test_joint_matrices_parent_child_chain() {
1328        // Root translated by (1,0,0) in local space.
1329        // Child at local identity, parent = 0.
1330        // child.inverse_bind offsets by -Y.
1331        // Expected child world = root_world * child_local = translate(1,0,0)
1332        // Final matrix = world * inverse_bind = translate(1,0,0) * translate(0,-1,0) = translate(1,-1,0)
1333        let sk = two_joint_skeleton();
1334        let mut pose = Pose::identity(2);
1335        pose.local_transforms[0] = glam::Affine3A::from_translation(glam::Vec3::X);
1336        let mats = JointMatrices::compute(&sk, &pose);
1337        let child = mats.as_slice()[1];
1338        let expected_translation = glam::Vec3A::new(1.0, -1.0, 0.0);
1339        assert!(
1340            child.translation.abs_diff_eq(expected_translation, 1e-5),
1341            "child translation was {:?}, expected {:?}",
1342            child.translation,
1343            expected_translation,
1344        );
1345    }
1346
1347    #[test]
1348    fn test_apply_skin_identity_pose() {
1349        let sk = Skeleton::new(vec![Joint {
1350            name: "root".into(),
1351            parent: None,
1352            inverse_bind: glam::Affine3A::IDENTITY,
1353        }]);
1354        let pose = Pose::identity(1);
1355        let mats = JointMatrices::compute(&sk, &pose);
1356
1357        let positions = vec![[1.0f32, 2.0, 3.0]];
1358        let normals = vec![[0.0f32, 1.0, 0.0]];
1359        let weights = single_vertex_weights(0, 1.0);
1360        let (out_pos, out_nrm) = apply_skin(&positions, &normals, &weights, &mats);
1361
1362        assert!((out_pos[0][0] - 1.0).abs() < 1e-5);
1363        assert!((out_pos[0][1] - 2.0).abs() < 1e-5);
1364        assert!((out_pos[0][2] - 3.0).abs() < 1e-5);
1365        assert!((out_nrm[0][1] - 1.0).abs() < 1e-5);
1366    }
1367
1368    #[test]
1369    fn test_apply_skin_single_joint_full_weight() {
1370        // Joint 0 translates by (5,0,0). Vertex at origin must move to (5,0,0).
1371        let sk = Skeleton::new(vec![Joint {
1372            name: "root".into(),
1373            parent: None,
1374            inverse_bind: glam::Affine3A::IDENTITY,
1375        }]);
1376        let mut pose = Pose::identity(1);
1377        pose.local_transforms[0] = glam::Affine3A::from_translation(glam::Vec3::new(5.0, 0.0, 0.0));
1378        let mats = JointMatrices::compute(&sk, &pose);
1379
1380        let positions = vec![[0.0f32, 0.0, 0.0]];
1381        let normals = vec![[1.0f32, 0.0, 0.0]];
1382        let weights = single_vertex_weights(0, 1.0);
1383        let (out_pos, _) = apply_skin(&positions, &normals, &weights, &mats);
1384        assert!((out_pos[0][0] - 5.0).abs() < 1e-5);
1385        assert!(out_pos[0][1].abs() < 1e-5);
1386    }
1387
1388    #[test]
1389    fn test_apply_skin_two_joint_blend() {
1390        // Two joints: joint 0 at (0,0,0), joint 1 at (10,0,0).
1391        // Vertex at origin, 50/50 blend -> output at (5,0,0).
1392        let sk = Skeleton::new(vec![
1393            Joint {
1394                name: "a".into(),
1395                parent: None,
1396                inverse_bind: glam::Affine3A::IDENTITY,
1397            },
1398            Joint {
1399                name: "b".into(),
1400                parent: None,
1401                inverse_bind: glam::Affine3A::IDENTITY,
1402            },
1403        ]);
1404        let mut pose = Pose::identity(2);
1405        pose.local_transforms[1] =
1406            glam::Affine3A::from_translation(glam::Vec3::new(10.0, 0.0, 0.0));
1407        let mats = JointMatrices::compute(&sk, &pose);
1408
1409        let positions = vec![[0.0f32, 0.0, 0.0]];
1410        let normals = vec![[1.0f32, 0.0, 0.0]];
1411        let weights = SkinWeights {
1412            joint_indices: vec![[0, 1, 0, 0]],
1413            joint_weights: vec![[0.5, 0.5, 0.0, 0.0]],
1414        };
1415        let (out_pos, _) = apply_skin(&positions, &normals, &weights, &mats);
1416        assert!(
1417            (out_pos[0][0] - 5.0).abs() < 1e-5,
1418            "expected 5.0, got {}",
1419            out_pos[0][0]
1420        );
1421    }
1422
1423    #[test]
1424    fn test_apply_skin_normal_renormalized() {
1425        // Even with a uniform scale, output normals must have unit length.
1426        let sk = Skeleton::new(vec![Joint {
1427            name: "root".into(),
1428            parent: None,
1429            inverse_bind: glam::Affine3A::IDENTITY,
1430        }]);
1431        let mut pose = Pose::identity(1);
1432        pose.local_transforms[0] = glam::Affine3A::from_scale(glam::Vec3::splat(2.0));
1433        let mats = JointMatrices::compute(&sk, &pose);
1434
1435        let positions = vec![[1.0f32, 0.0, 0.0]];
1436        let normals = vec![[1.0f32, 0.0, 0.0]];
1437        let weights = single_vertex_weights(0, 1.0);
1438        let (_, out_nrm) = apply_skin(&positions, &normals, &weights, &mats);
1439        let len = (out_nrm[0][0].powi(2) + out_nrm[0][1].powi(2) + out_nrm[0][2].powi(2)).sqrt();
1440        assert!((len - 1.0).abs() < 1e-5, "normal length was {len}");
1441    }
1442
1443    #[test]
1444    fn test_skeleton_plugin_writes_update_to_output() {
1445        let sk = Skeleton::new(vec![Joint {
1446            name: "root".into(),
1447            parent: None,
1448            inverse_bind: glam::Affine3A::IDENTITY,
1449        }]);
1450        let positions = vec![[0.0f32, 0.0, 0.0]];
1451        let normals = vec![[0.0f32, 1.0, 0.0]];
1452        let weights = single_vertex_weights(0, 1.0);
1453        let mesh_id = crate::resources::mesh::mesh_store::MeshId::new(0, 0);
1454
1455        let mut runtime = ViewportRuntime::new().with_plugin(SkeletonPlugin::new(
1456            sk, mesh_id, positions, normals, weights,
1457        ));
1458
1459        // Insert a pose so the plugin fires.
1460        runtime.resources_mut().insert(Pose::identity(1));
1461
1462        let output = run_one_frame(&mut runtime);
1463        let updates: Vec<&SkinnedMeshUpdate> = output.events.read::<SkinnedMeshUpdate>().collect();
1464        assert_eq!(updates.len(), 1);
1465        assert_eq!(updates[0].mesh_id, mesh_id);
1466    }
1467
1468    #[test]
1469    fn test_skinned_mesh_updates_empty_without_pose() {
1470        let sk = Skeleton::new(vec![Joint {
1471            name: "root".into(),
1472            parent: None,
1473            inverse_bind: glam::Affine3A::IDENTITY,
1474        }]);
1475        let mesh_id = crate::resources::mesh::mesh_store::MeshId::new(0, 0);
1476        let mut runtime = ViewportRuntime::new().with_plugin(SkeletonPlugin::new(
1477            sk,
1478            mesh_id,
1479            vec![[0.0f32; 3]],
1480            vec![[0.0f32, 1.0, 0.0]],
1481            single_vertex_weights(0, 1.0),
1482        ));
1483        // No Pose inserted -> no update.
1484        let output = run_one_frame(&mut runtime);
1485        assert_eq!(output.events.read::<SkinnedMeshUpdate>().count(), 0);
1486    }
1487
1488    #[test]
1489    fn test_skinned_mesh_updates_cleared_each_frame() {
1490        let sk = Skeleton::new(vec![Joint {
1491            name: "root".into(),
1492            parent: None,
1493            inverse_bind: glam::Affine3A::IDENTITY,
1494        }]);
1495        let mesh_id = crate::resources::mesh::mesh_store::MeshId::new(0, 0);
1496        let mut runtime = ViewportRuntime::new().with_plugin(SkeletonPlugin::new(
1497            sk,
1498            mesh_id,
1499            vec![[0.0f32; 3]],
1500            vec![[0.0f32, 1.0, 0.0]],
1501            single_vertex_weights(0, 1.0),
1502        ));
1503        runtime.resources_mut().insert(Pose::identity(1));
1504
1505        run_one_frame(&mut runtime);
1506        let output2 = run_one_frame(&mut runtime);
1507        // Each frame produces exactly 1 update, not 2.
1508        assert_eq!(output2.events.read::<SkinnedMeshUpdate>().count(), 1);
1509    }
1510}
1511
1512/// Per-frame scene orchestration layer.
1513///
1514/// Owns plugins, an optional fixed timestep accumulator, and a transform snapshot
1515/// table. Does not own the scene, selection, GPU resources, or window.
1516///
1517/// # Priority execution order
1518///
1519/// Each call to [`step`](Self::step) executes plugins in ascending priority order:
1520///
1521/// 1. `[i32::MIN, SELECT)` -- Prepare (100), Pick (200): once, at wall dt
1522/// 2. `[SELECT, MANIPULATE)` -- Select (300): once (built-in SelectionSystem runs first)
1523/// 3. `[MANIPULATE, SIMULATE)` -- Manipulate (400), Animate (500): once
1524/// 4. `[SIMULATE, POST_SIM)` -- Simulate (600): once per fixed step or once at wall dt
1525/// 5. `[POST_SIM, i32::MAX]` -- PostSim (700), Writeback (800): once at wall dt
1526pub struct ViewportRuntime {
1527    mode: SceneRuntimeMode,
1528    plugins: Vec<Box<dyn RuntimePlugin>>,
1529    gpu_plugins: Vec<Box<dyn GpuPlugin>>,
1530    /// False until every registered GPU plugin has had `init_gpu` called.
1531    /// Cleared again when a new GPU plugin is registered.
1532    gpu_initialized: bool,
1533    fixed_timestep: Option<FixedTimestep>,
1534    snapshots: TransformSnapshotTable,
1535    step_index: u64,
1536    selection_system: Option<SelectionSystem>,
1537    manipulation_system: Option<ManipulationSystem>,
1538    camera_follow: Option<CameraFollow>,
1539    /// Typed resource registry shared across plugins each frame.
1540    resources: RuntimeResources,
1541    /// Node IDs present at the end of the previous frame.
1542    prev_node_ids: std::collections::HashSet<crate::interaction::select::selection::NodeId>,
1543    /// False on the very first step call; after that, lifecycle events are emitted.
1544    scene_initialized: bool,
1545    /// Per-plugin timing from the most recent step / pre_prepare / post_paint.
1546    stats: RuntimeStats,
1547}
1548
1549impl Default for ViewportRuntime {
1550    fn default() -> Self {
1551        Self::new()
1552    }
1553}
1554
1555impl ViewportRuntime {
1556    /// Create a runtime with default settings and no plugins.
1557    pub fn new() -> Self {
1558        Self {
1559            mode: SceneRuntimeMode::default(),
1560            plugins: Vec::new(),
1561            gpu_plugins: Vec::new(),
1562            gpu_initialized: false,
1563            fixed_timestep: None,
1564            snapshots: TransformSnapshotTable::new(),
1565            step_index: 0,
1566            selection_system: None,
1567            manipulation_system: None,
1568            camera_follow: None,
1569            resources: RuntimeResources::new(),
1570            prev_node_ids: std::collections::HashSet::new(),
1571            scene_initialized: false,
1572            stats: RuntimeStats::default(),
1573        }
1574    }
1575
1576    /// Per-plugin timing from the most recent `step` / `pre_prepare` /
1577    /// `post_paint`. Lets a host or benchmark attribute frame time to a named
1578    /// plugin without linking it: any registered plugin is keyed by its
1579    /// [`RuntimePlugin::type_name`] / [`GpuPlugin::type_name`].
1580    pub fn last_stats(&self) -> &RuntimeStats {
1581        &self.stats
1582    }
1583
1584    /// Enable the built-in click-to-select system.
1585    ///
1586    /// The SelectionSystem runs before the `Select` phase each frame. It reads
1587    /// `RuntimeFrameContext::clicked`, `pick_hit`, and `shift_held` to produce
1588    /// SelectionOp entries in RuntimeOutput.
1589    pub fn with_selection_system(mut self) -> Self {
1590        self.selection_system = Some(SelectionSystem::new());
1591        self
1592    }
1593
1594    /// Enable the built-in manipulation system.
1595    ///
1596    /// The ManipulationSystem runs before the `Manipulate` phase each frame. It
1597    /// drives G/R/S sessions and gizmo drag from RuntimeFrameContext inputs, writing
1598    /// transform changes via TransformWriteback.
1599    pub fn with_manipulation_system(mut self) -> Self {
1600        self.manipulation_system = Some(ManipulationSystem::new());
1601        self
1602    }
1603
1604    /// True while the built-in manipulation system has an active G/R/S session or gizmo drag.
1605    ///
1606    /// Use this to suppress orbit camera movement while manipulating objects.
1607    pub fn is_manipulating(&self) -> bool {
1608        self.manipulation_system
1609            .as_ref()
1610            .map_or(false, |m| m.is_active())
1611    }
1612
1613    /// Access the built-in manipulation system, if enabled.
1614    pub fn manipulation_system(&self) -> Option<&ManipulationSystem> {
1615        self.manipulation_system.as_ref()
1616    }
1617
1618    /// Set the runtime mode.
1619    pub fn with_mode(mut self, mode: SceneRuntimeMode) -> Self {
1620        self.mode = mode;
1621        self
1622    }
1623
1624    /// Register a plugin. Plugins run in ascending priority order each frame.
1625    /// Plugins with equal priority run in registration order (stable sort).
1626    pub fn with_plugin(mut self, plugin: impl RuntimePlugin) -> Self {
1627        self.plugins.push(Box::new(plugin));
1628        self
1629    }
1630
1631    /// Register a plugin on an existing runtime.
1632    ///
1633    /// Same semantics as [`with_plugin`](Self::with_plugin) but takes
1634    /// `&mut self`, for hosts that need to add plugins after the runtime
1635    /// has been constructed and possibly already run frames.
1636    pub fn add_plugin(&mut self, plugin: impl RuntimePlugin) {
1637        self.plugins.push(Box::new(plugin));
1638    }
1639
1640    /// Register a GPU plugin. GPU plugins encode wgpu command buffers from
1641    /// [`ViewportRuntime::pre_prepare`], which the host calls each frame after
1642    /// `step` and before `renderer.prepare()`. Plugins run in ascending
1643    /// priority order; equal priorities preserve registration order.
1644    ///
1645    /// Registering a plugin after the runtime has already run a frame re-runs
1646    /// every GPU plugin's `init_gpu` on the next `pre_prepare`. Implementations
1647    /// should be idempotent or guard their own one-time setup.
1648    pub fn with_gpu_plugin(mut self, plugin: impl GpuPlugin) -> Self {
1649        self.gpu_plugins.push(Box::new(plugin));
1650        self.gpu_initialized = false;
1651        self
1652    }
1653
1654    /// Register a GPU plugin on an existing runtime.
1655    ///
1656    /// Same semantics as [`with_gpu_plugin`](Self::with_gpu_plugin) but
1657    /// takes `&mut self`.
1658    pub fn add_gpu_plugin(&mut self, plugin: impl GpuPlugin) {
1659        self.gpu_plugins.push(Box::new(plugin));
1660        self.gpu_initialized = false;
1661    }
1662
1663    /// Register a GPU plugin scoped to a single viewport.
1664    ///
1665    /// The plugin's `pre_prepare` and `post_paint` only execute when the
1666    /// host's [`GpuFrameContext::viewport_id`] equals `viewport`. `init_gpu`
1667    /// and `on_device_recreated` still run unconditionally.
1668    ///
1669    /// Use this in multi-viewport hosts (CAD quad-view, split-screen) that
1670    /// call `pre_prepare` / `post_paint` once per viewport with a populated
1671    /// `viewport_id`. Single-viewport hosts that never set `viewport_id`
1672    /// effectively disable scoped plugins; use [`with_gpu_plugin`](Self::with_gpu_plugin)
1673    /// instead.
1674    pub fn with_gpu_plugin_for_viewport(
1675        mut self,
1676        viewport: crate::renderer::ViewportId,
1677        plugin: impl GpuPlugin,
1678    ) -> Self {
1679        let scoped = gpu_plugin::ViewportScopedPlugin {
1680            viewport,
1681            inner: plugin,
1682        };
1683        self.gpu_plugins.push(Box::new(scoped));
1684        self.gpu_initialized = false;
1685        self
1686    }
1687
1688    /// Notify every registered GPU plugin that the wgpu device has been
1689    /// recreated (device loss, surface re-init, host-driven reset).
1690    ///
1691    /// Calls [`GpuPlugin::on_device_recreated`] on each plugin, then clears
1692    /// the init flag so `init_gpu` runs again with the new device on the
1693    /// next [`pre_prepare`](Self::pre_prepare). The runtime does not detect
1694    /// device loss on its own; the host is responsible for invoking this
1695    /// when it recreates the device.
1696    pub fn notify_device_recreated(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) {
1697        for p in self.gpu_plugins.iter_mut() {
1698            p.on_device_recreated(device, queue);
1699        }
1700        self.gpu_initialized = false;
1701    }
1702
1703    /// Run every registered GPU plugin's `pre_prepare` in ascending priority
1704    /// order and return the concatenated command buffers.
1705    ///
1706    /// Call after [`step`](Self::step) and before `renderer.prepare()`. The
1707    /// returned buffers should be submitted ahead of the renderer's own:
1708    ///
1709    /// ```text
1710    /// let plugin_bufs  = runtime.pre_prepare(device, queue, &gpu_ctx);
1711    /// let prepare_bufs = renderer.pass().prepare(device, queue, &frame);
1712    /// queue.submit(plugin_bufs.into_iter().chain(prepare_bufs));
1713    /// ```
1714    ///
1715    /// wgpu submit ordering guarantees plugin work completes before
1716    /// `prepare()`'s, so any storage buffer written by a plugin is observable
1717    /// by the standard render passes in the same frame.
1718    ///
1719    /// On the first call (or after a new plugin is registered), every plugin's
1720    /// `init_gpu` is invoked once before any `pre_prepare`.
1721    pub fn pre_prepare(
1722        &mut self,
1723        device: &wgpu::Device,
1724        queue: &wgpu::Queue,
1725        ctx: &GpuFrameContext<'_>,
1726    ) -> Vec<wgpu::CommandBuffer> {
1727        if !self.gpu_initialized {
1728            for p in self.gpu_plugins.iter_mut() {
1729                p.init_gpu(device);
1730            }
1731            self.gpu_initialized = true;
1732        }
1733        // Stable sort: equal priorities preserve registration order. Same
1734        // contract as the CPU plugin list.
1735        self.gpu_plugins.sort_by_key(|p| p.priority());
1736
1737        let mut timings: std::collections::HashMap<&'static str, f32> =
1738            std::collections::HashMap::new();
1739        let mut out = Vec::new();
1740        for p in self.gpu_plugins.iter_mut() {
1741            let t = std::time::Instant::now();
1742            out.extend(p.pre_prepare(device, queue, ctx));
1743            *timings.entry(p.type_name()).or_insert(0.0) += t.elapsed().as_secs_f32() * 1000.0;
1744        }
1745        self.stats.pre_prepare_ms = timings;
1746        out
1747    }
1748
1749    /// Run every registered GPU plugin's `post_paint` in ascending priority
1750    /// order and return the concatenated command buffers.
1751    ///
1752    /// Call after `renderer.paint_to()` has run for the frame. The host
1753    /// supplies views of the just-rendered color and depth targets (and
1754    /// optionally a pick-id view), and is responsible for compositing any
1755    /// plugin overlay output into the final image: the lib does not loop
1756    /// plugin output back into the rendered color.
1757    ///
1758    /// Plugins registered after the first frame still go through `init_gpu`
1759    /// on the next `pre_prepare`; this method does not trigger init on its
1760    /// own to keep the post-paint path deterministic.
1761    pub fn post_paint(
1762        &mut self,
1763        device: &wgpu::Device,
1764        queue: &wgpu::Queue,
1765        targets: &PostPaintTargets<'_>,
1766        ctx: &GpuFrameContext<'_>,
1767    ) -> Vec<wgpu::CommandBuffer> {
1768        // Stable sort: equal priorities preserve registration order. The list
1769        // is already sorted if `pre_prepare` ran first this frame; sorting
1770        // again is cheap and keeps `post_paint` safe to call standalone.
1771        self.gpu_plugins.sort_by_key(|p| p.priority());
1772
1773        let mut timings: std::collections::HashMap<&'static str, f32> =
1774            std::collections::HashMap::new();
1775        let mut out = Vec::new();
1776        for p in self.gpu_plugins.iter_mut() {
1777            let t = std::time::Instant::now();
1778            out.extend(p.post_paint(device, queue, targets, ctx));
1779            *timings.entry(p.type_name()).or_insert(0.0) += t.elapsed().as_secs_f32() * 1000.0;
1780        }
1781        self.stats.post_paint_ms = timings;
1782        out
1783    }
1784
1785    /// Enable fixed-timestep accumulation for physics plugins.
1786    ///
1787    /// When set, plugins in the `[SIMULATE, POST_SIM)` range run once per
1788    /// accumulated fixed step rather than once per frame at wall dt. All other
1789    /// priority ranges always run once per frame.
1790    pub fn with_fixed_timestep(mut self, ts: FixedTimestep) -> Self {
1791        self.fixed_timestep = Some(ts);
1792        self
1793    }
1794
1795    /// Replace the fixed timestep at runtime. Resets the accumulator.
1796    ///
1797    /// Use this to change the simulation rate after construction without
1798    /// rebuilding the runtime or its plugins.
1799    pub fn set_fixed_timestep(&mut self, ts: FixedTimestep) {
1800        self.fixed_timestep = Some(ts);
1801    }
1802
1803    /// Remove the fixed timestep, reverting to one simulate call per frame at wall dt.
1804    pub fn clear_fixed_timestep(&mut self) {
1805        self.fixed_timestep = None;
1806    }
1807
1808    /// The current runtime mode.
1809    pub fn mode(&self) -> SceneRuntimeMode {
1810        self.mode
1811    }
1812
1813    /// The transform snapshot table for interpolated rendering.
1814    ///
1815    /// Updated each frame during writeback. Pass [`alpha`](Self::alpha) as the
1816    /// blend factor to [`TransformSnapshotTable::interpolated`].
1817    pub fn snapshots(&self) -> &TransformSnapshotTable {
1818        &self.snapshots
1819    }
1820
1821    /// Read access to the shared resource registry.
1822    ///
1823    /// Use this after `step` to inspect resources without running the frame loop.
1824    /// During the frame loop, access resources through `RuntimeStepContext::resources`.
1825    pub fn resources(&self) -> &RuntimeResources {
1826        &self.resources
1827    }
1828
1829    /// Write access to the shared resource registry.
1830    ///
1831    /// Use this to pre-populate resources before the first `step`, or to inject
1832    /// resources from outside the plugin system (e.g. from the application).
1833    pub fn resources_mut(&mut self) -> &mut RuntimeResources {
1834        &mut self.resources
1835    }
1836
1837    /// Blend factor for rendering interpolation between fixed steps.
1838    ///
1839    /// Returns `1.0` if no fixed timestep is configured (render directly from
1840    /// current scene transforms without interpolation).
1841    pub fn alpha(&self) -> f32 {
1842        self.fixed_timestep.as_ref().map_or(1.0, |ts| ts.alpha())
1843    }
1844
1845    /// Monotonically increasing simulation step counter.
1846    ///
1847    /// Incremented once per simulate execution. With a fixed timestep this
1848    /// means it may increment multiple times per rendered frame. Wraps on overflow.
1849    pub fn step_index(&self) -> u64 {
1850        self.step_index
1851    }
1852
1853    /// Set a camera follow binding (builder style).
1854    ///
1855    /// Each call to [`step`](Self::step) will compute a suggested camera center
1856    /// from the followed node and emit it as a [`CameraFollowTarget`] event on
1857    /// [`RuntimeOutput::events`].
1858    pub fn with_camera_follow(mut self, follow: CameraFollow) -> Self {
1859        self.camera_follow = Some(follow);
1860        self
1861    }
1862
1863    /// Update the camera follow binding at runtime.
1864    pub fn set_camera_follow(&mut self, follow: CameraFollow) {
1865        self.camera_follow = Some(follow);
1866    }
1867
1868    /// Remove the camera follow binding. No [`CameraFollowTarget`] event
1869    /// will be emitted after this call.
1870    pub fn clear_camera_follow(&mut self) {
1871        self.camera_follow = None;
1872    }
1873
1874    /// The current camera follow binding.
1875    pub fn camera_follow(&self) -> Option<&CameraFollow> {
1876        self.camera_follow.as_ref()
1877    }
1878
1879    /// Run one frame of the runtime.
1880    ///
1881    /// Plugins run in ascending priority order. The simulate range
1882    /// `[SIMULATE, POST_SIM)` runs once per accumulated fixed step (or once at
1883    /// `frame.dt` when no fixed timestep is configured). All other ranges run
1884    /// once per frame. Transform ops are flushed to `scene` and the snapshot
1885    /// table is updated after the writeback range.
1886    ///
1887    /// Selection ops produced by plugins are applied to `selection` before returning.
1888    /// Contact events and the applied transform ops are returned in [`RuntimeOutput`].
1889    pub fn step(
1890        &mut self,
1891        scene: &mut Scene,
1892        selection: &mut Selection,
1893        frame: &RuntimeFrameContext,
1894    ) -> RuntimeOutput {
1895        let mut output = RuntimeOutput::default();
1896        let mut writeback = TransformWriteback::default();
1897
1898        // --- Lifecycle event detection ---------------------------------------
1899        // Collect current node IDs from the scene.
1900        let current_ids: std::collections::HashSet<crate::interaction::select::selection::NodeId> =
1901            scene.nodes().map(|n| n.id()).collect();
1902
1903        if self.scene_initialized {
1904            // Diff against previous frame and dispatch events.
1905            let mut events: Vec<RuntimeEvent> = Vec::new();
1906            for &id in current_ids.difference(&self.prev_node_ids) {
1907                events.push(RuntimeEvent::NodeAdded(id));
1908            }
1909            for &id in self.prev_node_ids.difference(&current_ids) {
1910                events.push(RuntimeEvent::NodeRemoved(id));
1911            }
1912
1913            if !events.is_empty() {
1914                // Take plugins out to avoid aliasing self while dispatching.
1915                let mut plugins = std::mem::take(&mut self.plugins);
1916                for event in &events {
1917                    for plugin in plugins.iter_mut() {
1918                        let mut ctx = RuntimeStepContext {
1919                            priority: plugin.priority(),
1920                            dt: frame.dt,
1921                            scene,
1922                            writeback: &mut writeback,
1923                            output: &mut output,
1924                            pick_hit: frame.pick_hit,
1925                            resources: &mut self.resources,
1926                        };
1927                        plugin.on_event(event, &mut ctx);
1928                    }
1929                }
1930                self.plugins = plugins;
1931            }
1932        } else {
1933            self.scene_initialized = true;
1934        }
1935        self.prev_node_ids = current_ids;
1936
1937        // --- Sort plugins by priority (stable: preserves registration order within a band).
1938        self.plugins.sort_by_key(|p| p.priority());
1939
1940        // Take plugins out so we can access self freely during the step loop.
1941        let mut plugins = std::mem::take(&mut self.plugins);
1942
1943        // Per-plugin CPU timing for this step (submit + step + collect), keyed
1944        // by plugin type name. Stored into `self.stats` at the end.
1945        let mut step_timings: std::collections::HashMap<&'static str, f32> =
1946            std::collections::HashMap::new();
1947
1948        // --- Submit pass: all plugins in priority order ----------------------
1949        for plugin in plugins.iter_mut() {
1950            let ctx = RuntimeStepContext {
1951                priority: plugin.priority(),
1952                dt: frame.dt,
1953                scene,
1954                writeback: &mut writeback,
1955                output: &mut output,
1956                pick_hit: frame.pick_hit,
1957                resources: &mut self.resources,
1958            };
1959            let t = std::time::Instant::now();
1960            plugin.submit(&ctx);
1961            *step_timings.entry(plugin.type_name()).or_insert(0.0) +=
1962                t.elapsed().as_secs_f32() * 1000.0;
1963        }
1964
1965        // --- Step loop -------------------------------------------------------
1966
1967        // Prepare + Pick: [MIN, SELECT)
1968        run_range(
1969            &mut plugins,
1970            i32::MIN,
1971            phase::SELECT,
1972            frame.dt,
1973            frame,
1974            scene,
1975            &mut writeback,
1976            &mut output,
1977            &mut self.resources,
1978            &mut step_timings,
1979        );
1980
1981        // Built-in selection system runs before the Select range.
1982        if let Some(sel_sys) = &self.selection_system {
1983            sel_sys.step(frame, &mut output);
1984        }
1985        // Select: [SELECT, MANIPULATE)
1986        run_range(
1987            &mut plugins,
1988            phase::SELECT,
1989            phase::MANIPULATE,
1990            frame.dt,
1991            frame,
1992            scene,
1993            &mut writeback,
1994            &mut output,
1995            &mut self.resources,
1996            &mut step_timings,
1997        );
1998
1999        // Built-in manipulation system runs before the Manipulate range.
2000        if self.manipulation_system.is_some() {
2001            let mut manip_sys = self.manipulation_system.take().unwrap();
2002            manip_sys.step(frame, scene, selection, &mut writeback, &mut output);
2003            self.manipulation_system = Some(manip_sys);
2004        }
2005        // Manipulate + Animate: [MANIPULATE, SIMULATE)
2006        run_range(
2007            &mut plugins,
2008            phase::MANIPULATE,
2009            phase::SIMULATE,
2010            frame.dt,
2011            frame,
2012            scene,
2013            &mut writeback,
2014            &mut output,
2015            &mut self.resources,
2016            &mut step_timings,
2017        );
2018
2019        // Simulate: [SIMULATE, POST_SIM) -- once per fixed step or once at wall dt.
2020        if self.fixed_timestep.is_some() {
2021            let mut ts = self.fixed_timestep.take().unwrap();
2022            for step_dt in ts.advance(frame.dt) {
2023                run_range(
2024                    &mut plugins,
2025                    phase::SIMULATE,
2026                    phase::POST_SIM,
2027                    step_dt,
2028                    frame,
2029                    scene,
2030                    &mut writeback,
2031                    &mut output,
2032                    &mut self.resources,
2033                    &mut step_timings,
2034                );
2035                self.step_index = self.step_index.wrapping_add(1);
2036            }
2037            self.fixed_timestep = Some(ts);
2038        } else {
2039            run_range(
2040                &mut plugins,
2041                phase::SIMULATE,
2042                phase::POST_SIM,
2043                frame.dt,
2044                frame,
2045                scene,
2046                &mut writeback,
2047                &mut output,
2048                &mut self.resources,
2049                &mut step_timings,
2050            );
2051            self.step_index = self.step_index.wrapping_add(1);
2052        }
2053
2054        // PostSim + Writeback: [POST_SIM, MAX]
2055        run_range(
2056            &mut plugins,
2057            phase::POST_SIM,
2058            i32::MAX,
2059            frame.dt,
2060            frame,
2061            scene,
2062            &mut writeback,
2063            &mut output,
2064            &mut self.resources,
2065            &mut step_timings,
2066        );
2067
2068        // --- Collect pass: all plugins in priority order ---------------------
2069        for plugin in plugins.iter_mut() {
2070            let mut ctx = RuntimeStepContext {
2071                priority: plugin.priority(),
2072                dt: frame.dt,
2073                scene,
2074                writeback: &mut writeback,
2075                output: &mut output,
2076                pick_hit: frame.pick_hit,
2077                resources: &mut self.resources,
2078            };
2079            let t = std::time::Instant::now();
2080            plugin.collect(&mut ctx);
2081            *step_timings.entry(plugin.type_name()).or_insert(0.0) +=
2082                t.elapsed().as_secs_f32() * 1000.0;
2083        }
2084
2085        // Restore plugins and publish this step's per-plugin timing.
2086        self.plugins = plugins;
2087        self.stats.step_ms = step_timings;
2088
2089        // --- Flush writeback -------------------------------------------------
2090        let ops = writeback.into_ops();
2091        for op in &ops {
2092            let new_local = if op.preserve_scale {
2093                // Decompose incoming transform to extract rotation +
2094                // translation, then recompose with the node's existing scale.
2095                // Physics and animation systems don't model scale, so they
2096                // shouldn't clobber a scale the caller set on the node.
2097                let incoming = glam::Mat4::from(op.transform);
2098                let (_, rot, trans) = incoming.to_scale_rotation_translation();
2099                let existing_scale = scene
2100                    .node(op.id)
2101                    .map(|n| n.local_transform().to_scale_rotation_translation().0)
2102                    .unwrap_or(glam::Vec3::ONE);
2103                glam::Mat4::from_scale_rotation_translation(existing_scale, rot, trans)
2104            } else {
2105                glam::Mat4::from(op.transform)
2106            };
2107            scene.set_local_transform(op.id, new_local);
2108            self.snapshots.update(op.id, op.transform);
2109        }
2110        if !ops.is_empty() {
2111            scene.update_transforms();
2112        }
2113        output.node_transform_ops = ops;
2114
2115        // Apply selection ops produced by plugins.
2116        for op in &output.selection_ops {
2117            op.apply_to(selection);
2118        }
2119
2120        // Compute camera follow target.
2121        if let Some(CameraFollow::Node { id, offset, .. }) = &self.camera_follow {
2122            let id = *id;
2123            let offset = *offset;
2124            let alpha = self.alpha();
2125            let pos = self
2126                .snapshots
2127                .interpolated(id, alpha)
2128                .map(|t| glam::Vec3::from(t.translation))
2129                .or_else(|| {
2130                    scene
2131                        .node(id)
2132                        .map(|n| n.world_transform().col(3).truncate())
2133                });
2134            if let Some(pos) = pos {
2135                output
2136                    .events
2137                    .emit(crate::runtime::output::CameraFollowTarget(pos + offset));
2138            }
2139        }
2140
2141        output
2142    }
2143}
2144
2145#[cfg(test)]
2146mod plugin_stats_tests {
2147    use super::*;
2148
2149    struct Probe;
2150    impl RuntimePlugin for Probe {
2151        fn priority(&self) -> i32 {
2152            phase::SIMULATE
2153        }
2154        fn type_name(&self) -> &'static str {
2155            "probe"
2156        }
2157        fn step(&mut self, _ctx: &mut RuntimeStepContext<'_>) {
2158            // A touch of work so the timer records something.
2159            let _ = (0..2000u64).fold(0u64, |a, b| a.wrapping_add(b));
2160        }
2161    }
2162
2163    #[test]
2164    fn last_stats_attributes_step_time_by_type_name() {
2165        let mut rt = ViewportRuntime::new().with_plugin(Probe);
2166        let mut scene = Scene::new();
2167        let mut sel = Selection::new();
2168        let frame = RuntimeFrameContext::default();
2169        rt.step(&mut scene, &mut sel, &frame);
2170
2171        let stats = rt.last_stats();
2172        assert!(
2173            stats.step_ms.contains_key("probe"),
2174            "expected per-plugin step timing keyed by type_name"
2175        );
2176        assert!(stats.total_step_ms() >= 0.0);
2177    }
2178}