perf: viewport wire cache, Arc hit_test_wires, snap world-space pre-rejection (I+J+K)

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<Vec<WireModel>> — 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 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-04-26 11:55:08 +03:00
commit 2da9301a2b
6 changed files with 149 additions and 32 deletions

View file

@ -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<HashMap<Handle, (u64, Arc<Vec<WireModel>>)>>` 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<Vec<WireModel>>` instead of `Vec<WireModel>`.
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<Vec<HatchModel>>`
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.

View file

@ -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<Handle> = 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<Handle> = 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<Handle> = 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<acadrust::Handle> = {
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)) =

View file

@ -88,6 +88,9 @@ pub struct Scene {
image_cache: RefCell<Option<(u64, Arc<Vec<ImageModel>>)>>,
/// Cached mesh models, keyed by geometry_epoch.
mesh_cache: RefCell<Option<(u64, Arc<Vec<MeshModel>>)>>,
/// Per-viewport wire cache for paper-space rendering.
/// Maps vp_handle → (geometry_epoch, Arc<Vec<WireModel>>).
viewport_wire_cache: RefCell<HashMap<Handle, (u64, Arc<Vec<WireModel>>)>>,
/// 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<WireModel> {
pub fn hit_test_wires(&self) -> Arc<Vec<WireModel>> {
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<HatchModel> {
self.synced_hatch_models()
pub fn paper_canvas_hatches(&self) -> Arc<Vec<HatchModel>> {
self.hatch_models_arc()
}
/// Wipeout (opaque background fill) models for the paper-space canvas.
pub fn paper_canvas_wipeouts(&self) -> Vec<HatchModel> {
self.wipeout_models()
pub fn paper_canvas_wipeouts(&self) -> Arc<Vec<HatchModel>> {
self.wipeout_models_arc()
}
pub(super) fn paper_sheet_wires(&self) -> Vec<WireModel> {
@ -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<Vec<WireModel>> {
{
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 {

View file

@ -83,12 +83,12 @@ impl<'a> canvas::Program<Message> 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);
}

View file

@ -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());
}

View file

@ -281,6 +281,41 @@ impl Snapper {
let mut best: Option<SnapResult> = 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);