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