Files
OneCAD-Draft/ROADMAP.md
T
Hakan SevenandClaude Opus 4.8 eb89717b47 perf(scene): per-entity tessellation memo for the render path
Phase 2.2. wires_for_block_culled re-tessellated every visible entity on
any geometry bump, so a single-entity edit cost a full model re-tess
(~30ms baseline after the block_epoch fix).

Add a per-entity memo (tess_memo: Handle -> Arc<Vec<WireModel>>) on the
culled Model render path. It's guarded by a hash of the tessellation
parameters (tol / view / anno / world_offset / bg / entered viewport); a
mismatch (zoom, layout switch) clears it. The render path now classifies
the visible set against the memo, materializes hits and tessellates only
the misses in parallel, then stores the misses back.

- bump_geometry clears the memo (structural change — safe default);
- incremental edits drop just the changed handle via mark_entity_dirty
  and bump with bump_geometry_no_blocks: line commit (new handle is a
  natural miss) and grip commit / Esc-cancel (mark the dragged handle).

The hit-test (view_aabb == None), paper and per-viewport paths bypass the
memo so their differing cull parameters don't thrash it. A single-entity
edit now skips the per-entity geometry math for the entire unchanged set;
the Vec assembly + batched-buffer upload stay O(N).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-05 15:46:31 +03:00

19 KiB
Raw Blame History

Open CAD Studio — File Open & Render Speed Roadmap

This document lists the planned improvements for cutting file open time and on-screen draw (render) time. It builds on the already-landed Rendering Optimization work (Phase 1-4); what is left now sits on the open-time, allocation, and draw-call sides.

Source-scan summary (references):


Phase 1 — File Open Time

Goal: measurably halve the wall time between "Open" click and "first frame" for a 50 MB DWG.

1.1 Drop the second purge_corrupt_entities ✅ DONE

Today purge_corrupt_entities runs once on the background thread and again in the FileOpened handler after xref resolve. XREF content already comes from a separate document — fold the purge inline into xref resolve and delete the outer one. On large files walking doc.entities() again is a measurable cost.

Work: make resolve_xrefs call purge as it merges each xref; remove update.rs:138.

1.2 Move XREF resolution to the background thread

resolve_xrefs runs on the UI thread today — large external references freeze the UI. Move it into the open_path_with_phase worker; have DerivedCaches carry the resolved-xref list back. The UI thread only emits log lines.

New phase tag: PHASE_XREFS (we already have 3 phases; this is the 4th).

1.3 Single-pass entity walk (parse + purge + cache planning)

load_file → purge → build_derived_caches does three separate entities() walks. A single pass can produce:

  • corrupt-entity detection,
  • hatch / image / mesh handle lists,
  • AABB accumulation for world_offset (currently a separate pass inside compute_world_offset). ✅ the world_offset AABB scan is now folded into the cache-handle walk (see 2.4); corrupt-detect + hatch/image/mesh planning remain a follow-up.

Target: three O(N) passes → one.

1.4 Memory-mapped file reads (DWG / DXF)

DwgReader::from_file / DxfReader::from_file likely load the whole file into RAM with std::fs::read. Switching to memmap2:

  • eliminates the cold-cache read syscall on large files,
  • lets the DWG section index be walked on disk (if the acadrust API supports it).

Dependency: acadrust upstream may need a from_reader / from_slice API; add it in our patched fork (hakanaktt/acadrust).

1.5 Parallelize the acadrust parser (long-term)

acadrust's DWG parser is single-threaded. Section-based parallelism (header / classes / objects / blocks / entities — independent offsets) is the biggest unrealized win. Lives in the upstream fork.

Order: profile first — is this really the largest slice? Measure with puffin.

1.6 Defer raster image decode

build_derived_caches calls ImageModel::from_raster_image for every RasterImage entity — pixel decode happens up front. Wasted if the entity is off-screen. Defer the decode until first render (per-handle lazy OnceCell).

1.7 File-hash cache (warm re-open)

When re-opening the same file ((path, mtime, size) key) keep a disk snapshot of CadDocument + DerivedCaches (e.g. ~/.cache/OpenCADStudio/). Skip DWG parse entirely. Win: most-recently-opened file goes from 1-2 s to sub-100 ms.

Risk: cache invalidation. Stay conservative — load only on exact mtime + size match, otherwise normal parse.


Phase 2 — First-Frame Wire Tessellation

After FileOpened, bump_geometry() fires; the first frame tessellates every model-space wire. Measurable hitch at ~100 k entities.

2.1 Parallelize block-definition build ✅ DONE

