diff --git a/examples/xref_states.rs b/examples/xref_states.rs new file mode 100644 index 000000000..dff5252a4 --- /dev/null +++ b/examples/xref_states.rs @@ -0,0 +1,23 @@ +// Dump every xref block_record's raw is_loaded bit before any resolve. +// cargo run --release --example xref_states -- +use acadrust::io::dwg::DwgReader; + +fn main() -> Result<(), Box> { + let path = std::env::args().nth(1).unwrap(); + let doc = DwgReader::from_file(&path)?.read()?; + + for br in doc.block_records.iter() { + if br.flags.is_xref || br.flags.is_xref_overlay { + println!( + "name={:<30} is_xref={} overlay={} is_loaded={:?} xref_path={:?} entity_handles={}", + br.name, + br.flags.is_xref, + br.flags.is_xref_overlay, + br.is_loaded, + br.xref_path, + br.entity_handles.len(), + ); + } + } + Ok(()) +} diff --git a/src/scene/block_cache.rs b/src/scene/block_cache.rs index ba878cab4..66041389b 100644 --- a/src/scene/block_cache.rs +++ b/src/scene/block_cache.rs @@ -42,6 +42,11 @@ pub struct LocalWire { pub color_is_byblock: bool, pub lt_is_byblock: bool, pub lw_is_byblock: bool, + /// XY bounding box of this wire in block-local coordinates. + /// `[min_x, min_y, max_x, max_y]`. Used for view-frustum culling at + /// expand-time: transform corners by the Insert transform → world AABB + /// → test against the camera's world-space view rect. + pub aabb_local: [f32; 4], } #[derive(Clone, Debug)] @@ -69,6 +74,10 @@ pub enum LocalSub { #[derive(Clone, Debug, Default)] pub struct BlockDefn { pub subs: Vec, + /// Union of every sub's local AABB (including nested-INSERT contributions + /// resolved at expand time via their own defn's `aabb_local`). XY only — + /// the wire renderer is 2D-dominant. + pub aabb_local: [f32; 4], } #[derive(Default, Debug)] @@ -97,8 +106,54 @@ impl BlockCache { let defn = build_defn(doc, name, anno_scale, bg_color); cache.defns.insert(name.clone(), Arc::new(defn)); } + cache.compute_block_aabbs(&referenced); cache } + + /// Compute and store the `aabb_local` for every cached defn. Direct wires + /// contribute their own aabb_local; nested INSERT references look up the + /// nested defn (already cached) and transform its aabb_local by the + /// nested Insert's transform before unioning. + /// + /// Run as a post-pass so it doesn't matter which order build_defn was + /// called in. Cycle guard: a self-referential block keeps an empty AABB + /// (will fail every frustum test → not emitted, which is correct). + fn compute_block_aabbs(&mut self, names: &[String]) { + // Snapshot defn pointers up front — we mutate the map below. + let names: Vec = names.to_vec(); + for name in &names { + let mut visited: Vec = Vec::new(); + let aabb = self.defn_aabb_recursive(name, &mut visited); + if let Some(defn_arc) = self.defns.get_mut(name) { + let mut defn = (**defn_arc).clone(); + defn.aabb_local = aabb; + *defn_arc = Arc::new(defn); + } + } + } + + fn defn_aabb_recursive(&self, block_name: &str, visited: &mut Vec) -> [f32; 4] { + if visited.iter().any(|n| n == block_name) { + return [0.0, 0.0, 0.0, 0.0]; + } + let Some(defn) = self.defns.get(block_name) else { + return [0.0, 0.0, 0.0, 0.0]; + }; + visited.push(block_name.to_string()); + let mut acc = [0.0_f32, 0.0, 0.0, 0.0]; + for sub in &defn.subs { + let aabb = match sub { + LocalSub::Wire(lw) => lw.aabb_local, + LocalSub::Nested(nref) => { + let nested_local = self.defn_aabb_recursive(&nref.block_name, visited); + transform_aabb_xy(nested_local, &nref.xform) + } + }; + acc = aabb_union(acc, aabb); + } + visited.pop(); + acc + } } /// Walk all entities + all block_record contents collecting every distinct @@ -163,7 +218,10 @@ fn build_defn( } } } - BlockDefn { subs } + BlockDefn { + subs, + aabb_local: [0.0; 4], + } } fn build_nested_ref( @@ -220,6 +278,10 @@ fn tessellate_sub_local( return None; } + let aabb_local = aabb_from_points_iter( + wire.points.iter().copied().chain(wire.fill_tris.iter().copied()), + ); + Some(LocalWire { points: wire.points, key_vertices: wire.key_vertices, @@ -235,9 +297,89 @@ fn tessellate_sub_local( color_is_byblock, lt_is_byblock, lw_is_byblock, + aabb_local, }) } +fn aabb_from_points_iter>(pts: I) -> [f32; 4] { + let mut min_x = f32::INFINITY; + let mut min_y = f32::INFINITY; + let mut max_x = f32::NEG_INFINITY; + let mut max_y = f32::NEG_INFINITY; + for p in pts { + if !p[0].is_finite() { + continue; + } + if p[0] < min_x { + min_x = p[0]; + } + if p[1] < min_y { + min_y = p[1]; + } + if p[0] > max_x { + max_x = p[0]; + } + if p[1] > max_y { + max_y = p[1]; + } + } + if min_x.is_infinite() { + [0.0, 0.0, 0.0, 0.0] + } else { + [min_x, min_y, max_x, max_y] + } +} + +/// Transform a local-space XY AABB by `t` and return the world-space XY AABB +/// of the transformed corners. For non-rotated transforms the corners stay +/// axis-aligned; for arbitrary OCS we still get a correct (looser) AABB by +/// taking the min/max of the four transformed corner points. +fn transform_aabb_xy(local: [f32; 4], t: &Transform) -> [f32; 4] { + let [x0, y0, x1, y1] = local; + let corners = [ + Vector3::new(x0 as f64, y0 as f64, 0.0), + Vector3::new(x1 as f64, y0 as f64, 0.0), + Vector3::new(x1 as f64, y1 as f64, 0.0), + Vector3::new(x0 as f64, y1 as f64, 0.0), + ]; + let mut min_x = f64::INFINITY; + let mut min_y = f64::INFINITY; + let mut max_x = f64::NEG_INFINITY; + let mut max_y = f64::NEG_INFINITY; + for c in corners { + let v = t.apply(c); + if v.x < min_x { + min_x = v.x; + } + if v.y < min_y { + min_y = v.y; + } + if v.x > max_x { + max_x = v.x; + } + if v.y > max_y { + max_y = v.y; + } + } + [min_x as f32, min_y as f32, max_x as f32, max_y as f32] +} + +fn aabb_union(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { + // [0,0,0,0] is the "empty AABB" sentinel produced by aabb_from_points_iter + // when a wire has no finite points — treat it as if the other side wins. + if a == [0.0, 0.0, 0.0, 0.0] { + return b; + } + if b == [0.0, 0.0, 0.0, 0.0] { + return a; + } + [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])] +} + +pub fn aabb_disjoint_xy(a: [f32; 4], b: [f32; 4]) -> bool { + a[2] < b[0] || a[0] > b[2] || a[3] < b[1] || a[1] > b[3] +} + // ── Use-time expansion ─────────────────────────────────────────────────────── /// Expand one top-level INSERT into world-space WireModels via the cache. @@ -255,12 +397,32 @@ pub fn expand_insert( selected: bool, world_offset: [f64; 3], pslt_factor: f32, + // World-space XY view AABB (with world_offset already subtracted, so the + // comparison is in the same f32 space as emitted wires). `None` disables + // frustum culling — every cached sub is emitted. + view_aabb: Option<[f32; 4]>, ) -> Option> { let defn = cache.defn(&ins.block_name)?; let xform = ins.get_transform(); let name = ins_handle.value().to_string(); let mut batches = Batches::default(); let mut visited: Vec = Vec::with_capacity(8); + let [ox, oy, _] = world_offset; + + // Whole-Insert cull: if the Insert's world AABB doesn't intersect the + // view, bail without doing any per-sub work. + if let Some(view) = view_aabb { + let insert_world = transform_aabb_xy(defn.aabb_local, &xform); + let insert_local = [ + (insert_world[0] - ox as f32), + (insert_world[1] - oy as f32), + (insert_world[2] - ox as f32), + (insert_world[3] - oy as f32), + ]; + if aabb_disjoint_xy(insert_local, view) { + return Some(vec![]); + } + } for offset in &array_offsets(ins) { let base_xform = if offset == &[0.0; 3] { @@ -280,6 +442,7 @@ pub fn expand_insert( selected, world_offset, pslt_factor, + view_aabb, }; expand_defn(defn, &base_xform, &ctx, &mut batches, &mut visited, 0); } @@ -295,6 +458,8 @@ struct ExpandCtx<'a> { selected: bool, world_offset: [f64; 3], pslt_factor: f32, + // World-space XY view AABB (post world_offset). `None` = no culling. + view_aabb: Option<[f32; 4]>, } /// Style fingerprint used to group local wires into a single GPU buffer. @@ -439,7 +604,22 @@ fn expand_defn( } for sub in &defn.subs { match sub { - LocalSub::Wire(lw) => emit_wire(lw, accum_xform, ctx, out), + LocalSub::Wire(lw) => { + if let Some(view) = ctx.view_aabb { + let world = transform_aabb_xy(lw.aabb_local, accum_xform); + let [ox, oy, _] = ctx.world_offset; + let local = [ + world[0] - ox as f32, + world[1] - oy as f32, + world[2] - ox as f32, + world[3] - oy as f32, + ]; + if aabb_disjoint_xy(local, view) { + continue; + } + } + emit_wire(lw, accum_xform, ctx, out); + } LocalSub::Nested(nref) => { if visited.iter().any(|n| n == &nref.block_name) { // Cycle — skip. @@ -448,6 +628,22 @@ fn expand_defn( let Some(nested_defn) = ctx.cache.defn(&nref.block_name) else { continue; }; + // Nested-INSERT cull: union AABB of the nested defn, transformed + // by composed xform, vs view rect. + if let Some(view) = ctx.view_aabb { + let composed = nref.xform.then(accum_xform); + let world = transform_aabb_xy(nested_defn.aabb_local, &composed); + let [ox, oy, _] = ctx.world_offset; + let local = [ + world[0] - ox as f32, + world[1] - oy as f32, + world[2] - ox as f32, + world[3] - oy as f32, + ]; + if aabb_disjoint_xy(local, view) { + continue; + } + } // Resolve ByBlock for this nested ref against the outer ctx. let nested_color = if nref.color_is_byblock { ctx.ins_color @@ -473,6 +669,7 @@ fn expand_defn( selected: ctx.selected, world_offset: ctx.world_offset, pslt_factor: ctx.pslt_factor, + view_aabb: ctx.view_aabb, }; visited.push(nref.block_name.clone()); for offset in &nref.instance_offsets { diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 6338b8946..ce38c12fe 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -220,9 +220,10 @@ pub struct Scene { /// skip re-uploading unchanged geometry buffers every frame. pub geometry_epoch: u64, /// Cached tessellation of all visible entity wires for the current layout. - /// Keyed by `geometry_epoch`; invalidated automatically when the epoch changes. + /// Keyed by `(geometry_epoch, camera_generation)` so a camera change + /// invalidates the cull-dependent wire list as well as a geometry change. /// Uses `Arc` so `build_primitive()` avoids a full Vec clone during navigation. - wire_cache: RefCell>)>>, + wire_cache: RefCell>)>>, /// Index built from every SortEntitiesTable in the document. /// Maps block_handle → (entity_handle.value() → sort_handle.value()). /// Replaces the O(objects) linear scan inside `wires_for_block()` with an O(1) lookup. @@ -240,7 +241,9 @@ pub struct Scene { viewport_wire_cache: RefCell>)>>, /// Cached tessellation of paper-space layout block entities (title block, annotations, etc.). /// Separate from `wire_cache` so paper_canvas_wires() doesn't re-tessellate on every frame. - paper_sheet_cache: RefCell>)>>, + /// Keyed by `(geometry_epoch, camera_generation)` — paper view changes + /// on zoom too, so culled wire output depends on camera. + paper_sheet_cache: RefCell>)>>, /// Per-viewport projected wire cache for the paper canvas (2-D Iced widget). /// Stores projected + clipped wires in paper-space coordinates. /// Maps vp_handle → (geometry_epoch, Vec). @@ -287,6 +290,9 @@ pub struct Scene { /// Lets Insert tessellation transform-copy cached wires instead of /// clone+explode+re-tessellate per reference. block_defn_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, } impl Scene { @@ -323,6 +329,43 @@ impl Scene { model_extents_cache: RefCell::new(None), entity_block_map_cache: RefCell::new(None), block_defn_cache: RefCell::new(None), + last_render_aspect: std::cell::Cell::new(16.0 / 9.0), + } + } + + /// Compute the current camera's world-space XY view AABB with + /// `world_offset` already subtracted (so the result is in the same f32 + /// space as emitted wire points). Adds a 25% margin around the + /// frustum to absorb pan inertia and avoid clipped-edge popping. + pub(super) fn view_world_aabb(&self) -> Option<[f32; 4]> { + if self.current_layout != "Model" { + // Paper-space viewport composition handles its own culling; the + // top-level paper canvas is small enough not to need it. + return None; + } + let cam = self.camera.borrow(); + let aspect = self.last_render_aspect.get().max(0.01); + let h = cam.ortho_size(); + let w = h * aspect; + // Generous margin: in 3D / perspective the projection actually covers + // more than the orthographic-equivalent rect, and pan inertia briefly + // moves geometry past the visible edge before camera_generation bumps. + let margin = 1.25_f32; + let cx = (cam.target.x - self.world_offset[0] as f32) as f32; + let cy = (cam.target.y - self.world_offset[1] as f32) as f32; + Some([ + cx - w * margin, + cy - h * margin, + cx + w * margin, + cy + h * margin, + ]) + } + + /// Called by the render pipeline once per frame so `view_world_aabb` knows + /// the active widget's aspect ratio. + pub fn set_render_aspect(&self, aspect: f32) { + if aspect.is_finite() && aspect > 0.0 { + self.last_render_aspect.set(aspect); } } @@ -752,17 +795,18 @@ impl Scene { /// Shared by both `entity_wires_arc()` and `paper_canvas_wires()` so a single /// cache miss triggers only one tessellation pass, not two. fn paper_sheet_wires_arc(&self) -> Arc> { + let key = (self.geometry_epoch, self.camera_generation); { let cache = self.paper_sheet_cache.borrow(); - if let Some((cached_epoch, ref arc)) = *cache { - if cached_epoch == self.geometry_epoch { + if let Some((cached_key, ref arc)) = *cache { + if cached_key == key { return Arc::clone(arc); } } } let layout_block = self.current_layout_block_handle(); let arc = Arc::new(self.wires_for_block(layout_block)); - *self.paper_sheet_cache.borrow_mut() = Some((self.geometry_epoch, Arc::clone(&arc))); + *self.paper_sheet_cache.borrow_mut() = Some((key, Arc::clone(&arc))); arc } @@ -770,10 +814,11 @@ impl Scene { /// Returns a shared `Arc` so `build_primitive()` can skip the clone during /// navigation frames where no preview wires are active. pub(super) fn entity_wires_arc(&self) -> Arc> { + let key = (self.geometry_epoch, self.camera_generation); { let cache = self.wire_cache.borrow(); - if let Some((cached_epoch, ref arc)) = *cache { - if cached_epoch == self.geometry_epoch { + if let Some((cached_key, ref arc)) = *cache { + if cached_key == key { return Arc::clone(arc); } } @@ -783,14 +828,14 @@ impl Scene { // no Vec clone needed. if self.current_layout == "Model" { let arc = self.paper_sheet_wires_arc(); - *self.wire_cache.borrow_mut() = Some((self.geometry_epoch, Arc::clone(&arc))); + *self.wire_cache.borrow_mut() = Some((key, Arc::clone(&arc))); return arc; } // Paper space: extend sheet wires with projected viewport content. let mut wires = (*self.paper_sheet_wires_arc()).clone(); wires.extend(self.viewport_content_wires(layout_block, None, None)); let arc = Arc::new(wires); - *self.wire_cache.borrow_mut() = Some((self.geometry_epoch, Arc::clone(&arc))); + *self.wire_cache.borrow_mut() = Some((key, Arc::clone(&arc))); arc } @@ -953,9 +998,12 @@ impl Scene { }; let blk_cache = self.block_cache_arc(); let blk_ref: &block_cache::BlockCache = &blk_cache; + let view_aabb = self.view_world_aabb(); let mut wires: Vec = visible .into_par_iter() - .flat_map(|e| tessellate_entity(doc, sel, avp, woff, bg, anno, e, Some(blk_ref))) + .flat_map(|e| { + tessellate_entity(doc, sel, avp, woff, bg, anno, e, Some(blk_ref), view_aabb) + }) .collect(); // Apply draw order via the cached index (O(1) block lookup). @@ -1052,6 +1100,8 @@ impl Scene { 1.0 }; let blk_cache = self.block_cache_arc(); + // tessellate_one is used for one-off lookups (hit test, properties). + // Skip culling here so the caller always gets the full geometry. tessellate_entity( &self.document, &self.selected, @@ -1061,6 +1111,7 @@ impl Scene { anno, e, Some(&blk_cache), + None, ) } @@ -1116,7 +1167,7 @@ impl Scene { // Prefer the already-computed wire AABB cache when available — avoids re-tessellating. if self.current_layout == "Model" { let cache = self.wire_cache.borrow(); - if let Some((epoch, ref arc)) = *cache { + if let Some(((epoch, _cam_gen), ref arc)) = *cache { if epoch == self.geometry_epoch { for wire in arc.iter() { let [ax, ay, bx, by] = wire.aabb; @@ -3564,6 +3615,10 @@ impl Scene { true }) .flat_map(|e| { + // Per-viewport tessellation uses the viewport's own camera — + // not the model-space camera — so we don't pass a view_aabb + // here. (Viewports are typically small enough that culling + // them isn't worth the added complexity.) tessellate_entity( &self.document, &self.selected, @@ -3573,6 +3628,7 @@ impl Scene { vp_anno_scale, e, Some(&blk_cache), + None, ) }) .collect() @@ -3763,10 +3819,29 @@ fn tessellate_entity( anno_scale: f32, e: &EntityType, block_cache: Option<&block_cache::BlockCache>, + // World-space XY view AABB (post `world_offset` subtraction). When + // `Some`, entities whose AABB doesn't intersect this rect are skipped. + view_aabb: Option<[f32; 4]>, ) -> Vec { let h = e.common().handle; let sel = selected.contains(&h); + // Frustum cull for non-Insert, non-Viewport entities. Insert is handled + // separately (its WCS bbox depends on the block defn AABB × Insert + // transform — done inside expand_insert). Viewports always emit so the + // viewport frame stays visible regardless of zoom. + if let Some(view) = view_aabb { + match e { + EntityType::Viewport(_) | EntityType::Insert(_) => {} + _ => { + let ab = entity_aabb(e, world_offset); + if ab != WireModel::UNBOUNDED_AABB && block_cache::aabb_disjoint_xy(ab, view) { + return vec![]; + } + } + } + } + if let EntityType::Viewport(vp) = e { // The sheet viewport (overall/id=1) is never shown — it represents the // paper boundary, not a user-defined content window. @@ -3897,6 +3972,7 @@ fn tessellate_entity( sel, world_offset, pslt_factor, + view_aabb, ) { wires.push(marker); return wires; diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index fb9b3a722..ff33fe8ff 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -65,9 +65,10 @@ pub struct Pipeline { gpu_face3d_fill: Option, gpu_face3d_edges: Vec, pub viewcube: ViewCubePipeline, - /// Last geometry epoch for which GPU buffers were uploaded. - /// Initialized to u64::MAX so the first frame always uploads. - pub cached_epoch: u64, + /// Last `(geometry_epoch, camera_generation)` value for which GPU buffers + /// were uploaded. We re-upload when either side changes — pan/zoom bumps + /// camera_generation, which triggers re-culling and a fresh upload. + pub cached_epoch: (u64, u64), } impl Pipeline { @@ -595,7 +596,7 @@ impl Pipeline { gpu_face3d_fill: None, gpu_face3d_edges: vec![], viewcube, - cached_epoch: u64::MAX, + cached_epoch: (u64::MAX, u64::MAX), } } diff --git a/src/scene/render.rs b/src/scene/render.rs index b798df136..595b96ccf 100644 --- a/src/scene/render.rs +++ b/src/scene/render.rs @@ -96,6 +96,10 @@ pub struct Primitive { pub(super) bg_color: [f32; 4], pub(super) show_viewcube: bool, pub(super) geometry_epoch: u64, + /// Camera generation captured when this Primitive was assembled. Paired + /// with `geometry_epoch` so the wire buffers re-upload when the view + /// changes (frustum culling produces a different wire list). + pub(super) camera_generation: u64, } // ── shader::Primitive impl ──────────────────────────────────────────────── @@ -123,14 +127,21 @@ impl shader::Primitive for Primitive { pipeline.ensure_depth_texture(device, clip_size); pipeline.viewcube.ensure_depth_texture(device, full_size); pipeline.upload_uniforms(queue, &self.uniforms); - if self.geometry_epoch != pipeline.cached_epoch { - pipeline.upload_hatches(device, &self.hatches[..]); - pipeline.upload_wipeouts(device, &self.wipeout_hatches[..]); - pipeline.upload_images(device, queue, &self.images[..]); - pipeline.upload_meshes(device, &self.meshes[..]); + let cur_key = (self.geometry_epoch, self.camera_generation); + if cur_key != pipeline.cached_epoch { + // Static buffers (hatches/images/meshes) only need refresh on a + // real geometry change, not on every camera tick. + if self.geometry_epoch != pipeline.cached_epoch.0 { + pipeline.upload_hatches(device, &self.hatches[..]); + pipeline.upload_wipeouts(device, &self.wipeout_hatches[..]); + pipeline.upload_images(device, queue, &self.images[..]); + pipeline.upload_meshes(device, &self.meshes[..]); + } + // Wires re-upload on every camera change because the visible + // subset shifts under frustum culling. pipeline.upload_wires(device, &self.wires[..]); pipeline.upload_face3d(device, &self.face3d_wires[..], &self.wires[..]); - pipeline.cached_epoch = self.geometry_epoch; + pipeline.cached_epoch = cur_key; } pipeline.compute_wire_scissors(self.uniforms.view_proj, clip_size.width, clip_size.height); if self.show_viewcube { @@ -306,6 +317,11 @@ impl Scene { ) -> Primitive { let cam = self.camera.borrow(); self.selection.borrow_mut().vp_size = (bounds.width, bounds.height); + // Record the active widget's aspect so view_world_aabb() can compute + // a correct culling rectangle before entity_wires_arc() runs. + if bounds.height > 0.0 { + self.set_render_aspect(bounds.width / bounds.height); + } let entity_arc = self.entity_wires_arc(); let (face3d_wires, other_wires) = split_face3d_wires(&entity_arc, &self.document); @@ -339,6 +355,7 @@ impl Scene { bg_color, show_viewcube, geometry_epoch: self.geometry_epoch, + camera_generation: self.camera_generation, } } @@ -381,6 +398,7 @@ impl Scene { bg_color: self.bg_color, show_viewcube: false, geometry_epoch: self.geometry_epoch, + camera_generation: self.camera_generation, } }