cad-editor/PERFORMANCE.md
Hakan Seven 2da9301a2b 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>
2026-04-26 11:55:08 +03:00

6.9 KiB
Raw Blame History

Performance Optimization Plan

Implemented

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

Remaining per-frame CPU work (navigation path)

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:

1. ImageModel.pixels cloned every frame — highest impact

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.


Option F — Cache hatch and wipeout models

Status: Done

Add epoch-keyed Arc caches for hatches and wipeouts, identical in structure to the wire cache:

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.


Option G — Arc-wrap image pixels

Status: Done

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:

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().


Option H — Arc-wrap mesh models

Status: Done

Apply the same Arc<Vec<MeshModel>> epoch cache to mesh models:

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.


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 → 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.