diff --git a/PERFORMANCE.md b/PERFORMANCE.md index 2e2049d30..400fffecf 100644 --- a/PERFORMANCE.md +++ b/PERFORMANCE.md @@ -1,177 +1,108 @@ -# Performance Optimization Plan +# Performance Improvement Opportunities -## Implemented +## Checklist -| Option | Description | -|--------|-------------| -| A | Wire tessellation cache — `Arc>` keyed by `geometry_epoch`; O(1) on navigation | -| B | GPU buffer cache — skip `upload_wires/hatches/images/meshes` when epoch unchanged | -| C | Rayon parallel tessellation — `tessellate_entity` free function + `par_iter()` in `wires_for_block()` | -| E | SortEntitiesTable scan cache — O(objects) linear scan replaced with O(1) HashMap lookup | +- [x] **M** — `model_space_extents()` epoch cache + wire AABB shortcut (`src/scene/mod.rs`) +- [x] **N** — `entity_wires_arc()` returns `paper_sheet_wires_arc()` directly in model space, no Vec clone (`src/scene/mod.rs`) +- [x] **L** — ApparentIntersection snap: pre-project screen coords per wire before pair loops (`src/snap/mod.rs:483`) +- [x] **O** — Intersection snap: replace loose `r_world * 60.0` midpoint cull with tight per-segment AABB overlap check (`src/snap/mod.rs:412`) +- [x] **S** — Intersection snap: move `a0/a1` Vec3 conversion outside inner `seg_b` loop (`src/snap/mod.rs:420`) +- [x] **P** — `belongs_to_visible_block()`: precompute entity→block reverse map; replace O(B) scan with O(1) HashMap lookup (`src/scene/mod.rs:782`) +- [x] **Q** — Hit test `click_hit/box_hit/poly_hit`: eliminate per-wire `Vec` allocation; iterate lazily with NaN reset (`src/scene/hit_test.rs:35`) +- [x] **R** — `HatchModel.boundary`: change `Vec<[f32;2]>` → `Arc>` so `HatchModel::clone()` is a pointer bump (`src/scene/hatch_model.rs`) --- -## Remaining per-frame CPU work (navigation path) +## Details -With Options A/B implemented, `build_primitive()` still runs every frame during navigation -(pan/zoom), even though the geometry epoch has not changed. The following work happens on every -frame and should be eliminated: +### L — ApparentIntersection snap: redundant world_to_screen per pair +**File:** `src/snap/mod.rs:483–506` -### 1. `ImageModel.pixels` cloned every frame — highest impact +For every segment pair tested, 4 `world_to_screen()` calls (matrix multiply + perspective divide) +are made just to get 2D coordinates for an intersection test. If no intersection is found, all 4 +projections are wasted. Dense drawings → O(N²M²) matrix multiplies per snap event. -`build_primitive()` calls `self.images.values().cloned().collect()`. -`ImageModel` contains `pixels: Vec` — raw RGBA pixel data for each image. -A single 2000×1500 image is ~12 MB. With three images, every mouse-move event copies ~36 MB. - -### 2. `synced_hatch_models()` — full document scan + Insert explosion every frame - -The inner loop `for entity in self.document.entities()` iterates every entity in the document, -and for each `Insert` calls `explode_from_document()` to find embedded hatches. -In drawings with many block references this is O(inserts × avg_block_size) per frame. - -### 3. `meshes.values().cloned().collect()` — 3D geometry cloned every frame - -`MeshModel` contains `verts: Vec<[f32;3]>` and `indices: Vec`. -A tessellated 3D solid can hold tens of thousands of triangles (several MB). -All mesh data is copied on every frame even during pure navigation. - -### 4. `wipeout_models()` — full entity scan every frame - -Iterates `self.document.entities()` to find `Wipeout` entities and rebuild their boundary -polygons. No caching; O(entities) on every frame. +**Fix:** Pre-project all segment endpoints for each in-range wire into a `Vec` before +entering the pair loops. Each endpoint is then projected once regardless of how many pairs it +participates in. --- -## Option F — Cache hatch and wipeout models +### M — model_space_extents() re-tessellates everything +**File:** `src/scene/mod.rs:823` -**Status:** Done +`model_space_extents()` called tessellate_one() for every entity on every ZOOM E / auto-fit. +No cached result was used — geometry rebuilt from scratch each call. -Add epoch-keyed `Arc` caches for hatches and wipeouts, identical in structure to the wire cache: - -```rust -hatch_cache: RefCell>)>>, -wipeout_cache: RefCell>)>>, -``` - -Introduce `hatch_models_arc()` and `wipeout_models_arc()` helpers that return the cached `Arc` -on an epoch hit (O(1) refcount bump) and rebuild on a miss. - -`build_primitive()` stores the `Arc` directly in `Primitive`, removing the per-frame -`synced_hatch_models()` / `wipeout_models()` calls on navigation frames. - -`Primitive.hatches` and `Primitive.wipeout_hatches` change from `Vec` to -`Arc>`. - -**Impact:** Eliminates O(inserts × block_size) work per frame in drawings with block references -containing hatch entities. Navigation becomes free for hatch-heavy files. - -**Difficulty:** Easy. Same pattern as the wire cache. +**Fix:** Epoch-keyed cache (`model_extents_cache`). On cache hit: O(1). On miss with wire cache +available: union wire AABBs (no tessellation). Fallback to tessellate_one() only on first call +or when wire cache is not for model space. --- -## Option G — Arc-wrap image pixels +### N — paper_canvas_wires() defeats Arc cache with full Vec clone +**File:** `src/scene/mod.rs:582` -**Status:** Done +In model space, `entity_wires_arc()` cloned the entire `paper_sheet_wires_arc()` Vec just to +wrap it in a new Arc. The Arc caching was bypassed on every cache miss. -Change `ImageModel.pixels` from `Vec` to `Arc>`. - -`ImageModel::clone()` then copies only the pointer (8 bytes) instead of megabytes of pixel data. -No other code needs to change — the GPU upload path reads `pixels` by reference. - -Add an epoch-keyed `Arc>` cache (same pattern as wire cache) so -`build_primitive()` performs only an O(1) Arc bump per frame during navigation: - -```rust -image_cache: RefCell>)>>, -``` - -**Impact:** Eliminates MB-scale copies per frame in files that contain raster images. -With a 4K image (~32 MB raw), this alone can cut per-frame CPU time by tens of milliseconds. - -**Difficulty:** Easy. Change one field type + add cache field + wire up in `build_primitive()`. +**Fix:** For model space, return `paper_sheet_wires_arc()` directly — the two caches share the +same Arc. Clone only needed in paper space where viewport-content wires must be appended. --- -## Option H — Arc-wrap mesh models +### O — Intersection snap O(N²·M²) segment pair testing +**File:** `src/snap/mod.rs:412–441` -**Status:** Done +The segment-level cull inside the intersection pair loop uses `r_world * 60.0` midpoint +distance, which is extremely loose and passes most segments. Every pair then runs +`seg_intersect_xy()` (cross-product math). -Apply the same `Arc>` epoch cache to mesh models: - -```rust -mesh_cache: RefCell>)>>, -``` - -`MeshModel` contains `verts: Vec<[f32;3]>` (vertex positions) and `indices: Vec` -(triangle list). A complex 3D solid can have hundreds of thousands of triangles. - -**Impact:** Eliminates per-frame mesh data copies in files that contain 3D solids (ACIS bodies, -extruded polylines). Navigation stays free regardless of model complexity. - -**Difficulty:** Easy. Same pattern as Options F and G. +**Fix:** Replace midpoint cull with tight AABB overlap check per segment pair: compute +`[min_x, max_x, min_y, max_y]` for each segment and skip if the two AABBs don't overlap. +Combined with the fix in S (pre-converted Vec3), this eliminates most cross-product calls. --- -## Option I — Per-viewport wire cache for paper space +### P — belongs_to_visible_block() O(B) scan repeated per entity +**File:** `src/scene/mod.rs:782–787` -**Status:** Done +The fallback path (null owner_handle, entity_handles not populated) iterates all `block_records` +to confirm an entity is not in any other block. Called once per entity per epoch rebuild. +O(E × B) total where E = entity count, B = block count. -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. +**Fix:** Epoch-keyed `entity_block_map_cache: HashMap` built from +`block_records[*].entity_handles`. Fallback becomes a single `HashMap::contains_key` lookup. --- -## Option J — Arc-return `hit_test_wires()` and `paper_canvas_*` +### Q — Hit testing: linear scan, no spatial index +**File:** `src/scene/hit_test.rs:35` -**Status:** Done +`click_hit()`, `box_hit()`, `poly_hit()` allocate a `Vec` for every wire by eagerly +projecting all points to screen space, even for wires far from the cursor/box. O(E × S) with +MB-scale allocation churn on large drawings. -`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. +**Fix:** Iterate lazily — project one point at a time, maintain a `prev: Option`, +test segment, advance. NaN points reset `prev`. Eliminates the per-wire Vec allocation entirely. --- -## Option K — Snap world-space wire pre-rejection +### R — synced_hatch_models(): unconditional HatchModel clone per epoch +**File:** `src/scene/hatch_model.rs`, `src/scene/mod.rs:1572` -**Status:** Done +Every visible hatch is cloned in full (boundary `Vec<[f32;2]>` + pattern enum + name String) +on every geometry epoch rebuild, even when only `angle_offset`, `scale`, or `color` changes. -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. +**Fix:** Change `HatchModel.boundary` to `Arc>`. `HatchModel::clone()` then +copies only the Arc pointer (8 bytes) for the boundary instead of heap-allocating a new Vec. --- -## Implementation order +### S — Intersection snap: repeated Vec3 endpoint copies in inner loop +**File:** `src/snap/mod.rs:420–423` -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. +`a0 = Vec3::from(seg_a[0])` and `a1 = Vec3::from(seg_a[1])` are recomputed for every +iteration of the inner `seg_b` loop. + +**Fix:** Move `a0` and `a1` conversions outside the inner loop. diff --git a/src/modules/home/draw/hatch.rs b/src/modules/home/draw/hatch.rs index 4f4081305..aed204280 100644 --- a/src/modules/home/draw/hatch.rs +++ b/src/modules/home/draw/hatch.rs @@ -113,7 +113,7 @@ impl HatchCommand { }] }); HatchModel { - boundary, + boundary: std::sync::Arc::new(boundary), pattern: HatchPattern::Pattern(families), name: pat_name.into(), color: [0.75, 0.75, 0.75, 0.85], @@ -245,7 +245,7 @@ impl GradientCommand { fn make_hatch(&self, boundary: Vec<[f32; 2]>) -> HatchModel { HatchModel { - boundary, + boundary: std::sync::Arc::new(boundary), pattern: HatchPattern::Gradient { angle_deg: 0.0, color2: [0.18, 0.18, 0.18, 0.0], @@ -396,7 +396,7 @@ impl CadCommand for BoundaryCommand { self.missed = false; // Store as a Hatch entity (solid fill) so it is selectable. let model = HatchModel { - boundary: outline.clone(), + boundary: std::sync::Arc::new(outline.clone()), pattern: HatchPattern::Solid, name: "SOLID".into(), color: [0.45, 0.45, 0.45, 0.60], diff --git a/src/scene/hatch_model.rs b/src/scene/hatch_model.rs index ad16808f1..19e29b0c9 100644 --- a/src/scene/hatch_model.rs +++ b/src/scene/hatch_model.rs @@ -1,4 +1,5 @@ // HatchModel — CPU-side hatch fill data; rendered entirely on the GPU. +use std::sync::Arc; // // The boundary is a closed polygon in world XY coordinates (max 64 vertices). // The GPU fragment shader performs point-in-polygon and hatch-line tests so @@ -41,7 +42,8 @@ pub enum HatchPattern { #[derive(Clone, Debug)] pub struct HatchModel { /// World XY coordinates of the boundary polygon vertices (max 64). - pub boundary: Vec<[f32; 2]>, + /// Arc so HatchModel::clone() is a pointer bump rather than a full Vec copy. + pub boundary: Arc>, /// Fill pattern. pub pattern: HatchPattern, /// Catalog name for this pattern (e.g. "ANSI31", "SOLID", "LINEAR"). diff --git a/src/scene/hit_test.rs b/src/scene/hit_test.rs index 02a62aa0c..0159c8a5d 100644 --- a/src/scene/hit_test.rs +++ b/src/scene/hit_test.rs @@ -31,19 +31,23 @@ pub fn click_hit<'a>( let mut best_dist = CLICK_THRESHOLD_PX; let mut best: Option<&str> = None; + // Q: lazy projection — no Vec allocation per wire; NaN resets the segment chain. for wire in wires { - let screen: Vec = wire - .points - .iter() - .map(|&p| world_to_screen(Vec3::from(p), view_proj, bounds)) - .collect(); - - for seg in screen.windows(2) { - let d = dist_point_to_segment(cursor, seg[0], seg[1]); - if d < best_dist { - best_dist = d; - best = Some(&wire.name); + let mut prev: Option = None; + for &[px, py, pz] in &wire.points { + if px.is_nan() { + prev = None; + continue; } + let cur = world_to_screen(Vec3::new(px, py, pz), view_proj, bounds); + if let Some(p0) = prev { + let d = dist_point_to_segment(cursor, p0, cur); + if d < best_dist { + best_dist = d; + best = Some(&wire.name); + } + } + prev = Some(cur); } } @@ -86,6 +90,7 @@ pub fn box_hit<'a>( let box_bl = Point { x: min_x, y: max_y }; let box_br = Point { x: max_x, y: max_y }; + // Q: lazy projection — accumulate screen points without allocating per-wire Vec. wires .iter() .filter_map(|wire| { @@ -93,32 +98,34 @@ pub fn box_hit<'a>( return None; } - let screen: Vec = wire - .points - .iter() - .map(|&p| world_to_screen(Vec3::from(p), view_proj, bounds)) - .collect(); + let mut hit = false; + let mut all_inside = true; + let mut prev: Option = None; - let hit = if crossing { - // A wire is hit when any vertex is inside the box OR any of its - // segments crosses one of the four box edges. - screen.iter().any(|&sp| inside(sp)) - || screen.windows(2).any(|seg| { - let (a, b) = (seg[0], seg[1]); - segments_intersect(a, b, box_tl, box_tr) - || segments_intersect(a, b, box_tr, box_br) - || segments_intersect(a, b, box_br, box_bl) - || segments_intersect(a, b, box_bl, box_tl) - }) - } else { - screen.iter().all(|&sp| inside(sp)) - }; - - if hit { - Some(wire.name.as_str()) - } else { - None + for &[px, py, pz] in &wire.points { + if px.is_nan() { + prev = None; + continue; + } + let sp = world_to_screen(Vec3::new(px, py, pz), view_proj, bounds); + if crossing { + if inside(sp) { hit = true; } + if let Some(p0) = prev { + if !hit { + hit = segments_intersect(p0, sp, box_tl, box_tr) + || segments_intersect(p0, sp, box_tr, box_br) + || segments_intersect(p0, sp, box_br, box_bl) + || segments_intersect(p0, sp, box_bl, box_tl); + } + } + } else { + if !inside(sp) { all_inside = false; } + } + prev = Some(sp); } + + let result = if crossing { hit } else { all_inside && prev.is_some() }; + if result { Some(wire.name.as_str()) } else { None } }) .collect() } @@ -141,6 +148,7 @@ pub fn poly_hit<'a>( return vec![]; } + // Q: lazy projection — no Vec allocation per wire. wires .iter() .filter_map(|wire| { @@ -148,27 +156,31 @@ pub fn poly_hit<'a>( return None; } - let screen: Vec = wire - .points - .iter() - .map(|&p| world_to_screen(Vec3::from(p), view_proj, bounds)) - .collect(); + let mut hit = false; + let mut all_inside = true; + let mut prev: Option = None; - let hit = if crossing { - // Any vertex inside OR any wire segment crosses any polygon edge. - screen.iter().any(|&sp| point_in_polygon(sp, poly)) - || screen - .windows(2) - .any(|seg| segment_crosses_polygon(seg[0], seg[1], poly)) - } else { - screen.iter().all(|&sp| point_in_polygon(sp, poly)) - }; - - if hit { - Some(wire.name.as_str()) - } else { - None + for &[px, py, pz] in &wire.points { + if px.is_nan() { + prev = None; + continue; + } + let sp = world_to_screen(Vec3::new(px, py, pz), view_proj, bounds); + if crossing { + if point_in_polygon(sp, poly) { hit = true; } + if !hit { + if let Some(p0) = prev { + if segment_crosses_polygon(p0, sp, poly) { hit = true; } + } + } + } else { + if !point_in_polygon(sp, poly) { all_inside = false; } + } + prev = Some(sp); } + + let result = if crossing { hit } else { all_inside && prev.is_some() }; + if result { Some(wire.name.as_str()) } else { None } }) .collect() } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 685ffc990..abe247d97 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -134,6 +134,12 @@ pub struct Scene { /// Multiplier applied to Text/MText/Dimension sizes during tessellation. /// 1.0 = no scaling. 50.0 = "1:50" drawing scale. pub annotation_scale: f32, + /// Cached model-space bounding box, keyed by geometry_epoch. + /// Avoids re-tessellating all entities on every ZOOM E / auto-fit call. + model_extents_cache: RefCell)>>, + /// Reverse map: entity_handle → block_record_handle, built from entity_handles lists. + /// Keyed by geometry_epoch. Eliminates the O(B) fallback scan in belongs_to_visible_block. + entity_block_map_cache: RefCell)>>, } impl Scene { @@ -167,6 +173,8 @@ impl Scene { world_offset: [0.0; 3], local_extent_max: 1e9, annotation_scale: 1.0, + model_extents_cache: RefCell::new(None), + entity_block_map_cache: RefCell::new(None), } } @@ -577,12 +585,16 @@ impl Scene { } } let layout_block = self.current_layout_block_handle(); - // Reuse the paper_sheet_cache to avoid a duplicate tessellation pass - // when both entity_wires_arc() and paper_canvas_wires() are called in the same frame. - let mut wires = (*self.paper_sheet_wires_arc()).clone(); - if self.current_layout != "Model" { - wires.extend(self.viewport_content_wires(layout_block, None, None)); + // Model space: paper_sheet_wires_arc IS the full entity wire set — share the Arc, + // 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))); + 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))); arc @@ -778,13 +790,38 @@ impl Scene { } } - // entity_handles not populated: fall back to "not listed in any other block". - !self - .document - .block_records - .iter() - .filter(|br| br.handle != block_handle) - .any(|br| br.entity_handles.contains(&entity_handle)) + // P: epoch-cached reverse map replaces O(B) block_records scan. + let map = self.entity_block_map(); + map.get(&entity_handle) + .map_or(true, |&owner| owner == block_handle) + } + + /// Build (and epoch-cache) a reverse map: entity_handle → block_record_handle, + /// covering every entity explicitly listed in a block_record's entity_handles. + fn entity_block_map(&self) -> std::cell::Ref<'_, HashMap> { + { + let cache = self.entity_block_map_cache.borrow(); + if let Some((epoch, _)) = *cache { + if epoch == self.geometry_epoch { + drop(cache); + return std::cell::Ref::map( + self.entity_block_map_cache.borrow(), + |c| &c.as_ref().unwrap().1, + ); + } + } + } + let mut map: HashMap = HashMap::new(); + for br in self.document.block_records.iter() { + for &eh in &br.entity_handles { + map.insert(eh, br.handle); + } + } + *self.entity_block_map_cache.borrow_mut() = Some((self.geometry_epoch, map)); + std::cell::Ref::map( + self.entity_block_map_cache.borrow(), + |c| &c.as_ref().unwrap().1, + ) } /// Full tessellation pipeline for one entity. @@ -817,17 +854,51 @@ impl Scene { .unwrap_or(Handle::NULL) } - /// Compute the axis-aligned bounding box of all model-space entities by - /// collecting their `key_vertices`. Returns `None` when there are no - /// vertices (empty drawing). + /// Compute the axis-aligned bounding box of all model-space entities. + /// Result is epoch-cached so repeated ZOOM E / auto-fit calls are O(1). pub fn model_space_extents(&self) -> Option<(glam::Vec3, glam::Vec3)> { + { + let cache = self.model_extents_cache.borrow(); + if let Some((epoch, ext)) = *cache { + if epoch == self.geometry_epoch { + return ext; + } + } + } + let result = self.compute_model_space_extents(); + *self.model_extents_cache.borrow_mut() = Some((self.geometry_epoch, result)); + result + } + + fn compute_model_space_extents(&self) -> Option<(glam::Vec3, glam::Vec3)> { let model_block = self.model_space_block_handle(); if model_block.is_null() { return None; } + let [ox, oy, _] = self.world_offset; let mut min = glam::Vec3::splat(f32::INFINITY); let mut max = glam::Vec3::splat(f32::NEG_INFINITY); let mut any = false; + + // 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 epoch == self.geometry_epoch { + for wire in arc.iter() { + let [ax, ay, bx, by] = wire.aabb; + if ax.is_finite() && bx.is_finite() { + min = min.min(glam::Vec3::new(ax + ox as f32, ay + oy as f32, 0.0)); + max = max.max(glam::Vec3::new(bx + ox as f32, by + oy as f32, 0.0)); + any = true; + } + } + return if any { Some((min, max)) } else { None }; + } + } + } + + // Fallback: tessellate (first call or paper-space context). for entity in self.document.entities() { let c = entity.common(); if c.owner_handle != model_block || c.invisible { @@ -1649,7 +1720,7 @@ impl Scene { let color = if selected { [0.15, 0.55, 1.00, 1.0] } else { base_color }; for boundary in fills { models.push(HatchModel { - boundary, + boundary: Arc::new(boundary), pattern: hatch_model::HatchPattern::Solid, name: "SOLID".into(), color, @@ -1693,7 +1764,7 @@ impl Scene { fill_color = [0.15, 0.55, 1.00, 0.35]; } models.push(HatchModel { - boundary, + boundary: Arc::new(boundary), pattern: hatch_model::HatchPattern::Solid, name: "WIPEOUT_FILL".into(), color: fill_color, @@ -1936,7 +2007,7 @@ impl Scene { dxf.pattern.name.clone() }; Some(HatchModel { - boundary, + boundary: std::sync::Arc::new(boundary), pattern, name, color, @@ -2062,7 +2133,7 @@ impl Scene { [(solid.third_corner.x - ox) as f32, (solid.third_corner.y - oy) as f32], ]; HatchModel { - boundary, + boundary: std::sync::Arc::new(boundary), pattern: hatch_model::HatchPattern::Solid, name: "SOLID".into(), color, diff --git a/src/snap/mod.rs b/src/snap/mod.rs index a4057edb7..ce778a661 100644 --- a/src/snap/mod.rs +++ b/src/snap/mod.rs @@ -410,24 +410,26 @@ impl Snapper { // ── Intersection — segment-segment intersections ────────── 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) { + // S: pre-convert outside inner loop + let a0 = Vec3::from(seg_a[0]); + let a1 = Vec3::from(seg_a[1]); + let a_min_x = a0.x.min(a1.x); + let a_max_x = a0.x.max(a1.x); + let a_min_y = a0.y.min(a1.y); + let a_max_y = a0.y.max(a1.y); for seg_b in wires[j].points.windows(2) { - let a0 = Vec3::from(seg_a[0]); - let a1 = Vec3::from(seg_a[1]); let b0 = Vec3::from(seg_b[0]); let b1 = Vec3::from(seg_b[1]); - // Quick AABB cull before expensive calculation. - let seg_mid_a = (a0 + a1) * 0.5; - let seg_mid_b = (b0 + b1) * 0.5; - if (seg_mid_a - cursor_world).length_squared() - > (r_world * 60.0).powi(2) - && (seg_mid_b - cursor_world).length_squared() - > (r_world * 60.0).powi(2) + // O: tight per-segment AABB overlap cull + if a_max_x < b0.x.min(b1.x) + || a_min_x > b0.x.max(b1.x) + || a_max_y < b0.y.min(b1.y) + || a_min_y > b0.y.max(b1.y) { continue; } @@ -481,23 +483,37 @@ impl Snapper { } // ── Apparent Intersection — screen-space intersections ───────────── + // L: pre-project each in-range wire's points to screen once, not once per segment pair. if self.is_on(SnapType::ApparentIntersection) { + let screen_pts: Vec>> = wires + .iter() + .map(|w| { + if !wire_in_range(w) { + return None; + } + Some( + w.points + .iter() + .map(|&p| world_to_screen(Vec3::from(p), view_proj, bounds)) + .collect::>(), + ) + }) + .collect(); + for i in 0..wires.len() { - if !wire_in_range(&wires[i]) { continue; } + let Some(ref si) = screen_pts[i] else { 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); - let sa1 = world_to_screen(Vec3::from(seg_a[1]), view_proj, bounds); - let sb0 = world_to_screen(Vec3::from(seg_b[0]), view_proj, bounds); - let sb1 = world_to_screen(Vec3::from(seg_b[1]), view_proj, bounds); + let Some(ref sj) = screen_pts[j] else { continue }; + for (ai, seg_a) in wires[i].points.windows(2).enumerate() { + let sa0 = si[ai]; + let sa1 = si[ai + 1]; + for (bi, _) in wires[j].points.windows(2).enumerate() { + let sb0 = sj[bi]; + let sb1 = sj[bi + 1]; if let Some((ta, _)) = seg_intersect_2d(sa0, sa1, sb0, sb1) { - // World position: interpolate along segment A. let wa0 = Vec3::from(seg_a[0]); let wa1 = Vec3::from(seg_a[1]); - let world = wa0 + ta * (wa1 - wa0); - try_pt(world, SnapType::ApparentIntersection); + try_pt(wa0 + ta * (wa1 - wa0), SnapType::ApparentIntersection); } } }