From 2da9301a2b920e8ba669c47dcda660c71f53c608 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 26 Apr 2026 11:55:08 +0300 Subject: [PATCH] perf: viewport wire cache, Arc hit_test_wires, snap world-space pre-rejection (I+J+K) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Option I: add per-viewport Arc wire cache so paper-space viewport rendering no longer re-tessellates model content every frame. Option J: hit_test_wires() returns Arc> — O(1) pointer copy in model space instead of a full Vec clone on every mouse move during commands. paper_canvas_hatches/wipeouts() now route through the epoch caches added in F. Option K: add world_snap_r + wire_in_range() pre-check to Snapper::snap(). All Endpoint/Midpoint/Nearest/Perpendicular/Intersection loops skip wires whose chord sphere is outside the snap circle using O(1) scalar comparisons, eliminating O(entities × vertices) matrix multiply cost when zoomed in. Co-Authored-By: Claude Sonnet 4.6 --- PERFORMANCE.md | 64 +++++++++++++++++++++++++++++++++++++-- src/app/update.rs | 24 +++++++-------- src/scene/mod.rs | 40 ++++++++++++++++-------- src/scene/paper_canvas.rs | 4 +-- src/scene/render.rs | 6 ++-- src/snap/mod.rs | 43 ++++++++++++++++++++++++++ 6 files changed, 149 insertions(+), 32 deletions(-) diff --git a/PERFORMANCE.md b/PERFORMANCE.md index feb61e7c2..2e2049d30 100644 --- a/PERFORMANCE.md +++ b/PERFORMANCE.md @@ -112,8 +112,66 @@ extruded polylines). Navigation stays free regardless of model complexity. --- +## Option I — Per-viewport wire cache for paper space + +**Status:** Done + +Added `viewport_wire_cache: RefCell>)>>` to `Scene`. +`model_wires_for_viewport_arc(vp_handle)` checks the cache first; on a hit it returns an +Arc clone (O(1)). On a miss it tessellates, stores the result, and returns it. + +`build_viewport_primitive()` in `render.rs` now calls `model_wires_for_viewport_arc()` instead +of `model_wires_for_viewport()`, so paper-space viewport rendering no longer re-tessellates +model-space content every frame. + +**Impact:** Eliminates per-frame tessellation when viewing paper space layouts. Before this fix, +every navigation frame in paper space re-tessellated the entire model-space entity set for each +visible viewport — the same bug Options A/B fixed for model space. + +--- + +## Option J — Arc-return `hit_test_wires()` and `paper_canvas_*` + +**Status:** Done + +`hit_test_wires()` now returns `Arc>` instead of `Vec`. +In the model-space case it returns `entity_wires_arc()` directly — O(1), no Vec clone. +In the paper-space case it still builds a Vec (no suitable cache for those paths), but the +interface is consistent. + +`paper_canvas_hatches()` and `paper_canvas_wipeouts()` now return `Arc>` +via the epoch caches added in Option F, eliminating per-frame hatch rebuilds in the paper +canvas widget. + +**Impact:** Every `ViewportMove` message during a command (grip drag, line drawing, etc.) +called `hit_test_wires()` up to twice. In model space this was a full Vec clone of all +tessellated wires — potentially MB-scale. Now it is a pointer copy. + +--- + +## Option K — Snap world-space wire pre-rejection + +**Status:** Done + +Added `world_snap_r` (snap radius in world units, derived from `view_proj`) and a +`wire_in_range()` closure to `Snapper::snap()`. Before iterating a wire's vertices for +Endpoint, Midpoint, Nearest, Perpendicular, Intersection, and ApparentIntersection, +the closure checks whether the wire's first↔last chord sphere overlaps the snap search +circle. Wires that fail the check are skipped with `continue` — no vertex projection, +no matrix multiplies. + +Closed wires (first ≈ last, e.g. tessellated circles) are always passed through since +their chord radius is ~0. + +**Impact:** When zoomed in on a small region of a large drawing, the vast majority of +wires are outside the snap circle and are rejected in O(1) scalar comparisons each. The +per-mouse-move snap cost drops from O(entities × vertices) to O(entities) for the +pre-check plus O(nearby_vertices) for the actual snap work. + +--- + ## Implementation order -F → G → H. Each is independent; all three can be done in a single pass since the pattern -is identical. F has the most complex rebuild logic (Insert explosion); G has the highest -raw byte impact; H is the simplest but matters for 3D-heavy files. +F → G → H → I → J → K (all implemented). +Each option is independent; the pattern (epoch-keyed Arc cache or a cheap pre-rejection +guard) is the same throughout. diff --git a/src/app/update.rs b/src/app/update.rs index 61f3321b8..219324b8e 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -1177,9 +1177,9 @@ impl H7CAD { self.tabs[i].snap_result = if needs_entity || is_gathering { None } else if needs_tan { - self.snapper.snap_tangent_only(cursor_world, p, &all_wires, view_proj, bounds) + self.snapper.snap_tangent_only(cursor_world, p, &all_wires[..], view_proj, bounds) } else { - self.snapper.snap(cursor_world, p, &all_wires, view_proj, bounds) + self.snapper.snap(cursor_world, p, &all_wires[..], view_proj, bounds) }; // Object Snap Tracking: update dwell and override snap if tracking. @@ -1224,7 +1224,7 @@ impl H7CAD { let mut previews = if needs_entity { let hover_handle = - scene::hit_test::click_hit(p, &all_wires, view_proj, bounds) + scene::hit_test::click_hit(p, &all_wires[..], view_proj, bounds) .and_then(|s| Scene::handle_from_wire_name(s)) .unwrap_or(acadrust::Handle::NULL); self.tabs[i].active_cmd.as_mut() @@ -1395,9 +1395,9 @@ impl H7CAD { let snap_hit = if needs_entity_click { None } else if needs_tan { - self.snapper.snap_tangent_only(raw, p, &all_wires, vp_mat, bounds) + self.snapper.snap_tangent_only(raw, p, &all_wires[..], vp_mat, bounds) } else { - self.snapper.snap(raw, p, &all_wires, vp_mat, bounds) + self.snapper.snap(raw, p, &all_wires[..], vp_mat, bounds) }; // snap.world is in paper-space (projected wire coords in MSPACE); // convert to model-space so commands receive consistent coordinates. @@ -1424,7 +1424,7 @@ impl H7CAD { { let vp_mat2 = self.tabs[i].scene.camera.borrow().view_proj(bounds); let all_wires2 = self.tabs[i].scene.hit_test_wires(); - let hit = scene::hit_test::click_hit(p, &all_wires2, vp_mat2, bounds) + let hit = scene::hit_test::click_hit(p, &all_wires2[..], vp_mat2, bounds) .and_then(|s| Scene::handle_from_wire_name(s)); if let Some(handle) = hit { let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_entity_pick(handle, world_pt)); @@ -1514,7 +1514,7 @@ impl H7CAD { let all_wires = self.tabs[i].scene.hit_test_wires(); let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds); let mut handles: Vec = scene::hit_test::box_hit( - a, p, crossing, &all_wires, vp_mat, bounds, + a, p, crossing, &all_wires[..], vp_mat, bounds, ).into_iter().filter_map(|s| Scene::handle_from_wire_name(s)).collect(); handles.extend(scene::hit_test::box_hit_hatch( a, p, crossing, &self.tabs[i].scene.hatches, vp_mat, bounds, @@ -1534,7 +1534,7 @@ impl H7CAD { let all_wires = self.tabs[i].scene.hit_test_wires(); let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds); let mut handles: Vec = scene::hit_test::poly_hit( - &poly_pts, crossing, &all_wires, vp_mat, bounds, + &poly_pts, crossing, &all_wires[..], vp_mat, bounds, ).into_iter().filter_map(|s| Scene::handle_from_wire_name(s)).collect(); handles.extend(scene::hit_test::poly_hit_hatch( &poly_pts, crossing, &self.tabs[i].scene.hatches, vp_mat, bounds, @@ -1555,7 +1555,7 @@ impl H7CAD { if box_anchor.is_none() { let all_wires = self.tabs[i].scene.hit_test_wires(); let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds); - let hit = scene::hit_test::click_hit(p, &all_wires, vp_mat, bounds) + let hit = scene::hit_test::click_hit(p, &all_wires[..], vp_mat, bounds) .and_then(|s| Scene::handle_from_wire_name(s)) .or_else(|| scene::hit_test::click_hit_hatch( p, &self.tabs[i].scene.hatches, vp_mat, bounds, @@ -1579,7 +1579,7 @@ impl H7CAD { let all_wires = self.tabs[i].scene.hit_test_wires(); let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds); let mut handles: Vec = scene::hit_test::box_hit( - a, p, crossing, &all_wires, vp_mat, bounds, + a, p, crossing, &all_wires[..], vp_mat, bounds, ).into_iter().filter_map(|s| Scene::handle_from_wire_name(s)).collect(); handles.extend(scene::hit_test::box_hit_hatch( a, p, crossing, &self.tabs[i].scene.hatches, vp_mat, bounds, @@ -1639,7 +1639,7 @@ impl H7CAD { let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: vw, height: vh }; let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds); let all_wires = self.tabs[i].scene.hit_test_wires(); - let hit = scene::hit_test::click_hit(p, &all_wires, vp_mat, bounds) + let hit = scene::hit_test::click_hit(p, &all_wires[..], vp_mat, bounds) .and_then(|s| Scene::handle_from_wire_name(s)); if let Some(handle) = hit { if let Some(entity) = self.tabs[i].scene.document.get_entity(handle) { @@ -1686,7 +1686,7 @@ impl H7CAD { let hit_vp: Option = { let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds); let all_wires = self.tabs[i].scene.hit_test_wires(); - scene::hit_test::click_hit(p, &all_wires, vp_mat, bounds) + scene::hit_test::click_hit(p, &all_wires[..], vp_mat, bounds) .and_then(|s| Scene::handle_from_wire_name(s)) .and_then(|h| { if let Some(AcadEntityType::Viewport(vp)) = diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 25520b555..8a04f704a 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -88,6 +88,9 @@ pub struct Scene { image_cache: RefCell>)>>, /// Cached mesh models, keyed by geometry_epoch. mesh_cache: RefCell>)>>, + /// Per-viewport wire cache for paper-space rendering. + /// Maps vp_handle → (geometry_epoch, Arc>). + viewport_wire_cache: RefCell>)>>, /// Active layout name — "Model" or a paper space layout name. pub current_layout: String, /// GPU render data for hatch fills, keyed by the DXF entity Handle. @@ -124,6 +127,7 @@ impl Scene { wipeout_cache: RefCell::new(None), image_cache: RefCell::new(None), mesh_cache: RefCell::new(None), + viewport_wire_cache: RefCell::new(HashMap::new()), current_layout: "Model".to_string(), hatches: HashMap::new(), meshes: HashMap::new(), @@ -472,19 +476,15 @@ impl Scene { /// viewport content is NOT interactive. /// - MSPACE (active viewport set): model-space content of the active viewport /// only — paper-space entities are NOT interactive. - pub fn hit_test_wires(&self) -> Vec { + pub fn hit_test_wires(&self) -> Arc> { if self.current_layout == "Model" { - return self.entity_wires(); + return self.entity_wires_arc(); } let layout_block = self.current_layout_block_handle(); match self.active_viewport { - None => { - // PSPACE: only paper-space entities (viewport borders, title blocks…) - self.wires_for_block(layout_block) - } + None => Arc::new(self.wires_for_block(layout_block)), Some(vp_handle) => { - // MSPACE: only model content visible through the active viewport - self.viewport_content_wires(layout_block, Some(vp_handle), None) + Arc::new(self.viewport_content_wires(layout_block, Some(vp_handle), None)) } } } @@ -2277,13 +2277,13 @@ impl Scene { } /// Hatch fills for the paper-space canvas. - pub fn paper_canvas_hatches(&self) -> Vec { - self.synced_hatch_models() + pub fn paper_canvas_hatches(&self) -> Arc> { + self.hatch_models_arc() } /// Wipeout (opaque background fill) models for the paper-space canvas. - pub fn paper_canvas_wipeouts(&self) -> Vec { - self.wipeout_models() + pub fn paper_canvas_wipeouts(&self) -> Arc> { + self.wipeout_models_arc() } pub(super) fn paper_sheet_wires(&self) -> Vec { @@ -2380,6 +2380,22 @@ impl Scene { .flat_map(|e| self.tessellate_one(e)) .collect() } + + pub(super) fn model_wires_for_viewport_arc(&self, vp_handle: Handle) -> Arc> { + { + let cache = self.viewport_wire_cache.borrow(); + if let Some((cached_epoch, ref arc)) = cache.get(&vp_handle) { + if *cached_epoch == self.geometry_epoch { + return Arc::clone(arc); + } + } + } + let arc = Arc::new(self.model_wires_for_viewport(vp_handle)); + self.viewport_wire_cache + .borrow_mut() + .insert(vp_handle, (self.geometry_epoch, Arc::clone(&arc))); + arc + } } impl Default for Scene { diff --git a/src/scene/paper_canvas.rs b/src/scene/paper_canvas.rs index 7922a3f21..b24d8c3c2 100644 --- a/src/scene/paper_canvas.rs +++ b/src/scene/paper_canvas.rs @@ -83,12 +83,12 @@ impl<'a> canvas::Program for PaperCanvas<'a> { } // ── Wipeout fills (rendered before wires, cover background) ────────── - for hatch in &self.scene.paper_canvas_wipeouts() { + for hatch in self.scene.paper_canvas_wipeouts().iter() { draw_hatch(&mut frame, hatch, &to_px); } // ── Hatch fills ─────────────────────────────────────────────────────── - for hatch in &self.scene.paper_canvas_hatches() { + for hatch in self.scene.paper_canvas_hatches().iter() { draw_hatch(&mut frame, hatch, &to_px); } diff --git a/src/scene/render.rs b/src/scene/render.rs index 85824c4fb..4ae306ee7 100644 --- a/src/scene/render.rs +++ b/src/scene/render.rs @@ -291,11 +291,11 @@ impl Scene { None => return self.build_primitive(hover_region, bounds, false), }; - let base_wires = self.model_wires_for_viewport(vp_handle); + let base_arc = self.model_wires_for_viewport_arc(vp_handle); let all_wires = if self.interim_wire.is_none() && self.preview_wires.is_empty() { - Arc::new(base_wires) + base_arc } else { - let mut v = base_wires; + let mut v = (*base_arc).clone(); if let Some(iw) = &self.interim_wire { v.push(iw.clone()); } diff --git a/src/snap/mod.rs b/src/snap/mod.rs index 1c69a731d..d4b5d6beb 100644 --- a/src/snap/mod.rs +++ b/src/snap/mod.rs @@ -281,6 +281,41 @@ impl Snapper { let mut best: Option = None; let mut best_d2 = self.snap_radius_px * self.snap_radius_px; + // World-space snap radius — derived from the view scale so wires whose + // entire extent is clearly outside the snap circle can be skipped cheaply + // before projecting any of their vertices to screen space. + // view_proj col-0 x = 2*zoom / viewport_width for an orthographic camera, + // so scale_x * (width/2) = pixels per world unit. + let world_snap_r = { + let s = view_proj.col(0).x.abs() * bounds.width * 0.5; + if s > 1e-6 { self.snap_radius_px / s } else { f32::MAX } + }; + + // Returns false when the wire's chord sphere is definitely outside the + // snap circle — safe to skip all vertex work for this wire. + // Uses first/last point of key_vertices (if any) or tessellated points. + // Closed wires (first ≈ last) are always passed through because the + // chord radius would be ~0 and their arc can still pass near the cursor. + let wire_in_range = |wire: &WireModel| -> bool { + let pts: &[[f32; 3]] = if !wire.key_vertices.is_empty() { + &wire.key_vertices + } else { + &wire.points + }; + let (Some(&f), Some(&l)) = (pts.first(), pts.last()) else { + return false; + }; + let dx = l[0] - f[0]; + let dy = l[1] - f[1]; + let half_chord = (dx * dx + dy * dy).sqrt() * 0.5; + if half_chord < 1e-4 { + return true; // closed or degenerate — can't prune + } + let cx = (f[0] + l[0]) * 0.5 - cursor_world.x; + let cy = (f[1] + l[1]) * 0.5 - cursor_world.y; + cx * cx + cy * cy <= (world_snap_r + half_chord) * (world_snap_r + half_chord) + }; + let mut try_pt = |world: Vec3, snap_type: SnapType| { let screen = world_to_screen(world, view_proj, bounds); let d2 = dist2(screen, cursor_screen); @@ -313,6 +348,7 @@ impl Snapper { // ── Endpoint ─────────────────────────────────────────────────────── if self.is_on(SnapType::Endpoint) { for wire in wires { + if !wire_in_range(wire) { continue; } if !wire.key_vertices.is_empty() { // Use explicit vertices (Line, LwPolyline): every vertex is an endpoint. for &p in &wire.key_vertices { @@ -335,6 +371,7 @@ impl Snapper { // ── Midpoint ─────────────────────────────────────────────────────── if self.is_on(SnapType::Midpoint) { for wire in wires { + if !wire_in_range(wire) { continue; } if !wire.key_vertices.is_empty() { // Use explicit vertices for accurate per-segment midpoints. for seg in wire.key_vertices.windows(2) { @@ -361,6 +398,7 @@ impl Snapper { // ── Nearest — closest point on any segment (clamped) ────────────── if self.is_on(SnapType::Nearest) { for wire in wires { + if !wire_in_range(wire) { continue; } for seg in wire.points.windows(2) { let p = nearest_on_segment(cursor_world, Vec3::from(seg[0]), Vec3::from(seg[1])); @@ -372,6 +410,7 @@ impl Snapper { // ── Perpendicular — foot of perpendicular from cursor (unclamped) ── if self.is_on(SnapType::Perpendicular) { for wire in wires { + if !wire_in_range(wire) { continue; } for seg in wire.points.windows(2) { if let Some(foot) = perp_foot(cursor_world, Vec3::from(seg[0]), Vec3::from(seg[1])) @@ -386,7 +425,9 @@ impl Snapper { if self.is_on(SnapType::Intersection) { let r_world = self.snap_radius_px * 0.5; // rough world-space cull for i in 0..wires.len() { + if !wire_in_range(&wires[i]) { continue; } for j in (i + 1)..wires.len() { + if !wire_in_range(&wires[j]) { continue; } for seg_a in wires[i].points.windows(2) { for seg_b in wires[j].points.windows(2) { let a0 = Vec3::from(seg_a[0]); @@ -455,7 +496,9 @@ impl Snapper { // ── Apparent Intersection — screen-space intersections ───────────── if self.is_on(SnapType::ApparentIntersection) { for i in 0..wires.len() { + if !wire_in_range(&wires[i]) { continue; } for j in (i + 1)..wires.len() { + if !wire_in_range(&wires[j]) { continue; } for seg_a in wires[i].points.windows(2) { for seg_b in wires[j].points.windows(2) { let sa0 = world_to_screen(Vec3::from(seg_a[0]), view_proj, bounds);