From b4745efb248726776c25859a8470c92454796a64 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 17 May 2026 16:02:09 +0300 Subject: [PATCH] fix(scene): emit entities with degenerate bbox from the quadtree path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2.1's wire path was silently dropping any entity whose `bounding_box()` returns the degenerate default (or non-finite coords). The legacy `entity_aabb` mapped those to the `UNBOUNDED_AABB` sentinel, which `tessellate_entity` treated as "render unconditionally". The new path filtered them at index time (no bbox → no quadtree entry) AND `is_unindexable_entity` only matched type variants (Insert/Viewport/Block/BlockEnd), so those entities fell through neither path. Symptom: opening a non-UTM file showed the floor plan with most walls / dimensions / hatching dropped while isolated entities (text, circles) still rendered. Fix: `Scene::entity_index()` now returns an `EntityIndex` carrying both the quadtree and a parallel `unbounded_handles: Vec` of entities whose bbox came back degenerate. The wire path always emits that list in addition to the view-rect candidates, mirroring the legacy "no bbox → never cull" behaviour. Co-Authored-By: Claude Opus 4.7 --- src/scene/mod.rs | 101 ++++++++++++++++++++++++++++++----------------- 1 file changed, 64 insertions(+), 37 deletions(-) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index b71239da..b7e57f17 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -15,6 +15,14 @@ pub mod paper_canvas; pub mod pipeline; pub mod properties; pub mod quadtree; + +/// Result of `Scene::entity_index()`. The wire path queries `tree` for +/// view-rect candidates and also always emits `unbounded_handles` +/// (entities with no usable bbox — legacy `UNBOUNDED_AABB` sentinel). +pub(super) struct EntityIndex { + pub tree: quadtree::QuadTree, + pub unbounded_handles: Vec, +} mod render; mod selection; pub mod solid3d_tess; @@ -476,11 +484,11 @@ pub struct Scene { /// Lets Insert tessellation transform-copy cached wires instead of /// clone+explode+re-tessellate per reference. block_defn_cache: RefCell)>>, - /// Spatial index of top-level entity AABBs in WCS f64 (Phase 2.1). - /// Keyed by geometry_epoch; lazily rebuilt by `entity_index()`. Skips - /// `Insert`/`Viewport`/`Block`/`BlockEnd` and entities with no usable - /// bbox — those are handled by special-case "always emit" iteration. - entity_index_cache: RefCell>, + /// Spatial index + always-emit list for top-level entities + /// (Phase 2.1). Lazily rebuilt by `entity_index()` on + /// `geometry_epoch` change. See `EntityIndex` for what each side + /// holds and why both are needed. + entity_index_cache: RefCell>, /// Last viewport aspect ratio captured by the render pipeline. Used by /// `view_world_aabb` to compute the world-space view rect on demand. last_render_aspect: std::cell::Cell, @@ -1280,11 +1288,12 @@ impl Scene { local_view[2] as f64 + ox, local_view[3] as f64 + oy, ]; - let candidates: Vec = { - let tree = self.entity_index(); - tree.query_rect(view_wcs) + let (candidates, unbounded): (Vec, Vec) = { + let idx = self.entity_index(); + (idx.tree.query_rect(view_wcs), idx.unbounded_handles.clone()) }; - let mut out: Vec<&EntityType> = Vec::with_capacity(candidates.len() + 16); + let mut out: Vec<&EntityType> = + Vec::with_capacity(candidates.len() + unbounded.len() + 16); for h in candidates { if let Some(e) = self.document.get_entity(h) { if visibility_ok(e) { @@ -1292,6 +1301,17 @@ impl Scene { } } } + // Unbounded entities — always emit regardless of view, mirroring + // legacy `entity_aabb`'s UNBOUNDED_AABB sentinel. + for h in unbounded { + if let Some(e) = self.document.get_entity(h) { + if visibility_ok(e) { + out.push(e); + } + } + } + // Inserts/Viewports/Block/BlockEnd — handled by their own paths + // (block expansion, viewport rendering); always candidates. for e in self.document.entities() { if is_unindexable_entity(e) && visibility_ok(e) { out.push(e); @@ -1457,13 +1477,18 @@ impl Scene { }) } - /// Spatial index of every indexable top-level entity. Lazily built - /// on first call after a geometry change; reused across queries - /// until `geometry_epoch` advances. Entities filtered out by - /// `is_unindexable_entity` (Insert/Viewport/Block/BlockEnd) and - /// entities with no usable bbox don't appear here — callers must - /// iterate those separately. - pub(super) fn entity_index(&self) -> std::cell::Ref<'_, quadtree::QuadTree> { + /// Spatial index + always-emit list for top-level entities. Lazily + /// rebuilt on `geometry_epoch` change. + /// + /// `tree` holds entities whose `bounding_box()` is finite and + /// non-degenerate. `unbounded_handles` holds entities whose bbox + /// is degenerate or non-finite — the legacy `entity_aabb` treated + /// those as `UNBOUNDED_AABB` (never culled), so the wire path must + /// always emit them regardless of view. Inserts/Viewports/Blocks + /// /BlockEnds are filtered out at build time and re-added by the + /// wire path via a separate scan (their WCS bbox depends on + /// transforms handled elsewhere). + pub(super) fn entity_index(&self) -> std::cell::Ref<'_, EntityIndex> { { let cache = self.entity_index_cache.borrow(); if let Some((epoch, _)) = *cache { @@ -1476,30 +1501,28 @@ impl Scene { } } - // Pass 1 — collect (handle, aabb) for every indexable entity. - // Union them to derive a tight root bound; anything with a - // non-finite or degenerate bbox is skipped here, so the root - // bounds stay finite. Wide margin (1%) avoids items sitting - // exactly on the root edge. let mut items: Vec<(Handle, [f64; 4])> = Vec::new(); + let mut unbounded: Vec = Vec::new(); let mut union: Option<[f64; 4]> = None; for e in self.document.entities() { if is_unindexable_entity(e) { continue; } - let Some(ab) = entity_world_aabb_f64(e) else { - continue; - }; - union = Some(match union { - None => ab, - Some(u) => [ - u[0].min(ab[0]), - u[1].min(ab[1]), - u[2].max(ab[2]), - u[3].max(ab[3]), - ], - }); - items.push((e.common().handle, ab)); + match entity_world_aabb_f64(e) { + Some(ab) => { + union = Some(match union { + None => ab, + Some(u) => [ + u[0].min(ab[0]), + u[1].min(ab[1]), + u[2].max(ab[2]), + u[3].max(ab[3]), + ], + }); + items.push((e.common().handle, ab)); + } + None => unbounded.push(e.common().handle), + } } let root = match union { Some(u) => { @@ -1509,8 +1532,6 @@ impl Scene { let my = h * 0.01; [u[0] - mx, u[1] - my, u[2] + mx, u[3] + my] } - // Empty doc — pick a small finite root; query_rect on an - // empty tree returns nothing regardless. None => [-1.0, -1.0, 1.0, 1.0], }; let mut tree = quadtree::QuadTree::new(root); @@ -1518,7 +1539,13 @@ impl Scene { tree.insert(h, ab); } - *self.entity_index_cache.borrow_mut() = Some((self.geometry_epoch, tree)); + *self.entity_index_cache.borrow_mut() = Some(( + self.geometry_epoch, + EntityIndex { + tree, + unbounded_handles: unbounded, + }, + )); std::cell::Ref::map(self.entity_index_cache.borrow(), |c| { &c.as_ref().unwrap().1 })