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