From da34c6817a548f824affe46b4600abcd2e769f03 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 25 Jul 2026 01:06:00 +0300 Subject: [PATCH] perf: eliminate dense edit stalls Keep resident wires, draw depths, GPU slabs, and edit history incremental across entity changes. Defer rollover work to avoid cursor jumps on dense drawings. --- src/app/command_driver.rs | 71 +++- src/app/history.rs | 49 ++- src/app/mod.rs | 5 +- src/app/update/command.rs | 5 +- src/app/update/viewport.rs | 69 ++- src/scene/entity.rs | 8 +- src/scene/mod.rs | 630 +++++++++++++++++++++------- src/scene/pick/interaction_index.rs | 51 ++- src/scene/pipeline/mod.rs | 12 +- src/scene/pipeline/wire_arena.rs | 136 ++++-- src/scene/view/render.rs | 216 +++++++--- 11 files changed, 956 insertions(+), 296 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 1bdc71006..72e1f7ad2 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -435,13 +435,16 @@ impl OpenCADStudio { } CmdResult::CommitSolid { entity, solid } => { let label = self.history_label_from_active_cmd(i, "SOLID"); - self.push_undo_snapshot(i, label); + let pending = self.begin_undo(i, label, 1, true); self.add_solid_model(entity, *solid); self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.restore_pre_cmd_tangent(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } } CmdResult::CommitAndEditText(entity) => { let label = self.history_label_from_active_cmd(i, "ENTITY"); @@ -466,7 +469,10 @@ impl OpenCADStudio { edit_index, } => { let label = self.history_label_from_active_cmd(i, "ENTITY"); - self.push_undo_snapshot(i, label); + let delta_safe = entities + .iter() + .all(|entity| self.delta_add_safe(i, entity)); + let pending = self.begin_undo(i, label, entities.len(), delta_safe); let mut edit_handle = None; let mut leader_handle = None; for (idx, entity) in entities.into_iter().enumerate() { @@ -494,6 +500,9 @@ impl OpenCADStudio { self.tabs[i].snap_result = None; self.restore_pre_cmd_tangent(); self.ribbon.deactivate_tool(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } if let Some(h) = edit_handle { return self.begin_text_edit(h); } @@ -533,7 +542,7 @@ impl OpenCADStudio { } CmdResult::CommitHatch(hatch) => { let label = self.history_label_from_active_cmd(i, "HATCH"); - self.push_undo_snapshot(i, label); + let pending = self.begin_undo(i, label, 1, true); let new_handle = self.tabs[i].scene.add_hatch(hatch); if !new_handle.is_null() { self.tabs[i].scene.select_entity(new_handle, true); @@ -544,6 +553,9 @@ impl OpenCADStudio { self.tabs[i].snap_result = None; self.restore_pre_cmd_tangent(); self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } } CmdResult::BatchCopy(handles, transforms) => { let label = self.history_label_from_active_cmd(i, "ARRAY"); @@ -747,13 +759,17 @@ impl OpenCADStudio { .and_then(|c| c.attreq_take_insert()); if let Some(entity) = entity { let label = self.history_label_from_active_cmd(i, "INSERT"); - self.push_undo_snapshot(i, label); + let delta_safe = self.delta_add_safe(i, &entity); + let pending = self.begin_undo(i, label, 1, delta_safe); self.commit_entity(entity); self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.restore_pre_cmd_tangent(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } } } else { // Inject attdefs so the command enters attr-filling mode. @@ -768,11 +784,19 @@ impl OpenCADStudio { } CmdResult::CommitLiveEntity(entity) => { let label = self.history_label_from_active_cmd(i, "ENTITY"); - self.push_undo_snapshot(i, label); + let delta_safe = self.delta_add_safe(i, &entity); + let pending = self.begin_undo(i, label, 1, delta_safe); let handle = self.commit_entity_handle(entity); self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } if let Some(h) = handle { + // Keep the live document Arc unique while later vertices + // replace the geometry in place. The final Arc is captured + // by UpdateLiveEntity when the command completes. + self.defer_live_entity_history_after(i, h); if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { cmd.set_live_handle(h); } @@ -800,11 +824,13 @@ impl OpenCADStudio { new.as_entity_mut().set_handle(old_handle); new.as_entity_mut().set_layer(layer); *old = new; - self.tabs[i].scene.mark_entity_dirty(handle); - self.tabs[i].scene.bump_geometry_no_blocks(); + self.tabs[i] + .scene + .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); self.tabs[i].dirty = true; } if finish { + self.finish_live_entity_history(i, handle); self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; @@ -819,10 +845,10 @@ impl OpenCADStudio { CmdResult::RemoveLiveEntity(handle) => { // The command backed off below a valid entity (PLINE Undo at // one remaining vertex): take the live entity out of the - // document and keep prompting. No undo bookkeeping — the - // create's snapshot already covers the whole in-progress - // object as one unit. + // document, drop its provisional history entry and keep + // prompting. A later second point creates one fresh entry. self.tabs[i].scene.erase_entities(&[handle]); + self.discard_last_undo_entry(i); self.tabs[i].dirty = true; let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); if let Some(p) = prompt { @@ -1790,7 +1816,7 @@ impl OpenCADStudio { // ── Solid3D creation (BOX / SPHERE / CYLINDER) ──────────────── CmdResult::CommitSolid3D { mesh_fn } => { use crate::modules::insert::solid3d_cmds::empty_solid3d; - self.push_undo_snapshot(i, "SOLID3D"); + let pending = self.begin_undo(i, "SOLID3D", 1, true); let entity = empty_solid3d(); let handle = self.tabs[i].scene.add_entity(entity); if !handle.is_null() { @@ -1806,6 +1832,9 @@ impl OpenCADStudio { self.tabs[i].dirty = true; self.command_line.push_output("Solid created."); } + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); @@ -1861,7 +1890,7 @@ impl OpenCADStudio { } }); if let Some((mut mesh, solid)) = result { - self.push_undo_snapshot(i, "EXTRUDE"); + let pending = self.begin_undo(i, "EXTRUDE", 1, true); let new_entity = empty_solid3d(); let new_handle = self.tabs[i].scene.add_entity(new_entity); mesh.name = format!("{}", new_handle.value()); @@ -1874,6 +1903,9 @@ impl OpenCADStudio { self.tabs[i].scene.solid_models.insert(new_handle, solid); self.tabs[i].dirty = true; self.command_line.push_output("EXTRUDE: solid created."); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } } else { self.command_line.push_error("EXTRUDE: could not build profile. Select a closed 2D entity (Circle, LwPolyline, etc.)."); } @@ -1943,7 +1975,7 @@ impl OpenCADStudio { } }); if let Some(mut mesh) = result { - self.push_undo_snapshot(i, "REVOLVE"); + let pending = self.begin_undo(i, "REVOLVE", 1, true); let new_entity = empty_solid3d(); let new_handle = self.tabs[i].scene.add_entity(new_entity); mesh.name = format!("{}", new_handle.value()); @@ -1954,6 +1986,9 @@ impl OpenCADStudio { self.tabs[i].dirty = true; self.command_line .push_output(&format!("REVOLVE: solid created ({:.0}°).", angle_deg)); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } } else { self.command_line .push_error("REVOLVE: could not revolve profile."); @@ -2111,7 +2146,7 @@ impl OpenCADStudio { }); if let Some(mut mesh) = result { - self.push_undo_snapshot(i, "SWEEP"); + let pending = self.begin_undo(i, "SWEEP", 1, true); let new_entity = empty_solid3d(); let new_handle = self.tabs[i].scene.add_entity(new_entity); mesh.name = format!("{}", new_handle.value()); @@ -2121,6 +2156,9 @@ impl OpenCADStudio { .insert(new_handle, crate::scene::MeshLodSet::from_single(mesh)); self.tabs[i].dirty = true; self.command_line.push_output("SWEEP: solid created."); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } } else { self.command_line.push_error("SWEEP: could not sweep profile along path. Use a closed 2D profile and a Line or Polyline path."); } @@ -2200,7 +2238,7 @@ impl OpenCADStudio { ); if let Some(mut mesh) = result { - self.push_undo_snapshot(i, "LOFT"); + let pending = self.begin_undo(i, "LOFT", 1, true); let new_entity = empty_solid3d(); let new_handle = self.tabs[i].scene.add_entity(new_entity); mesh.name = format!("{}", new_handle.value()); @@ -2213,6 +2251,9 @@ impl OpenCADStudio { "LOFT: solid created from {} profiles.", handles.len() )); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } } else { self.command_line.push_error("LOFT: could not loft profiles. Ensure sections have the same edge count and are compatible."); } diff --git a/src/app/history.rs b/src/app/history.rs index a003f2f88..cd185a1ac 100644 --- a/src/app/history.rs +++ b/src/app/history.rs @@ -148,6 +148,46 @@ impl OpenCADStudio { self.push_undo_entry(i, HistorySnapshot::Delta(delta)); } + pub(super) fn defer_live_entity_history_after(&mut self, i: usize, handle: Handle) { + let Some(HistorySnapshot::Delta(delta)) = + self.tabs[i].history.undo_stack.last_mut() + else { + return; + }; + if let Some((_, _, after)) = delta + .entities + .iter_mut() + .find(|(entry_handle, _, _)| *entry_handle == handle) + { + *after = None; + } + } + + pub(super) fn finish_live_entity_history(&mut self, i: usize, handle: Handle) { + let Some(after) = self.tabs[i].scene.document.get_entity_arc(handle) else { + return; + }; + let selected_after = self.tabs[i].scene.selected.iter().copied().collect(); + let current_layout_after = self.tabs[i].scene.current_layout.clone(); + let dirty_after = self.tabs[i].dirty; + let Some(HistorySnapshot::Delta(delta)) = + self.tabs[i].history.undo_stack.last_mut() + else { + return; + }; + let Some((_, _, entry_after)) = delta + .entities + .iter_mut() + .find(|(entry_handle, _, _)| *entry_handle == handle) + else { + return; + }; + *entry_after = Some(after); + delta.selected_after = selected_after; + delta.current_layout_after = current_layout_after; + delta.dirty_after = dirty_after; + } + pub(super) fn discard_last_undo_entry(&mut self, i: usize) { if self.tabs[i].history.pending.take().is_some() { self.tabs[i].scene.document.end_entity_change_recording(); @@ -296,16 +336,15 @@ impl OpenCADStudio { } /// Add is delta-safe only for a plain drawable on an already-existing layer: - /// an insert / block / image / dimension add also creates block records, - /// image definitions or layers (non-entity state). + /// a block / image add also creates block records, image definitions or + /// layers (non-entity state). INSERT only references an existing block and + /// is entity-delta safe. pub(super) fn delta_add_safe(&self, i: usize, entity: &EntityType) -> bool { if matches!( entity, - EntityType::Insert(_) - | EntityType::Block(_) + EntityType::Block(_) | EntityType::BlockEnd(_) | EntityType::RasterImage(_) - | EntityType::Dimension(_) // A viewport commit routes through add_entity_to_layout + // bump_geometry_no_blocks, bypassing the recorded Scene::add_entity // — nothing would be captured, so keep the full snapshot. diff --git a/src/app/mod.rs b/src/app/mod.rs index 16b44d6c8..a854cd002 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -67,7 +67,10 @@ pub struct HoverDwell { } /// How long the cursor must sit still before the idle rollover pick runs. -pub const HOVER_DWELL_MS: u128 = 120; +pub const HOVER_DWELL_MS: u128 = 500; +/// Dense resident sets also retain the previous rollover while moving; this +/// threshold gates that extra redraw-avoidance behavior. +pub const HOVER_DWELL_DENSE_WIRES: usize = 50_000; /// Open multi-functional-grip popup state. #[derive(Clone, Debug)] diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 034f83584..f676fa707 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -518,8 +518,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { 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.tabs[i] + .scene + .bump_entities(&[(h, crate::scene::ChangeKind::Modified)]); self.refresh_selected_grips(); } self.tabs[self.active_tab].snap_result = None; diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 23b3a0ff9..6c28ec0bc 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -496,6 +496,8 @@ impl OpenCADStudio { return Task::none(); } let i = self.active_tab; + let perf_move = std::env::var_os("OCS_PERF").is_some(); + let move_started = Instant::now(); // UCS icon grip drag: map the cursor onto the UCS plane and // slide the origin / rotate the axis. Short-circuits pan & snap. @@ -694,6 +696,7 @@ impl OpenCADStudio { // ── Grip drag ───────────────────────────────────────────── if let Some(grip) = self.tabs[i].active_grip.clone() { + let grip_started = Instant::now(); let (vw, vh) = vp_size; let bounds = iced::Rectangle { x: 0.0, @@ -734,9 +737,11 @@ impl OpenCADStudio { // Back up the original geometry so Esc can cancel the drag. self.grip_original = self.tabs[i].scene.document.get_entity(grip.handle).cloned(); self.tabs[i].scene.hidden.insert(grip.handle); - // Grip drag never changes a block definition — keep the - // block cache so the hide doesn't re-tessellate blocks. - self.tabs[i].scene.bump_geometry_no_blocks(); + // Hiding changes exactly one resident run. Publishing a full + // delta here made the first grip move rebuild every wire. + self.tabs[i] + .scene + .bump_entities(&[(grip.handle, crate::scene::ChangeKind::Modified)]); self.grip_preview_handle = Some(grip.handle); // Snapshot the entity's glyph quads once so each move can // slide the already-shaped text rather than re-shaping it @@ -760,6 +765,8 @@ impl OpenCADStudio { && snap.iter().all(|w| w.points.is_empty()) && square_grip; } + let setup_ms = grip_started.elapsed().as_secs_f64() * 1000.0; + let snap_started = Instant::now(); // The edited entity is hidden, so it's already absent from // `hit_test_wires` — snap against the set directly, no clone @@ -833,6 +840,8 @@ impl OpenCADStudio { } } + let snap_ms = snap_started.elapsed().as_secs_f64() * 1000.0; + let apply_started = Instant::now(); let apply = if grip.is_translate { GripApply::Translate(snapped - grip.last_world) } else { @@ -843,6 +852,8 @@ impl OpenCADStudio { .apply_grip(grip.handle, grip.grip_id, apply); self.tabs[i].dirty = true; self.tabs[i].active_grip.as_mut().unwrap().last_world = snapped; + let apply_ms = apply_started.elapsed().as_secs_f64() * 1000.0; + let preview_started = Instant::now(); // Overlay the moved entity (hidden from the base). Pure text // moved as a whole slides its drag-start glyphs — no re-shaping // and no wire re-tess. Anything else (wire geometry, or a point @@ -865,8 +876,25 @@ impl OpenCADStudio { let preview = self.tabs[i].scene.wire_models_for(&[grip.handle]); self.tabs[i].scene.set_preview_wires(preview); } + let preview_ms = preview_started.elapsed().as_secs_f64() * 1000.0; + let geometry_ms = grip_started.elapsed().as_secs_f64() * 1000.0; self.refresh_selected_grips(); - self.refresh_properties(); + let grips_ms = grip_started.elapsed().as_secs_f64() * 1000.0 - geometry_ms; + // Properties are refreshed when the grip is committed or cancelled. + // Rebuilding the inspector on every pointer event adds no drawing + // value and can monopolize the UI thread during a drag. + let total_ms = grip_started.elapsed().as_secs_f64() * 1000.0; + if perf_move && total_ms >= 50.0 { + eprintln!( + "[perf] grip-move {:>7.1}ms setup={:.1} snap={:.1} apply={:.1} preview={:.1} grips={:.1}", + total_ms, + setup_ms, + snap_ms, + apply_ms, + preview_ms, + grips_ms, + ); + } return Task::none(); } @@ -888,11 +916,17 @@ impl OpenCADStudio { // drag), defer the pick until the cursor stops. The full // pick (wires + hatches + block hatches + shaded meshes) is // O(N) per frame and stalls the cursor on large drawings, - // so each move clears the current highlight and resets the - // dwell timer — `HoverDwellTick` runs the hit-test only + // so each move resets the dwell timer — `HoverDwellTick` runs the hit-test only // once the cursor has been still for `HOVER_DWELL_MS`. if !dragging && self.tabs[i].active_cmd.is_none() { - self.tabs[i].scene.set_hover_highlight(None); + // On dense drawings, clearing a rollover immediately schedules a + // second full scene frame just as motion resumes. Keep the previous + // highlight until the next settled pick replaces it. + if self.tabs[i].scene.last_tess_wires.get() + < crate::app::HOVER_DWELL_DENSE_WIRES + { + self.tabs[i].scene.set_hover_highlight(None); + } self.hover_dwell = Some(crate::app::HoverDwell { last_move_at: Instant::now(), point: p, @@ -1417,6 +1451,13 @@ impl OpenCADStudio { } self.sync_dyn_fields(); + let move_ms = move_started.elapsed().as_secs_f64() * 1000.0; + if perf_move && self.tabs[i].active_cmd.is_some() && move_ms >= 50.0 { + eprintln!( + "[perf] pointer-move {:>7.1}ms", + move_ms, + ); + } Task::none() } @@ -3410,13 +3451,16 @@ impl OpenCADStudio { let Some(dwell) = self.hover_dwell.clone() else { return Task::none(); }; + let dwell_ms = crate::app::HOVER_DWELL_MS; if Instant::now() .duration_since(dwell.last_move_at) .as_millis() - < crate::app::HOVER_DWELL_MS + < dwell_ms { return Task::none(); } + let perf = std::env::var_os("OCS_PERF").is_some(); + let hover_started = Instant::now(); let i = dwell.tab; // Re-check the gate — drag / command may have started // between the move that armed the dwell and this tick. @@ -3493,6 +3537,15 @@ impl OpenCADStudio { }); self.tabs[i].scene.set_hover_highlight(hovered); self.hover_dwell = None; + let hover_ms = hover_started.elapsed().as_secs_f64() * 1000.0; + if perf && hover_ms >= 5.0 { + eprintln!( + "[perf] hover-dwell {:>7.1}ms wires={} hit={}", + hover_ms, + self.tabs[i].scene.last_tess_wires.get(), + hovered.is_some(), + ); + } Task::none() } diff --git a/src/scene/entity.rs b/src/scene/entity.rs index ddae10909..280da901b 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -120,6 +120,12 @@ impl Scene { &entity, EntityType::Insert(_) | EntityType::Block(_) | EntityType::BlockEnd(_) ); + // INSERT invalidates rendered block instances, but it does not mutate + // the referenced block definition. Only block sentinels require a + // structure image for undo; ordinary owner membership is intrinsic add + // bookkeeping and remains in place while an entity delta is undone. + let mutates_block_structure = + matches!(&entity, EntityType::Block(_) | EntityType::BlockEnd(_)); let hatch_seed = if let EntityType::Hatch(dxf) = &entity { let color = self.render_style(&entity).0; Self::hatch_model_from_dxf(dxf, color) @@ -208,7 +214,7 @@ impl Scene { // pure-entity delta would be incomplete. if self.is_recording_undo() { self.record_undo_before(handle, None); - if creates_layer || affects_blocks || is_image { + if creates_layer || mutates_block_structure || is_image { self.poison_undo_recording(); } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 0ba751db1..d90ca9670 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -216,6 +216,55 @@ struct GeometryDelta { full: bool, } +/// Mutable assembly metadata for one resident wire set. Keeping entity ranges +/// beside the flat render Vec lets a one-entity edit splice that run directly; +/// rebuilding this directory by grouping every WireModel was the dominant +/// edit-time CPU cost on dense drawings. +struct ResidentWireSet { + epoch: u64, + gen: u64, + wires: Arc>, + layout: Option, +} + +struct ResidentWireLayout { + /// Entity handles in final submission order. A temporarily hidden entity + /// remains here with no range so grip commit can restore it in place. + order: Vec, + /// Flat wire range `(start, len)` for each currently visible entity. + ranges: HashMap, + /// First synthesized marker wire. Markers have no entity handle and remain + /// at the tail while entity runs are inserted/removed before them. + marker_start: usize, +} + +#[derive(Debug)] +pub(crate) struct WireGpuPatch { + pub(crate) changes: Arc>, + pub(crate) runs: Arc>>>, + pub(crate) index_edits: Arc>, + pub(crate) new_handles_are_suffix: bool, + /// Whether Face3D / generic fill buffers can differ after this patch. + pub(crate) face_pass_changed: bool, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct WireIndexEdit { + pub(crate) handle: Handle, + pub(crate) start: usize, + pub(crate) old_len: usize, + pub(crate) new_len: usize, +} + +struct DrawDepthCache { + epoch: u64, + depths: Arc>, + /// Per-block handles already sorted by effective draw-order key. + blocks: HashMap>, + /// Reverse owner lookup retained so Removed deltas need no document scan. + owners: HashMap, +} + /// Bound on the geometry-delta ring. A consumer that fell more than this many /// mutations behind (or predates the oldest retained delta) can't be replayed /// and does a one-time full rebuild — the safe fallback, not a correctness hole. @@ -1021,7 +1070,7 @@ pub struct Scene { /// sub-range a block INSERT's children may occupy without crossing the /// insert's siblings. Fill explosion and band wires compose child depths /// as `depth + child_rank * half`. - draw_depth_cache: RefCell>)>>, + draw_depth_cache: RefCell>, /// Cached hatch fill models, keyed by geometry_epoch. View culling /// is handled at draw time via `hatch_skip_flags` in the pipeline, /// not at build time — that lets the GPU buffer stay stable across @@ -1194,7 +1243,7 @@ pub struct Scene { /// camera-keyed re-tessellation anywhere. Rebuilt per key when /// `geometry_epoch` changes; stale entries evicted on insert. #[allow(clippy::type_complexity)] - resident_wire_sets: RefCell>)>>, + resident_wire_sets: RefCell>, /// Memoized `(face3d, other)` split of a resident wire set, keyed by its /// [`WIRE_CONTENT_GEN`] id. `split_face3d_wires` is an O(N) per-wire /// handle lookup + clone that otherwise re-runs every frame. A map, not a @@ -1249,7 +1298,7 @@ pub struct Scene { /// those entities' instance slabs instead of re-uploading every wire. The /// render layer matches `new_gen` against the viewport's content id and /// `prev_gen` against what the GPU currently holds; a mismatch just rebuilds. - model_wire_gpu_patch: RefCell>)>>, + model_wire_gpu_patch: RefCell)>>, /// Active delta-undo recording, or `None` when no entity-only command is /// capturing. Populated by the five mutation primitives via /// [`Scene::record_undo_before`]; consumed by the app (`take_undo_recording`) @@ -3121,10 +3170,10 @@ impl Scene { }; { let sets = self.resident_wire_sets.borrow(); - if let Some((epoch, gen, arc)) = sets.get(&key) { - if *epoch == self.geometry_epoch { - self.last_model_wire_gen.set(*gen); - return Arc::clone(arc); + if let Some(set) = sets.get(&key) { + if set.epoch == self.geometry_epoch { + self.last_model_wire_gen.set(set.gen); + return Arc::clone(&set.wires); } } } @@ -3149,6 +3198,7 @@ impl Scene { self.append_scene_markers(&mut wires, bg); } self.apply_refedit_fade(&mut wires, bg); + let layout = Self::resident_wire_layout(&wires); self.last_tess_ms .set(t_tess.elapsed().as_secs_f32() * 1000.0); self.last_tess_wires.set(wires.len()); @@ -3164,11 +3214,19 @@ impl Scene { // Evict stale entries (older epochs / abandoned keys) so switching // spaces or re-scaling a viewport can't accumulate dead full sets. let cur_epoch = self.geometry_epoch; - sets.retain(|_, (e, ..)| *e == cur_epoch); + sets.retain(|_, set| set.epoch == cur_epoch); if sets.len() > 8 { sets.clear(); } - sets.insert(key, (cur_epoch, gen, Arc::clone(&arc))); + sets.insert( + key, + ResidentWireSet { + epoch: cur_epoch, + gen, + wires: Arc::clone(&arc), + layout, + }, + ); arc } @@ -3180,13 +3238,63 @@ impl Scene { pub(crate) fn model_wire_patch_for( &self, gen: u64, - ) -> Option<(u64, Arc>)> { + ) -> Option<(u64, Arc)> { match &*self.model_wire_gpu_patch.borrow() { - Some((prev, new, changes)) if *new == gen => Some((*prev, Arc::clone(changes))), + Some((prev, new, patch)) if *new == gen => Some((*prev, Arc::clone(patch))), _ => None, } } + fn resident_wire_layout(wires: &[WireModel]) -> Option { + let mut order = Vec::new(); + let mut ranges = HashMap::default(); + let mut current: Option<(Handle, usize)> = None; + let mut marker_start = wires.len(); + + for (index, wire) in wires.iter().enumerate() { + let Some(handle) = Self::handle_from_wire_name(&wire.name) else { + if let Some((handle, start)) = current.take() { + if ranges.insert(handle, (start, index - start)).is_some() { + return None; + } + } + marker_start = index; + if wires[index..] + .iter() + .any(|tail| Self::handle_from_wire_name(&tail.name).is_some()) + { + return None; + } + break; + }; + if current.map(|(h, _)| h) != Some(handle) { + if let Some((previous, start)) = current.take() { + if ranges.insert(previous, (start, index - start)).is_some() { + return None; + } + } + if ranges.contains_key(&handle) { + return None; + } + order.push(handle); + current = Some((handle, index)); + } + } + if let Some((handle, start)) = current { + if ranges + .insert(handle, (start, marker_start - start)) + .is_some() + { + return None; + } + } + Some(ResidentWireLayout { + order, + ranges, + marker_start, + }) + } + /// Bring the resident set for `key` up to the current epoch by replaying only /// the changed entities into the cached assembly. Returns the patched `Arc`, /// or `None` to fall back to a full rebuild — the safe default whenever the @@ -3204,62 +3312,41 @@ impl Scene { anno_scale_override: Option, frozen_layers: Option<&HashSet>, ) -> Option>> { + let perf = std::env::var_os("OCS_PERF").is_some(); + let t_patch = iced::time::Instant::now(); // The entry must exist, be stale, and be uniquely held so we can move // its wires out rather than deep-clone them. let cached_epoch = { let sets = self.resident_wire_sets.borrow(); let entry = sets.get(&key)?; - if entry.0 == self.geometry_epoch || Arc::strong_count(&entry.2) != 1 { + if entry.epoch == self.geometry_epoch || entry.layout.is_none() { return None; } - entry.0 + let strong = Arc::strong_count(&entry.wires); + if strong != 1 { + if perf { + eprintln!("[perf] resident-shared strong={strong}"); + } + return None; + } + entry.epoch }; let deltas = self.replay_since(cached_epoch)?; // Take ownership of the cached assembly (guaranteed unique above). // `prev_gen` is the content id the GPU currently holds for this set — the // base the wire-arena patch replays from. - let (prev_gen, owned) = { + let (prev_gen, mut owned, mut layout) = { let removed = self.resident_wire_sets.borrow_mut().remove(&key)?; - (removed.1, Arc::try_unwrap(removed.2).ok()?) + ( + removed.gen, + Arc::try_unwrap(removed.wires).ok()?, + removed.layout?, + ) }; - - // Group into per-entity runs. Bail if any wire isn't named with a handle - // or an entity's wires aren't contiguous — the from-scratch sort puts - // each entity's wires in one contiguous run, so a violation means our - // move-and-reassemble model doesn't hold and we must full-rebuild. - let mut old_runs: HashMap> = HashMap::default(); - // The assembled draw order (one entry per entity, in the order they were - // laid out). Lets an all-Modified edit — which never reorders — reassemble - // by walking just these (~10k) handles instead of the whole document. - let mut old_order: Vec = Vec::new(); - { - let mut cur: Option = None; - let mut run: Vec = Vec::new(); - for w in owned { - let h = Self::handle_from_wire_name(&w.name)?; - if Some(h) != cur { - if let Some(ph) = cur.take() { - if old_runs.insert(ph, std::mem::take(&mut run)).is_some() { - return None; - } - } - cur = Some(h); - old_order.push(h); - } - run.push(w); - } - if let Some(ph) = cur { - if old_runs.insert(ph, run).is_some() { - return None; - } - } + if layout.marker_start > owned.len() { + return None; } - // An all-Modified edit keeps the layout (no entity added / removed), so - // the draw order is exactly `old_order` — skip the whole-document scan. - let structural = deltas - .iter() - .any(|(_, k)| !matches!(k, ChangeKind::Modified)); // Re-tessellate the changed entities, replicating the full build's exact // context (anno derivation, empty selection, no cull / no LOD). Faded @@ -3276,10 +3363,8 @@ impl Scene { let empty_sel: HashSet = HashSet::default(); let mut new_runs: HashMap> = HashMap::default(); let mut memo_updates: Vec<(Handle, Arc>)> = Vec::new(); + let mut visible_changed: HashSet = HashSet::default(); for (h, kind) in &deltas { - // A changed entity's old run never carries over; unchanged entities - // are the only ones left in old_runs after this. - old_runs.remove(h); if matches!(kind, ChangeKind::Removed) { continue; } @@ -3289,6 +3374,7 @@ impl Scene { if !self.resident_entity_visible(e, block, frozen_layers) { continue; } + visible_changed.insert(*h); let raw = tessellate_entity( &self.document, &empty_sel, @@ -3313,51 +3399,123 @@ impl Scene { } } - // Reassemble, then apply the identical draw-order sort - // wires_for_block_culled uses. Emission order IS the final order when the - // block has no SortEntitiesTable (the sort below no-ops), so it must match - // a from-scratch build: document order. An all-Modified edit never reorders - // — reuse the recorded `old_order` (~10k handles) instead of walking the - // whole document (~800k). Add/Remove change positions, so fall back to the - // document scan. Either way a leftover run (a kept handle we couldn't - // place) means an inconsistency we don't patch. - let mut new_vec: Vec = Vec::new(); - if structural { - for e in self.document.entities() { - let h = e.common().handle; - if let Some(run) = new_runs.remove(&h) { - new_vec.extend(run); - } else if let Some(run) = old_runs.remove(&h) { - new_vec.extend(run); - } - } - } else { - for h in old_order { - if let Some(run) = new_runs.remove(&h) { - new_vec.extend(run); - } else if let Some(run) = old_runs.remove(&h) { - new_vec.extend(run); - } - } - } - if !new_runs.is_empty() || !old_runs.is_empty() { - return None; - } + // Keep the submission-order directory current. Adds are inserted by + // the same effective SortEntitiesTable key used by the full builder; + // hidden Modified entities retain a zero-length placeholder so grip + // commit restores them at their original position. { let cache = self.sort_cache.borrow(); - if let Some((_, ref idx)) = *cache { - if let Some(sort_map) = idx.get(&block) { - new_vec.sort_by_key(|w| { - let key = Self::handle_from_wire_name(&w.name) - .map(|h| h.value()) - .unwrap_or(u64::MAX); - sort_map.get(&key).copied().unwrap_or(key) - }); + let sort_map = cache + .as_ref() + .and_then(|(_, index)| index.get(&block)); + let effective = |handle: Handle| { + sort_map + .and_then(|map| map.get(&handle.value())) + .copied() + .unwrap_or(handle.value()) + }; + for &(handle, kind) in &deltas { + if matches!(kind, ChangeKind::Removed) { + layout.order.retain(|&h| h != handle); + continue; + } + if visible_changed.contains(&handle) && !layout.order.contains(&handle) { + let key = effective(handle); + let position = layout + .order + .partition_point(|&existing| effective(existing) <= key); + layout.order.insert(position, handle); } } } - let arc = Arc::new(new_vec); + // Splice only changed runs into the uniquely-owned flat Vec. Same-size + // edits overwrite in place. Grip hide/show shifts shallow WireModel + // structs once, instead of cloning/grouping all nested geometry. + let mut gpu_runs: HashMap>> = HashMap::default(); + let mut index_edits = Vec::new(); + for &(handle, kind) in &deltas { + let new_run = if matches!(kind, ChangeKind::Removed) { + Vec::new() + } else { + new_runs.remove(&handle).unwrap_or_default() + }; + gpu_runs.insert(handle, Arc::new(new_run.clone())); + let new_len = new_run.len(); + let old_range = layout.ranges.remove(&handle); + if old_range.is_none() && new_len == 0 { + continue; + } + let (start, old_len) = match old_range { + Some(range) => range, + None => { + let order_index = layout.order.iter().position(|&h| h == handle)?; + let start = layout.order[order_index + 1..] + .iter() + .find_map(|next| layout.ranges.get(next).map(|range| range.0)) + .unwrap_or(layout.marker_start); + (start, 0) + } + }; + if start + old_len > layout.marker_start || layout.marker_start > owned.len() { + return None; + } + + if old_len == new_len { + for (slot, wire) in owned[start..start + old_len] + .iter_mut() + .zip(new_run) + { + *slot = wire; + } + } else { + index_edits.push(WireIndexEdit { + handle, + start, + old_len, + new_len, + }); + owned.splice(start..start + old_len, new_run); + let delta = new_len as isize - old_len as isize; + for range in layout.ranges.values_mut() { + if range.0 >= start + old_len { + range.0 = (range.0 as isize + delta) as usize; + } + } + layout.marker_start = (layout.marker_start as isize + delta) as usize; + } + if new_len != 0 { + layout.ranges.insert(handle, (start, new_len)); + } + } + if !new_runs.is_empty() { + return None; + } + let added: HashSet = deltas + .iter() + .filter_map(|&(handle, kind)| { + matches!(kind, ChangeKind::Added).then_some(handle) + }) + .collect(); + let mut saw_added = false; + let mut new_handles_are_suffix = true; + for handle in &layout.order { + if added.contains(handle) { + saw_added = true; + } else if saw_added && layout.ranges.contains_key(handle) { + new_handles_are_suffix = false; + break; + } + } + let face_pass_changed = deltas.iter().any(|&(handle, kind)| { + matches!(kind, ChangeKind::Removed) + || matches!(self.document.get_entity(handle), Some(EntityType::Face3D(_))) + || gpu_runs.get(&handle).is_some_and(|run| { + run.iter().any(|wire| !wire.fill_tris.is_empty()) + }) + }); + + let arc = Arc::new(owned); self.last_tess_wires.set(arc.len()); self.resident_patch_hits .set(self.resident_patch_hits.get() + 1); @@ -3367,13 +3525,38 @@ impl Scene { // those entities' slabs. Only for the Model set (the arena is model-only); // the render layer verifies prev_gen against what the GPU actually holds. if wire_gpu_patch_enabled() && block == self.model_space_block_handle() { - *self.model_wire_gpu_patch.borrow_mut() = - Some((prev_gen, gen, Arc::new(deltas.clone()))); + *self.model_wire_gpu_patch.borrow_mut() = Some(( + prev_gen, + gen, + Arc::new(WireGpuPatch { + changes: Arc::new(deltas.clone()), + runs: Arc::new(gpu_runs), + index_edits: Arc::new(index_edits), + new_handles_are_suffix, + face_pass_changed, + }), + )); } let cur_epoch = self.geometry_epoch; let mut sets = self.resident_wire_sets.borrow_mut(); - sets.retain(|_, (e, ..)| *e == cur_epoch); - sets.insert(key, (cur_epoch, gen, Arc::clone(&arc))); + sets.retain(|_, set| set.epoch == cur_epoch); + sets.insert( + key, + ResidentWireSet { + epoch: cur_epoch, + gen, + wires: Arc::clone(&arc), + layout: Some(layout), + }, + ); + if perf { + eprintln!( + "[perf] resident-patch {:>7.1}ms wires={} changes={}", + t_patch.elapsed().as_secs_f64() * 1000.0, + arc.len(), + deltas.len(), + ); + } Some(arc) } @@ -3469,33 +3652,105 @@ impl Scene { /// entities of different types order correctly against each other. pub(super) fn draw_depth_map(&self) -> Arc> { { - // Draw order depends on each block's entity COUNT (the rank - // denominator) and the entities' handles / SortEntitiesTable — none of - // which a Modify (move / rotate / scale / colour / hide) touches. So an - // all-Modified edit keeps the map; only Add / Remove (count change) or - // a table change (which arrives as a `full` delta) rebuilds it. - let reuse = { - let cache = self.draw_depth_cache.borrow(); - match *cache { - Some((epoch, ref arc)) - if epoch == self.geometry_epoch - || matches!( - self.replay_since(epoch), - Some(ref d) if d.iter().all(|&(_, k)| k == ChangeKind::Modified) - ) => - { - Some(Arc::clone(arc)) - } - _ => None, + let cache = self.draw_depth_cache.borrow(); + if let Some(cache) = cache.as_ref() { + if cache.epoch == self.geometry_epoch { + return Arc::clone(&cache.depths); } - }; - if let Some(arc) = reuse { - if let Some((ref mut e, _)) = *self.draw_depth_cache.borrow_mut() { - *e = self.geometry_epoch; + } + } + let perf = std::env::var_os("OCS_PERF").is_some(); + let t_depth = iced::time::Instant::now(); + + // Replay Add/Remove into the retained block order. This avoids rescanning + // every document entity and re-sorting every block for one new LINE. + let stale_cache = self.draw_depth_cache.borrow_mut().take(); + if let Some(mut cache) = stale_cache { + if let Some(deltas) = self.replay_since(cache.epoch) { + if deltas + .iter() + .all(|(_, kind)| matches!(kind, ChangeKind::Modified)) + { + cache.epoch = self.geometry_epoch; + let arc = Arc::clone(&cache.depths); + *self.draw_depth_cache.borrow_mut() = Some(cache); + return arc; + } + + let mut depths = (*cache.depths).clone(); + let mut affected: HashSet = HashSet::default(); + let ms = self.model_space_block_handle(); + for &(handle, kind) in &deltas { + match kind { + ChangeKind::Modified => {} + ChangeKind::Removed => { + if let Some(block) = cache.owners.remove(&handle.value()) { + if let Some(order) = cache.blocks.get_mut(&block) { + order.retain(|(value, _)| *value != handle.value()); + } + affected.insert(block); + } + depths.remove(&handle.value()); + } + ChangeKind::Added => { + let Some(entity) = self.document.get_entity(handle) else { + continue; + }; + if matches!( + entity, + EntityType::Solid3D(_) + | EntityType::Region(_) + | EntityType::Body(_) + | EntityType::Surface(_) + ) { + continue; + } + let common = entity.common(); + let block = if common.owner_handle.is_null() { + ms + } else { + common.owner_handle + }; + let value = handle.value(); + // A newly allocated handle cannot already have a + // SortEntitiesTable override; its effective key is + // therefore its own handle. + let order = cache.blocks.entry(block).or_default(); + let position = + order.partition_point(|(_, effective)| *effective <= value); + order.insert(position, (value, value)); + cache.owners.insert(value, block); + affected.insert(block); + } + } + } + for block in affected { + let Some(order) = cache.blocks.get(&block) else { + continue; + }; + let denom = order.len() as f32 + 1.0; + let half = 1.0 / denom; + for (rank, (value, _)) in order.iter().enumerate() { + let norm = (rank as f32 + 1.0) / denom; + depths.insert(*value, [(norm - 0.5) * 2.0, half]); + } + } + let arc = Arc::new(depths); + cache.epoch = self.geometry_epoch; + cache.depths = Arc::clone(&arc); + *self.draw_depth_cache.borrow_mut() = Some(cache); + if perf { + eprintln!( + "[perf] draw-depth-patch {:>7.1}ms entries={} changes={}", + t_depth.elapsed().as_secs_f64() * 1000.0, + arc.len(), + deltas.len(), + ); } return arc; } } + use acadrust::objects::ObjectType; // Per-block SortEntitiesTable overrides: block -> (entity_val -> sort_val). let mut overrides: HashMap> = HashMap::default(); @@ -3540,24 +3795,40 @@ impl Scene { .unwrap_or(hv); by_block.entry(block).or_default().push((hv, eff)); } + for order in by_block.values_mut() { + order.sort_by_key(|(_, effective)| *effective); + } let mut depth_map: HashMap = HashMap::default(); - for (_block, mut v) in by_block { - v.sort_by_key(|(_, eff)| *eff); - let denom = (v.len() as f32) + 1.0; + let mut owners: HashMap = HashMap::default(); + for (&block, order) in &by_block { + let denom = (order.len() as f32) + 1.0; // Adjacent ranks are 2/denom apart; a child sub-range of // ±half = ±1/denom around the parent depth never crosses them. let half = 1.0 / denom; - for (rank, (hv, _)) in v.into_iter().enumerate() { + for (rank, (hv, _)) in order.iter().enumerate() { // Signed (-1,1): back ranks → negative, front → positive, // mid → ~0. The shader applies `z -= draw_depth * BIAS`, so a // default/unranked 0.0 means "no bias" (neutral) — which keeps // 3D mesh faces and transient wires at their real depth. let norm = (rank as f32 + 1.0) / denom; // (0,1) - depth_map.insert(hv, [(norm - 0.5) * 2.0, half]); + depth_map.insert(*hv, [(norm - 0.5) * 2.0, half]); + owners.insert(*hv, block); } } let arc = Arc::new(depth_map); - *self.draw_depth_cache.borrow_mut() = Some((self.geometry_epoch, Arc::clone(&arc))); + *self.draw_depth_cache.borrow_mut() = Some(DrawDepthCache { + epoch: self.geometry_epoch, + depths: Arc::clone(&arc), + blocks: by_block, + owners, + }); + if perf { + eprintln!( + "[perf] draw-depth {:>7.1}ms entries={}", + t_depth.elapsed().as_secs_f64() * 1000.0, + arc.len(), + ); + } arc } @@ -4624,35 +4895,50 @@ impl Scene { fn interaction_overlay_wires( &self, - base_handles: impl IntoIterator, + base_keys: impl IntoIterator, changes: &[(Handle, ChangeKind)], ) -> Arc> { let changed: HashSet = changes.iter().map(|(handle, _)| *handle).collect(); - let mut handles: HashSet = base_handles - .into_iter() - .map(Handle::new) - .filter(|handle| !changed.contains(handle)) - .collect(); - handles.extend(changes.iter().filter_map(|(handle, kind)| { - (!matches!(kind, ChangeKind::Removed)).then_some(*handle) - })); - let memo = self.resident_tess_memo.borrow(); let mut wires = Vec::new(); - let mut misses = Vec::new(); - for handle in handles { - if self.document.get_entity(handle).is_none() { + let mut misses: HashMap> = HashMap::default(); + for (handle, ordinal) in base_keys { + let handle = Handle::new(handle); + if changed.contains(&handle) || self.document.get_entity(handle).is_none() { continue; } - if let Some(entity_wires) = memo.get(&handle) { - wires.extend(entity_wires.iter().cloned()); + if let Some(wire) = memo + .get(&handle) + .and_then(|entity_wires| entity_wires.get(ordinal as usize)) + { + wires.push(wire.clone()); } else { - misses.push(handle); + misses.entry(handle).or_default().push(ordinal); } } drop(memo); - if !misses.is_empty() { - wires.extend(self.wire_models_for(&misses)); + // A memo miss is uncommon (guard change / legacy source). Preserve + // correctness without expanding every nearby entity: regenerate that + // handle, then retain only the exact wire ordinals the base index + // reported. + for (handle, ordinals) in misses { + let entity_wires = self.wire_models_for(&[handle]); + for ordinal in ordinals { + if let Some(wire) = entity_wires.get(ordinal as usize) { + wires.push(wire.clone()); + } + } + } + // Changed handles are absent or stale in the immutable base index. The + // journal is deliberately small, so append their current runs in full. + let changed_live: Vec = changes + .iter() + .filter_map(|(handle, kind)| { + (!matches!(kind, ChangeKind::Removed)).then_some(*handle) + }) + .collect(); + if !changed_live.is_empty() { + wires.extend(self.wire_models_for(&changed_live)); } Arc::new(wires) } @@ -4663,11 +4949,31 @@ impl Scene { aabb: [f64; 4], ) -> crate::scene::pick::interaction_index::InteractionCandidates { if let Some((base, changes)) = self.interaction_overlay_base() { - let handles = base.query_wire_handles_xy(aabb); - let local = self.interaction_overlay_wires(handles, &changes); + let perf = std::env::var_os("OCS_PERF").is_some(); + let t0 = iced::time::Instant::now(); + let keys = base.query_wire_keys_xy(aabb); + let key_ms = t0.elapsed().as_secs_f64() * 1000.0; + let t_local = iced::time::Instant::now(); + let local = self.interaction_overlay_wires(keys.iter().copied(), &changes); + let local_ms = t_local.elapsed().as_secs_f64() * 1000.0; + let t_index = iced::time::Instant::now(); let local_index = crate::scene::pick::interaction_index::InteractionIndex::build(&local); - return local_index.query_xy(local, aabb); + let index_ms = t_index.elapsed().as_secs_f64() * 1000.0; + let result = local_index.query_xy(Arc::clone(&local), aabb); + let total_ms = t0.elapsed().as_secs_f64() * 1000.0; + if perf && total_ms >= 50.0 { + eprintln!( + "[perf] interaction-overlay {:>7.1}ms keys={} wires={} query={:.1} gather={:.1} index={:.1}", + total_ms, + keys.len(), + local.len(), + key_ms, + local_ms, + index_ms, + ); + } + return result; } self.interaction_index(&wires).query_xy(wires, aabb) } @@ -4681,11 +4987,37 @@ impl Scene { bounds: iced::Rectangle, ) -> crate::scene::pick::interaction_index::InteractionCandidates { if let Some((base, changes)) = self.interaction_overlay_base() { - let handles = base.query_wire_handles_screen(screen_rect, view_rot, eye, bounds); - let local = self.interaction_overlay_wires(handles, &changes); + let perf = std::env::var_os("OCS_PERF").is_some(); + let t0 = iced::time::Instant::now(); + let keys = base.query_wire_keys_screen(screen_rect, view_rot, eye, bounds); + let key_ms = t0.elapsed().as_secs_f64() * 1000.0; + let t_local = iced::time::Instant::now(); + let local = self.interaction_overlay_wires(keys.iter().copied(), &changes); + let local_ms = t_local.elapsed().as_secs_f64() * 1000.0; + let t_index = iced::time::Instant::now(); let local_index = crate::scene::pick::interaction_index::InteractionIndex::build(&local); - return local_index.query_screen(local, screen_rect, view_rot, eye, bounds); + let index_ms = t_index.elapsed().as_secs_f64() * 1000.0; + let result = local_index.query_screen( + Arc::clone(&local), + screen_rect, + view_rot, + eye, + bounds, + ); + let total_ms = t0.elapsed().as_secs_f64() * 1000.0; + if perf && total_ms >= 50.0 { + eprintln!( + "[perf] interaction-overlay {:>7.1}ms keys={} wires={} query={:.1} gather={:.1} index={:.1}", + total_ms, + keys.len(), + local.len(), + key_ms, + local_ms, + index_ms, + ); + } + return result; } self.interaction_index(&wires) .query_screen(wires, screen_rect, view_rot, eye, bounds) diff --git a/src/scene/pick/interaction_index.rs b/src/scene/pick/interaction_index.rs index afae31f17..a396424f8 100644 --- a/src/scene/pick/interaction_index.rs +++ b/src/scene/pick/interaction_index.rs @@ -459,6 +459,11 @@ pub struct InteractionIndex { /// index feed an incremental overlay after small edits without retaining /// the old heavyweight wire set or trusting shifted vector indices. wire_handles: Vec>, + /// Stable occurrence number of each wire inside its owning entity run. + /// Small-edit overlays use `(handle, ordinal)` to recover only the exact + /// nearby wires from the current resident memo instead of cloning every + /// wire emitted by a large INSERT. + wire_ordinals: Vec>, segments: SpatialSet, snap_points: SpatialSet, key_vertices: SpatialSet, @@ -526,6 +531,18 @@ impl InteractionIndex { .iter() .map(|wire| wire.name.parse::().ok()) .collect(); + let mut next_ordinal: rustc_hash::FxHashMap = + rustc_hash::FxHashMap::default(); + let wire_ordinals: Vec> = wire_handles + .iter() + .map(|handle| { + let handle = (*handle)?; + let ordinal = next_ordinal.entry(handle).or_default(); + let current = *ordinal; + *ordinal += 1; + Some(current) + }) + .collect(); let mut wire_entries = Vec::with_capacity(wires.len()); let mut segment_entries = Vec::new(); let mut snap_point_entries = Vec::new(); @@ -701,6 +718,7 @@ impl InteractionIndex { Self { wires, wire_handles, + wire_ordinals, segments, snap_points, key_vertices, @@ -717,29 +735,40 @@ impl InteractionIndex { base_radius_px.max(self.max_line_half_width_px) } - fn queried_wire_handles(&self, mut indices: Vec) -> Vec { + fn queried_wire_keys(&self, mut indices: Vec) -> Vec<(u64, u32)> { indices.extend_from_slice(&self.unbounded_wires); - let mut handles: Vec = indices + let mut keys: Vec<(u64, u32)> = indices .into_iter() - .filter_map(|index| self.wire_handles.get(index as usize).copied().flatten()) + .filter_map(|index| { + self.wire_handles + .get(index as usize) + .copied() + .flatten() + .zip( + self.wire_ordinals + .get(index as usize) + .copied() + .flatten(), + ) + }) .collect(); - handles.sort_unstable(); - handles.dedup(); - handles + keys.sort_unstable(); + keys.dedup(); + keys } - pub fn query_wire_handles_xy(&self, aabb: [f64; 4]) -> Vec { - self.queried_wire_handles(self.wires.query_xy(aabb)) + pub fn query_wire_keys_xy(&self, aabb: [f64; 4]) -> Vec<(u64, u32)> { + self.queried_wire_keys(self.wires.query_xy(aabb)) } - pub fn query_wire_handles_screen( + pub fn query_wire_keys_screen( &self, screen_rect: [f32; 4], view_rot: Mat4, eye: DVec3, bounds: Rectangle, - ) -> Vec { - self.queried_wire_handles(self.wires.query_screen(screen_rect, view_rot, eye, bounds)) + ) -> Vec<(u64, u32)> { + self.queried_wire_keys(self.wires.query_screen(screen_rect, view_rot, eye, bounds)) } pub fn query_xy(&self, wires: Arc>, aabb: [f64; 4]) -> InteractionCandidates { diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 15ef14d85..7f94f1323 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -226,7 +226,6 @@ pub struct Pipeline { pub cached_text_source: Option>>, pub cached_mesh_source: Option>>, pub cached_face3d_source: Option>>, - pub cached_face3d_wire_source: Option>>, pub cached_face3d_depth_source: Option>>, pub cached_fill_mode: bool, @@ -253,12 +252,10 @@ pub struct Pipeline { /// re-upload with their tint. #[allow(dead_code)] // per-mesh LOD path, bypassed by the batched mesh draw pub cached_mesh_key: (u64, u64), - /// `(geometry_epoch, face3d_fill_active)` the Face3D edge/fill buffers were - /// uploaded for. The buffers are world-space and selection-independent, so - /// they only change when the geometry changes or the 3D-fill mode toggles — - /// never on a pan/orbit. Keyed separately from `cached_epoch` (which carries - /// `camera_generation`) so a camera move no longer re-walks every wire to - /// rebuild the Face3D fill buffer. + /// `(wire_content_id, face3d_fill_active)` the Face3D edge/fill buffers were + /// uploaded for. A stable content id avoids retaining the resident wire Arc: + /// that Arc must stay uniquely owned by Scene so a small edit can splice it + /// in place instead of rebuilding the whole drawing. pub cached_face3d_key: (u64, bool), /// Handle → indices into the resident wire set, built once per wire upload /// (when `cached_wire_id` changes). Lets the selection/hover xray overlay @@ -1477,7 +1474,6 @@ impl Pipeline { cached_text_source: None, cached_mesh_source: None, cached_face3d_source: None, - cached_face3d_wire_source: None, cached_face3d_depth_source: None, cached_fill_mode: false, cached_epoch: (u64::MAX, u64::MAX, u64::MAX), diff --git a/src/scene/pipeline/wire_arena.rs b/src/scene/pipeline/wire_arena.rs index ab50896fb..09502b317 100644 --- a/src/scene/pipeline/wire_arena.rs +++ b/src/scene/pipeline/wire_arena.rs @@ -62,6 +62,11 @@ struct Slab { inst_len: u32, const_off: u32, const_len: u32, + /// Entity-level draw depth used when this slab was emitted. Individual + /// consts may carry block-local offsets around it; structural edits shift + /// the whole slab by the base-depth delta instead of flattening those + /// offsets. + base_depth: f32, } pub struct WireArena { @@ -76,6 +81,9 @@ pub struct WireArena { /// slab's draw_depth (denominator change) without re-emitting geometry. consts_cpu: Vec, slabs: FxHashMap, + /// Temporarily hidden Modified slabs. Grip drag blanks these but keeps their + /// offsets so commit/cancel can restore the original submission order. + vacant: FxHashMap, /// Tombstoned instances (blanked, not reclaimed) — past half the tail a patch /// bails so the caller compacts with a full rebuild. tombstoned: u32, @@ -84,6 +92,8 @@ pub struct WireArena { /// them black in filled-with-edges modes. The regular and mesh-edge subsets /// of the resident set each get their own arena so both patch incrementally. mesh_edge: bool, + /// Conservative submission-order sensitivity of current arena content. + order_sensitive: bool, } fn handle_of(w: &WireModel) -> Option { @@ -105,7 +115,7 @@ pub fn is_mesh_edge(w: &WireModel, mesh_names: &rustc_hash::FxHashSet) -> b /// Surface) are excluded from `draw_depth_map`, so their fallback edge wires /// get draw_depth 0.0. Two coincident opaque such wires share a z-bias and /// resolve by submission order, which a tail relocation would flip. -fn append_unsafe(wires: &[&WireModel], depth_map: &FxHashMap) -> bool { +fn order_sensitive(wires: &[&WireModel], depth_map: &FxHashMap) -> bool { wires.iter().any(|w| { w.color[3] < 0.999 || handle_of(w).map_or(true, |h| !depth_map.contains_key(&h.value())) @@ -125,6 +135,37 @@ pub fn build_handle_index(wires: &[WireModel]) -> std::sync::Arc>>, + edits: &[crate::scene::WireIndexEdit], +) { + let index = std::sync::Arc::make_mut(index); + for edit in edits { + index.remove(&edit.handle.value()); + let old_end = edit.start + edit.old_len; + let delta = edit.new_len as isize - edit.old_len as isize; + if delta != 0 { + for slots in index.values_mut() { + for slot in slots { + if *slot as usize >= old_end { + *slot = (*slot as isize + delta) as u32; + } + } + } + } + if edit.new_len != 0 { + index.insert( + edit.handle.value(), + (edit.start..edit.start + edit.new_len) + .map(|slot| slot as u32) + .collect(), + ); + } + } +} + /// Group `wires` (draw-order sorted, entity-contiguous) into per-handle ranges. fn handle_ranges(wires: &[&WireModel]) -> Option> { let mut out: Vec<(Handle, usize, usize)> = Vec::new(); @@ -215,6 +256,11 @@ impl WireArena { for (h, i, j) in ranges { let inst_off = instances.len() as u32; let const_off = consts_cpu.len() as u32; + let base_depth = if mesh_edge { + 0.0 + } else { + depth_map.get(&h.value()).map_or(0.0, |d| d[0]) + }; for &w in &wires[i..j] { let wire_id = consts_cpu.len() as u32; // 3D mesh outline edges are occluded by true depth and must NOT @@ -232,6 +278,7 @@ impl WireArena { inst_len: instances.len() as u32 - inst_off, const_off, const_len: consts_cpu.len() as u32 - const_off, + base_depth, }, ); } @@ -267,8 +314,10 @@ impl WireArena { const_tail, consts_cpu, slabs, + vacant: FxHashMap::default(), tombstoned: 0, mesh_edge, + order_sensitive: order_sensitive(wires, depth_map), }) } @@ -287,34 +336,33 @@ impl WireArena { &mut self, queue: &wgpu::Queue, changes: &[(Handle, ChangeKind)], - wires: &[&WireModel], + runs: &FxHashMap>, + new_handles_are_suffix: bool, depth_map: &FxHashMap, ) -> bool { - let Some(ranges) = handle_ranges(wires) else { - return false; - }; - let range_of: FxHashMap = - ranges.into_iter().map(|(h, i, j)| (h, (i, j))).collect(); - let append_unsafe = append_unsafe(wires, depth_map); - - let mut structural = false; + let depth_structural = changes + .iter() + .any(|(_, kind)| !matches!(kind, ChangeKind::Modified)); for &(h, kind) in changes { - let new_range = range_of.get(&h).copied(); + let run = runs.get(&h).map(Vec::as_slice).unwrap_or(&[]); // Removed / now-hidden ⇒ tombstone the slab. A handle not in THIS // arena's subset (it belongs to the other batch) simply isn't in its // slabs, so this is a no-op for it. - if matches!(kind, ChangeKind::Removed) || new_range.is_none() { + if matches!(kind, ChangeKind::Removed) || run.is_empty() { if let Some(slab) = self.slabs.remove(&h) { let blanks = vec![blank_instance(); slab.inst_len as usize]; self.write_insts(queue, slab.inst_off, &blanks); self.tombstoned += slab.inst_len; - structural = true; + if matches!(kind, ChangeKind::Modified) { + self.vacant.insert(h, slab); + } + } + if matches!(kind, ChangeKind::Removed) { + self.vacant.remove(&h); } continue; } - let (i, j) = new_range.unwrap(); - let run = &wires[i..j]; // Emit into fresh, run-local const slots (patched to absolute below). let mut insts: Vec = Vec::new(); @@ -328,7 +376,22 @@ impl WireArena { } let inst_len = insts.len() as u32; let const_len = csts.len() as u32; + let base_depth = if self.mesh_edge { + 0.0 + } else { + depth_map.get(&h.value()).map_or(0.0, |d| d[0]) + }; + if !self.slabs.contains_key(&h) + && self + .vacant + .get(&h) + .is_some_and(|s| s.inst_len == inst_len && s.const_len == const_len) + { + let slab = self.vacant.remove(&h).unwrap(); + self.tombstoned = self.tombstoned.saturating_sub(slab.inst_len); + self.slabs.insert(h, slab); + } let in_place = self .slabs .get(&h) @@ -356,6 +419,7 @@ impl WireArena { const_off as u64 * csz, bytemuck::cast_slice(&csts), ); + self.slabs.get_mut(&h).unwrap().base_depth = base_depth; continue; } @@ -364,7 +428,12 @@ impl WireArena { // draw-order depth, or the mesh-edge arena (all its wires are forced // to depth 0, so coincident edges resolve by submission order). Fall // back to a full rebuild instead. - if append_unsafe || self.mesh_edge { + let is_new = !self.slabs.contains_key(&h); + let preserves_submission_order = is_new && new_handles_are_suffix; + let run_order_sensitive = order_sensitive(run, depth_map); + if (self.order_sensitive || run_order_sensitive || self.mesh_edge) + && !preserves_submission_order + { return false; } if self.inst_tail + inst_len > self.inst_cap @@ -372,7 +441,7 @@ impl WireArena { { return false; } - structural = true; + self.vacant.remove(&h); if let Some(s) = self.slabs.remove(&h) { let blanks = vec![blank_instance(); s.inst_len as usize]; self.write_insts(queue, s.inst_off, &blanks); @@ -396,30 +465,21 @@ impl WireArena { inst_len, const_off, const_len, + base_depth, }, ); + self.order_sensitive |= run_order_sensitive; } - if structural { + if depth_structural { // The entity count changed ⇒ draw_depth_map re-normalised every // entity's z-bias. Refresh each live slab's draw_depth from the new // depth map and re-upload the whole (small) const buffer; the instance // buffer is untouched. - // A slab whose consts carry DIFFERENT depths holds block-band wires - // with composed per-child offsets (`depth_override`); a flat refresh - // to the entity's base depth would erase them. Fall back to a full - // rebuild, which recomputes each wire's composed depth. - if !self.mesh_edge { - for slab in self.slabs.values() { - let first = self.consts_cpu[slab.const_off as usize].draw_depth; - for k in 1..slab.const_len { - if self.consts_cpu[(slab.const_off + k) as usize].draw_depth != first { - return false; - } - } - } - } - for (h, slab) in &self.slabs { + // Preserve block-local depth composition. A slab may contain many + // different child offsets around its entity base; shifting every + // const by the base delta keeps those offsets intact. + for (h, slab) in &mut self.slabs { // Mesh-edge wires keep depth 0 (no draw-order bias); regular wires // take the re-normalised map value. let dd = if self.mesh_edge { @@ -427,14 +487,16 @@ impl WireArena { } else { depth_map.get(&h.value()).map_or(0.0, |d| d[0]) }; + let delta = dd - slab.base_depth; for k in 0..slab.const_len { - self.consts_cpu[(slab.const_off + k) as usize].draw_depth = dd; + self.consts_cpu[(slab.const_off + k) as usize].draw_depth += delta; } + slab.base_depth = dd; } queue.write_buffer(&self.const_buf, 0, bytemuck::cast_slice(&self.consts_cpu)); - if self.tombstoned > self.inst_tail / 2 { - return false; - } + } + if self.tombstoned > self.inst_tail / 2 { + return false; } true } diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index d3c54760a..01094e3f5 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -36,7 +36,10 @@ pub struct ViewportData { /// drops off-canvas viewports, so this lets the slot detect when it has been /// reused by a different viewport and reset its (index-addressed) caches. pub(in crate::scene) instance_id: u64, - pub(in crate::scene) wires: Arc>, + /// Weak resident source. Scene owns the strong Arc; retaining another one + /// in the previous shader Primitive prevented the next UI event from + /// splicing a changed entity in place and forced a full rebuild. + pub(in crate::scene) wires: std::sync::Weak>, /// This content viewport's non-rectangular clip boundary (paper layouts /// only), as a polygon already projected into the viewport's render-target /// NDC. The GPU stamps it into the stencil so content is clipped to the @@ -120,8 +123,7 @@ pub struct ViewportData { /// GPU wire-arena handoff (`OCS_WIRE_GPU_PATCH`): `(prev_gen, changed)` when /// this Model set reached `wire_content_id` by an incremental resident patch, /// so `prepare` can patch just those entities' slabs. `None` ⇒ full build. - pub(in crate::scene) wire_patch: - Option<(u64, Arc>)>, + pub(in crate::scene) wire_patch: Option<(u64, Arc)>, /// Selected handles only (no hover) — solid meshes tint these blue. pub(in crate::scene) selected_handles: Arc>, /// Currently hovered handle — solid meshes tint it orange. @@ -268,7 +270,6 @@ impl shader::Primitive for Primitive { inner.cached_text_source = None; inner.cached_mesh_source = None; inner.cached_face3d_source = None; - inner.cached_face3d_wire_source = None; inner.cached_face3d_depth_source = None; inner.render_sig = u64::MAX; } @@ -326,6 +327,9 @@ impl shader::Primitive for Primitive { } continue; } + let Some(vp_wires) = vp.wires.upgrade() else { + continue; + }; // Third component is the *selected-set* signature (not // selection_generation, which also bumps on hover) so a rollover // doesn't re-upload the static hatch / face3d buffers. @@ -385,33 +389,36 @@ impl shader::Primitive for Primitive { // Face3D edge/fill buffers are world-space and selection-independent // (upload_face3d takes no selection input), so they only change with // the geometry or the 3D-fill toggle — never on a pan/orbit. Gating - // on its three source Arcs avoids rebuilding it when another entity - // category alone advances `geometry_epoch`. + // on its category sources plus the stable wire content id avoids + // rebuilding it when another entity category alone changes. Never + // retain `vp.wires` here: Scene needs unique ownership to splice a + // one-entity edit into the resident set. + let face_pass_unchanged = vp + .wire_patch + .as_ref() + .is_some_and(|(_, patch)| !patch.face_pass_changed); let face3d_changed = inner .cached_face3d_source .as_ref() .map_or(true, |source| !Arc::ptr_eq(source, &vp.face3d_wires)) - || inner - .cached_face3d_wire_source - .as_ref() - .map_or(true, |source| !Arc::ptr_eq(source, &vp.wires)) || inner .cached_face3d_depth_source .as_ref() - .map_or(true, |source| !Arc::ptr_eq(source, &vp.draw_depths)); + .map_or(true, |source| !Arc::ptr_eq(source, &vp.draw_depths)) + || (inner.cached_face3d_key.0 != vp.wire_content_id + && !face_pass_unchanged); if face3d_changed || face3d_fill_active != inner.cached_face3d_key.1 { inner.upload_face3d( device, &vp.face3d_wires[..], - &vp.wires[..], + &vp_wires[..], !face3d_fill_active, &vp.draw_depths, ); inner.cached_face3d_source = Some(Arc::clone(&vp.face3d_wires)); - inner.cached_face3d_wire_source = Some(Arc::clone(&vp.wires)); inner.cached_face3d_depth_source = Some(Arc::clone(&vp.draw_depths)); - inner.cached_face3d_key = (vp.geometry_epoch, face3d_fill_active); } + inner.cached_face3d_key = (vp.wire_content_id, face3d_fill_active); // Wire buffers are world-space, so a camera move alone doesn't // change them — only the view_proj uniform (uploaded every frame). // Gate the upload on the wire content id instead of the camera tick: @@ -435,55 +442,120 @@ impl shader::Primitive for Primitive { #[cfg(not(target_arch = "wasm32"))] if crate::scene::wire_gpu_patch_enabled() && inner.wire_const_bgl.is_some() { use crate::scene::pipeline::wire_arena::{self, WireArena}; - // Split the resident set into the regular 2D wires and the - // mesh/solid EDGE wires (which need the mesh-edge draw - // treatment), one arena each, so both patch incrementally. - // Fill-only wires (no segments) are drawn in the face3d pass, - // not here, so they go in neither. - let mesh_names: rustc_hash::FxHashSet = vp - .wires - .iter() - .filter(|w| !w.fill_tris.is_empty() && !w.fill_tris_low.is_empty()) - .filter_map(|w| w.name.parse::().ok()) - .collect(); - let regular: Vec<&crate::scene::WireModel> = vp - .wires - .iter() - .filter(|w| { - !w.points.is_empty() && !wire_arena::is_mesh_edge(w, &mesh_names) - }) - .collect(); - let mesh: Vec<&crate::scene::WireModel> = vp - .wires - .iter() - .filter(|w| wire_arena::is_mesh_edge(w, &mesh_names)) - .collect(); let bgl = inner.wire_const_bgl.as_ref().unwrap(); let base_ok = vp .wire_patch .as_ref() - .map_or(false, |(base, changes)| { - inner.wire_arena_id == *base && !changes.is_empty() + .map_or(false, |(base, patch)| { + inner.wire_arena_id == *base && !patch.changes.is_empty() }); - let changes = vp.wire_patch.as_ref().map(|(_, c)| c); + let patch = vp.wire_patch.as_ref().map(|(_, patch)| patch); + if _perf { + eprintln!( + "[perf] arena-base ok={} held={} patch={:?} changes={}", + base_ok, + inner.wire_arena_id, + vp.wire_patch.as_ref().map(|(base, _)| *base), + patch.map_or(0, |p| p.changes.len()), + ); + } - // Each batch: patch from the shared base if possible, else - // rebuild just that batch. They advance together. + // Split only changed runs on a patch. The previous path + // scanned/parses all resident wires repeatedly here even + // though WireArena emits just one changed entity. + let mut regular_changed: rustc_hash::FxHashMap< + acadrust::Handle, + Vec<&crate::scene::WireModel>, + > = rustc_hash::FxHashMap::default(); + let mut mesh_changed: rustc_hash::FxHashMap< + acadrust::Handle, + Vec<&crate::scene::WireModel>, + > = rustc_hash::FxHashMap::default(); + if let Some(patch) = patch { + for &(handle, _) in patch.changes.iter() { + let run = patch + .runs + .get(&handle) + .map(|wires| wires.as_slice()) + .unwrap_or(&[]); + let mesh_entity = run + .iter() + .any(|w| !w.fill_tris.is_empty() && !w.fill_tris_low.is_empty()); + regular_changed.insert( + handle, + run.iter() + .filter(|w| !w.points.is_empty() && !mesh_entity) + .collect(), + ); + mesh_changed.insert( + handle, + run.iter() + .filter(|w| !w.points.is_empty() && mesh_entity) + .collect(), + ); + } + } let reg_ok = base_ok && inner.wire_arena.as_mut().map_or(false, |a| { - a.patch(queue, changes.unwrap(), ®ular, &vp.draw_depths) + let patch = patch.unwrap(); + a.patch( + queue, + &patch.changes, + ®ular_changed, + patch.new_handles_are_suffix, + &vp.draw_depths, + ) }); - if !reg_ok { - inner.wire_arena = - WireArena::build(device, queue, ®ular, &vp.draw_depths, bgl, false); - } let mesh_ok = base_ok && inner.wire_arena_mesh.as_mut().map_or(false, |a| { - a.patch(queue, changes.unwrap(), &mesh, &vp.draw_depths) + let patch = patch.unwrap(); + a.patch( + queue, + &patch.changes, + &mesh_changed, + patch.new_handles_are_suffix, + &vp.draw_depths, + ) }); - if !mesh_ok { - inner.wire_arena_mesh = - WireArena::build(device, queue, &mesh, &vp.draw_depths, bgl, true); + if !reg_ok || !mesh_ok { + // Initial upload or a patch that outgrew arena capacity: + // only then pay the full regular/mesh split. + let mesh_names: rustc_hash::FxHashSet = vp_wires + .iter() + .filter(|w| !w.fill_tris.is_empty() && !w.fill_tris_low.is_empty()) + .filter_map(|w| w.name.parse::().ok()) + .collect(); + if !reg_ok { + let regular: Vec<&crate::scene::WireModel> = vp_wires + .iter() + .filter(|w| { + !w.points.is_empty() + && !wire_arena::is_mesh_edge(w, &mesh_names) + }) + .collect(); + inner.wire_arena = WireArena::build( + device, + queue, + ®ular, + &vp.draw_depths, + bgl, + false, + ); + } + if !mesh_ok { + let mesh: Vec<&crate::scene::WireModel> = vp_wires + .iter() + .filter(|w| wire_arena::is_mesh_edge(w, &mesh_names)) + .collect(); + inner.wire_arena_mesh = WireArena::build( + device, + queue, + &mesh, + &vp.draw_depths, + bgl, + true, + ); + } } _patched = reg_ok && mesh_ok; @@ -493,7 +565,15 @@ impl shader::Primitive for Primitive { let mut gpus = reg.wire_gpus(); gpus.extend(me.wire_gpus()); inner.gpu_wires = std::sync::Arc::new(gpus); - inner.wire_handle_index = wire_arena::build_handle_index(&vp.wires[..]); + if _patched { + wire_arena::patch_handle_index( + &mut inner.wire_handle_index, + &patch.unwrap().index_edits, + ); + } else { + inner.wire_handle_index = + wire_arena::build_handle_index(&vp_wires[..]); + } inner.wire_arena_id = vp.wire_content_id; arena_served = true; } else { @@ -515,7 +595,7 @@ impl shader::Primitive for Primitive { Some(entry) => entry, None => { let entry = - inner.build_wire_buffers(device, &vp.wires[..], &vp.draw_depths); + inner.build_wire_buffers(device, &vp_wires[..], &vp.draw_depths); pipeline .wire_buffer_cache .insert(vp.wire_content_id, entry.clone()); @@ -548,7 +628,7 @@ impl shader::Primitive for Primitive { "[perf] wire {:>7.1}ms {:<18} wires={} gpu_instances={}", _t0.elapsed().as_secs_f64() * 1000.0, outcome, - vp.wires.len(), + vp_wires.len(), gi, ); } @@ -561,7 +641,7 @@ impl shader::Primitive for Primitive { if sel_key != inner.cached_selection { inner.upload_selected_wires( device, - &vp.wires[..], + &vp_wires[..], &vp.selected_handles, vp.hover_handle, &vp.draw_depths, @@ -571,7 +651,7 @@ impl shader::Primitive for Primitive { // base text buffer. inner.upload_text_highlight( device, - &vp.wires[..], + &vp_wires[..], &vp.selected_handles, vp.hover_handle, ); @@ -1310,13 +1390,27 @@ impl Scene { // overlay buffer below), so the base id is the source's stable content // gen — a drag or camera move never re-uploads the base wire set. let wire_content_id = self.last_model_wire_gen.get(); + let wire_patch = self.model_wire_patch_for(wire_content_id); // Split Face3D wires from the rest. The split is content-only (keyed // by the wire-set content id), so while the geometry is unchanged it's // memoized rather than re-walking every wire (handle lookup + clone) // each frame — for every source, since all ids are stable now. let (face3d_wires, other_arc) = { let cached = { self.split_cache.borrow().get(&wire_content_id).cloned() }; - let (fa, oa) = cached.unwrap_or_else(|| { + let inherited_empty = if let Some((base, patch)) = wire_patch.as_ref() { + if patch.face_pass_changed { + None + } else { + self.split_cache + .borrow() + .get(base) + .filter(|(_, others)| others.is_none()) + .cloned() + } + } else { + None + }; + let (fa, oa) = cached.or(inherited_empty).unwrap_or_else(|| { // No Face3D wire at all (pure 2-D drawings, mesh imports): // "others" would be a wire-for-wire copy of the base set — // mark it `None` and use the base set directly instead of @@ -1337,6 +1431,10 @@ impl Scene { c.insert(wire_content_id, (fa.clone(), oa.clone())); (fa, oa) }); + self.split_cache + .borrow_mut() + .entry(wire_content_id) + .or_insert_with(|| (fa.clone(), oa.clone())); (fa, oa.unwrap_or_else(|| Arc::clone(&base_arc))) }; // Base wire set — the cached `other` Arc directly, never cloned to @@ -1517,7 +1615,7 @@ impl Scene { }; Some(ViewportData { instance_id, - wires: all_wires, + wires: Arc::downgrade(&all_wires), clip_boundary_ndc, preview_wires, face3d_wires, @@ -1552,7 +1650,7 @@ impl Scene { geometry_epoch: self.geometry_epoch, camera_generation: self.camera_generation, wire_content_id, - wire_patch: self.model_wire_patch_for(wire_content_id), + wire_patch, selected_handles: Arc::new(self.selected.iter().copied().collect()), hover_handle: self.hover_highlight, selection_generation: self.selection_generation,