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