block_cache::build was single-threaded. No topological stratification was needed after all: build_defn stores nested INSERTs as by-name references (LocalSub::Nested) and never expands them at build time, so each defn depends only on the read-only doc — the builds are embarrassingly parallel. Now a plain rayon par_iter().collect(). compute_block_aabbs is also parallelized: its defn_aabb_recursive walk is read-only over the finished defns map and re-walks shared nested defns (no memo), so the per-name resolves fan out across rayon (read phase), then a serial phase stores each AABB back.

2.2 Incremental wire cache (delta tessellation) ✅ DONE (render path)

A per-entity tessellation memo (tess_memo, Handle → Arc<Vec<WireModel>>) sits inside wires_for_block_culled on the culled Model render path. It's keyed by a guard hash of the tessellation parameters (tol / view / anno / offset / bg / entered viewport); a mismatch (zoom, layout) clears it. bump_geometry clears it (structural change); incremental edits drop just the changed handle via mark_entity_dirty and bump with bump_geometry_no_blocks. So a single-entity edit (line commit, grip commit/cancel) re-tessellates only that entity and reuses every other — the per-entity geometry math is skipped for the unchanged set. The hit-test (view_aabb == None), paper and per-viewport paths bypass the memo so their cull params don't thrash it.

Remaining: the assembly still concatenates into one Vec and rebuilds the batched GPU buffer (O(N) clone + upload), and the uncalled hit-test (entity_wires_arc) still rebuilds on edit — driving those to true O(1) needs per-handle GPU slots, which trade against the single-draw batch (3.3).

bump_geometry() invalidates the whole wire cache today (scene/mod.rs:650). Edits usually touch 1-2 entities — re-tessellating the whole doc is waste.

Fix: wire cache becomes HashMap<Handle, (entity_version, Vec<WireModel>)>. The editing command bumps the version of the affected handles; the render path re-tessellates only those, reusing the rest.

Also useful on open: any partial cache (e.g. from block defns) can be re-used.

Partial (landed): block cache survives non-block edits. The block-definition tessellation cache (BlockCache::build, every block defn) was keyed on geometry_epoch, so any edit rebuilt all block defns — a ~30 ms baseline wire re-tess turned into a ~400 ms spike on block-heavy drawings at line-commit / grip-start / grip-release. The cache now keys on a separate block_epoch. bump_geometry bumps both (safe default); the operations that provably can't change a block defn — adding a top-level entity (add_entity for non-Insert/Block) and grip-moving an entity/insert — call bump_geometry_no_blocks, which re-tessellates only the visible wires (block cache reused). Block-content edits (REFCLOSE, block create, xref, explode) still go through full bump_geometry.

Partial (landed): grip drag. Dragging an entity's grip called scene.apply_grip every move, which bump_geometry'd → a full model re-tessellation per move (plus an O(N) clone of all wires for snapping). Now the first move hides the edited entity from the base (one re-tess) and shows it as a one-entity overlay preview; subsequent moves only re-tessellate that one entity (cheap) — the base stays a cache hit. Snapping runs against the set directly (the edited entity is already hidden, so no clone and no self-snap). The drag commits on release / Esc: un-hide + one final re-tess.

Partial (landed): preview / interim overlays. set_preview_wires, clear_preview_wire and set_interim_wire used to bump_geometry, so every rubber-band frame of a drawing command re-tessellated the whole model (the same 30 ms → 400 ms spike, but while drawing). Preview / interim wires are an overlay appended to the cached base wire set in build_primitive, not part of the tessellation cache — so they no longer bump geometry. The base stays a cache hit (no re-tess), and the overlay forces only a GPU wire re-upload via the has_overlay content-id path. Snap/hit-test (entity_wires_arc) also stops re-tessellating per preview frame.

2.3 Progressive first render

On the first frame emit a coarse-tol wire pass (e.g. 4× the normal tol); refine to full tol on the second frame. The user sees something within 16 ms; detail snaps in smoothly afterwards.

2.4 Merge the world-offset scan into the single-pass walk ✅ DONE

compute_world_offset walks the whole MSPACE AABB when the header is unreliable. That scan should join the single-pass walk from 1.3 (we are already iterating entities()).


Phase 3 — Per-Frame Render Cost

After Phase 1-4 culling/LOD, what's left is upload bytes and draw call count.

3.1 Camera-only invalidation: don't re-tessellate

The wire cache key today is (geometry_epoch, camera_generation) (scene/mod.rs:414). A camera change should not force re-tessellation — only zoom-adaptive curve-tol changes need resampling, and only for curve entities (Arc / Spline / Ellipse). Straight geometry is camera-invariant.

