cad-editor/ROADMAP.md
Hakan Seven 99b7ab1176 perf(scene): incremental tessellation for MOVE / COPY / ERASE
Extends the Phase 2.2 per-entity memo to the common modify commands, which
still did a full bump_geometry (whole-model re-tess + block rebuild):

- transform_entities (MOVE / ROTATE / SCALE / MIRROR) marks each
  transformed handle dirty, then bump_geometry_no_blocks;
- erase_entities drops the deleted handles from the memo and
  bump_geometry_no_blocks;
- copy_entities' new handles are natural memo misses, so just
  bump_geometry_no_blocks.

All three are top-level operations (including on inserts) that can't change
a block definition, so keeping the block cache is sound. Each now
re-tessellates only the touched entities and reuses the memoized wires for
the rest, matching the grip / line-commit path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 16:16:44 +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_filepurgebuild_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, MOVE / ROTATE / SCALE / MIRROR, COPY, ERASE) re-tessellates only the touched entities and reuses every other — the per-entity geometry math is skipped for the unchanged set. transform_entities marks the moved handles dirty, erase_entities drops the deleted ones, and copy_entities' new handles are natural memo misses; all use bump_geometry_no_blocks so the block cache is kept too. 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_phaseparse, purge, caches spans.
  • Scene::wires_for_blockblock_cache, tess, sort spans.
  • Pipeline::prepareupload, 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.