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::skinning::{SkinWeights, SkinnedMeshUpdate};
1219    use crate::plugins::skeleton::{Joint, JointMatrices, Pose, Skeleton, SkeletonPlugin, apply_skin};
1220
1221    fn two_joint_skeleton() -> Skeleton {
1222        Skeleton::new(vec![
1223            Joint {
1224                name: "root".into(),
1225                parent: None,
1226                inverse_bind: glam::Affine3A::IDENTITY,
1227            },
1228            Joint {
1229                name: "child".into(),
1230                parent: Some(0),
1231                inverse_bind: glam::Affine3A::from_translation(-glam::Vec3::Y),
1232            },
1233        ])
1234    }
1235
1236    fn single_vertex_weights(joint: u8, weight: f32) -> SkinWeights {
1237        SkinWeights {
1238            joint_indices: vec![[joint, 0, 0, 0]],
1239            joint_weights: vec![[weight, 0.0, 0.0, 0.0]],
1240        }
1241    }
1242
1243    #[test]
1244    fn test_skeleton_joint_count() {
1245        let sk = two_joint_skeleton();
1246        assert_eq!(sk.joint_count(), 2);
1247    }
1248
1249    #[test]
1250    fn test_skeleton_find_joint() {
1251        let sk = two_joint_skeleton();
1252        assert_eq!(sk.find_joint("root"), Some(0));
1253        assert_eq!(sk.find_joint("child"), Some(1));
1254        assert_eq!(sk.find_joint("missing"), None);
1255    }
1256
1257    #[test]
1258    fn test_pose_identity_transforms() {
1259        let pose = Pose::identity(3);
1260        assert_eq!(pose.joint_count(), 3);
1261        for t in &pose.local_transforms {
1262            assert_eq!(*t, glam::Affine3A::IDENTITY);
1263        }
1264    }
1265
1266    #[test]
1267    fn test_joint_matrices_single_root_identity() {
1268        let sk = Skeleton::new(vec![Joint {
1269            name: "root".into(),
1270            parent: None,
1271            inverse_bind: glam::Affine3A::IDENTITY,
1272        }]);
1273        let pose = Pose::identity(1);
1274        let mats = JointMatrices::compute(&sk, &pose);
1275        let m = mats.as_slice()[0];
1276        // identity local * identity inverse_bind = identity
1277        assert!(m.matrix3.col(0).abs_diff_eq(glam::Vec3A::X, 1e-5));
1278        assert!(m.translation.abs_diff_eq(glam::Vec3A::ZERO, 1e-5));
1279    }
1280
1281    #[test]
1282    fn test_joint_matrices_parent_child_chain() {
1283        // Root translated by (1,0,0) in local space.
1284        // Child at local identity, parent = 0.
1285        // child.inverse_bind offsets by -Y.
1286        // Expected child world = root_world * child_local = translate(1,0,0)
1287        // Final matrix = world * inverse_bind = translate(1,0,0) * translate(0,-1,0) = translate(1,-1,0)
1288        let sk = two_joint_skeleton();
1289        let mut pose = Pose::identity(2);
1290        pose.local_transforms[0] = glam::Affine3A::from_translation(glam::Vec3::X);
1291        let mats = JointMatrices::compute(&sk, &pose);
1292        let child = mats.as_slice()[1];
1293        let expected_translation = glam::Vec3A::new(1.0, -1.0, 0.0);
1294        assert!(
1295            child.translation.abs_diff_eq(expected_translation, 1e-5),
1296            "child translation was {:?}, expected {:?}",
1297            child.translation,
1298            expected_translation,
1299        );
1300    }
1301
1302    #[test]
1303    fn test_apply_skin_identity_pose() {
1304        let sk = Skeleton::new(vec![Joint {
1305            name: "root".into(),
1306            parent: None,
1307            inverse_bind: glam::Affine3A::IDENTITY,
1308        }]);
1309        let pose = Pose::identity(1);
1310        let mats = JointMatrices::compute(&sk, &pose);
1311
1312        let positions = vec![[1.0f32, 2.0, 3.0]];
1313        let normals = vec![[0.0f32, 1.0, 0.0]];
1314        let weights = single_vertex_weights(0, 1.0);
1315        let (out_pos, out_nrm) = apply_skin(&positions, &normals, &weights, &mats);
1316
1317        assert!((out_pos[0][0] - 1.0).abs() < 1e-5);
1318        assert!((out_pos[0][1] - 2.0).abs() < 1e-5);
1319        assert!((out_pos[0][2] - 3.0).abs() < 1e-5);
1320        assert!((out_nrm[0][1] - 1.0).abs() < 1e-5);
1321    }
1322
1323    #[test]
1324    fn test_apply_skin_single_joint_full_weight() {
1325        // Joint 0 translates by (5,0,0). Vertex at origin must move to (5,0,0).
1326        let sk = Skeleton::new(vec![Joint {
1327            name: "root".into(),
1328            parent: None,
1329            inverse_bind: glam::Affine3A::IDENTITY,
1330        }]);
1331        let mut pose = Pose::identity(1);
1332        pose.local_transforms[0] = glam::Affine3A::from_translation(glam::Vec3::new(5.0, 0.0, 0.0));
1333        let mats = JointMatrices::compute(&sk, &pose);
1334
1335        let positions = vec![[0.0f32, 0.0, 0.0]];
1336        let normals = vec![[1.0f32, 0.0, 0.0]];
1337        let weights = single_vertex_weights(0, 1.0);
1338        let (out_pos, _) = apply_skin(&positions, &normals, &weights, &mats);
1339        assert!((out_pos[0][0] - 5.0).abs() < 1e-5);
1340        assert!(out_pos[0][1].abs() < 1e-5);
1341    }
1342
1343    #[test]
1344    fn test_apply_skin_two_joint_blend() {
1345        // Two joints: joint 0 at (0,0,0), joint 1 at (10,0,0).
1346        // Vertex at origin, 50/50 blend -> output at (5,0,0).
1347        let sk = Skeleton::new(vec![
1348            Joint {
1349                name: "a".into(),
1350                parent: None,
1351                inverse_bind: glam::Affine3A::IDENTITY,
1352            },
1353            Joint {
1354                name: "b".into(),
1355                parent: None,
1356                inverse_bind: glam::Affine3A::IDENTITY,
1357            },
1358        ]);
1359        let mut pose = Pose::identity(2);
1360        pose.local_transforms[1] =
1361            glam::Affine3A::from_translation(glam::Vec3::new(10.0, 0.0, 0.0));
1362        let mats = JointMatrices::compute(&sk, &pose);
1363
1364        let positions = vec![[0.0f32, 0.0, 0.0]];
1365        let normals = vec![[1.0f32, 0.0, 0.0]];
1366        let weights = SkinWeights {
1367            joint_indices: vec![[0, 1, 0, 0]],
1368            joint_weights: vec![[0.5, 0.5, 0.0, 0.0]],
1369        };
1370        let (out_pos, _) = apply_skin(&positions, &normals, &weights, &mats);
1371        assert!(
1372            (out_pos[0][0] - 5.0).abs() < 1e-5,
1373            "expected 5.0, got {}",
1374            out_pos[0][0]
1375        );
1376    }
1377
1378    #[test]
1379    fn test_apply_skin_normal_renormalized() {
1380        // Even with a uniform scale, output normals must have unit length.
1381        let sk = Skeleton::new(vec![Joint {
1382            name: "root".into(),
1383            parent: None,
1384            inverse_bind: glam::Affine3A::IDENTITY,
1385        }]);
1386        let mut pose = Pose::identity(1);
1387        pose.local_transforms[0] = glam::Affine3A::from_scale(glam::Vec3::splat(2.0));
1388        let mats = JointMatrices::compute(&sk, &pose);
1389
1390        let positions = vec![[1.0f32, 0.0, 0.0]];
1391        let normals = vec![[1.0f32, 0.0, 0.0]];
1392        let weights = single_vertex_weights(0, 1.0);
1393        let (_, out_nrm) = apply_skin(&positions, &normals, &weights, &mats);
1394        let len = (out_nrm[0][0].powi(2) + out_nrm[0][1].powi(2) + out_nrm[0][2].powi(2)).sqrt();
1395        assert!((len - 1.0).abs() < 1e-5, "normal length was {len}");
1396    }
1397
1398    #[test]
1399    fn test_skeleton_plugin_writes_update_to_output() {
1400        let sk = Skeleton::new(vec![Joint {
1401            name: "root".into(),
1402            parent: None,
1403            inverse_bind: glam::Affine3A::IDENTITY,
1404        }]);
1405        let positions = vec![[0.0f32, 0.0, 0.0]];
1406        let normals = vec![[0.0f32, 1.0, 0.0]];
1407        let weights = single_vertex_weights(0, 1.0);
1408        let mesh_id = crate::resources::mesh_store::MeshId(0);
1409
1410        let mut runtime = ViewportRuntime::new().with_plugin(SkeletonPlugin::new(
1411            sk, mesh_id, positions, normals, weights,
1412        ));
1413
1414        // Insert a pose so the plugin fires.
1415        runtime.resources_mut().insert(Pose::identity(1));
1416
1417        let output = run_one_frame(&mut runtime);
1418        let updates: Vec<&SkinnedMeshUpdate> = output.events.read::<SkinnedMeshUpdate>().collect();
1419        assert_eq!(updates.len(), 1);
1420        assert_eq!(updates[0].mesh_id, mesh_id);
1421    }
1422
1423    #[test]
1424    fn test_skinned_mesh_updates_empty_without_pose() {
1425        let sk = Skeleton::new(vec![Joint {
1426            name: "root".into(),
1427            parent: None,
1428            inverse_bind: glam::Affine3A::IDENTITY,
1429        }]);
1430        let mesh_id = crate::resources::mesh_store::MeshId(0);
1431        let mut runtime = ViewportRuntime::new().with_plugin(SkeletonPlugin::new(
1432            sk,
1433            mesh_id,
1434            vec![[0.0f32; 3]],
1435            vec![[0.0f32, 1.0, 0.0]],
1436            single_vertex_weights(0, 1.0),
1437        ));
1438        // No Pose inserted -> no update.
1439        let output = run_one_frame(&mut runtime);
1440        assert_eq!(output.events.read::<SkinnedMeshUpdate>().count(), 0);
1441    }
1442
1443    #[test]
1444    fn test_skinned_mesh_updates_cleared_each_frame() {
1445        let sk = Skeleton::new(vec![Joint {
1446            name: "root".into(),
1447            parent: None,
1448            inverse_bind: glam::Affine3A::IDENTITY,
1449        }]);
1450        let mesh_id = crate::resources::mesh_store::MeshId(0);
1451        let mut runtime = ViewportRuntime::new().with_plugin(SkeletonPlugin::new(
1452            sk,
1453            mesh_id,
1454            vec![[0.0f32; 3]],
1455            vec![[0.0f32, 1.0, 0.0]],
1456            single_vertex_weights(0, 1.0),
1457        ));
1458        runtime.resources_mut().insert(Pose::identity(1));
1459
1460        run_one_frame(&mut runtime);
1461        let output2 = run_one_frame(&mut runtime);
1462        // Each frame produces exactly 1 update, not 2.
1463        assert_eq!(output2.events.read::<SkinnedMeshUpdate>().count(), 1);
1464    }
1465}
1466
1467/// Per-frame scene orchestration layer.
1468///
1469/// Owns plugins, an optional fixed timestep accumulator, and a transform snapshot
1470/// table. Does not own the scene, selection, GPU resources, or window.
1471///
1472/// # Priority execution order
1473///
1474/// Each call to [`step`](Self::step) executes plugins in ascending priority order:
1475///
1476/// 1. `[i32::MIN, SELECT)` -- Prepare (100), Pick (200): once, at wall dt
1477/// 2. `[SELECT, MANIPULATE)` -- Select (300): once (built-in SelectionSystem runs first)
1478/// 3. `[MANIPULATE, SIMULATE)` -- Manipulate (400), Animate (500): once
1479/// 4. `[SIMULATE, POST_SIM)` -- Simulate (600): once per fixed step or once at wall dt
1480/// 5. `[POST_SIM, i32::MAX]` -- PostSim (700), Writeback (800): once at wall dt
1481pub struct ViewportRuntime {
1482    mode: SceneRuntimeMode,
1483    plugins: Vec<Box<dyn RuntimePlugin>>,
1484    gpu_plugins: Vec<Box<dyn GpuPlugin>>,
1485    /// False until every registered GPU plugin has had `init_gpu` called.
1486    /// Cleared again when a new GPU plugin is registered.
1487    gpu_initialized: bool,
1488    fixed_timestep: Option<FixedTimestep>,
1489    snapshots: TransformSnapshotTable,
1490    step_index: u64,
1491    selection_system: Option<SelectionSystem>,
1492    manipulation_system: Option<ManipulationSystem>,
1493    camera_follow: Option<CameraFollow>,
1494    /// Typed resource registry shared across plugins each frame.
1495    resources: RuntimeResources,
1496    /// Node IDs present at the end of the previous frame.
1497    prev_node_ids: std::collections::HashSet<crate::interaction::selection::NodeId>,
1498    /// False on the very first step call; after that, lifecycle events are emitted.
1499    scene_initialized: bool,
1500}
1501
1502impl Default for ViewportRuntime {
1503    fn default() -> Self {
1504        Self::new()
1505    }
1506}
1507
1508impl ViewportRuntime {
1509    /// Create a runtime with default settings and no plugins.
1510    pub fn new() -> Self {
1511        Self {
1512            mode: SceneRuntimeMode::default(),
1513            plugins: Vec::new(),
1514            gpu_plugins: Vec::new(),
1515            gpu_initialized: false,
1516            fixed_timestep: None,
1517            snapshots: TransformSnapshotTable::new(),
1518            step_index: 0,
1519            selection_system: None,
1520            manipulation_system: None,
1521            camera_follow: None,
1522            resources: RuntimeResources::new(),
1523            prev_node_ids: std::collections::HashSet::new(),
1524            scene_initialized: false,
1525        }
1526    }
1527
1528    /// Enable the built-in click-to-select system.
1529    ///
1530    /// The SelectionSystem runs before the `Select` phase each frame. It reads
1531    /// `RuntimeFrameContext::clicked`, `pick_hit`, and `shift_held` to produce
1532    /// SelectionOp entries in RuntimeOutput.
1533    pub fn with_selection_system(mut self) -> Self {
1534        self.selection_system = Some(SelectionSystem::new());
1535        self
1536    }
1537
1538    /// Enable the built-in manipulation system.
1539    ///
1540    /// The ManipulationSystem runs before the `Manipulate` phase each frame. It
1541    /// drives G/R/S sessions and gizmo drag from RuntimeFrameContext inputs, writing
1542    /// transform changes via TransformWriteback.
1543    pub fn with_manipulation_system(mut self) -> Self {
1544        self.manipulation_system = Some(ManipulationSystem::new());
1545        self
1546    }
1547
1548    /// True while the built-in manipulation system has an active G/R/S session or gizmo drag.
1549    ///
1550    /// Use this to suppress orbit camera movement while manipulating objects.
1551    pub fn is_manipulating(&self) -> bool {
1552        self.manipulation_system
1553            .as_ref()
1554            .map_or(false, |m| m.is_active())
1555    }
1556
1557    /// Access the built-in manipulation system, if enabled.
1558    pub fn manipulation_system(&self) -> Option<&ManipulationSystem> {
1559        self.manipulation_system.as_ref()
1560    }
1561
1562    /// Set the runtime mode.
1563    pub fn with_mode(mut self, mode: SceneRuntimeMode) -> Self {
1564        self.mode = mode;
1565        self
1566    }
1567
1568    /// Register a plugin. Plugins run in ascending priority order each frame.
1569    /// Plugins with equal priority run in registration order (stable sort).
1570    pub fn with_plugin(mut self, plugin: impl RuntimePlugin) -> Self {
1571        self.plugins.push(Box::new(plugin));
1572        self
1573    }
1574
1575    /// Register a plugin on an existing runtime.
1576    ///
1577    /// Same semantics as [`with_plugin`](Self::with_plugin) but takes
1578    /// `&mut self`, for hosts that need to add plugins after the runtime
1579    /// has been constructed and possibly already run frames.
1580    pub fn add_plugin(&mut self, plugin: impl RuntimePlugin) {
1581        self.plugins.push(Box::new(plugin));
1582    }
1583
1584    /// Register a GPU plugin. GPU plugins encode wgpu command buffers from
1585    /// [`ViewportRuntime::pre_prepare`], which the host calls each frame after
1586    /// `step` and before `renderer.prepare()`. Plugins run in ascending
1587    /// priority order; equal priorities preserve registration order.
1588    ///
1589    /// Registering a plugin after the runtime has already run a frame re-runs
1590    /// every GPU plugin's `init_gpu` on the next `pre_prepare`. Implementations
1591    /// should be idempotent or guard their own one-time setup.
1592    pub fn with_gpu_plugin(mut self, plugin: impl GpuPlugin) -> Self {
1593        self.gpu_plugins.push(Box::new(plugin));
1594        self.gpu_initialized = false;
1595        self
1596    }
1597
1598    /// Register a GPU plugin on an existing runtime.
1599    ///
1600    /// Same semantics as [`with_gpu_plugin`](Self::with_gpu_plugin) but
1601    /// takes `&mut self`.
1602    pub fn add_gpu_plugin(&mut self, plugin: impl GpuPlugin) {
1603        self.gpu_plugins.push(Box::new(plugin));
1604        self.gpu_initialized = false;
1605    }
1606
1607    /// Register a GPU plugin scoped to a single viewport.
1608    ///
1609    /// The plugin's `pre_prepare` and `post_paint` only execute when the
1610    /// host's [`GpuFrameContext::viewport_id`] equals `viewport`. `init_gpu`
1611    /// and `on_device_recreated` still run unconditionally.
1612    ///
1613    /// Use this in multi-viewport hosts (CAD quad-view, split-screen) that
1614    /// call `pre_prepare` / `post_paint` once per viewport with a populated
1615    /// `viewport_id`. Single-viewport hosts that never set `viewport_id`
1616    /// effectively disable scoped plugins; use [`with_gpu_plugin`](Self::with_gpu_plugin)
1617    /// instead.
1618    pub fn with_gpu_plugin_for_viewport(
1619        mut self,
1620        viewport: crate::renderer::ViewportId,
1621        plugin: impl GpuPlugin,
1622    ) -> Self {
1623        let scoped = gpu_plugin::ViewportScopedPlugin {
1624            viewport,
1625            inner: plugin,
1626        };
1627        self.gpu_plugins.push(Box::new(scoped));
1628        self.gpu_initialized = false;
1629        self
1630    }
1631
1632    /// Notify every registered GPU plugin that the wgpu device has been
1633    /// recreated (device loss, surface re-init, host-driven reset).
1634    ///
1635    /// Calls [`GpuPlugin::on_device_recreated`] on each plugin, then clears
1636    /// the init flag so `init_gpu` runs again with the new device on the
1637    /// next [`pre_prepare`](Self::pre_prepare). The runtime does not detect
1638    /// device loss on its own; the host is responsible for invoking this
1639    /// when it recreates the device.
1640    pub fn notify_device_recreated(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) {
1641        for p in self.gpu_plugins.iter_mut() {
1642            p.on_device_recreated(device, queue);
1643        }
1644        self.gpu_initialized = false;
1645    }
1646
1647    /// Run every registered GPU plugin's `pre_prepare` in ascending priority
1648    /// order and return the concatenated command buffers.
1649    ///
1650    /// Call after [`step`](Self::step) and before `renderer.prepare()`. The
1651    /// returned buffers should be submitted ahead of the renderer's own:
1652    ///
1653    /// ```text
1654    /// let plugin_bufs  = runtime.pre_prepare(device, queue, &gpu_ctx);
1655    /// let prepare_bufs = renderer.pass().prepare(device, queue, &frame);
1656    /// queue.submit(plugin_bufs.into_iter().chain(prepare_bufs));
1657    /// ```
1658    ///
1659    /// wgpu submit ordering guarantees plugin work completes before
1660    /// `prepare()`'s, so any storage buffer written by a plugin is observable
1661    /// by the standard render passes in the same frame.
1662    ///
1663    /// On the first call (or after a new plugin is registered), every plugin's
1664    /// `init_gpu` is invoked once before any `pre_prepare`.
1665    pub fn pre_prepare(
1666        &mut self,
1667        device: &wgpu::Device,
1668        queue: &wgpu::Queue,
1669        ctx: &GpuFrameContext<'_>,
1670    ) -> Vec<wgpu::CommandBuffer> {
1671        if !self.gpu_initialized {
1672            for p in self.gpu_plugins.iter_mut() {
1673                p.init_gpu(device);
1674            }
1675            self.gpu_initialized = true;
1676        }
1677        // Stable sort: equal priorities preserve registration order. Same
1678        // contract as the CPU plugin list.
1679        self.gpu_plugins.sort_by_key(|p| p.priority());
1680
1681        let mut out = Vec::new();
1682        for p in self.gpu_plugins.iter_mut() {
1683            out.extend(p.pre_prepare(device, queue, ctx));
1684        }
1685        out
1686    }
1687
1688    /// Run every registered GPU plugin's `post_paint` in ascending priority
1689    /// order and return the concatenated command buffers.
1690    ///
1691    /// Call after `renderer.paint_to()` has run for the frame. The host
1692    /// supplies views of the just-rendered color and depth targets (and
1693    /// optionally a pick-id view), and is responsible for compositing any
1694    /// plugin overlay output into the final image: the lib does not loop
1695    /// plugin output back into the rendered color.
1696    ///
1697    /// Plugins registered after the first frame still go through `init_gpu`
1698    /// on the next `pre_prepare`; this method does not trigger init on its
1699    /// own to keep the post-paint path deterministic.
1700    pub fn post_paint(
1701        &mut self,
1702        device: &wgpu::Device,
1703        queue: &wgpu::Queue,
1704        targets: &PostPaintTargets<'_>,
1705        ctx: &GpuFrameContext<'_>,
1706    ) -> Vec<wgpu::CommandBuffer> {
1707        // Stable sort: equal priorities preserve registration order. The list
1708        // is already sorted if `pre_prepare` ran first this frame; sorting
1709        // again is cheap and keeps `post_paint` safe to call standalone.
1710        self.gpu_plugins.sort_by_key(|p| p.priority());
1711
1712        let mut out = Vec::new();
1713        for p in self.gpu_plugins.iter_mut() {
1714            out.extend(p.post_paint(device, queue, targets, ctx));
1715        }
1716        out
1717    }
1718
1719    /// Enable fixed-timestep accumulation for physics plugins.
1720    ///
1721    /// When set, plugins in the `[SIMULATE, POST_SIM)` range run once per
1722    /// accumulated fixed step rather than once per frame at wall dt. All other
1723    /// priority ranges always run once per frame.
1724    pub fn with_fixed_timestep(mut self, ts: FixedTimestep) -> Self {
1725        self.fixed_timestep = Some(ts);
1726        self
1727    }
1728
1729    /// Replace the fixed timestep at runtime. Resets the accumulator.
1730    ///
1731    /// Use this to change the simulation rate after construction without
1732    /// rebuilding the runtime or its plugins.
1733    pub fn set_fixed_timestep(&mut self, ts: FixedTimestep) {
1734        self.fixed_timestep = Some(ts);
1735    }
1736
1737    /// Remove the fixed timestep, reverting to one simulate call per frame at wall dt.
1738    pub fn clear_fixed_timestep(&mut self) {
1739        self.fixed_timestep = None;
1740    }
1741
1742    /// The current runtime mode.
1743    pub fn mode(&self) -> SceneRuntimeMode {
1744        self.mode
1745    }
1746
1747    /// The transform snapshot table for interpolated rendering.
1748    ///
1749    /// Updated each frame during writeback. Pass [`alpha`](Self::alpha) as the
1750    /// blend factor to [`TransformSnapshotTable::interpolated`].
1751    pub fn snapshots(&self) -> &TransformSnapshotTable {
1752        &self.snapshots
1753    }
1754
1755    /// Read access to the shared resource registry.
1756    ///
1757    /// Use this after `step` to inspect resources without running the frame loop.
1758    /// During the frame loop, access resources through `RuntimeStepContext::resources`.
1759    pub fn resources(&self) -> &RuntimeResources {
1760        &self.resources
1761    }
1762
1763    /// Write access to the shared resource registry.
1764    ///
1765    /// Use this to pre-populate resources before the first `step`, or to inject
1766    /// resources from outside the plugin system (e.g. from the application).
1767    pub fn resources_mut(&mut self) -> &mut RuntimeResources {
1768        &mut self.resources
1769    }
1770
1771    /// Blend factor for rendering interpolation between fixed steps.
1772    ///
1773    /// Returns `1.0` if no fixed timestep is configured (render directly from
1774    /// current scene transforms without interpolation).
1775    pub fn alpha(&self) -> f32 {
1776        self.fixed_timestep.as_ref().map_or(1.0, |ts| ts.alpha())
1777    }
1778
1779    /// Monotonically increasing simulation step counter.
1780    ///
1781    /// Incremented once per simulate execution. With a fixed timestep this
1782    /// means it may increment multiple times per rendered frame. Wraps on overflow.
1783    pub fn step_index(&self) -> u64 {
1784        self.step_index
1785    }
1786
1787    /// Set a camera follow binding (builder style).
1788    ///
1789    /// Each call to [`step`](Self::step) will compute a suggested camera center
1790    /// from the followed node and emit it as a [`CameraFollowTarget`] event on
1791    /// [`RuntimeOutput::events`].
1792    pub fn with_camera_follow(mut self, follow: CameraFollow) -> Self {
1793        self.camera_follow = Some(follow);
1794        self
1795    }
1796
1797    /// Update the camera follow binding at runtime.
1798    pub fn set_camera_follow(&mut self, follow: CameraFollow) {
1799        self.camera_follow = Some(follow);
1800    }
1801
1802    /// Remove the camera follow binding. No [`CameraFollowTarget`] event
1803    /// will be emitted after this call.
1804    pub fn clear_camera_follow(&mut self) {
1805        self.camera_follow = None;
1806    }
1807
1808    /// The current camera follow binding.
1809    pub fn camera_follow(&self) -> Option<&CameraFollow> {
1810        self.camera_follow.as_ref()
1811    }
1812
1813    /// Run one frame of the runtime.
1814    ///
1815    /// Plugins run in ascending priority order. The simulate range
1816    /// `[SIMULATE, POST_SIM)` runs once per accumulated fixed step (or once at
1817    /// `frame.dt` when no fixed timestep is configured). All other ranges run
1818    /// once per frame. Transform ops are flushed to `scene` and the snapshot
1819    /// table is updated after the writeback range.
1820    ///
1821    /// Selection ops produced by plugins are applied to `selection` before returning.
1822    /// Contact events and the applied transform ops are returned in [`RuntimeOutput`].
1823    pub fn step(
1824        &mut self,
1825        scene: &mut Scene,
1826        selection: &mut Selection,
1827        frame: &RuntimeFrameContext,
1828    ) -> RuntimeOutput {
1829        let mut output = RuntimeOutput::default();
1830        let mut writeback = TransformWriteback::default();
1831
1832        // --- Lifecycle event detection ---------------------------------------
1833        // Collect current node IDs from the scene.
1834        let current_ids: std::collections::HashSet<crate::interaction::selection::NodeId> =
1835            scene.nodes().map(|n| n.id()).collect();
1836
1837        if self.scene_initialized {
1838            // Diff against previous frame and dispatch events.
1839            let mut events: Vec<RuntimeEvent> = Vec::new();
1840            for &id in current_ids.difference(&self.prev_node_ids) {
1841                events.push(RuntimeEvent::NodeAdded(id));
1842            }
1843            for &id in self.prev_node_ids.difference(&current_ids) {
1844                events.push(RuntimeEvent::NodeRemoved(id));
1845            }
1846
1847            if !events.is_empty() {
1848                // Take plugins out to avoid aliasing self while dispatching.
1849                let mut plugins = std::mem::take(&mut self.plugins);
1850                for event in &events {
1851                    for plugin in plugins.iter_mut() {
1852                        let mut ctx = RuntimeStepContext {
1853                            priority: plugin.priority(),
1854                            dt: frame.dt,
1855                            scene,
1856                            writeback: &mut writeback,
1857                            output: &mut output,
1858                            pick_hit: frame.pick_hit,
1859                            resources: &mut self.resources,
1860                        };
1861                        plugin.on_event(event, &mut ctx);
1862                    }
1863                }
1864                self.plugins = plugins;
1865            }
1866        } else {
1867            self.scene_initialized = true;
1868        }
1869        self.prev_node_ids = current_ids;
1870
1871        // --- Sort plugins by priority (stable: preserves registration order within a band).
1872        self.plugins.sort_by_key(|p| p.priority());
1873
1874        // Take plugins out so we can access self freely during the step loop.
1875        let mut plugins = std::mem::take(&mut self.plugins);
1876
1877        // --- Submit pass: all plugins in priority order ----------------------
1878        for plugin in plugins.iter_mut() {
1879            let ctx = RuntimeStepContext {
1880                priority: plugin.priority(),
1881                dt: frame.dt,
1882                scene,
1883                writeback: &mut writeback,
1884                output: &mut output,
1885                pick_hit: frame.pick_hit,
1886                resources: &mut self.resources,
1887            };
1888            plugin.submit(&ctx);
1889        }
1890
1891        // --- Step loop -------------------------------------------------------
1892
1893        // Prepare + Pick: [MIN, SELECT)
1894        run_range(
1895            &mut plugins,
1896            i32::MIN,
1897            phase::SELECT,
1898            frame.dt,
1899            frame,
1900            scene,
1901            &mut writeback,
1902            &mut output,
1903            &mut self.resources,
1904        );
1905
1906        // Built-in selection system runs before the Select range.
1907        if let Some(sel_sys) = &self.selection_system {
1908            sel_sys.step(frame, &mut output);
1909        }
1910        // Select: [SELECT, MANIPULATE)
1911        run_range(
1912            &mut plugins,
1913            phase::SELECT,
1914            phase::MANIPULATE,
1915            frame.dt,
1916            frame,
1917            scene,
1918            &mut writeback,
1919            &mut output,
1920            &mut self.resources,
1921        );
1922
1923        // Built-in manipulation system runs before the Manipulate range.
1924        if self.manipulation_system.is_some() {
1925            let mut manip_sys = self.manipulation_system.take().unwrap();
1926            manip_sys.step(frame, scene, selection, &mut writeback, &mut output);
1927            self.manipulation_system = Some(manip_sys);
1928        }
1929        // Manipulate + Animate: [MANIPULATE, SIMULATE)
1930        run_range(
1931            &mut plugins,
1932            phase::MANIPULATE,
1933            phase::SIMULATE,
1934            frame.dt,
1935            frame,
1936            scene,
1937            &mut writeback,
1938            &mut output,
1939            &mut self.resources,
1940        );
1941
1942        // Simulate: [SIMULATE, POST_SIM) -- once per fixed step or once at wall dt.
1943        if self.fixed_timestep.is_some() {
1944            let mut ts = self.fixed_timestep.take().unwrap();
1945            for step_dt in ts.advance(frame.dt) {
1946                run_range(
1947                    &mut plugins,
1948                    phase::SIMULATE,
1949                    phase::POST_SIM,
1950                    step_dt,
1951                    frame,
1952                    scene,
1953                    &mut writeback,
1954                    &mut output,
1955                    &mut self.resources,
1956                );
1957                self.step_index = self.step_index.wrapping_add(1);
1958            }
1959            self.fixed_timestep = Some(ts);
1960        } else {
1961            run_range(
1962                &mut plugins,
1963                phase::SIMULATE,
1964                phase::POST_SIM,
1965                frame.dt,
1966                frame,
1967                scene,
1968                &mut writeback,
1969                &mut output,
1970                &mut self.resources,
1971            );
1972            self.step_index = self.step_index.wrapping_add(1);
1973        }
1974
1975        // PostSim + Writeback: [POST_SIM, MAX]
1976        run_range(
1977            &mut plugins,
1978            phase::POST_SIM,
1979            i32::MAX,
1980            frame.dt,
1981            frame,
1982            scene,
1983            &mut writeback,
1984            &mut output,
1985            &mut self.resources,
1986        );
1987
1988        // --- Collect pass: all plugins in priority order ---------------------
1989        for plugin in plugins.iter_mut() {
1990            let mut ctx = RuntimeStepContext {
1991                priority: plugin.priority(),
1992                dt: frame.dt,
1993                scene,
1994                writeback: &mut writeback,
1995                output: &mut output,
1996                pick_hit: frame.pick_hit,
1997                resources: &mut self.resources,
1998            };
1999            plugin.collect(&mut ctx);
2000        }
2001
2002        // Restore plugins.
2003        self.plugins = plugins;
2004
2005        // --- Flush writeback -------------------------------------------------
2006        let ops = writeback.into_ops();
2007        for op in &ops {
2008            let new_local = if op.preserve_scale {
2009                // Decompose incoming transform to extract rotation +
2010                // translation, then recompose with the node's existing scale.
2011                // Physics and animation systems don't model scale, so they
2012                // shouldn't clobber a scale the caller set on the node.
2013                let incoming = glam::Mat4::from(op.transform);
2014                let (_, rot, trans) = incoming.to_scale_rotation_translation();
2015                let existing_scale = scene
2016                    .node(op.id)
2017                    .map(|n| n.local_transform().to_scale_rotation_translation().0)
2018                    .unwrap_or(glam::Vec3::ONE);
2019                glam::Mat4::from_scale_rotation_translation(existing_scale, rot, trans)
2020            } else {
2021                glam::Mat4::from(op.transform)
2022            };
2023            scene.set_local_transform(op.id, new_local);
2024            self.snapshots.update(op.id, op.transform);
2025        }
2026        if !ops.is_empty() {
2027            scene.update_transforms();
2028        }
2029        output.node_transform_ops = ops;
2030
2031        // Apply selection ops produced by plugins.
2032        for op in &output.selection_ops {
2033            op.apply_to(selection);
2034        }
2035
2036        // Compute camera follow target.
2037        if let Some(CameraFollow::Node { id, offset, .. }) = &self.camera_follow {
2038            let id = *id;
2039            let offset = *offset;
2040            let alpha = self.alpha();
2041            let pos = self
2042                .snapshots
2043                .interpolated(id, alpha)
2044                .map(|t| glam::Vec3::from(t.translation))
2045                .or_else(|| {
2046                    scene
2047                        .node(id)
2048                        .map(|n| n.world_transform().col(3).truncate())
2049                });
2050            if let Some(pos) = pos {
2051                output
2052                    .events
2053                    .emit(crate::runtime::output::CameraFollowTarget(pos + offset));
2054            }
2055        }
2056
2057        output
2058    }
2059}