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