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