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]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-05 15:46:31 +03:00
1 parent 7f94a9d66a
commit eb89717b47
3 files changed
+125 -18

No files matched your search

+17 -1
View File
@@ -119,7 +119,23 @@ 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)
### 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`](src/scene/mod.rs#L650)). Edits usually touch 1-2
+4
View File
@@ -1325,6 +1325,8 @@ impl OpenCADStudio {
}
self.tabs[i].scene.hidden.remove(&h);
self.tabs[i].scene.clear_preview_wire();
// Geometry restored to the backup — re-tessellate just it.
self.tabs[i].scene.mark_entity_dirty(h);
self.tabs[i].scene.bump_geometry_no_blocks();
self.refresh_selected_grips();
}
@@ -2652,6 +2654,8 @@ impl OpenCADStudio {
self.grip_original = None;
self.tabs[i].scene.hidden.remove(&h);
self.tabs[i].scene.clear_preview_wire();
// Only the dragged entity changed — re-tessellate just it.
self.tabs[i].scene.mark_entity_dirty(h);
self.tabs[i].scene.bump_geometry_no_blocks();
}
// Placement confirmed — keep the just-added leader.
+104 -17
View File
@@ -775,6 +775,17 @@ pub struct Scene {
/// `selection_generation`. Rebuilt only when the selection changes so
/// `build_primitive` doesn't clone the set every frame.
highlight_set_cache: RefCell<Option<(u64, Arc<HashSet<Handle>>)>>,
/// Per-entity tessellation memo for the culled Model render path (Phase
/// 2.2). Maps a top-level handle to its already-tessellated wires so a
/// single-entity edit re-tessellates only the changed entity and reuses the
/// rest, instead of re-running the whole model. Keyed implicitly by
/// `tess_memo_guard` (tol / view / anno / offset / bg); a guard mismatch
/// (zoom, layout, …) clears it. `bump_geometry` clears it (structural
/// change); `mark_entity_dirty` drops one handle (incremental edit).
tess_memo: RefCell<HashMap<Handle, Arc<Vec<WireModel>>>>,
/// Hash of the tessellation parameters `tess_memo` was built under. When
/// the current call's parameters differ, the memo is stale and cleared.
tess_memo_guard: std::cell::Cell<u64>,
}
impl Scene {
@@ -840,6 +851,8 @@ impl Scene {
wire_force_nonce: std::cell::Cell::new(0),
split_cache: RefCell::new(None),
highlight_set_cache: RefCell::new(None),
tess_memo: RefCell::new(HashMap::default()),
tess_memo_guard: std::cell::Cell::new(0),
}
}
@@ -954,6 +967,15 @@ impl Scene {
// Default: also invalidate block definitions. Safe for every caller;
// operations that know blocks are untouched use `bump_geometry_no_blocks`.
self.block_epoch = GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed);
// Structural change — drop the whole per-entity tessellation memo.
self.tess_memo.borrow_mut().clear();
}
/// Drop a single entity from the tessellation memo so the next render
/// re-tessellates just that entity while reusing every other. Pair with
/// [`bump_geometry_no_blocks`] for an incremental single-entity edit.
pub fn mark_entity_dirty(&mut self, handle: Handle) {
self.tess_memo.borrow_mut().remove(&handle);
}
/// Invalidate the visible-wire tessellation but KEEP the cached block
@@ -2243,23 +2265,88 @@ impl Scene {
crate::scene::truck_tess::set_curve_tol_override(Some((w * 0.5) as f64));
CurveTolGuard
});
let mut wires: Vec<WireModel> = visible
.into_par_iter()
.flat_map(|e| {
tessellate_entity(
doc,
sel,
avp,
woff,
bg,
anno,
e,
Some(blk_ref),
view_aabb,
wpp,
)
})
.collect();
// Per-entity tessellation memo (Phase 2.2) — only on the culled Model
// render path. A single-entity edit re-tessellates just the changed
// entity (dropped from the memo via `mark_entity_dirty`) and reuses the
// rest, instead of re-running every visible entity. The hit-test path
// (`view_aabb == None`) and paper / per-viewport paths bypass it so
// their different cull parameters don't thrash the memo.
let memo_active = view_aabb.is_some()
&& is_model_block
&& frozen_layers.is_none()
&& anno_scale_override.is_none();
let mut wires: Vec<WireModel> = if memo_active {
// Guard hash of everything tessellate_entity output depends on
// besides the entity itself. A mismatch (zoom/tol, view, anno,
// offset, bg, entered viewport) means the memo is stale.
let guard = {
let mut g: u64 = 0xcbf2_9ce4_8422_2325;
let mut mix = |x: u64| g = g.rotate_left(13) ^ x;
mix(wpp.map(|w| w.to_bits() as u64).unwrap_or(u64::MAX));
if let Some(v) = view_aabb {
for c in v {
mix(c.to_bits() as u64);
}
}
mix(anno.to_bits() as u64);
for c in woff {
mix(c.to_bits());
}
for c in bg {
mix(c.to_bits() as u64);
}
mix(avp.map(|h| h.value()).unwrap_or(0));
g
};
if self.tess_memo_guard.get() != guard {
self.tess_memo.borrow_mut().clear();
self.tess_memo_guard.set(guard);
}
// Classify (serial, cheap): reuse memoized Arcs, collect misses.
let mut hit_arcs: Vec<Arc<Vec<WireModel>>> = Vec::new();
let mut misses: Vec<&EntityType> = Vec::new();
{
let memo = self.tess_memo.borrow();
for e in &visible {
let h = e.common().handle;
match memo.get(&h) {
Some(a) => hit_arcs.push(Arc::clone(a)),
None => misses.push(*e),
}
}
}
// Materialize hits + tessellate misses, both in parallel.
let hit_wires: Vec<WireModel> =
hit_arcs.par_iter().flat_map_iter(|a| a.iter().cloned()).collect();
let miss_pairs: Vec<(Handle, Arc<Vec<WireModel>>)> = misses
.par_iter()
.map(|e| {
let e: &EntityType = e;
let w = tessellate_entity(
doc, sel, avp, woff, bg, anno, e, Some(blk_ref), view_aabb, wpp,
);
(e.common().handle, Arc::new(w))
})
.collect();
let mut out = hit_wires;
{
let mut memo = self.tess_memo.borrow_mut();
for (h, a) in &miss_pairs {
out.extend(a.iter().cloned());
memo.insert(*h, Arc::clone(a));
}
}
out
} else {
visible
.into_par_iter()
.flat_map(|e| {
tessellate_entity(
doc, sel, avp, woff, bg, anno, e, Some(blk_ref), view_aabb, wpp,
)
})
.collect()
};
// Apply draw order via the cached index (O(1) block lookup).
{