perf(render): batch solid meshes, fix per-pick freezes on heavy 3D models

Opening / navigating a heavy 3D DWG (e.g. a BIM model with ~10k ACIS solids)
was effectively frozen. Several independent costs, all addressed here:

- Face3D edge/fill buffers were re-uploaded on every camera move (keyed on
  camera_generation). They are world-space and selection-independent, so gate
  them on (geometry_epoch, fill_mode) instead — a pan no longer re-walks every
  wire.
- Solid meshes were drawn one buffer + one draw call per solid (~10k draws/
  frame), which strangled the GPU front end. Concatenate every solid's LOD0
  into a few large buffers (split only to stay under the 256 MB buffer cap) and
  draw the whole set in a handful of calls. Built once per geometry epoch, so
  selection / hover never re-pack it.
- Selection / hover highlight is now a small tinted overlay of just the picked
  solids, drawn last with an Always-depth pipeline so it shows on top even when
  the solid is occluded — instead of re-tinting and re-uploading all meshes.
- Hover / pick hit-testing scanned every triangle of every solid each move
  (seconds on a heavy model). Add a 3D-AABB screen-rect broad phase
  (MeshLodSet now carries z_aabb) and reuse the renderer's cached expanded mesh
  set, so picking is O(solids) cheap projections plus the few solids actually
  under the cursor.
- Block-internal hatch hit-testing exploded every INSERT on every hover.
  Cache the result per geometry epoch and skip blocks that contain no hatch.
- Curved-surface (cone/sphere/torus) tessellation tolerance is now radius-
  relative (CURVE_REL_TOL) so a cylinder's facet count is size-independent and
  matches the circle/arc wire tessellation, instead of exploding on large radii.

Also adds docs/tessellation.md mapping every EntityType to its tessellation
path (truck B-rep vs truck curve topology vs direct).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-30 22:46:29 +03:00
1 parent 27896e3401
commit a994a148fe
10 files changed
+572 -146

No files matched your search

