perf: implement L–S performance improvements

- L: pre-project wire endpoints to screen before ApparentIntersection pair loops
- O+S: tight per-segment AABB cull + move a0/a1 Vec3 outside inner snap loop
- P: epoch-cached entity→block reverse map replaces O(E×B) scan
- Q: lazy per-point projection in hit tests eliminates per-wire Vec<Point> alloc
- R: HatchModel.boundary Arc<Vec> so clone is pointer bump not heap alloc
This commit is contained in:
Hakan Seven committed 2026-05-06 22:42:22 +03:00
1 parent b83c982bc2
commit 45f9f96c93
6 files changed
+265 -233

No files matched your search

+66 -135
View File
@@ -1,177 +1,108 @@
# Performance Optimization Plan
# Performance Improvement Opportunities
## Implemented
## Checklist
| Option | Description |
|--------|-------------|
| A | Wire tessellation cache — `Arc<Vec<WireModel>>` 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<Point>` allocation; iterate lazily with NaN reset (`src/scene/hit_test.rs:35`)
- [x] **R** — `HatchModel.boundary`: change `Vec<[f32;2]>` → `Arc<Vec<[f32;2]>>` 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<u8>` — 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<u32>`.
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<Point>` 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<Option<(u64, Arc<Vec<HatchModel>>)>>,
wipeout_cache: RefCell<Option<(u64, Arc<Vec<HatchModel>>)>>,
```
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<HatchModel>` to
`Arc<Vec<HatchModel>>`.
**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<u8>` to `Arc<Vec<u8>>`.
`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<Vec<ImageModel>>` cache (same pattern as wire cache) so
`build_primitive()` performs only an O(1) Arc bump per frame during navigation:
```rust
image_cache: RefCell<Option<(u64, Arc<Vec<ImageModel>>)>>,
```
**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<Vec<MeshModel>>` epoch cache to mesh models:
```rust
mesh_cache: RefCell<Option<(u64, Arc<Vec<MeshModel>>)>>,
```
`MeshModel` contains `verts: Vec<[f32;3]>` (vertex positions) and `indices: Vec<u32>`
(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<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.
**Fix:** Epoch-keyed `entity_block_map_cache: HashMap<Handle, Handle>` 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<Point>` 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<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.
**Fix:** Iterate lazily — project one point at a time, maintain a `prev: Option<Point>`,
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<Vec<[f32;2]>>`. `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.
+3 -3
View File
@@ -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],
+3 -1
View File
@@ -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<Vec<[f32; 2]>>,
/// Fill pattern.
pub pattern: HatchPattern,
/// Catalog name for this pattern (e.g. "ANSI31", "SOLID", "LINEAR").
+66 -54
View File
@@ -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<Point> = 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<Point> = 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<Point> = 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<Point> = 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<Point> = 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<Point> = 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()
}
+90 -19
View File
@@ -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<Option<(u64, Option<(glam::Vec3, glam::Vec3)>)>>,
/// 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<Option<(u64, HashMap<Handle, Handle>)>>,
}
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<Handle, Handle>> {
{
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<Handle, Handle> = 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,
+37 -21
View File
@@ -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<Option<Vec<Point>>> = 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::<Vec<_>>(),
)
})
.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);
}
}
}