perf(scene): make geometry edits incremental

Avoid full document, cache, and GPU rebuilds for entity deltas so add, delete, history, selection, and live polyline edits stay responsive on large drawings.
This commit is contained in:
Hakan Seven committed 2026-07-28 17:16:27 +03:00
1 parent 73b8dbe9d4
commit 05b044f367
32 files changed
+1936 -516

No files matched your search

+53 -39
View File
@@ -112,13 +112,12 @@ impl Scene {
}
pub fn add_entity(&mut self, mut entity: EntityType) -> Handle {
// Only Insert / Block entities can introduce or reference a block
// definition that the block cache must (re)build. Adding a plain
// top-level entity (line, arc, text, …) leaves every block defn intact,
// so it keeps the cache and skips the all-blocks re-tessellation.
// Only block sentinels mutate a block definition and require rebuilding
// the block cache. A top-level INSERT merely references an existing
// definition, so adding it can patch just that new render handle.
let affects_blocks = matches!(
&entity,
EntityType::Insert(_) | EntityType::Block(_) | EntityType::BlockEnd(_)
EntityType::Block(_) | EntityType::BlockEnd(_)
);
// INSERT invalidates rendered block instances, but it does not mutate
// the referenced block definition. Only block sentinels require a
@@ -161,6 +160,9 @@ impl Scene {
if img.definition_handle.is_none() {
use acadrust::objects::{ImageDefinition, ObjectType};
let def_handle = Handle::new(self.document.next_handle());
if self.is_recording_undo() {
self.record_undo_object_before(def_handle, None);
}
let mut img_def = ImageDefinition::with_dimensions(
&img.file_path,
img.size.x as u32,
@@ -184,7 +186,6 @@ impl Scene {
// definition mutates non-entity state a pure-entity delta can't undo.
let creates_layer =
self.is_recording_undo() && !layer.trim().is_empty() && !self.document.layers.contains(&layer);
let is_image = matches!(&entity, EntityType::RasterImage(_));
self.ensure_layer(&layer);
// Route to the correct block based on current editing mode:
@@ -238,11 +239,12 @@ impl Scene {
}
// Delta-undo: the new handle's before-image is "nothing" (it did not
// exist). Poison the recording if this add also mutated non-entity
// state (a new layer / block / image definition) so the app knows a
// pure-entity delta would be incomplete.
// state (a new layer / block) so the app knows a pure-entity delta
// would be incomplete. Raster image definitions are captured as
// targeted object before-images above.
if self.is_recording_undo() {
self.record_undo_before(handle, None);
if creates_layer || mutates_block_structure || is_image {
if creates_layer || mutates_block_structure {
self.poison_undo_recording();
}
}
@@ -323,14 +325,15 @@ impl Scene {
// The caller edited a snapshot copy; keep the live entity in its block.
entity.common_mut().owner_handle = existing.common().owner_handle;
// Replacing (or becoming) a block entity forces a full block-cache
// rebuild; a plain entity only needs its own wires re-tessellated.
// Replacing (or becoming) a block sentinel forces a full block-cache
// rebuild. INSERT edits (including retargeting to another existing
// definition) only change that top-level render handle.
let affects_blocks = matches!(
existing,
EntityType::Insert(_) | EntityType::Block(_) | EntityType::BlockEnd(_)
EntityType::Block(_) | EntityType::BlockEnd(_)
) || matches!(
&entity,
EntityType::Insert(_) | EntityType::Block(_) | EntityType::BlockEnd(_)
EntityType::Block(_) | EntityType::BlockEnd(_)
);
// A plugin edit may retarget the entity to a novel layer; register it
@@ -481,6 +484,34 @@ impl Scene {
if handles.is_empty() {
return;
}
let mesh_entities: Vec<(Handle, std::sync::Arc<EntityType>)> = handles
.iter()
.filter_map(|&handle| {
let entity = self.document.get_entity_arc(handle)?;
matches!(
entity.as_ref(),
EntityType::Solid3D(_)
| EntityType::Region(_)
| EntityType::Body(_)
| EntityType::Surface(_)
| EntityType::Mesh(_)
| EntityType::PolygonMesh(_)
| EntityType::PolyfaceMesh(_)
)
.then_some((handle, entity))
})
.collect();
for handle in handles {
self.meshes.remove(handle);
self.block_meshes.remove(handle);
self.solid_models.remove(handle);
}
// The overwhelmingly common Undo/Redo target is 2-D geometry. Return
// before scanning every Layout object just to discover there is no mesh
// to rebuild.
if mesh_entities.is_empty() {
return;
}
let layout_blocks: std::collections::HashSet<Handle> = self
.document
.objects
@@ -492,32 +523,15 @@ impl Scene {
_ => None,
})
.collect();
let entries: Vec<(Handle, std::sync::Arc<EntityType>, [f32; 4], bool)> = handles
.iter()
.filter_map(|&handle| {
let entity = self.document.get_entity_arc(handle)?;
if !matches!(
entity.as_ref(),
EntityType::Solid3D(_)
| EntityType::Region(_)
| EntityType::Body(_)
| EntityType::Surface(_)
| EntityType::Mesh(_)
| EntityType::PolygonMesh(_)
| EntityType::PolyfaceMesh(_)
) {
return None;
}
let color = self.render_style(entity.as_ref()).0;
let top_level = layout_blocks.contains(&entity.common().owner_handle);
Some((handle, entity, color, top_level))
})
.collect();
for handle in handles {
self.meshes.remove(handle);
self.block_meshes.remove(handle);
self.solid_models.remove(handle);
}
let entries: Vec<(Handle, std::sync::Arc<EntityType>, [f32; 4], bool)> =
mesh_entities
.into_iter()
.map(|(handle, entity)| {
let color = self.render_style(entity.as_ref()).0;
let top_level = layout_blocks.contains(&entity.common().owner_handle);
(handle, entity, color, top_level)
})
.collect();
let facet_res = self.document.header.facet_resolution;
let isolines = self.document.header.isolines.max(0) as usize;
use crate::par::prelude::*;
+9
View File
@@ -89,6 +89,7 @@ impl Scene {
) -> usize {
let group_dict_handle = self.document.header.acad_group_dict_handle;
let mut copied = 0;
let mut dictionary_recorded = false;
for source in sources {
let entities: Vec<Handle> = source
.entities
@@ -105,6 +106,14 @@ impl Scene {
group.name = name.clone();
group.entities = entities;
let gh = group.handle;
if self.is_recording_undo() {
if !dictionary_recorded {
let before = self.document.objects.get(&group_dict_handle).cloned();
self.record_undo_object_before(group_dict_handle, before);
dictionary_recorded = true;
}
self.record_undo_object_before(gh, None);
}
self.document.objects.insert(gh, ObjectType::Group(group));
if let Some(ObjectType::Dictionary(dict)) =
self.document.objects.get_mut(&group_dict_handle)
+994 -211
View File
File diff suppressed because it is too large. Load diff
+3 -6
View File
@@ -410,12 +410,9 @@ impl Scene {
}
}
let copied_groups = self.copy_complete_groups(&handle_map);
if copied_groups > 0 && self.is_recording_undo() {
// Group copies add Group objects / dictionary entries, which a pure
// entity delta cannot restore.
self.poison_undo_recording();
}
// Complete group copies record their new Group objects and dictionary
// entry as targeted object deltas inside copy_complete_groups.
self.copy_complete_groups(&handle_map);
// The copies are new handles (natural memo misses, tessellated fresh)
// and reference only already-cached blocks — no block defn changes.
// Report them as additions so derived caches patch in exactly the copies.
+39 -6
View File
@@ -196,22 +196,55 @@ impl Scene {
Arc<Vec<HatchModel>>,
Arc<Vec<ImageModel>>,
) {
let selected = self.selected_set_sig();
{
let selected = self.selected_hatch_sig();
let reuse = {
let cache = self.paper_sheet_render_cache.borrow();
if let Some(cache) = cache.get(&self.current_layout) {
if cache.epoch == self.geometry_epoch
&& cache.layout == self.current_layout
if cache.layout == self.current_layout
&& cache.selected == selected
&& cache.paper_bg == self.paper_bg_color
&& self.category_cache_valid(
cache.epoch,
super::CACHE_CATEGORY_HATCH,
|handle| self.hatches.contains_key(&handle),
)
&& self.category_cache_valid(
cache.epoch,
super::CACHE_CATEGORY_WIPEOUT,
|handle| {
matches!(
self.document.get_entity(handle),
Some(EntityType::Wipeout(_))
)
},
)
&& self.category_cache_valid(
cache.epoch,
super::CACHE_CATEGORY_IMAGE,
|handle| self.images.contains_key(&handle),
)
{
return (
Some((
Arc::clone(&cache.hatches),
Arc::clone(&cache.wipeouts),
Arc::clone(&cache.images),
);
))
} else {
None
}
} else {
None
}
};
if let Some(models) = reuse {
if let Some(cache) = self
.paper_sheet_render_cache
.borrow_mut()
.get_mut(&self.current_layout)
{
cache.epoch = self.geometry_epoch;
}
return models;
}
let mut hatches = Vec::new();
+110 -16
View File
@@ -232,6 +232,13 @@ pub struct Pipeline {
/// ranges from the resident arena.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) wire_cull_key: (u64, u64, u32, u32),
/// View/source keys for CPU visibility passes. A plain entity edit changes
/// the scene render signature, but it must not rescan every unchanged hatch,
/// wipeout, or mesh AABB when the camera and corresponding source stayed
/// identical.
pub(crate) hatch_lod_key: (usize, u64, u32, u32, bool),
pub(crate) wipeout_lod_key: (usize, u64, u32, u32, bool),
pub(crate) mesh_lod_key: (usize, u64, u32, u32),
/// This content viewport's non-rectangular clip boundary as a triangle-fan
/// vertex buffer in the render target's normalized device coords (`None` =
/// rectangular / unclipped, where the viewport's own render rectangle does
@@ -292,7 +299,7 @@ pub struct Pipeline {
pub cached_mesh_source: Option<std::sync::Arc<Vec<MeshLodSet>>>,
pub cached_face3d_source: Option<std::sync::Arc<Vec<WireModel>>>,
pub cached_face3d_depth_source:
Option<std::sync::Arc<rustc_hash::FxHashMap<u64, [f32; 2]>>>,
Option<std::sync::Weak<rustc_hash::FxHashMap<u64, [f32; 2]>>>,
pub cached_fill_mode: bool,
/// Last `(geometry_epoch, camera_generation)` value for which GPU buffers
/// were uploaded. We re-upload when either side changes — pan/zoom bumps
@@ -1812,6 +1819,9 @@ impl Pipeline {
wire_arena_id: u64::MAX,
#[cfg(not(target_arch = "wasm32"))]
wire_cull_key: (u64::MAX, u64::MAX, 0, 0),
hatch_lod_key: (usize::MAX, u64::MAX, 0, 0, false),
wipeout_lod_key: (usize::MAX, u64::MAX, 0, 0, false),
mesh_lod_key: (usize::MAX, u64::MAX, 0, 0),
clip_boundary: None,
gpu_selected_wires: vec![],
gpu_preview_wires: vec![],
@@ -1928,36 +1938,64 @@ impl Pipeline {
hover: Option<acadrust::Handle>,
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
) {
let perf_started = crate::perf::enabled().then(iced::time::Instant::now);
let hover = hover.filter(|h| !selected.contains(h));
if selected.is_empty() && hover.is_none() {
self.gpu_selected_wires = vec![];
return;
}
// Gather the highlighted entities' wires via the prebuilt index —
// O(highlighted), no per-wire string parse. Selection recolours blue,
// hover orange. Drawn on top (depth-compare Always) over the base pass.
let mut out: Vec<WireModel> = Vec::new();
let push = |handle_val: u64, color: [f32; 4], wires: &[WireModel], out: &mut Vec<WireModel>| {
if let Some(idxs) = self.wire_handle_index.get(&handle_val) {
// Gather borrowed highlighted wires via the prebuilt index —
// O(highlighted), no per-wire string parse or deep geometry clone.
let mut selected_wires: Vec<&WireModel> = Vec::new();
let mut hover_wires: Vec<&WireModel> = Vec::new();
for h in selected {
if let Some(idxs) = self.wire_handle_index.get(&h.value()) {
let mut slots = idxs.clone();
slots.sort_unstable();
for &i in &slots {
if let Some(w) = wires.get(i as usize) {
let mut c = w.clone();
c.color = color;
out.push(c);
selected_wires.push(w);
}
}
}
};
for h in selected {
push(h.value(), WireModel::SELECTED, wires, &mut out);
}
if let Some(h) = hover {
push(h.value(), WireModel::HOVER, wires, &mut out);
if let Some(idxs) = self.wire_handle_index.get(&h.value()) {
let mut slots = idxs.clone();
slots.sort_unstable();
for &i in &slots {
if let Some(w) = wires.get(i as usize) {
hover_wires.push(w);
}
}
}
}
let mut gpu = WireGpu::from_highlight_refs(
device,
&selected_wires,
WireModel::SELECTED,
depth_map,
self.wire_const_bgl.as_ref(),
);
gpu.extend(WireGpu::from_highlight_refs(
device,
&hover_wires,
WireModel::HOVER,
depth_map,
self.wire_const_bgl.as_ref(),
));
self.gpu_selected_wires = gpu;
if let Some(started) = perf_started {
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
if elapsed_ms >= 1.0 {
crate::perf_record!(
"[perf] wire-highlight {:>7.1}ms handles={} wires={}",
elapsed_ms,
selected.len() + usize::from(hover.is_some()),
selected_wires.len() + hover_wires.len(),
);
}
}
self.gpu_selected_wires =
WireGpu::from_run(device, &out, depth_map, false, self.wire_const_bgl.as_ref());
}
/// Build the text-highlight overlay: the glyph quads of just the selected /
@@ -1971,6 +2009,7 @@ impl Pipeline {
selected: &rustc_hash::FxHashSet<acadrust::Handle>,
hover: Option<acadrust::Handle>,
) {
let perf_started = crate::perf::enabled().then(iced::time::Instant::now);
let hover = hover.filter(|h| !selected.contains(h));
if selected.is_empty() && hover.is_none() {
self.text_highlight_vbuf = None;
@@ -2001,6 +2040,16 @@ impl Pipeline {
}
self.text_highlight_vcount = out.len() as u32;
self.text_highlight_vbuf = text_gpu::upload_vertices(device, &out);
if let Some(started) = perf_started {
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
if elapsed_ms >= 1.0 {
crate::perf_record!(
"[perf] text-highlight {:>7.1}ms vertices={}",
elapsed_ms,
out.len(),
);
}
}
}
/// Upload the live overlay (command preview / interim / grip-drag) wires.
@@ -2297,6 +2346,7 @@ impl Pipeline {
clip_w: u32,
clip_h: u32,
) {
let perf_started = crate::perf::enabled().then(iced::time::Instant::now);
let Some(batch) = &mut self.gpu_hatch else {
return;
};
@@ -2306,6 +2356,16 @@ impl Pipeline {
batch.visibility[i] = if skip { 0 } else { 1 };
}
batch.upload_visibility(queue);
if let Some(started) = perf_started {
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
if elapsed_ms >= 1.0 {
crate::perf_record!(
"[perf] hatch-lod {:>7.1}ms instances={}",
elapsed_ms,
batch.instance_aabbs.len(),
);
}
}
}
/// Per-frame wipeout frustum-skip flag (Phase 2.3). Mirrors
@@ -2331,6 +2391,7 @@ impl Pipeline {
wireframe_only: bool,
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
) {
let perf_started = crate::perf::enabled().then(iced::time::Instant::now);
// Edge buffer is always built from `face3d_wires`, so 3DFACE
// outlines stay on the screen regardless of mode.
self.gpu_face3d_edges =
@@ -2359,6 +2420,17 @@ impl Pipeline {
depth_map,
));
}
if let Some(started) = perf_started {
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
if elapsed_ms >= 1.0 {
crate::perf_record!(
"[perf] face3d-upload {:>7.1}ms face-wires={} all-wires={}",
elapsed_ms,
face3d_wires.len(),
all_wires.len(),
);
}
}
}
/// Build the batched mesh buffers (a few large buffers for the whole solid
@@ -2773,6 +2845,7 @@ impl Pipeline {
queue: &wgpu::Queue,
hatches: &[HatchModel],
) {
let perf_started = crate::perf::enabled().then(iced::time::Instant::now);
if let Some(bgl1) = &self.hatch_compat_bgl1 {
self.gpu_hatches_compat = hatches
.iter()
@@ -2792,6 +2865,16 @@ impl Pipeline {
let renderable: Vec<HatchModel> =
hatches.iter().filter(|h| h.boundary.len() >= 3).cloned().collect();
self.gpu_hatch = hatch_gpu::HatchGpu::build(device, bgl1, &renderable);
if let Some(started) = perf_started {
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
if elapsed_ms >= 1.0 {
crate::perf_record!(
"[perf] hatch-upload {:>7.1}ms models={}",
elapsed_ms,
renderable.len(),
);
}
}
}
pub fn upload_wipeouts(&mut self, device: &wgpu::Device, wipeouts: &[HatchModel]) {
@@ -2823,6 +2906,7 @@ impl Pipeline {
queue: &wgpu::Queue,
verts: &[text_gpu::TextVertex],
) {
let perf_started = crate::perf::enabled().then(iced::time::Instant::now);
if let Ok(mut atlas) = crate::scene::text::sdf_atlas::text_atlas().lock() {
if self.text_atlas_gpu.is_none() || atlas.is_dirty() {
self.text_atlas_gpu = Some(text_gpu::TextAtlasGpu::upload(
@@ -2836,6 +2920,16 @@ impl Pipeline {
}
self.text_vbuf = text_gpu::upload_vertices(device, verts);
self.text_vcount = verts.len() as u32;
if let Some(started) = perf_started {
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
if elapsed_ms >= 1.0 {
crate::perf_record!(
"[perf] text-upload {:>7.1}ms vertices={}",
elapsed_ms,
verts.len(),
);
}
}
}
pub fn upload_uniforms(&self, queue: &wgpu::Queue, uniforms: &Uniforms) {
+165 -54
View File
@@ -10,16 +10,15 @@
// * Modify in place — a move / rotate / scale / colour change keeps the
// entity's segment count, so its slab is overwritten where it sits.
// * Add — bump-allocate a fresh slab at the tail.
// * A Modify whose segment count changed — reject the patch before touching
// GPU state, so the caller can rebuild this wire arena cleanly.
// * A tail Modify whose segment count changed — resize its terminal slab in
// place. A non-tail shape change rejects the patch before touching GPU state
// so the caller can rebuild this wire arena cleanly.
// * Erase — tombstone the slab (blank instances that render nothing).
//
// Two correctness points make add/remove safe:
// * draw_depth_map normalises each entity's draw-order z-bias by the block's
// entity count, so ANY add/remove re-scales EVERY entity's bias. We keep a
// CPU mirror of the WireConst array and, on a structural change, refresh every
// slab's draw_depth and re-upload the (small, ~1 MB) const buffer — the huge
// instance buffer is untouched.
// * draw_depth_map uses stable sparse labels, so Add/Remove changes only the
// named entity's depth. Existing slabs retain their WireConst values and no
// whole-const-buffer upload is needed.
// * A tail-appended entity draws last, which only mis-orders alpha-blended /
// coincident wires. So when the set contains ANY transparent wire we bail to a
// full rebuild instead of appending. Opaque overlap resolves by the z-bias, so
@@ -80,6 +79,23 @@ struct PreparedPatchRun {
order_sensitive: bool,
}
fn can_resize_terminal_slab(
slab: &Slab,
inst_tail: u32,
const_tail: u32,
inst_cap: u32,
const_cap: u32,
new_inst_len: u32,
new_const_len: u32,
change_count: usize,
) -> bool {
change_count == 1
&& slab.inst_off + slab.inst_len == inst_tail
&& slab.const_off + slab.const_len == const_tail
&& slab.inst_off + new_inst_len <= inst_cap
&& slab.const_off + new_const_len <= const_cap
}
pub struct WireArena {
inst_buf: wgpu::Buffer,
inst_cap: u32,
@@ -88,8 +104,7 @@ pub struct WireArena {
const_bind_group: std::sync::Arc<wgpu::BindGroup>,
const_cap: u32,
const_tail: u32,
/// CPU mirror of the const buffer so a structural edit can refresh every
/// slab's draw_depth (denominator change) without re-emitting geometry.
/// CPU mirror of the const buffer, used for in-place tail resize/patches.
consts_cpu: Vec<WireConst>,
slabs: FxHashMap<Handle, Slab>,
/// Temporarily hidden Modified slabs. Grip drag blanks these but keeps their
@@ -166,7 +181,7 @@ pub(crate) fn patch_handle_index(
}
}
}
if edit.new_len != 0 {
if edit.visible && edit.new_len != 0 {
index.insert(
edit.handle.value(),
(edit.start..edit.start + edit.new_len)
@@ -504,17 +519,14 @@ impl WireArena {
new_handles_are_suffix: bool,
depth_map: &FxHashMap<u64, [f32; 2]>,
) -> bool {
let depth_structural = changes
.iter()
.any(|(_, kind)| !matches!(kind, ChangeKind::Modified));
// Prepare and validate every visible run before mutating either GPU
// buffer. Growing/shrinking a Modified slab used to tombstone the old
// range and append a new one. Repeated live-polyline updates could then
// leave the arena half-patched when a later guard requested a rebuild,
// producing alternating old/new submissions. A shape change now takes
// the clean full-arena fallback, while the scene itself still
// re-tessellates only the named entity.
// producing alternating old/new submissions. The terminal slab can
// grow/shrink without relocation (the common live-polyline case);
// other shape changes take the clean full-arena fallback while the
// scene itself still re-tessellates only the named entity.
let mut prepared: FxHashMap<Handle, PreparedPatchRun> = FxHashMap::default();
for &(h, kind) in changes {
let run = runs.get(&h).map(Vec::as_slice).unwrap_or(&[]);
@@ -538,16 +550,25 @@ impl WireArena {
let inst_len = insts.len() as u32;
let const_len = csts.len() as u32;
if matches!(kind, ChangeKind::Modified)
&& self
.slabs
.get(&h)
.or_else(|| self.vacant.get(&h))
.is_some_and(|slab| {
slab.inst_len != inst_len || slab.const_len != const_len
})
{
return false;
if matches!(kind, ChangeKind::Modified) {
let known = self.slabs.get(&h).or_else(|| self.vacant.get(&h));
let shape_changed = known
.is_some_and(|slab| slab.inst_len != inst_len || slab.const_len != const_len);
let can_resize_tail = self.slabs.get(&h).is_some_and(|slab| {
can_resize_terminal_slab(
slab,
self.inst_tail,
self.const_tail,
self.inst_cap,
self.const_cap,
inst_len,
const_len,
changes.len(),
)
});
if shape_changed && !can_resize_tail {
return false;
}
}
prepared.insert(
@@ -642,6 +663,50 @@ impl WireArena {
continue;
}
// A live entity is normally the newest handle and therefore owns
// the terminal slab. Resize that slab at the same offsets instead
// of tombstoning it and rebuilding the whole arena as its segment
// count grows on every click.
let can_resize_tail = self.slabs.get(&h).is_some_and(|slab| {
can_resize_terminal_slab(
slab,
self.inst_tail,
self.const_tail,
self.inst_cap,
self.const_cap,
inst_len,
const_len,
changes.len(),
)
});
if can_resize_tail {
let (inst_off, const_off) = {
let slab = self.slabs.get(&h).unwrap();
(slab.inst_off, slab.const_off)
};
for instance in &mut insts {
instance.wire_id += const_off;
}
self.write_insts(queue, inst_off, &insts);
self.consts_cpu.truncate(const_off as usize);
self.consts_cpu.extend(csts.iter().copied());
let csz = std::mem::size_of::<WireConst>() as u64;
queue.write_buffer(
&self.const_buf,
const_off as u64 * csz,
bytemuck::cast_slice(&csts),
);
self.inst_tail = inst_off + inst_len;
self.const_tail = const_off + const_len;
let slab = self.slabs.get_mut(&h).unwrap();
slab.inst_len = inst_len;
slab.const_len = const_len;
slab.base_depth = base_depth;
slab.aabb = aabb;
self.order_sensitive |= run_order_sensitive;
continue;
}
// Layout changed ⇒ append at the tail. Unsafe to relocate when the set
// resolves overlap by submission order: transparency, a wire with no
// draw-order depth, or the mesh-edge arena (all its wires are forced
@@ -674,6 +739,16 @@ impl WireArena {
for c in &csts {
self.consts_cpu.push(*c);
}
// Appended instances immediately reference these constant slots.
// Keep the GPU buffer in sync without relying on a full-scene upload.
if !csts.is_empty() {
let const_size = std::mem::size_of::<WireConst>() as u64;
queue.write_buffer(
&self.const_buf,
const_off as u64 * const_size,
bytemuck::cast_slice(&csts),
);
}
self.inst_tail += inst_len;
self.const_tail += const_len;
self.slabs.insert(
@@ -690,30 +765,6 @@ impl WireArena {
self.order_sensitive |= run_order_sensitive;
}
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.
// 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 {
0.0
} 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 += 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;
}
@@ -746,6 +797,8 @@ impl WireArena {
clip_w: u32,
clip_h: u32,
) -> Vec<WireGpu> {
let perf = crate::perf::enabled();
let perf_started = perf.then(iced::time::Instant::now);
if self.inst_tail == 0 {
return vec![];
}
@@ -799,14 +852,18 @@ impl WireArena {
.collect();
}
if crate::perf::enabled() {
if perf {
let submitted: u64 = ranges
.iter()
.map(|(start, end)| (end - start) as u64)
.sum();
if submitted < self.inst_tail as u64 {
let elapsed_ms = perf_started
.map(|started| started.elapsed().as_secs_f64() * 1000.0)
.unwrap_or_default();
if submitted < self.inst_tail as u64 || elapsed_ms >= 1.0 {
crate::perf_record!(
"[perf] wire-cull submitted={} resident={} ranges={}",
"[perf] wire-cull {:>7.1}ms submitted={} resident={} ranges={}",
elapsed_ms,
submitted,
self.inst_tail,
ranges.len(),
@@ -826,3 +883,57 @@ impl WireArena {
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn slab() -> Slab {
Slab {
inst_off: 100,
inst_len: 10,
const_off: 20,
const_len: 2,
aabb: [0.0; 4],
base_depth: 0.0,
}
}
#[test]
fn terminal_slab_can_grow_without_relocating_the_arena() {
assert!(can_resize_terminal_slab(
&slab(),
110,
22,
1000,
100,
18,
3,
1,
));
}
#[test]
fn non_terminal_or_batched_resize_uses_clean_fallback() {
assert!(!can_resize_terminal_slab(
&slab(),
111,
22,
1000,
100,
18,
3,
1,
));
assert!(!can_resize_terminal_slab(
&slab(),
110,
22,
1000,
100,
18,
3,
2,
));
}
}
+78
View File
@@ -607,6 +607,84 @@ fn build_const_bind_group(
}
impl WireGpu {
/// Build a small selection/hover overlay from borrowed resident wires while
/// overriding their colour. Avoids deep-cloning every point/text/fill array
/// of a large selected polyline or block before packing the overlay.
pub fn from_highlight_refs(
device: &wgpu::Device,
wires: &[&WireModel],
color: [f32; 4],
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
const_bgl: Option<&wgpu::BindGroupLayout>,
) -> Vec<Self> {
#[cfg(not(target_arch = "wasm32"))]
if let Some(const_bgl) = const_bgl {
const MAX_INSTANCES: usize =
268_435_456 / std::mem::size_of::<WireInstance>();
use crate::par::prelude::*;
let per: Vec<(Vec<WireInstance>, WireConst)> = wires
.par_iter()
.enumerate()
.map(|(idx, &wire)| {
emit_wire_native(wire, idx as u32, color, wire_draw_depth(wire, depth_map))
})
.collect();
let mut instances =
Vec::with_capacity(per.iter().map(|(items, _)| items.len()).sum());
let mut consts = Vec::with_capacity(per.len());
for (mut items, constant) in per {
instances.append(&mut items);
consts.push(constant);
}
if instances.is_empty() {
return Vec::new();
}
let bind_group = build_const_bind_group(device, const_bgl, &consts);
return instances
.chunks(MAX_INSTANCES)
.map(|chunk| Self {
instance_buffer: instance_buffer_mapped(
device,
"wire.highlight.ibuf",
chunk,
),
first_instance: 0,
instance_count: chunk.len() as u32,
is_3d_mesh_edge: false,
const_bind_group: Some(bind_group.clone()),
})
.collect();
}
let _ = const_bgl;
const MAX_PACKED_INSTANCES: usize =
268_435_456 / std::mem::size_of::<PackedWireInstance>();
let per: Vec<Vec<PackedWireInstance>> = wires
.iter()
.map(|wire| {
emit_wire_packed(wire, color, wire_draw_depth(wire, depth_map))
})
.collect();
let mut instances = Vec::with_capacity(per.iter().map(Vec::len).sum());
for mut items in per {
instances.append(&mut items);
}
instances
.chunks(MAX_PACKED_INSTANCES)
.map(|chunk| Self {
instance_buffer: instance_buffer_mapped(
device,
"wire.highlight.compat.ibuf",
chunk,
),
first_instance: 0,
instance_count: chunk.len() as u32,
is_3d_mesh_edge: false,
const_bind_group: None,
})
.collect()
}
/// Native-only equivalent of [`from_run`] for an already partitioned set
/// of borrowed wires. Used when one arena partition exceeds the 256 MB
/// buffer limit: the compatible partition stays patchable while only the
+38 -11
View File
@@ -331,7 +331,7 @@ impl Scene {
// ── Erase ─────────────────────────────────────────────────────────────
pub fn erase_entities(&mut self, handles: &[Handle]) {
let handle_set: HashSet<Handle> = handles.iter().copied().collect();
let mut handle_set: HashSet<Handle> = HashSet::default();
let mut erased: Vec<(Handle, ChangeKind)> = Vec::new();
for &h in handles {
// Objects on a locked layer can't be erased.
@@ -343,6 +343,7 @@ impl Scene {
let before = self.document.get_entity_arc(h);
self.record_undo_before(h, before);
}
self.remember_removed_cache_categories(h);
self.document.remove_entity_arc(h);
self.selected.remove(&h);
self.hatches.remove(&h);
@@ -350,22 +351,53 @@ impl Scene {
self.meshes.remove(&h);
self.block_meshes.remove(&h);
self.solid_models.remove(&h);
handle_set.insert(h);
erased.push((h, ChangeKind::Removed));
}
// Capture exactly the group objects that this erase will rewrite, plus
// the group dictionary when an emptied group will be removed. Do this
// before taking mutable object-map borrows below.
if self.is_recording_undo() && !handle_set.is_empty() {
let affected_groups: Vec<(Handle, ObjectType)> = self
.document
.objects
.iter()
.filter_map(|(&handle, object)| match object {
ObjectType::Group(group)
if group.entities.iter().any(|h| handle_set.contains(h)) =>
{
Some((handle, object.clone()))
}
_ => None,
})
.collect();
if !affected_groups.is_empty() {
let removes_group = affected_groups.iter().any(|(_, object)| {
matches!(
object,
ObjectType::Group(group)
if group.entities.iter().all(|h| handle_set.contains(h))
)
});
if removes_group {
let dictionary = self.document.header.acad_group_dict_handle;
let dictionary_before = self.document.objects.get(&dictionary).cloned();
self.record_undo_object_before(dictionary, dictionary_before);
}
for (handle, object) in affected_groups {
self.record_undo_object_before(handle, Some(object));
}
}
}
// Remove erased handles from all groups; delete groups that become empty.
let group_dict_handle = self.document.header.acad_group_dict_handle;
let mut groups_changed = false;
let to_remove: Vec<Handle> = self
.document
.objects
.values_mut()
.filter_map(|obj| match obj {
ObjectType::Group(g) => {
let before = g.entities.len();
g.entities.retain(|h| !handle_set.contains(h));
if g.entities.len() != before {
groups_changed = true;
}
if g.entities.is_empty() {
Some(g.handle)
} else {
@@ -375,11 +407,6 @@ impl Scene {
_ => None,
})
.collect();
// Delta-undo: editing a group (or deleting an emptied one) mutates
// document.objects — non-entity state a pure-entity delta can't restore.
if groups_changed {
self.poison_undo_recording();
}
for gh in &to_remove {
if let Some(ObjectType::Dictionary(dict)) =
self.document.objects.get_mut(&group_dict_handle)
+93 -25
View File
@@ -66,7 +66,8 @@ pub struct ViewportData {
/// by the wire / face3d pipelines as a clip-z bias. WireModels carry no
/// depth field (84 construction sites); the bias is looked up by handle
/// at GPU-upload time from this map instead.
pub(in crate::scene) draw_depths: Arc<rustc_hash::FxHashMap<u64, [f32; 2]>>,
pub(in crate::scene) draw_depths:
std::sync::Weak<rustc_hash::FxHashMap<u64, [f32; 2]>>,
pub(in crate::scene) hatches: Arc<Vec<HatchModel>>,
/// Wipeout fills — rendered in a separate pass AFTER wires.
pub(in crate::scene) wipeout_hatches: Arc<Vec<HatchModel>>,
@@ -277,6 +278,9 @@ impl shader::Primitive for Primitive {
{
inner.wire_cull_key = (u64::MAX, u64::MAX, 0, 0);
}
inner.hatch_lod_key = (usize::MAX, u64::MAX, 0, 0, false);
inner.wipeout_lod_key = (usize::MAX, u64::MAX, 0, 0, false);
inner.mesh_lod_key = (usize::MAX, u64::MAX, 0, 0);
inner.render_sig = u64::MAX;
}
// The MSAA / depth / resolve textures are always sized to the
@@ -337,6 +341,9 @@ impl shader::Primitive for Primitive {
let Some(vp_wires) = vp.wires.upgrade() else {
continue;
};
let Some(draw_depths) = vp.draw_depths.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.
@@ -411,7 +418,8 @@ impl shader::Primitive for Primitive {
|| inner
.cached_face3d_depth_source
.as_ref()
.map_or(true, |source| !Arc::ptr_eq(source, &vp.draw_depths))
.and_then(std::sync::Weak::upgrade)
.map_or(true, |source| !Arc::ptr_eq(&source, &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 {
@@ -420,10 +428,10 @@ impl shader::Primitive for Primitive {
&vp.face3d_wires[..],
&vp_wires[..],
!face3d_fill_active,
&vp.draw_depths,
&draw_depths,
);
inner.cached_face3d_source = Some(Arc::clone(&vp.face3d_wires));
inner.cached_face3d_depth_source = Some(Arc::clone(&vp.draw_depths));
inner.cached_face3d_depth_source = Some(Arc::downgrade(&draw_depths));
}
inner.cached_face3d_key = (vp.wire_content_id, face3d_fill_active);
// Wire buffers are world-space, so a camera move alone doesn't
@@ -527,7 +535,7 @@ impl shader::Primitive for Primitive {
&patch.changes,
&regular_changed,
patch.new_handles_are_suffix,
&vp.draw_depths,
&draw_depths,
)
} else {
inner.wire_arena_fallback_kind == Some(false)
@@ -541,7 +549,7 @@ impl shader::Primitive for Primitive {
&patch.changes,
&mesh_changed,
patch.new_handles_are_suffix,
&vp.draw_depths,
&draw_depths,
)
} else {
inner.wire_arena_fallback_kind == Some(true)
@@ -571,7 +579,7 @@ impl shader::Primitive for Primitive {
device,
queue,
&regular,
&vp.draw_depths,
&draw_depths,
bgl,
false,
);
@@ -580,7 +588,7 @@ impl shader::Primitive for Primitive {
crate::scene::pipeline::WireGpu::from_run_refs(
device,
&regular,
&vp.draw_depths,
&draw_depths,
false,
bgl,
),
@@ -607,7 +615,7 @@ impl shader::Primitive for Primitive {
device,
queue,
&mesh,
&vp.draw_depths,
&draw_depths,
bgl,
true,
);
@@ -616,7 +624,7 @@ impl shader::Primitive for Primitive {
crate::scene::pipeline::WireGpu::from_run_refs(
device,
&mesh,
&vp.draw_depths,
&draw_depths,
true,
bgl,
),
@@ -700,7 +708,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[..], &draw_depths);
pipeline
.wire_buffer_cache
.insert(vp.wire_content_id, entry.clone());
@@ -745,13 +753,26 @@ impl shader::Primitive for Primitive {
// so this refreshes without re-tessellating or re-uploading the main
// wire buffers.
let sel_key = (vp.wire_content_id, vp.selection_generation);
if sel_key != inner.cached_selection {
let highlighted_geometry_unchanged =
vp.selected_handles.is_empty() && vp.hover_handle.is_none()
|| vp.wire_patch.as_ref().is_some_and(|(previous, patch)| {
*previous == inner.cached_selection.0
&& patch.changes.iter().all(|(handle, _)| {
!vp.selected_handles.contains(handle)
&& vp.hover_handle != Some(*handle)
})
});
let selection_changed = inner.cached_selection.1 != vp.selection_generation;
let highlighted_geometry_changed = inner.cached_selection.0
!= vp.wire_content_id
&& !highlighted_geometry_unchanged;
if selection_changed || highlighted_geometry_changed {
inner.upload_selected_wires(
device,
&vp_wires[..],
&vp.selected_handles,
vp.hover_handle,
&vp.draw_depths,
&draw_depths,
);
// Text highlight rides the same selection key: a pick / rollover
// recolours the selected / hovered glyphs without touching the
@@ -762,8 +783,11 @@ impl shader::Primitive for Primitive {
&vp.selected_handles,
vp.hover_handle,
);
inner.cached_selection = sel_key;
}
// Advance the content id even when an unrelated entity patch kept
// the existing overlay valid, so future deltas compare to the
// arena generation actually on screen.
inner.cached_selection = sel_key;
// Batched solid meshes stay resident while unrelated entity
// categories change.
if inner
@@ -800,7 +824,7 @@ impl shader::Primitive for Primitive {
// Live overlay (command preview / interim / grip drag) — small and
// refreshed every frame it's present, so a drag never re-uploads
// the resident base wire buffer.
inner.upload_preview_wires(device, &vp.preview_wires[..], &vp.draw_depths);
inner.upload_preview_wires(device, &vp.preview_wires[..], &draw_depths);
inner.upload_preview_text(device, queue, &vp.preview_text_verts[..]);
// Cull / scissor / LOD project AABBs relative-to-eye (matching the
// GPU's RTE path) so the math stays precise at UTM-scale coords.
@@ -819,15 +843,44 @@ impl shader::Primitive for Primitive {
inner.upload_silhouettes(device, &[], vp.view_dir);
}
inner.upload_clip_boundary(device, &vp.clip_boundary_ndc);
inner.compute_hatch_lod(queue, view_rot, eye, clip_size.width, clip_size.height);
inner.compute_wipeout_lod(view_rot, eye, clip_size.width, clip_size.height);
inner.compute_mesh_lod(
queue,
view_rot,
eye,
let hatch_lod_key = (
Arc::as_ptr(&vp.hatches) as usize,
vp.camera_generation,
clip_size.width,
clip_size.height,
fill_mode,
);
if inner.hatch_lod_key != hatch_lod_key {
inner.compute_hatch_lod(queue, view_rot, eye, clip_size.width, clip_size.height);
inner.hatch_lod_key = hatch_lod_key;
}
let wipeout_lod_key = (
Arc::as_ptr(&vp.wipeout_hatches) as usize,
vp.camera_generation,
clip_size.width,
clip_size.height,
fill_mode,
);
if inner.wipeout_lod_key != wipeout_lod_key {
inner.compute_wipeout_lod(view_rot, eye, clip_size.width, clip_size.height);
inner.wipeout_lod_key = wipeout_lod_key;
}
let mesh_lod_key = (
Arc::as_ptr(&vp.meshes) as usize,
vp.camera_generation,
clip_size.width,
clip_size.height,
);
if inner.mesh_lod_key != mesh_lod_key {
inner.compute_mesh_lod(
queue,
view_rot,
eye,
clip_size.width,
clip_size.height,
);
inner.mesh_lod_key = mesh_lod_key;
}
#[cfg(not(target_arch = "wasm32"))]
{
let cull_key = (
@@ -881,6 +934,7 @@ impl shader::Primitive for Primitive {
);
}
}
let prepare_ms = nav_prepare_started.elapsed().as_secs_f64() * 1000.0;
if let Some(sample) = self.nav_perf {
crate::perf::record(format_args!(
"[perf] nav-prepare op={} space={} mode={} input={:.2}ms build={:.2}ms prepare={:.2}ms elapsed={:.2}ms viewports={}",
@@ -889,10 +943,16 @@ impl shader::Primitive for Primitive {
sample.mode,
sample.input_ms,
sample.build_ms,
nav_prepare_started.elapsed().as_secs_f64() * 1000.0,
prepare_ms,
sample.started.elapsed().as_secs_f64() * 1000.0,
self.viewports.len(),
));
} else if crate::perf::enabled() && prepare_ms >= 5.0 {
crate::perf_record!(
"[perf] frame-prepare {:>7.1}ms viewports={}",
prepare_ms,
self.viewports.len(),
);
}
}
@@ -970,16 +1030,23 @@ impl shader::Primitive for Primitive {
inner.viewcube.render(encoder, target, vp_clip);
}
}
let render_ms = nav_render_started.elapsed().as_secs_f64() * 1000.0;
if let Some(sample) = self.nav_perf {
crate::perf::record(format_args!(
"[perf] nav-render op={} space={} mode={} encode={:.2}ms elapsed={:.2}ms viewports={}",
sample.op.label(),
sample.space,
sample.mode,
nav_render_started.elapsed().as_secs_f64() * 1000.0,
render_ms,
sample.started.elapsed().as_secs_f64() * 1000.0,
self.viewports.len(),
));
} else if crate::perf::enabled() && render_ms >= 5.0 {
crate::perf_record!(
"[perf] frame-encode {:>7.1}ms viewports={}",
render_ms,
self.viewports.len(),
);
}
}
}
@@ -2062,11 +2129,12 @@ impl Scene {
} else {
0x3000_0000_0000_0000 | inst.handle.value()
};
let draw_depths = self.draw_depth_map();
let text_verts = self.gather_text_verts(
&all_wires,
wire_content_id,
text_source_key,
&self.draw_depth_map(),
&draw_depths,
);
// Grip-drag / command-preview glyphs, excluded from the epoch-cached base
// gather above. Two sources, both tiny (one operation's worth) and walked
@@ -2117,7 +2185,7 @@ impl Scene {
face3d_wires,
text_verts,
preview_text_verts,
draw_depths: self.draw_depth_map(),
draw_depths: Arc::downgrade(&draw_depths),
hatches,
wipeout_hatches,
images,