+79 -30
View File
@@ -570,6 +570,7 @@ fn offset_mesh_lod_set(mut set: MeshLodSet) -> MeshLodSet {
if min_x.is_finite() {
set.world_aabb = [min_x, min_y, max_x, max_y];
}
set.recompute_aabb();
set
}
@@ -626,6 +627,7 @@ fn transform_block_mesh_lod_set(
if min_x.is_finite() {
out.world_aabb = [min_x, min_y, max_x, max_y];
}
out.recompute_aabb();
out
}
@@ -762,6 +764,12 @@ pub struct Scene {
image_cache: RefCell<Option<(u64, Arc<Vec<ImageModel>>)>>,
/// Cached mesh models, keyed by geometry_epoch.
mesh_cache: RefCell<Option<(u64, Arc<Vec<MeshLodSet>>)>>,
/// Cached block-INSERT hatches for hit-testing, keyed by geometry_epoch.
/// Building this explodes every model-space INSERT, so without the cache a
/// heavy block-instanced drawing re-explodes thousands of inserts on every
/// hover. The set is geometry-derived, so a camera move / hover never
/// invalidates it.
insert_hatch_cache: RefCell<Option<(u64, Arc<Vec<(Handle, HatchModel)>>)>>,
/// Per-viewport wire cache for paper-space rendering.
/// Maps vp_handle → (geometry_epoch, Arc<Vec<WireModel>>).
viewport_wire_cache: RefCell<HashMap<Handle, ((u64, u32, u64), Arc<Vec<WireModel>>)>>,
@@ -935,6 +943,7 @@ impl Scene {
wipeout_cache: RefCell::new(None),
image_cache: RefCell::new(None),
mesh_cache: RefCell::new(None),
insert_hatch_cache: RefCell::new(None),
viewport_wire_cache: RefCell::new(HashMap::default()),
paper_sheet_cache: RefCell::new(None),
paper_projected_cache: RefCell::new(HashMap::default()),
@@ -2528,7 +2537,43 @@ impl Scene {
/// handle so a click on a block-internal hatch can select the parent
/// Insert (AutoCAD behaviour: sub-entities of a block aren't directly
/// selectable; the click resolves to the Insert).
pub fn insert_hatches_for_click(&self) -> Vec<(Handle, HatchModel)> {
/// Whether a block definition (recursively) contains any Hatch. Memoised in
/// `memo` across calls; lets the pick path skip exploding solid-only blocks.
fn block_has_hatch(
&self,
block_name: &str,
memo: &mut std::collections::HashMap<String, bool>,
) -> bool {
if let Some(&v) = memo.get(block_name) {
return v;
}
// Seed `false` first so a cyclic block reference terminates.
memo.insert(block_name.to_string(), false);
let result = self
.document
.block_records
.get(block_name)
.map(|br| {
br.entity_handles.iter().any(|&h| match self.document.get_entity(h) {
Some(EntityType::Hatch(_)) => true,
Some(EntityType::Insert(ins)) => self.block_has_hatch(&ins.block_name, memo),
_ => false,
})
})
.unwrap_or(false);
memo.insert(block_name.to_string(), result);
result
}
pub fn insert_hatches_for_click(&self) -> Arc<Vec<(Handle, HatchModel)>> {
{
let c = self.insert_hatch_cache.borrow();
if let Some((epoch, ref arc)) = *c {
if epoch == self.geometry_epoch {
return Arc::clone(arc);
}
}
}
let layout_block = self.current_layout_block_handle();
let layer_hidden = |layer: &str| {
self.document
@@ -2538,6 +2583,10 @@ impl Scene {
.unwrap_or(false)
};
let mut out: Vec<(Handle, HatchModel)> = Vec::new();
// Exploding an INSERT to find block-internal hatches is expensive, so
// skip blocks that contain no hatch at all (the common case for solid-
// only blocks). The hatch-presence test is memoised across inserts.
let mut hatch_memo: std::collections::HashMap<String, bool> = std::collections::HashMap::new();
for entity in self.document.entities() {
let EntityType::Insert(ins) = entity else {
continue;
@@ -2552,6 +2601,9 @@ impl Scene {
) {
continue;
}
if !self.block_has_hatch(&ins.block_name, &mut hatch_memo) {
continue;
}
for sub in ins
.explode_from_document(&self.document)
.into_iter()
@@ -2569,7 +2621,9 @@ impl Scene {
}
}
}
out
let arc = Arc::new(out);
*self.insert_hatch_cache.borrow_mut() = Some((self.geometry_epoch, Arc::clone(&arc)));
arc
}
/// Wires that should participate in hit-testing, snapping, and selection.
@@ -2672,35 +2726,30 @@ impl Scene {
eye: glam::DVec3,
bounds: iced::Rectangle,
) -> Option<Handle> {
// Block-internal instances must be owned (transformed copies); keep
// them in a Vec and chain references alongside the top-level meshes so
// neither set is cloned wholesale.
let mut block_owned: Vec<(Handle, crate::scene::model::mesh_model::MeshModel)> = Vec::new();
if !self.block_meshes.is_empty() {
let layout_block = self.current_layout_block_handle();
for e in self.document.entities() {
if e.common().owner_handle != layout_block {
continue;
}
let EntityType::Insert(ins) = e else { continue };
if !self.mesh_entity_visible(ins.common.handle) {
continue;
}
let mut sets = Vec::new();
self.expand_block_meshes(&ins.block_name, &ins.get_transform(), 0, None, &mut sets);
for set in sets {
if let Some(m) = set.lods.into_iter().next() {
block_owned.push((ins.common.handle, m));
}
}
// Reuse the renderer's expanded mesh set (top-level solids + per-INSERT
// block instances), cached per geometry epoch — so hover no longer
// re-expands every block instance on each move. Every `MeshLodSet`
// carries its handle (in `mesh.name`) and a 3D AABB.
let meshes = self.meshes_arc();
// Broad-phase: project each solid's 3D AABB and skip the per-triangle
// ray test for those whose footprint isn't under the cursor — O(solids)
// cheap projections instead of O(total triangles) on every hover.
let candidates = meshes.iter().filter_map(|set| {
let m = set.lods.first()?;
if !pick::hit_test::aabb_under_cursor(
set.world_aabb,
set.z_aabb,
cursor,
view_rot,
eye,
bounds,
) {
return None;
}
}
let top = self
.meshes
.iter()
.filter_map(|(h, set)| set.lods.first().map(|m| (*h, m)));
let blk = block_owned.iter().map(|(h, m)| (*h, m));
pick::hit_test::mesh_click_hit(cursor, top.chain(blk), view_rot, eye, bounds)
let handle = Handle::new(m.name.parse::<u64>().ok()?);
Some((handle, m))
});
pick::hit_test::mesh_click_hit(cursor, candidates, view_rot, eye, bounds)
}
/// Parent INSERT handles whose block-internal solid meshes fall in a