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