Files
OneCAD-Draft/ROADMAP.md
T
Hakan SevenandClaude Opus 4.8 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 <[email protected]>
2026-06-05 16:16:44 +03:00

419 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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):
- File open flow: [`src/io/mod.rs`](src/io/mod.rs)
(`open_path_with_phase`, `load_file`, `purge_corrupt_entities`).
- Post-open UI work: [`src/app/update.rs:90-192`](src/app/update.rs#L90-L192)
(`FileOpened` handler — xref resolve, second purge, linetype populate, etc.).
- Derived-cache build: [`src/scene/mod.rs:90-212`](src/scene/mod.rs#L90-L212)
(`build_derived_caches` — rayon-parallel for hatch / image / mesh).
- Wire tessellation: [`src/scene/mod.rs:1328-1402`](src/scene/mod.rs#L1328-L1402)
(rayon, zoom-adaptive curve tol).
- Block defn cache: [`src/scene/block_cache.rs:238`](src/scene/block_cache.rs#L238)
(`build_defn` — single-threaded today; nested expansion is topological).
- Pipeline: [`src/scene/pipeline/mod.rs`](src/scene/pipeline/mod.rs)
(batched hatch, frustum cull, LOD — Phase 1-4 done).
---
## 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](src/io/mod.rs#L62-L74) and again in the
[`FileOpened` handler after xref resolve](src/app/update.rs#L138-L145).
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`](src/app/update.rs#L138).
### 1.2 Move XREF resolution to the background thread
[`resolve_xrefs`](src/app/update.rs#L132-L166) 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`](src/scene/mod.rs#L177-L186) 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`](src/scene/block_cache.rs#L127) 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`](src/scene/mod.rs#L650)). 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`](src/scene/mod.rs#L128) 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`](src/scene/mod.rs#L414)). 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`](src/scene/pipeline/mod.rs#L101) 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.