Practical: split the wire cache in two:

  • tess_cache[handle] → WireModel (rebuild only if tol-invariant content changed),
  • frame_visible[handle] → bool (recomputed per camera_generation).

Partial (landed): pan reuse. The Model-tile wire cache no longer keys on the exact camera hash. It now keys on (geometry_epoch, pan_invariant_hash, tessellated_region) where pan_invariant_hash covers rotation + tol (wpp) but NOT the pan target. A pure pan keeps the epoch + signature and only shifts the view, so as long as the new visible rect still fits inside the 1.25×-margin region the wires were culled to, the tessellation is reused outright — no re-tessellation, tess ms drops to ~0 on the PERF HUD. Zoom (changes wpp), orbit (changes rotation) and edits (change epoch) rebuild exactly as before; the cull margin is unchanged, so miss cost is identical (no zoom regression).

Partial (landed): selection decoupled. Picking an entity used to call bump_geometry, invalidating the wire cache and re-tessellating the WHOLE model just to repaint one entity (30 ms → 400 ms on a large drawing). The highlight is no longer baked into tessellation: wires are always base-coloured (sel is empty in wires_for_block_culled), and the selection highlight is applied in the GPU xray overlay from the live selected ∪ hover set, recoloured to WireModel::SELECTED. Selection / hover now bump a cheap selection_generation instead of geometry_epoch, so the overlay refreshes without any re-tessellation or main-buffer re-upload. tess ms stays flat when selecting.

Partial (landed): per-frame split. build_primitive ran split_face3d_wires every frame — an O(N) per-wire handle lookup + clone to separate Face3D wires — even on a pan that reused the tessellation. It's now memoized by the tile's wire content id, and the non-overlay frame reuses the other Arc with no clone at all. So a pan reuses tessellation, the split, and the GPU upload; only the uniform + scissors update per frame.

Partial (landed): hover. set_hover_highlight no longer bumps the geometry epoch (a full re-tessellation) when the hovered entity is already selected — the effective highlight set selected ∪ {hover} is then unchanged, so the tessellation output is identical. Hovering over / between selected entities is now free. The full camera/selection-from-tessellation split is still open and needs running-app verification (highlight colour is baked into WireModel.color across several tessellation sites).

3.2 Persistent GPU buffer pool — diff upload ✅ DONE (wire pan path)

Wire vertex buffers are world-space, so a camera move alone never changes them — only the view_proj uniform (already uploaded per frame). The wire upload was gated on (geometry_epoch, camera_generation), re-sending every pan. Now each Model-tile tessellation is stamped with a monotonic content id (WIRE_CONTENT_GEN), reused when a pan reuses the tessellation; the pipeline holds the resident buffer's id and upload_wires is skipped when it matches. Gate is independent of the camera tick so a preview/interim change still uploads. Non-tile paths and overlay frames force a fresh id (unchanged behaviour). Monotonic id avoids the ABA hazard of a raw Arc pointer.

Still open: a true HashMap<Handle, GpuSlot> per-entity pool for partial edits (re-upload only the changed slots); this covers the whole-buffer pan/idle case, the dominant one.

Today every wire GPU buffer is re-uploaded when cached_epoch changes. A persistent pool — HashMap<Handle, GpuSlot> — uploads only the slots that actually changed. Big win in CAD-edit scenarios.

3.3 Single-draw batched wire pipeline (Phase 4-B-style) ✅ DONE

upload_wires made one GPU buffer + one draw call per WireModel (tens of thousands on a large drawing). Now it merges maximal runs of consecutive wires sharing scissor + mesh-edge state into one concatenated instance buffer each (WireGpu::from_run), so the existing draw loop issues one draw per run — a 2D model collapses to a single buffer + single draw. Runs stay consecutive (not globally regrouped) so the sorted draw order is preserved bit-for-bit; depth bias and alpha blending are unchanged. The WireInstance layout, shader, scissor logic and draw loop are untouched — only the buffer packing changed. (No iced widget-pipeline limits were hit: the change lives entirely inside the existing custom wgpu pipeline.)

Every WireModel today costs one draw call plus a bind-group swap. Port the batched hatch pipeline (hatch_batched_gpu.rs) to wires:

  • pack all wire vertices into one storage buffer,
  • per-instance (color, pattern_id, lw_px, visibility) in a side buffer,
  • vertex shader pulls instance data via instance_index,
  • a single pass.draw(0..V, 0..N) covers everything.

At 100 k wires that collapses thousands of draw calls into one. If iced 0.14's widget-pipeline limits allow, immediate win.

