2026-05-23 17:16:15 +03:00
|
|
|
|
# Open CAD Studio — File Open & Render Speed Roadmap
|
2026-05-17 21:34:12 +03:00
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
|
2026-06-05 00:32:39 +03:00
|
|
|
|
### 1.1 Drop the second `purge_corrupt_entities` ✅ DONE
|
2026-05-17 21:34:12 +03:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-06-05 00:32:39 +03:00
|
|
|
|
`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.
|
2026-05-17 21:34:12 +03:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-05-23 17:32:29 +03:00
|
|
|
|
snapshot of `CadDocument` + `DerivedCaches` (e.g. `~/.cache/OpenCADStudio/`). Skip
|
2026-05-17 21:34:12 +03:00
|
|
|
|
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.
|
|
|
|
|
|
|
2026-06-05 00:46:21 +03:00
|
|
|
|
### 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()`.
|
2026-06-05 08:52:16 +03:00
|
|
|
|
`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.
|
2026-05-17 21:34:12 +03:00
|
|
|
|
|
2026-06-05 15:46:31 +03:00
|
|
|
|
### 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`.
|
2026-06-05 16:16:44 +03:00
|
|
|
|
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
|
2026-06-05 15:46:31 +03:00
|
|
|
|
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).
|
2026-05-17 21:34:12 +03:00
|
|
|
|
|
|
|
|
|
|
`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.
|
|
|
|
|
|
|
perf(scene): keep block cache across non-block edits
The block-definition tessellation cache (BlockCache::build — every block
defn) was keyed on geometry_epoch, so every edit rebuilt all block defns.
On a block-heavy drawing that turned the ~30ms visible-wire re-tess into a
~400ms spike at line-commit, grip first-move and grip release.
Key the block cache on a separate block_epoch. bump_geometry bumps both
geometry_epoch and block_epoch (safe default — xref, block create,
explode, REFCLOSE, undo, open all keep rebuilding blocks). Operations that
provably can't change a block definition now call bump_geometry_no_blocks,
which bumps only geometry_epoch so the visible wires re-tessellate against
the reused block cache:
- add_entity for a non-Insert/Block entity (line, arc, text, hatch, …) —
scene.add_entity only ever routes to model/paper space, never block
content;
- grip drag hide / commit / Esc-cancel — moving a top-level entity or an
insert never edits a block defn.
Line-commit and grip start/end drop from ~400ms to the ~30ms baseline.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 15:24:02 +03:00
|
|
|
|
**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`.
|
|
|
|
|
|
|
2026-06-05 10:33:08 +03:00
|
|
|
|
**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.
|
|
|
|
|
|
|
2026-06-05 10:21:32 +03:00
|
|
|
|
**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.
|
|
|
|
|
|
|
2026-05-17 21:34:12 +03:00
|
|
|
|
### 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.
|
|
|
|
|
|
|
2026-06-05 00:32:39 +03:00
|
|
|
|
### 2.4 Merge the world-offset scan into the single-pass walk ✅ DONE
|
2026-05-17 21:34:12 +03:00
|
|
|
|
|
|
|
|
|
|
[`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`).
|
|
|
|
|
|
|
2026-06-05 09:06:12 +03:00
|
|
|
|
**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).
|
|
|
|
|
|
|
2026-06-05 10:04:22 +03:00
|
|
|
|
**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.
|
|
|
|
|
|
|
2026-06-05 09:23:52 +03:00
|
|
|
|
**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.
|
|
|
|
|
|
|
2026-06-05 09:06:12 +03:00
|
|
|
|
**Partial (landed): hover.** `set_hover_highlight` no longer bumps the geometry
|
2026-06-05 00:58:35 +03:00
|
|
|
|
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).
|
|
|
|
|
|
|
2026-06-05 09:16:44 +03:00
|
|
|
|
### 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.
|
2026-05-17 21:34:12 +03:00
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
|
2026-06-05 09:35:18 +03:00
|
|
|
|
### 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.)
|
2026-05-17 21:34:12 +03:00
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
|
2026-06-05 10:57:38 +03:00
|
|
|
|
**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.
|
|
|
|
|
|
|
2026-05-17 21:34:12 +03:00
|
|
|
|
### 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
|
|
|
|
|
|
|
2026-06-05 00:35:53 +03:00
|
|
|
|
### 4.1 Swap `HashMap` for `rustc-hash::FxHashMap` ✅ DONE
|
2026-05-17 21:34:12 +03:00
|
|
|
|
|
|
|
|
|
|
`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.
|
|
|
|
|
|
|
2026-06-05 00:41:46 +03:00
|
|
|
|
### 5.2 Open-time breakdown log ✅ DONE
|
2026-05-17 21:34:12 +03:00
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
|
2026-06-05 01:07:17 +03:00
|
|
|
|
### 5.3 Frame-budget HUD ✅ DONE (CPU tess slice)
|
2026-05-17 21:34:12 +03:00
|
|
|
|
|
2026-06-05 01:07:17 +03:00
|
|
|
|
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.
|
2026-05-17 21:34:12 +03:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 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;
|
2026-05-23 17:16:15 +03:00
|
|
|
|
typical Open CAD Studio files are not there yet.
|
2026-05-17 21:34:12 +03:00
|
|
|
|
- **Multi-frame async tessellation pipeline:** if 2.3 progressive render
|
|
|
|
|
|
works cleanly, this isn't needed.
|