Skip to main content

viewport_lib/scene/
scene.rs

1//! Scene graph with parent-child hierarchy, layers, and dirty-flag transform propagation.
2//!
3//! `Scene` is a standalone struct that apps own alongside `ViewportRenderer`.
4//! It produces `Vec<SceneRenderItem>` via `collect_render_items()`, which feeds
5//! into `SceneFrame::surfaces` (usually via `SceneFrame::from_surface_items(...)`).
6//! The renderer itself remains stateless.
7
8use std::collections::{HashMap, HashSet};
9use std::sync::atomic::{AtomicU64, Ordering};
10
11use crate::interaction::selection::{NodeId, Selection};
12use crate::renderer::{PickId, SceneRenderItem};
13use crate::resources::mesh_store::MeshId;
14use crate::scene::aabb::Aabb;
15use crate::scene::material::Material;
16use crate::scene::spatial_index::SpatialIndex;
17use crate::scene::traits::ViewportObject;
18use crate::{LightKind, LightSource};
19
20/// Node count above which `collect_render_items_culled` uses the octree
21/// spatial index instead of the flat walk.
22const SPATIAL_THRESHOLD: usize = 500;
23
24// ---------------------------------------------------------------------------
25// Layer
26// ---------------------------------------------------------------------------
27
28/// Opaque layer identifier.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
30#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
31pub struct LayerId(pub u32);
32
33/// A named visibility layer. Nodes belong to exactly one layer.
34#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
35pub struct Layer {
36    /// Unique layer identifier.
37    pub id: LayerId,
38    /// Human-readable layer name.
39    pub name: String,
40    /// Whether nodes on this layer are rendered.
41    pub visible: bool,
42    /// When true, nodes on this layer render but cannot appear selected.
43    pub locked: bool,
44    /// Display colour for this layer (RGBA, each component 0.0–1.0).
45    pub colour: [f32; 4],
46    /// Sort order for layer display. Lower values appear first.
47    pub order: u32,
48}
49
50// ---------------------------------------------------------------------------
51// Group
52// ---------------------------------------------------------------------------
53
54/// Opaque group identifier.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
56pub struct GroupId(pub u32);
57
58/// A named group of scene nodes. Membership is independent of parent-child hierarchy.
59pub struct Group {
60    /// Unique group identifier.
61    pub id: GroupId,
62    /// Human-readable group name.
63    pub name: String,
64    /// Set of node IDs belonging to this group.
65    pub members: HashSet<NodeId>,
66}
67
68// ---------------------------------------------------------------------------
69// SceneNode
70// ---------------------------------------------------------------------------
71
72/// A node in the scene graph.
73pub struct SceneNode {
74    id: NodeId,
75    name: String,
76    mesh_id: Option<MeshId>,
77    material: Material,
78    appearance: crate::scene::material::ItemSettings,
79    visible: bool,
80    show_normals: bool,
81    local_transform: glam::Mat4,
82    world_transform: glam::Mat4,
83    parent: Option<NodeId>,
84    children: Vec<NodeId>,
85    layer: LayerId,
86    dirty: bool,
87    /// Per-node deformer instance ID. See
88    /// [`SceneRenderItem::deform_instance`](crate::renderer::SceneRenderItem::deform_instance).
89    deform_instance: Option<u32>,
90    /// Whether projected decals land on this surface. Default: `true`.
91    receives_decals: bool,
92    /// Light source carried by this node. Position and direction are resolved
93    /// from `world_transform` at collect time; the values stored here serve
94    /// as the local-space template.
95    pub light: Option<LightSource>,
96}
97
98impl SceneNode {
99    /// Unique identifier for this node.
100    pub fn id(&self) -> NodeId {
101        self.id
102    }
103
104    /// Display name of this node.
105    pub fn name(&self) -> &str {
106        &self.name
107    }
108
109    /// GPU mesh associated with this node, or `None` if no mesh has been uploaded.
110    pub fn mesh_id(&self) -> Option<MeshId> {
111        self.mesh_id
112    }
113
114    /// Material parameters (colour, shading, texture) for this node.
115    pub fn material(&self) -> &Material {
116        &self.material
117    }
118
119    /// Per-node appearance overrides (hidden, unlit, opacity, wireframe).
120    pub fn appearance(&self) -> &crate::scene::material::ItemSettings {
121        &self.appearance
122    }
123
124    /// Whether this node is visible.
125    pub fn is_visible(&self) -> bool {
126        self.visible
127    }
128
129    /// Whether per-vertex normals are rendered for this node.
130    pub fn show_normals(&self) -> bool {
131        self.show_normals
132    }
133
134    /// Local transform relative to this node's parent (or world if no parent).
135    pub fn local_transform(&self) -> glam::Mat4 {
136        self.local_transform
137    }
138
139    /// World-space transform. Updated by `Scene::update_transforms()`.
140    pub fn world_transform(&self) -> glam::Mat4 {
141        self.world_transform
142    }
143
144    /// Parent node ID, or `None` if this is a root node.
145    pub fn parent(&self) -> Option<NodeId> {
146        self.parent
147    }
148
149    /// IDs of this node's direct children.
150    pub fn children(&self) -> &[NodeId] {
151        &self.children
152    }
153
154    /// Layer this node belongs to.
155    pub fn layer(&self) -> LayerId {
156        self.layer
157    }
158
159    /// Whether projected decals land on this surface.
160    pub fn receives_decals(&self) -> bool {
161        self.receives_decals
162    }
163
164    /// Set whether projected decals land on this surface.
165    pub fn set_receives_decals(&mut self, v: bool) {
166        self.receives_decals = v;
167    }
168}
169
170impl ViewportObject for SceneNode {
171    fn id(&self) -> u64 {
172        self.id
173    }
174
175    fn mesh_id(&self) -> Option<u64> {
176        self.mesh_id.map(|m| m.index() as u64)
177    }
178
179    fn model_matrix(&self) -> glam::Mat4 {
180        self.world_transform
181    }
182
183    fn position(&self) -> glam::Vec3 {
184        self.world_transform.col(3).truncate()
185    }
186
187    fn rotation(&self) -> glam::Quat {
188        let (_scale, rotation, _translation) = self.world_transform.to_scale_rotation_translation();
189        rotation
190    }
191
192    fn scale(&self) -> glam::Vec3 {
193        let (scale, _rotation, _translation) = self.world_transform.to_scale_rotation_translation();
194        scale
195    }
196
197    fn is_visible(&self) -> bool {
198        self.visible
199    }
200
201    fn colour(&self) -> glam::Vec3 {
202        glam::Vec3::from(self.material.base_colour)
203    }
204
205    fn show_normals(&self) -> bool {
206        self.show_normals
207    }
208
209    fn material(&self) -> Material {
210        self.material
211    }
212}
213
214// ---------------------------------------------------------------------------
215// SceneStats
216// ---------------------------------------------------------------------------
217
218/// Per-frame diagnostics from `collect_render_items_culled`.
219#[derive(Debug, Clone, Copy, Default)]
220pub struct SceneStats {
221    /// Number of nodes currently held in the spatial index.
222    /// Zero when the flat walk is active (scene below the threshold).
223    pub spatial_index_nodes: u32,
224    /// Time spent in the frustum cull traversal, in milliseconds.
225    pub frustum_cull_traversal_ms: f32,
226}
227
228// ---------------------------------------------------------------------------
229// Scene
230// ---------------------------------------------------------------------------
231
232/// Resolve a light source's geometric parameters into world space.
233///
234/// - Directional: the stored direction is rotated by the node's world rotation.
235/// - Point: position comes from the world translation; stored position is ignored.
236/// - Spot: position from world translation, direction rotated by world rotation.
237fn resolve_light_to_world(src: &LightSource, world: glam::Mat4) -> LightSource {
238    let translation = world.col(3).truncate();
239    let kind = match &src.kind {
240        LightKind::Directional { direction } => {
241            let rotated = world
242                .transform_vector3(glam::Vec3::from(*direction))
243                .normalize();
244            LightKind::Directional {
245                direction: rotated.into(),
246            }
247        }
248        LightKind::Point { range, .. } => LightKind::Point {
249            position: translation.into(),
250            range: *range,
251        },
252        LightKind::Spot {
253            direction,
254            range,
255            inner_angle,
256            outer_angle,
257            ..
258        } => {
259            let rotated = world
260                .transform_vector3(glam::Vec3::from(*direction))
261                .normalize();
262            LightKind::Spot {
263                position: translation.into(),
264                direction: rotated.into(),
265                range: *range,
266                inner_angle: *inner_angle,
267                outer_angle: *outer_angle,
268            }
269        }
270    };
271    LightSource {
272        kind,
273        colour: src.colour,
274        intensity: src.intensity,
275        importance: src.importance,
276    }
277}
278
279/// Default layer ID (always exists, cannot be removed).
280const DEFAULT_LAYER: LayerId = LayerId(0);
281
282/// A scene graph managing nodes with parent-child hierarchy and layers.
283pub struct Scene {
284    nodes: HashMap<NodeId, SceneNode>,
285    roots: Vec<NodeId>,
286    layers: Vec<Layer>,
287    next_id: u64,
288    next_layer_id: u32,
289    groups: Vec<Group>,
290    next_group_id: u32,
291    /// Monotonically increasing generation counter. Incremented on every mutation.
292    /// Callers can compare against a cached value to detect changes without hashing.
293    version: u64,
294    spatial: SpatialIndex,
295    /// True after the first full octree build. Incremental updates apply from here.
296    spatial_built: bool,
297    last_scene_stats: SceneStats,
298    // D4: live decals with lifetime / animation.
299    live_decals: Vec<LiveDecal>,
300    next_decal_id: u64,
301}
302
303/// Global monotonic clock for scene versions.
304///
305/// Each `Scene::new()` draws an initial offset from this counter so that two
306/// distinct scenes can never share the same `version()` value, even when they
307/// have been mutated the same number of times.  The renderer's batch cache
308/// therefore correctly invalidates when the active scene changes.
309static SCENE_VERSION_CLOCK: AtomicU64 = AtomicU64::new(0);
310
311impl Scene {
312    /// Create an empty scene with a default layer.
313    pub fn new() -> Self {
314        // Reserve a block of 2^20 (~1 M) version slots per scene so that
315        // wrapping_add(1) mutations stay within this scene's unique range.
316        let base = SCENE_VERSION_CLOCK.fetch_add(1 << 20, Ordering::Relaxed);
317        Self {
318            nodes: HashMap::new(),
319            roots: Vec::new(),
320            layers: vec![Layer {
321                id: DEFAULT_LAYER,
322                name: "Default".to_string(),
323                visible: true,
324                locked: false,
325                colour: [1.0, 1.0, 1.0, 1.0],
326                order: 0,
327            }],
328            next_id: 1,
329            next_layer_id: 1,
330            groups: Vec::new(),
331            next_group_id: 0,
332            version: base,
333            spatial: SpatialIndex::new(),
334            spatial_built: false,
335            last_scene_stats: SceneStats::default(),
336            live_decals: Vec::new(),
337            next_decal_id: 0,
338        }
339    }
340
341    /// Monotonically increasing generation counter.
342    ///
343    /// Incremented by `wrapping_add(1)` on every mutation. Compare against a
344    /// cached value to detect scene changes without hashing instance data.
345    pub fn version(&self) -> u64 {
346        self.version
347    }
348
349    // -- Node lifecycle --
350
351    /// Add a node with a mesh, transform, and material. Returns the new node's ID.
352    pub fn add(
353        &mut self,
354        mesh_id: Option<MeshId>,
355        transform: glam::Mat4,
356        material: Material,
357    ) -> NodeId {
358        self.add_named("", mesh_id, transform, material)
359    }
360
361    /// Add a named node. Returns the new node's ID.
362    pub fn add_named(
363        &mut self,
364        name: &str,
365        mesh_id: Option<MeshId>,
366        transform: glam::Mat4,
367        material: Material,
368    ) -> NodeId {
369        let id = self.next_id;
370        self.next_id += 1;
371        let node = SceneNode {
372            id,
373            name: name.to_string(),
374            mesh_id,
375            material,
376            appearance: crate::scene::material::ItemSettings::default(),
377            visible: true,
378            show_normals: false,
379            local_transform: transform,
380            world_transform: transform,
381            parent: None,
382            children: Vec::new(),
383            layer: DEFAULT_LAYER,
384            dirty: true,
385            deform_instance: None,
386            receives_decals: true,
387            light: None,
388        };
389        self.nodes.insert(id, node);
390        self.roots.push(id);
391        self.version = self.version.wrapping_add(1);
392        if self.spatial_built {
393            self.spatial.mark_dirty(id);
394        }
395        id
396    }
397
398    /// Remove a node and all its descendants. Returns all removed IDs
399    /// (caller can use these to release mesh references).
400    pub fn remove(&mut self, id: NodeId) -> Vec<NodeId> {
401        let mut removed = Vec::new();
402        self.remove_recursive(id, &mut removed);
403
404        // Remove from parent's children list or from roots.
405        if let Some(parent_id) = self.nodes.get(&id).and_then(|n| n.parent) {
406            if let Some(parent) = self.nodes.get_mut(&parent_id) {
407                parent.children.retain(|c| *c != id);
408            }
409        } else {
410            self.roots.retain(|r| *r != id);
411        }
412
413        // Remove from spatial index before nodes are gone.
414        if self.spatial_built {
415            for &rid in &removed {
416                self.spatial.remove_node(rid);
417            }
418        }
419
420        // Actually remove nodes.
421        for &rid in &removed {
422            self.nodes.remove(&rid);
423        }
424        // Also remove from roots any descendant that might have been listed.
425        self.roots.retain(|r| !removed.contains(r));
426
427        // Remove removed nodes from all groups.
428        for group in &mut self.groups {
429            for &rid in &removed {
430                group.members.remove(&rid);
431            }
432        }
433
434        self.version = self.version.wrapping_add(1);
435        removed
436    }
437
438    fn remove_recursive(&self, id: NodeId, out: &mut Vec<NodeId>) {
439        out.push(id);
440        if let Some(node) = self.nodes.get(&id) {
441            for &child in &node.children {
442                self.remove_recursive(child, out);
443            }
444        }
445    }
446
447    // -- Hierarchy --
448
449    /// Reparent a node. `None` makes it a root node.
450    pub fn set_parent(&mut self, child_id: NodeId, new_parent: Option<NodeId>) {
451        // Remove from current parent or roots.
452        let old_parent = self.nodes.get(&child_id).and_then(|n| n.parent);
453        if let Some(old_pid) = old_parent {
454            if let Some(old_p) = self.nodes.get_mut(&old_pid) {
455                old_p.children.retain(|c| *c != child_id);
456            }
457        } else {
458            self.roots.retain(|r| *r != child_id);
459        }
460
461        // Add to new parent or roots.
462        if let Some(new_pid) = new_parent {
463            if let Some(new_p) = self.nodes.get_mut(&new_pid) {
464                new_p.children.push(child_id);
465            }
466        } else {
467            self.roots.push(child_id);
468        }
469
470        if let Some(node) = self.nodes.get_mut(&child_id) {
471            node.parent = new_parent;
472            node.dirty = true;
473        }
474        self.version = self.version.wrapping_add(1);
475        if self.spatial_built {
476            self.mark_spatial_dirty_subtree(child_id);
477        }
478    }
479
480    /// Children of a node.
481    pub fn children(&self, id: NodeId) -> &[NodeId] {
482        self.nodes
483            .get(&id)
484            .map(|n| n.children.as_slice())
485            .unwrap_or(&[])
486    }
487
488    /// Parent of a node.
489    pub fn parent(&self, id: NodeId) -> Option<NodeId> {
490        self.nodes.get(&id).and_then(|n| n.parent)
491    }
492
493    /// Root nodes.
494    pub fn roots(&self) -> &[NodeId] {
495        &self.roots
496    }
497
498    // -- Properties --
499
500    /// Set the local transform of a node, marking it and its descendants dirty.
501    pub fn set_local_transform(&mut self, id: NodeId, transform: glam::Mat4) {
502        if let Some(node) = self.nodes.get_mut(&id) {
503            node.local_transform = transform;
504            node.dirty = true;
505        }
506        self.mark_descendants_dirty(id);
507        self.version = self.version.wrapping_add(1);
508        if self.spatial_built {
509            self.mark_spatial_dirty_subtree(id);
510        }
511    }
512
513    /// Set node visibility.
514    pub fn set_visible(&mut self, id: NodeId, visible: bool) {
515        if let Some(node) = self.nodes.get_mut(&id) {
516            node.visible = visible;
517        }
518        self.version = self.version.wrapping_add(1);
519        if self.spatial_built {
520            self.spatial.mark_dirty(id);
521        }
522    }
523
524    /// Set node material.
525    pub fn set_material(&mut self, id: NodeId, material: Material) {
526        if let Some(node) = self.nodes.get_mut(&id) {
527            node.material = material;
528        }
529        self.version = self.version.wrapping_add(1);
530    }
531
532    /// Set node appearance overrides.
533    pub fn set_appearance(&mut self, id: NodeId, appearance: crate::scene::material::ItemSettings) {
534        if let Some(node) = self.nodes.get_mut(&id) {
535            node.appearance = appearance;
536        }
537        self.version = self.version.wrapping_add(1);
538    }
539
540    /// Set node mesh.
541    pub fn set_mesh(&mut self, id: NodeId, mesh_id: Option<MeshId>) {
542        if let Some(node) = self.nodes.get_mut(&id) {
543            node.mesh_id = mesh_id;
544        }
545        self.version = self.version.wrapping_add(1);
546        if self.spatial_built {
547            self.spatial.mark_dirty(id);
548        }
549    }
550
551    /// Set node name.
552    pub fn set_name(&mut self, id: NodeId, name: &str) {
553        if let Some(node) = self.nodes.get_mut(&id) {
554            node.name = name.to_string();
555        }
556        self.version = self.version.wrapping_add(1);
557    }
558
559    /// Set the per-instance deformer ID for a node.
560    ///
561    /// Addresses per-(mesh, instance) deformer data such as a GPU skinning
562    /// joint palette. `None` treats the node as having no per-instance
563    /// deformer data and uses the per-mesh slot only.
564    pub fn set_deform_instance(&mut self, id: NodeId, instance: Option<u32>) {
565        if let Some(node) = self.nodes.get_mut(&id) {
566            node.deform_instance = instance;
567        }
568        self.version = self.version.wrapping_add(1);
569    }
570
571    /// Set whether projected decals land on this node's surface.
572    pub fn set_receives_decals(&mut self, id: NodeId, v: bool) {
573        if let Some(node) = self.nodes.get_mut(&id) {
574            node.receives_decals = v;
575        }
576        self.version = self.version.wrapping_add(1);
577    }
578
579    /// Set whether to show normals.
580    pub fn set_show_normals(&mut self, id: NodeId, show: bool) {
581        if let Some(node) = self.nodes.get_mut(&id) {
582            node.show_normals = show;
583        }
584        self.version = self.version.wrapping_add(1);
585    }
586
587    /// Set the layer of a node.
588    pub fn set_layer(&mut self, id: NodeId, layer: LayerId) {
589        if let Some(node) = self.nodes.get_mut(&id) {
590            node.layer = layer;
591        }
592        self.version = self.version.wrapping_add(1);
593    }
594
595    /// Get a reference to a node.
596    pub fn node(&self, id: NodeId) -> Option<&SceneNode> {
597        self.nodes.get(&id)
598    }
599
600    /// Number of nodes in the scene.
601    pub fn node_count(&self) -> usize {
602        self.nodes.len()
603    }
604
605    /// Iterate over all nodes.
606    pub fn nodes(&self) -> impl Iterator<Item = &SceneNode> {
607        self.nodes.values()
608    }
609
610    // -- Layers --
611
612    /// Add a new layer, returning its ID.
613    pub fn add_layer(&mut self, name: &str) -> LayerId {
614        let id = LayerId(self.next_layer_id);
615        let order = self.next_layer_id;
616        self.next_layer_id += 1;
617        self.layers.push(Layer {
618            id,
619            name: name.to_string(),
620            visible: true,
621            locked: false,
622            colour: [1.0, 1.0, 1.0, 1.0],
623            order,
624        });
625        self.version = self.version.wrapping_add(1);
626        id
627    }
628
629    /// Remove a layer, moving all its nodes to the default layer.
630    /// Cannot remove the default layer (LayerId(0)).
631    pub fn remove_layer(&mut self, id: LayerId) {
632        if id == DEFAULT_LAYER {
633            return;
634        }
635        // Move nodes to default layer.
636        for node in self.nodes.values_mut() {
637            if node.layer == id {
638                node.layer = DEFAULT_LAYER;
639            }
640        }
641        self.layers.retain(|l| l.id != id);
642        self.version = self.version.wrapping_add(1);
643    }
644
645    /// Set layer visibility.
646    pub fn set_layer_visible(&mut self, id: LayerId, visible: bool) {
647        if let Some(layer) = self.layers.iter_mut().find(|l| l.id == id) {
648            layer.visible = visible;
649        }
650        self.version = self.version.wrapping_add(1);
651    }
652
653    /// Set layer locked state. Locked layers render but nodes cannot appear selected.
654    pub fn set_layer_locked(&mut self, id: LayerId, locked: bool) {
655        if let Some(layer) = self.layers.iter_mut().find(|l| l.id == id) {
656            layer.locked = locked;
657        }
658        self.version = self.version.wrapping_add(1);
659    }
660
661    /// Set layer display colour.
662    pub fn set_layer_colour(&mut self, id: LayerId, colour: [f32; 4]) {
663        if let Some(layer) = self.layers.iter_mut().find(|l| l.id == id) {
664            layer.colour = colour;
665        }
666        self.version = self.version.wrapping_add(1);
667    }
668
669    /// Set layer sort order.
670    pub fn set_layer_order(&mut self, id: LayerId, order: u32) {
671        if let Some(layer) = self.layers.iter_mut().find(|l| l.id == id) {
672            layer.order = order;
673        }
674        self.version = self.version.wrapping_add(1);
675    }
676
677    /// Whether a layer is currently locked.
678    pub fn is_layer_locked(&self, id: LayerId) -> bool {
679        self.layers
680            .iter()
681            .find(|l| l.id == id)
682            .map(|l| l.locked)
683            .unwrap_or(false)
684    }
685
686    /// All layers, sorted by their `order` field (ascending).
687    pub fn layers(&self) -> Vec<&Layer> {
688        let mut sorted: Vec<&Layer> = self.layers.iter().collect();
689        sorted.sort_by_key(|l| l.order);
690        sorted
691    }
692
693    // -- Groups --
694
695    /// Create a new named group, returning its ID.
696    pub fn create_group(&mut self, name: &str) -> GroupId {
697        let id = GroupId(self.next_group_id);
698        self.next_group_id += 1;
699        self.groups.push(Group {
700            id,
701            name: name.to_string(),
702            members: HashSet::new(),
703        });
704        self.version = self.version.wrapping_add(1);
705        id
706    }
707
708    /// Remove a group by ID. Does not affect its member nodes.
709    pub fn remove_group(&mut self, id: GroupId) {
710        self.groups.retain(|g| g.id != id);
711        self.version = self.version.wrapping_add(1);
712    }
713
714    /// Add a node to a group.
715    pub fn add_to_group(&mut self, node: NodeId, group: GroupId) {
716        if let Some(g) = self.groups.iter_mut().find(|g| g.id == group) {
717            g.members.insert(node);
718        }
719        self.version = self.version.wrapping_add(1);
720    }
721
722    /// Remove a node from a group.
723    pub fn remove_from_group(&mut self, node: NodeId, group: GroupId) {
724        if let Some(g) = self.groups.iter_mut().find(|g| g.id == group) {
725            g.members.remove(&node);
726        }
727        self.version = self.version.wrapping_add(1);
728    }
729
730    /// Get a group by ID.
731    pub fn get_group(&self, id: GroupId) -> Option<&Group> {
732        self.groups.iter().find(|g| g.id == id)
733    }
734
735    /// All groups in the scene.
736    pub fn groups(&self) -> &[Group] {
737        &self.groups
738    }
739
740    /// Which groups contain the given node.
741    pub fn node_groups(&self, node: NodeId) -> Vec<GroupId> {
742        self.groups
743            .iter()
744            .filter(|g| g.members.contains(&node))
745            .map(|g| g.id)
746            .collect()
747    }
748
749    // -- Transform propagation --
750
751    /// Recompute world transforms for all dirty nodes (BFS from roots).
752    pub fn update_transforms(&mut self) {
753        // We need to iterate roots and process the tree. Since we can't borrow
754        // self mutably while iterating, collect the root list first.
755        let roots: Vec<NodeId> = self.roots.clone();
756        for &root_id in &roots {
757            self.propagate_transform(root_id, glam::Mat4::IDENTITY);
758        }
759    }
760
761    fn propagate_transform(&mut self, id: NodeId, parent_world: glam::Mat4) {
762        let (dirty, local, children) = {
763            let Some(node) = self.nodes.get(&id) else {
764                return;
765            };
766            (node.dirty, node.local_transform, node.children.clone())
767        };
768
769        if dirty {
770            let world = parent_world * local;
771            let node = self.nodes.get_mut(&id).unwrap();
772            node.world_transform = world;
773            node.dirty = false;
774            // All children must recompute.
775            for &child_id in &children {
776                self.mark_dirty(child_id);
777                self.propagate_transform(child_id, world);
778            }
779        } else {
780            let world = self.nodes[&id].world_transform;
781            for &child_id in &children {
782                self.propagate_transform(child_id, world);
783            }
784        }
785    }
786
787    fn mark_dirty(&mut self, id: NodeId) {
788        if let Some(node) = self.nodes.get_mut(&id) {
789            node.dirty = true;
790        }
791    }
792
793    fn mark_descendants_dirty(&mut self, id: NodeId) {
794        let children = self
795            .nodes
796            .get(&id)
797            .map(|n| n.children.clone())
798            .unwrap_or_default();
799        for child_id in children {
800            self.mark_dirty(child_id);
801            self.mark_descendants_dirty(child_id);
802        }
803    }
804
805    /// Mark `id` and all of its descendants dirty in the spatial index.
806    fn mark_spatial_dirty_subtree(&mut self, id: NodeId) {
807        self.spatial.mark_dirty(id);
808        let children = self
809            .nodes
810            .get(&id)
811            .map(|n| n.children.clone())
812            .unwrap_or_default();
813        for child_id in children {
814            self.mark_spatial_dirty_subtree(child_id);
815        }
816    }
817
818    /// Build the spatial index from scratch using all currently visible nodes
819    /// that have a mesh. World transforms must be up to date before calling.
820    fn build_spatial_index_from(&mut self, mesh_aabb_fn: &impl Fn(MeshId) -> Option<Aabb>) {
821        let entries: Vec<(NodeId, Aabb)> = self
822            .nodes
823            .values()
824            .filter(|n| n.visible && n.mesh_id.is_some())
825            .filter_map(|n| {
826                let local_aabb = mesh_aabb_fn(n.mesh_id.unwrap())?;
827                let world_aabb = local_aabb.transformed(&n.world_transform);
828                Some((n.id, world_aabb))
829            })
830            .collect();
831        self.spatial.rebuild(&entries);
832        self.spatial_built = true;
833    }
834
835    /// Process all pending dirty entries in the spatial index.
836    /// World transforms must be up to date before calling.
837    fn flush_spatial_dirty(&mut self, mesh_aabb_fn: &impl Fn(MeshId) -> Option<Aabb>) {
838        let dirty = self.spatial.drain_dirty();
839        for id in dirty {
840            self.spatial.remove_entry(id);
841            let new_aabb = self.nodes.get(&id).and_then(|node| {
842                if !node.visible {
843                    return None;
844                }
845                let mesh_id = node.mesh_id?;
846                let local_aabb = mesh_aabb_fn(mesh_id)?;
847                Some(local_aabb.transformed(&node.world_transform))
848            });
849            if let Some(aabb) = new_aabb {
850                self.spatial.insert_entry(id, aabb);
851            }
852        }
853    }
854
855    // -- Spatial index public API --
856
857    /// Rebuild the spatial index from scratch.
858    ///
859    /// Call this after bulk scene construction (level load, import) to avoid
860    /// accumulating many incremental dirty updates. The index is maintained
861    /// automatically from this point on.
862    pub fn rebuild_spatial_index(&mut self, mesh_aabb_fn: impl Fn(MeshId) -> Option<Aabb>) {
863        self.update_transforms();
864        self.build_spatial_index_from(&mesh_aabb_fn);
865    }
866
867    /// Diagnostic statistics from the most recent `collect_render_items_culled` call.
868    pub fn scene_stats(&self) -> SceneStats {
869        self.last_scene_stats
870    }
871
872    // -- Render collection --
873
874    /// Update transforms and collect render items for all visible nodes.
875    ///
876    /// Skips nodes that are invisible, on an invisible layer, or have no mesh.
877    /// Marks selected nodes based on the provided `Selection`.
878    pub fn collect_render_items(&mut self, selection: &Selection) -> Vec<SceneRenderItem> {
879        self.update_transforms();
880
881        let layer_visible: HashMap<LayerId, bool> =
882            self.layers.iter().map(|l| (l.id, l.visible)).collect();
883
884        let layer_locked: HashMap<LayerId, bool> =
885            self.layers.iter().map(|l| (l.id, l.locked)).collect();
886
887        let mut items = Vec::new();
888        for node in self.nodes.values() {
889            if !node.visible {
890                continue;
891            }
892            if !layer_visible.get(&node.layer).copied().unwrap_or(true) {
893                continue;
894            }
895            let Some(mesh_id) = node.mesh_id else {
896                continue;
897            };
898            let locked = layer_locked.get(&node.layer).copied().unwrap_or(false);
899            let mut item_settings = node.appearance;
900            item_settings.pick_id = PickId(node.id);
901            item_settings.selected = if locked {
902                false
903            } else {
904                selection.contains(node.id)
905            };
906            items.push(SceneRenderItem {
907                mesh_id,
908                model: node.world_transform.to_cols_array_2d(),
909                settings: item_settings,
910                show_normals: node.show_normals,
911                material: node.material,
912                active_attribute: None,
913                scalar_range: None,
914                colourmap_id: None,
915                nan_colour: None,
916                warp_attribute: None,
917                warp_scale: 1.0,
918                deform_instance: node.deform_instance,
919                receives_decals: node.receives_decals,
920                lic: None,
921            });
922        }
923        items
924    }
925
926    /// Update transforms and collect render items, culling objects outside the frustum.
927    ///
928    /// Like `collect_render_items`, but skips objects whose world-space AABB is
929    /// entirely outside the given frustum. `mesh_aabb_fn` should return the
930    /// local-space AABB for a given `MeshId` (typically read from `GpuMesh::aabb`).
931    ///
932    /// When the scene has at least 500 nodes, a loose octree spatial index is
933    /// used to prune subtrees that are entirely outside the frustum. Below that
934    /// threshold the existing flat walk is used.
935    pub fn collect_render_items_culled(
936        &mut self,
937        selection: &Selection,
938        frustum: &crate::camera::frustum::Frustum,
939        mesh_aabb_fn: impl Fn(MeshId) -> Option<Aabb>,
940    ) -> (Vec<SceneRenderItem>, crate::camera::frustum::CullStats) {
941        self.update_transforms();
942
943        let layer_visible: HashMap<LayerId, bool> =
944            self.layers.iter().map(|l| (l.id, l.visible)).collect();
945        let layer_locked: HashMap<LayerId, bool> =
946            self.layers.iter().map(|l| (l.id, l.locked)).collect();
947
948        let mut items = Vec::new();
949        let mut stats = crate::camera::frustum::CullStats::default();
950
951        if self.nodes.len() >= SPATIAL_THRESHOLD {
952            // -- Octree path --
953            if !self.spatial_built {
954                self.build_spatial_index_from(&mesh_aabb_fn);
955            } else {
956                self.flush_spatial_dirty(&mesh_aabb_fn);
957            }
958
959            let t0 = std::time::Instant::now();
960            let mut candidate_ids = Vec::new();
961            self.spatial
962                .collect_visible(frustum, &mut candidate_ids, &mut stats);
963            let traversal_ms = t0.elapsed().as_secs_f32() * 1000.0;
964
965            for id in candidate_ids {
966                let Some(node) = self.nodes.get(&id) else {
967                    continue;
968                };
969                if !node.visible {
970                    continue;
971                }
972                if !layer_visible.get(&node.layer).copied().unwrap_or(true) {
973                    continue;
974                }
975                let Some(mesh_id) = node.mesh_id else {
976                    continue;
977                };
978                let locked = layer_locked.get(&node.layer).copied().unwrap_or(false);
979                let mut item_settings = node.appearance;
980                item_settings.pick_id = PickId(node.id);
981                item_settings.selected = if locked {
982                    false
983                } else {
984                    selection.contains(node.id)
985                };
986                items.push(SceneRenderItem {
987                    mesh_id,
988                    model: node.world_transform.to_cols_array_2d(),
989                    settings: item_settings,
990                    show_normals: node.show_normals,
991                    material: node.material,
992                    active_attribute: None,
993                    scalar_range: None,
994                    colourmap_id: None,
995                    nan_colour: None,
996                    warp_attribute: None,
997                    warp_scale: 1.0,
998                    deform_instance: node.deform_instance,
999                    receives_decals: node.receives_decals,
1000                    lic: None,
1001                });
1002            }
1003
1004            self.last_scene_stats = SceneStats {
1005                spatial_index_nodes: self.spatial.node_count(),
1006                frustum_cull_traversal_ms: traversal_ms,
1007            };
1008        } else {
1009            // -- Flat walk --
1010            let t0 = std::time::Instant::now();
1011
1012            for node in self.nodes.values() {
1013                if !node.visible {
1014                    continue;
1015                }
1016                if !layer_visible.get(&node.layer).copied().unwrap_or(true) {
1017                    continue;
1018                }
1019                let Some(mesh_id) = node.mesh_id else {
1020                    continue;
1021                };
1022
1023                stats.total += 1;
1024
1025                // Frustum cull using world-space AABB.
1026                if let Some(local_aabb) = mesh_aabb_fn(mesh_id) {
1027                    let world_aabb = local_aabb.transformed(&node.world_transform);
1028                    if frustum.cull_aabb(&world_aabb) {
1029                        stats.culled += 1;
1030                        continue;
1031                    }
1032                }
1033
1034                let locked = layer_locked.get(&node.layer).copied().unwrap_or(false);
1035                stats.visible += 1;
1036                let mut item_settings = node.appearance;
1037                item_settings.pick_id = PickId(node.id);
1038                item_settings.selected = if locked {
1039                    false
1040                } else {
1041                    selection.contains(node.id)
1042                };
1043                items.push(SceneRenderItem {
1044                    mesh_id,
1045                    model: node.world_transform.to_cols_array_2d(),
1046                    settings: item_settings,
1047                    show_normals: node.show_normals,
1048                    material: node.material,
1049                    active_attribute: None,
1050                    scalar_range: None,
1051                    colourmap_id: None,
1052                    nan_colour: None,
1053                    warp_attribute: None,
1054                    warp_scale: 1.0,
1055                    deform_instance: node.deform_instance,
1056                    receives_decals: node.receives_decals,
1057                    lic: None,
1058                });
1059            }
1060
1061            self.last_scene_stats = SceneStats {
1062                spatial_index_nodes: 0,
1063                frustum_cull_traversal_ms: t0.elapsed().as_secs_f32() * 1000.0,
1064            };
1065        }
1066
1067        (items, stats)
1068    }
1069
1070    // -- Lights --
1071
1072    /// Add a light source to the scene graph. Returns the new node's ID.
1073    ///
1074    /// The node has no mesh. Set its transform with [`Scene::set_local_transform`]
1075    /// to position and orient the light. For directional lights, rotation drives
1076    /// the shading direction; for point and spot lights, translation drives position.
1077    ///
1078    /// Pass the result of [`Scene::collect_lights`] into `SceneFrame::lights` each
1079    /// frame so the renderer unions these lights with `EffectsFrame::lighting.lights`.
1080    pub fn add_light(&mut self, light: LightSource) -> NodeId {
1081        let id = self.next_id;
1082        self.next_id += 1;
1083        let node = SceneNode {
1084            id,
1085            name: String::new(),
1086            mesh_id: None,
1087            material: crate::scene::material::Material::default(),
1088            appearance: crate::scene::material::ItemSettings::default(),
1089            visible: true,
1090            show_normals: false,
1091            local_transform: glam::Mat4::IDENTITY,
1092            world_transform: glam::Mat4::IDENTITY,
1093            parent: None,
1094            children: Vec::new(),
1095            layer: DEFAULT_LAYER,
1096            dirty: true,
1097            deform_instance: None,
1098            receives_decals: false,
1099            light: Some(light),
1100        };
1101        self.nodes.insert(id, node);
1102        self.roots.push(id);
1103        self.version = self.version.wrapping_add(1);
1104        id
1105    }
1106
1107    /// Collect world-space light sources from all visible nodes on visible layers.
1108    ///
1109    /// Position and direction values are resolved from each node's world transform:
1110    /// directional lights rotate their direction, point lights take their position
1111    /// from the world translation, and spot lights take both.
1112    ///
1113    /// Push the returned vec into `SceneFrame::lights` each frame. The renderer
1114    /// unions it with `EffectsFrame::lighting.lights` before building the GPU
1115    /// light uniform.
1116    pub fn collect_lights(&mut self) -> Vec<LightSource> {
1117        self.update_transforms();
1118
1119        let layer_visible: HashMap<LayerId, bool> =
1120            self.layers.iter().map(|l| (l.id, l.visible)).collect();
1121
1122        let mut out = Vec::new();
1123        for node in self.nodes.values() {
1124            if !node.visible {
1125                continue;
1126            }
1127            if !layer_visible.get(&node.layer).copied().unwrap_or(true) {
1128                continue;
1129            }
1130            let Some(src) = &node.light else {
1131                continue;
1132            };
1133            out.push(resolve_light_to_world(src, node.world_transform));
1134        }
1135        out
1136    }
1137
1138    /// Replace the light source on a node. Pass `None` to remove the light.
1139    pub fn set_light(&mut self, id: NodeId, light: Option<LightSource>) {
1140        if let Some(node) = self.nodes.get_mut(&id) {
1141            node.light = light;
1142        }
1143        self.version = self.version.wrapping_add(1);
1144    }
1145
1146    // -- Tree walking --
1147
1148    // -- Mesh ref counting --
1149
1150    /// Count how many scene nodes reference the given mesh.
1151    ///
1152    /// O(n) over all nodes. Useful for deciding when to free a GPU mesh.
1153    pub fn mesh_ref_count(&self, mesh_id: MeshId) -> usize {
1154        self.nodes
1155            .values()
1156            .filter(|n| n.mesh_id == Some(mesh_id))
1157            .count()
1158    }
1159
1160    // -- Tree walking --
1161
1162    /// Depth-first traversal of the scene tree. Returns `(NodeId, depth)` pairs.
1163    pub fn walk_depth_first(&self) -> Vec<(NodeId, usize)> {
1164        let mut result = Vec::new();
1165        for &root_id in &self.roots {
1166            self.walk_recursive(root_id, 0, &mut result);
1167        }
1168        result
1169    }
1170
1171    fn walk_recursive(&self, id: NodeId, depth: usize, out: &mut Vec<(NodeId, usize)>) {
1172        out.push((id, depth));
1173        if let Some(node) = self.nodes.get(&id) {
1174            for &child_id in &node.children {
1175                self.walk_recursive(child_id, depth + 1, out);
1176            }
1177        }
1178    }
1179}
1180
1181impl Default for Scene {
1182    fn default() -> Self {
1183        Self::new()
1184    }
1185}
1186
1187// ---------------------------------------------------------------------------
1188// D4: Live decals with lifetime and animation.
1189// ---------------------------------------------------------------------------
1190
1191/// Opaque handle returned by [`Scene::add_decal`].
1192///
1193/// Pass to [`Scene::remove_decal`] to delete the decal before it expires.
1194#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1195pub struct DecalHandle(u64);
1196
1197/// A persistent decal managed by the scene, with optional lifetime and animation.
1198///
1199/// Created via [`Scene::add_decal`] or [`Scene::add_decal_with_lifetime`].
1200/// Call [`Scene::update_decals`] once per frame with the frame delta-time to
1201/// advance ages and expire finished decals. Call [`Scene::collect_decal_items`]
1202/// to get the current [`DecalItem`] list ready to push into `fd.scene.decals`.
1203pub struct LiveDecal {
1204    id: u64,
1205    /// The base decal parameters. `uv_offset` and `uv_scale` are recomputed
1206    /// from `animation` each frame; all other fields are used as-is.
1207    pub item: crate::renderer::DecalItem,
1208    /// Optional UV animation. See [`DecalAnimation`].
1209    pub animation: Option<crate::renderer::DecalAnimation>,
1210    /// Total lifetime in seconds. `None` = permanent.
1211    pub lifetime: Option<f32>,
1212    /// How many seconds the fade-out lasts at the end of the lifetime.
1213    /// Must be <= `lifetime`. Default: 20% of lifetime when 0.0.
1214    pub fade_duration: f32,
1215    /// Elapsed time in seconds since the decal was added.
1216    pub age: f32,
1217}
1218
1219impl Scene {
1220    /// Add a permanent decal to the scene. Returns a handle for later removal.
1221    pub fn add_decal(&mut self, item: crate::renderer::DecalItem) -> DecalHandle {
1222        self.add_live_decal(item, None, None, 0.0)
1223    }
1224
1225    /// Add a decal that fades and is automatically removed after `lifetime` seconds.
1226    ///
1227    /// `fade_duration` controls how many seconds the alpha ramps to zero before
1228    /// expiry. Pass `0.0` to use the default (20% of lifetime).
1229    pub fn add_decal_with_lifetime(
1230        &mut self,
1231        item: crate::renderer::DecalItem,
1232        lifetime: f32,
1233        fade_duration: f32,
1234    ) {
1235        self.add_live_decal(item, Some(lifetime), None, fade_duration);
1236    }
1237
1238    /// Add a decal with an optional animation and optional lifetime.
1239    pub fn add_decal_animated(
1240        &mut self,
1241        item: crate::renderer::DecalItem,
1242        animation: crate::renderer::DecalAnimation,
1243        lifetime: Option<f32>,
1244    ) -> DecalHandle {
1245        self.add_live_decal(item, lifetime, Some(animation), 0.0)
1246    }
1247
1248    /// Remove a decal by handle. No-op if the handle is no longer valid.
1249    pub fn remove_decal(&mut self, handle: DecalHandle) {
1250        self.live_decals.retain(|d| d.id != handle.0);
1251    }
1252
1253    /// Advance all live decals by `dt` seconds and drop expired ones.
1254    ///
1255    /// Call once per frame before [`Scene::collect_decal_items`].
1256    pub fn update_decals(&mut self, dt: f32) {
1257        for ld in &mut self.live_decals {
1258            ld.age += dt;
1259        }
1260        self.live_decals
1261            .retain(|ld| ld.lifetime.map_or(true, |lt| ld.age < lt));
1262    }
1263
1264    /// Build the [`DecalItem`] list for this frame's `fd.scene.decals`.
1265    ///
1266    /// Applies lifetime fading (alpha ramps to 0 in the last 20% of life) and
1267    /// computes UV offset/scale for animated decals.
1268    pub fn collect_decal_items(&self) -> Vec<crate::renderer::DecalItem> {
1269        self.live_decals
1270            .iter()
1271            .map(|ld| {
1272                let mut item = ld.item.clone();
1273
1274                // Fade out at the end of lifetime.
1275                if let Some(lt) = ld.lifetime {
1276                    let fade = if ld.fade_duration > 0.0 {
1277                        ld.fade_duration.min(lt)
1278                    } else {
1279                        lt * 0.2
1280                    };
1281                    let time_left = lt - ld.age;
1282                    if time_left < fade {
1283                        item.alpha *= (time_left / fade).clamp(0.0, 1.0);
1284                    }
1285                }
1286
1287                // Apply animation.
1288                if let Some(anim) = &ld.animation {
1289                    match anim {
1290                        crate::renderer::DecalAnimation::UvScroll { vx, vy } => {
1291                            // Accumulate offset from base, wrapping in [0, 1].
1292                            item.uv_offset[0] =
1293                                (ld.item.uv_offset[0] + vx * ld.age).rem_euclid(1.0);
1294                            item.uv_offset[1] =
1295                                (ld.item.uv_offset[1] + vy * ld.age).rem_euclid(1.0);
1296                        }
1297                        crate::renderer::DecalAnimation::SpriteSheet { cols, rows, fps } => {
1298                            let total = cols * rows;
1299                            let frame = ((ld.age * fps) as u32).rem_euclid(total.max(1));
1300                            let col = frame % cols;
1301                            let row = frame / cols;
1302                            item.uv_scale = [1.0 / *cols as f32, 1.0 / *rows as f32];
1303                            item.uv_offset = [col as f32 / *cols as f32, row as f32 / *rows as f32];
1304                        }
1305                    }
1306                }
1307
1308                item
1309            })
1310            .collect()
1311    }
1312
1313    fn add_live_decal(
1314        &mut self,
1315        item: crate::renderer::DecalItem,
1316        lifetime: Option<f32>,
1317        animation: Option<crate::renderer::DecalAnimation>,
1318        fade_duration: f32,
1319    ) -> DecalHandle {
1320        let id = self.next_decal_id;
1321        self.next_decal_id += 1;
1322        self.live_decals.push(LiveDecal {
1323            id,
1324            item,
1325            animation,
1326            lifetime,
1327            fade_duration,
1328            age: 0.0,
1329        });
1330        DecalHandle(id)
1331    }
1332}
1333
1334#[cfg(test)]
1335mod tests {
1336    use super::*;
1337
1338    #[test]
1339    fn test_add_and_remove() {
1340        let mut scene = Scene::new();
1341        let id = scene.add(None, glam::Mat4::IDENTITY, Material::default());
1342        assert!(scene.node(id).is_some());
1343        assert_eq!(scene.node_count(), 1);
1344
1345        let removed = scene.remove(id);
1346        assert_eq!(removed, vec![id]);
1347        assert!(scene.node(id).is_none());
1348        assert_eq!(scene.node_count(), 0);
1349    }
1350
1351    #[test]
1352    fn test_remove_cascades_to_children() {
1353        let mut scene = Scene::new();
1354        let parent = scene.add(None, glam::Mat4::IDENTITY, Material::default());
1355        let child1 = scene.add(None, glam::Mat4::IDENTITY, Material::default());
1356        let child2 = scene.add(None, glam::Mat4::IDENTITY, Material::default());
1357        scene.set_parent(child1, Some(parent));
1358        scene.set_parent(child2, Some(parent));
1359
1360        let removed = scene.remove(parent);
1361        assert_eq!(removed.len(), 3);
1362        assert!(removed.contains(&parent));
1363        assert!(removed.contains(&child1));
1364        assert!(removed.contains(&child2));
1365        assert_eq!(scene.node_count(), 0);
1366    }
1367
1368    #[test]
1369    fn test_set_parent_updates_world_transform() {
1370        let mut scene = Scene::new();
1371        let parent = scene.add(
1372            None,
1373            glam::Mat4::from_translation(glam::Vec3::new(5.0, 0.0, 0.0)),
1374            Material::default(),
1375        );
1376        let child = scene.add(
1377            None,
1378            glam::Mat4::from_translation(glam::Vec3::new(1.0, 0.0, 0.0)),
1379            Material::default(),
1380        );
1381        scene.set_parent(child, Some(parent));
1382        scene.update_transforms();
1383
1384        let world = scene.node(child).unwrap().world_transform();
1385        let pos = world.col(3).truncate();
1386        assert!((pos.x - 6.0).abs() < 1e-5, "expected x=6.0, got {}", pos.x);
1387    }
1388
1389    #[test]
1390    fn test_dirty_propagation() {
1391        let mut scene = Scene::new();
1392        let parent = scene.add(
1393            None,
1394            glam::Mat4::from_translation(glam::Vec3::new(1.0, 0.0, 0.0)),
1395            Material::default(),
1396        );
1397        let child = scene.add(
1398            None,
1399            glam::Mat4::from_translation(glam::Vec3::new(2.0, 0.0, 0.0)),
1400            Material::default(),
1401        );
1402        scene.set_parent(child, Some(parent));
1403        scene.update_transforms();
1404
1405        // Now move the parent.
1406        scene.set_local_transform(
1407            parent,
1408            glam::Mat4::from_translation(glam::Vec3::new(10.0, 0.0, 0.0)),
1409        );
1410        scene.update_transforms();
1411
1412        let child_pos = scene
1413            .node(child)
1414            .unwrap()
1415            .world_transform()
1416            .col(3)
1417            .truncate();
1418        assert!(
1419            (child_pos.x - 12.0).abs() < 1e-5,
1420            "expected x=12.0, got {}",
1421            child_pos.x
1422        );
1423    }
1424
1425    #[test]
1426    fn test_layer_visibility_hides_nodes() {
1427        let mut scene = Scene::new();
1428        let layer = scene.add_layer("Hidden");
1429        let id = scene.add(Some(MeshId(0)), glam::Mat4::IDENTITY, Material::default());
1430        scene.set_layer(id, layer);
1431        scene.set_layer_visible(layer, false);
1432
1433        let items = scene.collect_render_items(&Selection::new());
1434        assert!(items.is_empty());
1435    }
1436
1437    #[test]
1438    fn test_collect_skips_invisible_nodes() {
1439        let mut scene = Scene::new();
1440        let id = scene.add(Some(MeshId(0)), glam::Mat4::IDENTITY, Material::default());
1441        scene.set_visible(id, false);
1442
1443        let items = scene.collect_render_items(&Selection::new());
1444        assert!(items.is_empty());
1445    }
1446
1447    #[test]
1448    fn test_collect_skips_meshless_nodes() {
1449        let mut scene = Scene::new();
1450        scene.add(None, glam::Mat4::IDENTITY, Material::default());
1451
1452        let items = scene.collect_render_items(&Selection::new());
1453        assert!(items.is_empty());
1454    }
1455
1456    #[test]
1457    fn test_collect_marks_selected() {
1458        let mut scene = Scene::new();
1459        let id = scene.add(Some(MeshId(0)), glam::Mat4::IDENTITY, Material::default());
1460
1461        let mut sel = Selection::new();
1462        sel.select_one(id);
1463
1464        let items = scene.collect_render_items(&sel);
1465        assert_eq!(items.len(), 1);
1466        assert!(items[0].settings.selected);
1467    }
1468
1469    #[test]
1470    fn test_unparent_makes_root() {
1471        let mut scene = Scene::new();
1472        let parent = scene.add(None, glam::Mat4::IDENTITY, Material::default());
1473        let child = scene.add(None, glam::Mat4::IDENTITY, Material::default());
1474        scene.set_parent(child, Some(parent));
1475        assert!(!scene.roots().contains(&child));
1476
1477        scene.set_parent(child, None);
1478        assert!(scene.roots().contains(&child));
1479        assert!(scene.node(child).unwrap().parent().is_none());
1480    }
1481
1482    #[test]
1483    fn test_collect_culled_filters_offscreen() {
1484        let mut scene = Scene::new();
1485        // Object at origin : should be visible.
1486        let visible_id = scene.add(Some(MeshId(0)), glam::Mat4::IDENTITY, Material::default());
1487        // Object far behind camera : should be culled.
1488        let _behind = scene.add(
1489            Some(MeshId(1)),
1490            glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.0, 100.0)),
1491            Material::default(),
1492        );
1493
1494        let sel = Selection::new();
1495        // Camera at z=5 looking toward origin.
1496        let view = glam::Mat4::look_at_rh(
1497            glam::Vec3::new(0.0, 0.0, 5.0),
1498            glam::Vec3::ZERO,
1499            glam::Vec3::Y,
1500        );
1501        let proj = glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 50.0);
1502        let frustum = crate::camera::frustum::Frustum::from_view_proj(&(proj * view));
1503
1504        // Both meshes get a unit-cube AABB.
1505        let unit_aabb = crate::scene::aabb::Aabb {
1506            min: glam::Vec3::splat(-0.5),
1507            max: glam::Vec3::splat(0.5),
1508        };
1509
1510        let (items, stats) =
1511            scene.collect_render_items_culled(&sel, &frustum, |_mesh_id| Some(unit_aabb));
1512
1513        assert_eq!(stats.total, 2);
1514        assert_eq!(stats.visible, 1);
1515        assert_eq!(stats.culled, 1);
1516        assert_eq!(items.len(), 1);
1517        // The visible item should be the one at the origin (mesh_index 0).
1518        assert_eq!(items[0].mesh_id.index(), visible_id as usize - 1); // MeshId(0).index() == 0
1519        let _ = visible_id; // suppress unused warning
1520    }
1521
1522    // --- Layer lock/colour/order tests ---
1523
1524    #[test]
1525    fn test_layer_locked_field_default_false() {
1526        let scene = Scene::new();
1527        let layers = scene.layers();
1528        let default_layer = layers.iter().find(|l| l.id == LayerId(0)).unwrap();
1529        assert!(!default_layer.locked);
1530    }
1531
1532    #[test]
1533    fn test_add_layer_has_locked_false_colour_white_and_order() {
1534        let mut scene = Scene::new();
1535        let layer_id = scene.add_layer("Test");
1536        let layers = scene.layers();
1537        let layer = layers.iter().find(|l| l.id == layer_id).unwrap();
1538        assert!(!layer.locked);
1539        assert_eq!(layer.colour, [1.0, 1.0, 1.0, 1.0]);
1540        assert!(layer.order > 0); // non-default layer has order >= 1
1541    }
1542
1543    #[test]
1544    fn test_set_layer_locked() {
1545        let mut scene = Scene::new();
1546        let layer_id = scene.add_layer("Locked");
1547        scene.set_layer_locked(layer_id, true);
1548        assert!(scene.is_layer_locked(layer_id));
1549        scene.set_layer_locked(layer_id, false);
1550        assert!(!scene.is_layer_locked(layer_id));
1551    }
1552
1553    #[test]
1554    fn test_set_layer_colour() {
1555        let mut scene = Scene::new();
1556        let layer_id = scene.add_layer("Coloured");
1557        scene.set_layer_colour(layer_id, [1.0, 0.0, 0.0, 1.0]);
1558        let layers = scene.layers();
1559        let layer = layers.iter().find(|l| l.id == layer_id).unwrap();
1560        assert_eq!(layer.colour, [1.0, 0.0, 0.0, 1.0]);
1561    }
1562
1563    #[test]
1564    fn test_set_layer_order() {
1565        let mut scene = Scene::new();
1566        let layer_id = scene.add_layer("Orderly");
1567        scene.set_layer_order(layer_id, 99);
1568        let layers = scene.layers();
1569        let layer = layers.iter().find(|l| l.id == layer_id).unwrap();
1570        assert_eq!(layer.order, 99);
1571    }
1572
1573    #[test]
1574    fn test_locked_layer_suppresses_selection_in_render_items() {
1575        let mut scene = Scene::new();
1576        let layer_id = scene.add_layer("Locked");
1577        let node_id = scene.add(Some(MeshId(0)), glam::Mat4::IDENTITY, Material::default());
1578        scene.set_layer(node_id, layer_id);
1579        scene.set_layer_locked(layer_id, true);
1580
1581        let mut sel = Selection::new();
1582        sel.select_one(node_id);
1583
1584        let items = scene.collect_render_items(&sel);
1585        assert_eq!(items.len(), 1, "locked layer nodes still render");
1586        assert!(
1587            !items[0].settings.selected,
1588            "locked layer nodes must not appear selected"
1589        );
1590    }
1591
1592    #[test]
1593    fn test_layers_sorted_by_order() {
1594        let mut scene = Scene::new();
1595        let a = scene.add_layer("A");
1596        let b = scene.add_layer("B");
1597        // Set reverse order
1598        scene.set_layer_order(a, 10);
1599        scene.set_layer_order(b, 5);
1600        let layers = scene.layers();
1601        // Find positions
1602        let pos_b = layers.iter().position(|l| l.id == b).unwrap();
1603        let pos_a = layers.iter().position(|l| l.id == a).unwrap();
1604        assert!(
1605            pos_b < pos_a,
1606            "layer B (order=5) should appear before A (order=10)"
1607        );
1608    }
1609
1610    // --- Group tests ---
1611
1612    #[test]
1613    fn test_create_group_returns_id() {
1614        let mut scene = Scene::new();
1615        let gid = scene.create_group("MyGroup");
1616        let group = scene.get_group(gid).unwrap();
1617        assert_eq!(group.name, "MyGroup");
1618        assert!(group.members.is_empty());
1619    }
1620
1621    #[test]
1622    fn test_add_to_group_and_remove_from_group() {
1623        let mut scene = Scene::new();
1624        let gid = scene.create_group("G");
1625        let node_id = scene.add(None, glam::Mat4::IDENTITY, Material::default());
1626        scene.add_to_group(node_id, gid);
1627        assert!(scene.get_group(gid).unwrap().members.contains(&node_id));
1628        scene.remove_from_group(node_id, gid);
1629        assert!(!scene.get_group(gid).unwrap().members.contains(&node_id));
1630    }
1631
1632    #[test]
1633    fn test_groups_returns_all_groups() {
1634        let mut scene = Scene::new();
1635        scene.create_group("G1");
1636        scene.create_group("G2");
1637        assert_eq!(scene.groups().len(), 2);
1638    }
1639
1640    #[test]
1641    fn test_node_groups_returns_containing_groups() {
1642        let mut scene = Scene::new();
1643        let g1 = scene.create_group("G1");
1644        let g2 = scene.create_group("G2");
1645        let node_id = scene.add(None, glam::Mat4::IDENTITY, Material::default());
1646        scene.add_to_group(node_id, g1);
1647        scene.add_to_group(node_id, g2);
1648        let groups = scene.node_groups(node_id);
1649        assert_eq!(groups.len(), 2);
1650        assert!(groups.contains(&g1));
1651        assert!(groups.contains(&g2));
1652    }
1653
1654    #[test]
1655    fn test_remove_node_cleans_up_group_membership() {
1656        let mut scene = Scene::new();
1657        let gid = scene.create_group("G");
1658        let node_id = scene.add(None, glam::Mat4::IDENTITY, Material::default());
1659        scene.add_to_group(node_id, gid);
1660        scene.remove(node_id);
1661        assert!(!scene.get_group(gid).unwrap().members.contains(&node_id));
1662    }
1663
1664    // --- Mesh ref count tests ---
1665
1666    #[test]
1667    fn test_mesh_ref_count_zero_for_unused_mesh() {
1668        let scene = Scene::new();
1669        assert_eq!(scene.mesh_ref_count(MeshId(42)), 0);
1670    }
1671
1672    #[test]
1673    fn test_mesh_ref_count_correct_for_nodes() {
1674        let mut scene = Scene::new();
1675        scene.add(Some(MeshId(0)), glam::Mat4::IDENTITY, Material::default());
1676        scene.add(Some(MeshId(0)), glam::Mat4::IDENTITY, Material::default());
1677        scene.add(Some(MeshId(1)), glam::Mat4::IDENTITY, Material::default());
1678        assert_eq!(scene.mesh_ref_count(MeshId(0)), 2);
1679        assert_eq!(scene.mesh_ref_count(MeshId(1)), 1);
1680    }
1681
1682    #[test]
1683    fn test_mesh_ref_count_decreases_after_remove() {
1684        let mut scene = Scene::new();
1685        let node_a = scene.add(Some(MeshId(0)), glam::Mat4::IDENTITY, Material::default());
1686        scene.add(Some(MeshId(0)), glam::Mat4::IDENTITY, Material::default());
1687        assert_eq!(scene.mesh_ref_count(MeshId(0)), 2);
1688        scene.remove(node_a);
1689        assert_eq!(scene.mesh_ref_count(MeshId(0)), 1);
1690    }
1691
1692    #[test]
1693    fn test_remove_group() {
1694        let mut scene = Scene::new();
1695        let gid = scene.create_group("G");
1696        scene.remove_group(gid);
1697        assert!(scene.get_group(gid).is_none());
1698        assert!(scene.groups().is_empty());
1699    }
1700
1701    #[test]
1702    fn test_walk_depth_first_order() {
1703        let mut scene = Scene::new();
1704        let root = scene.add_named("root", None, glam::Mat4::IDENTITY, Material::default());
1705        let child_a = scene.add_named("a", None, glam::Mat4::IDENTITY, Material::default());
1706        let child_b = scene.add_named("b", None, glam::Mat4::IDENTITY, Material::default());
1707        let grandchild = scene.add_named("a1", None, glam::Mat4::IDENTITY, Material::default());
1708        scene.set_parent(child_a, Some(root));
1709        scene.set_parent(child_b, Some(root));
1710        scene.set_parent(grandchild, Some(child_a));
1711
1712        let walk = scene.walk_depth_first();
1713        assert_eq!(walk.len(), 4);
1714        assert_eq!(walk[0], (root, 0));
1715        assert_eq!(walk[1], (child_a, 1));
1716        assert_eq!(walk[2], (grandchild, 2));
1717        assert_eq!(walk[3], (child_b, 1));
1718    }
1719
1720    // --- Octree spatial index tests ---
1721
1722    fn make_test_frustum() -> crate::camera::frustum::Frustum {
1723        let view = glam::Mat4::look_at_rh(
1724            glam::Vec3::new(0.0, 0.0, 300.0),
1725            glam::Vec3::ZERO,
1726            glam::Vec3::Y,
1727        );
1728        let proj = glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 1.0, 400.0);
1729        crate::camera::frustum::Frustum::from_view_proj(&(proj * view))
1730    }
1731
1732    fn unit_aabb() -> crate::scene::aabb::Aabb {
1733        crate::scene::aabb::Aabb {
1734            min: glam::Vec3::splat(-0.5),
1735            max: glam::Vec3::splat(0.5),
1736        }
1737    }
1738
1739    /// Octree path produces the same visible set as the flat walk.
1740    ///
1741    /// 100 nodes at the origin (visible) + 500 nodes far behind the camera
1742    /// (culled). With 600 total the octree path is active.
1743    #[test]
1744    fn test_octree_parity_with_known_counts() {
1745        let mut scene = Scene::new();
1746        let frustum = make_test_frustum();
1747
1748        // 100 visible nodes at origin.
1749        for _ in 0..100 {
1750            scene.add(Some(MeshId(0)), glam::Mat4::IDENTITY, Material::default());
1751        }
1752        // 500 culled nodes behind camera (z >> 300 far plane).
1753        for _ in 0..500 {
1754            let mat = glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.0, 9000.0));
1755            scene.add(Some(MeshId(0)), mat, Material::default());
1756        }
1757
1758        assert!(scene.node_count() >= SPATIAL_THRESHOLD);
1759
1760        let sel = Selection::new();
1761        let (items, stats) =
1762            scene.collect_render_items_culled(&sel, &frustum, |_| Some(unit_aabb()));
1763
1764        assert_eq!(
1765            items.len(),
1766            100,
1767            "expected 100 visible items, got {}",
1768            items.len()
1769        );
1770        assert_eq!(stats.visible, 100);
1771        let scene_stats = scene.scene_stats();
1772        assert!(
1773            scene_stats.spatial_index_nodes > 0,
1774            "spatial index should be active"
1775        );
1776    }
1777
1778    /// After moving nodes outside the frustum via set_local_transform, the
1779    /// octree reflects the update without a full rebuild.
1780    #[test]
1781    fn test_octree_incremental_transform_update() {
1782        let mut scene = Scene::new();
1783        let frustum = make_test_frustum();
1784
1785        let mut ids = Vec::new();
1786        // 600 nodes all at origin (all visible).
1787        for _ in 0..600 {
1788            let id = scene.add(Some(MeshId(0)), glam::Mat4::IDENTITY, Material::default());
1789            ids.push(id);
1790        }
1791
1792        let sel = Selection::new();
1793        let (items, _) = scene.collect_render_items_culled(&sel, &frustum, |_| Some(unit_aabb()));
1794        assert_eq!(items.len(), 600, "all 600 should be visible initially");
1795
1796        // Move the first 300 nodes behind the camera.
1797        let behind = glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.0, 9000.0));
1798        for &id in &ids[..300] {
1799            scene.set_local_transform(id, behind);
1800        }
1801
1802        let (items2, _) = scene.collect_render_items_culled(&sel, &frustum, |_| Some(unit_aabb()));
1803        assert_eq!(
1804            items2.len(),
1805            300,
1806            "300 nodes should be visible after moving the others out"
1807        );
1808    }
1809
1810    /// Removing nodes from a large scene updates the octree correctly.
1811    #[test]
1812    fn test_octree_node_removal() {
1813        let mut scene = Scene::new();
1814        let frustum = make_test_frustum();
1815
1816        let mut ids = Vec::new();
1817        for _ in 0..600 {
1818            let id = scene.add(Some(MeshId(0)), glam::Mat4::IDENTITY, Material::default());
1819            ids.push(id);
1820        }
1821
1822        let sel = Selection::new();
1823        let (items, _) = scene.collect_render_items_culled(&sel, &frustum, |_| Some(unit_aabb()));
1824        assert_eq!(items.len(), 600);
1825
1826        // Remove 150 nodes.
1827        for &id in &ids[..150] {
1828            scene.remove(id);
1829        }
1830
1831        let (items2, _) = scene.collect_render_items_culled(&sel, &frustum, |_| Some(unit_aabb()));
1832        assert_eq!(items2.len(), 450, "should have 450 after removing 150");
1833    }
1834}