Follow-up (landed): separate overlay buffer. Command-preview / interim / grip-drag wires were appended into the main wire buffer, so every drag frame re-uploaded the entire (batched) base set. They now ride in their own small per-frame gpu_preview_wires buffer drawn on top in the wire pass, so the resident base buffer stays untouched during a drag — only the tiny overlay re-uploads. Combined with the no-re-tess work, a grip/command drag now costs one small overlay upload per move, nothing else.

3.4 Hardware instancing for repeated block inserts

When the same block defn is INSERT-ed N times (every door / window in an architectural drawing) each instance currently renders as its own wire set. Hardware instancing:

  • upload the block defn vertex buffer once,
  • one 4×4 transform row per Insert in an instance buffer,
  • pass.draw_indexed(0..V, 0..N_instances).

Typical architectural DWGs: 10-100× faster.

3.5 Glyph-stroke batching

tessellate.rs produces one WireModel per glyph stroke today — one text entity = dozens of models. Cache stroke geometry per font once (HashMap<(font, glyph), Vec<Point2>>), then per-text only a transform matters.


Phase 4 — Allocation & Memory

4.1 Swap HashMap for rustc-hash::FxHashMap ✅ DONE

Handle is an integer wrapper; the default SipHash is overkill. FxHashMap gives 20-40 % in hash-heavy sites (block_cache, hatches / images / meshes, viewport_wire_cache).

4.2 Arena (bumpalo) for transient wire vertices

Tessellation allocates millions of small Vec<Vec3>s. A bump arena — single allocation, frame-end reset — kills the per-vertex malloc cost. bumpalo plays well with rayon (per-thread arenas).

4.3 SmallVec for small collections

Polyline.vertices, Hatch.boundary_paths, glyph-stroke lists are typically < 8-16 entries. SmallVec<[T; 8]> skips the heap on the common case.

4.4 Compact entity-ID representation

Handle is 8 bytes. 100 k entities → 800 KB just in keys. Hot handle HashSet / HashMap usage can be flattened to Vec<u32> indices plus a single FxHashMap<Handle, u32> translation table — cache-friendlier.


Phase 5 — Profiling Infrastructure (prerequisite)

Don't start any of the above without measuring first.

5.1 Add puffin or tracy spans

  • io::open_path_with_phase → parse, purge, caches spans.
  • Scene::wires_for_block → block_cache, tess, sort spans.
  • Pipeline::prepare → upload, cull, draw spans.

Gate behind debug_assertions or a --features profile flag.

5.2 Open-time breakdown log ✅ DONE

When open completes, push to the command line:

Opened "x.dwg" — 84321 entities — parse 1.2s, purge 80ms, caches 340ms, xref 60ms, first frame 210ms

Regressions are visible immediately.

5.3 Frame-budget HUD ✅ DONE (CPU tess slice)

A CLI PERF toggle overlays the cost of the most recent wire re-tessellation (ms + wire count + geometry epoch) on the active viewport, anchored top-left. Reads ~0 ms while the wire cache is warm (idle pan/zoom), so it isolates exactly the work a cache miss costs — the slice every render-path change (2.2 / 3.1 / 3.3) moves. Timed at the miss paths in model_tile_wires_arc / paper_sheet_wires_arc and stored on Scene.

Still open: GPU-side upload / draw / GPU-wait spans need wgpu timestamp queries; the CPU tessellation slice covers the current hot path.


Priority Order

Phase 5 first (profiling) — avoids speculation.

Then, measurement-guided:

  1. Phase 1.1 + 1.2 (cheap, low-risk, certain win).
  2. Phase 1.3 + 1.6 (single-pass + lazy image).
  3. Phase 2.2 (incremental wire cache — wins on both edit and open).
  4. Phase 3.1 (camera-only invalidation — users pan/zoom constantly).
  5. Phase 3.3 (batched wire pipeline) and Phase 3.4 (instancing) — biggest render win, highest complexity.
  6. Phase 1.7 (warm cache) — dramatic UX, but invalidation must be correct or it creates nasty bugs.
  7. Phase 1.5 (acadrust parallel parse) — hardest, longest-term; only worth it if profiling confirms it is the dominant slice.

Deliberate non-goals (for now)

  • GPU compute culling: for orthographic 2D CAD the CPU quadtree is enough. Already covered by Phase 1-4.
  • Out-of-core entity streaming: meaningful for 100 MB+ single files; typical Open CAD Studio files are not there yet.
  • Multi-frame async tessellation pipeline: if 2.3 progressive render works cleanly, this isn't needed.