perf(scene): multi-resolution mesh LOD (Phase 3.4)

ACIS SAT entities (Solid3D / Region / Body) were tessellated at one
fixed sampling density (CIRC_SEGS=48, GRID_U=32, GRID_V=16) regardless
of how far they were from the camera. Distant solids paid the full
triangle bill for fragments that mostly resolved to sub-pixel size.

Generate three pre-built LODs per entity at load time:

  * LOD 0 (HIGH)  — 48/32/16 segments. Use above 200 px projected diag.
  * LOD 1 (MID)   — 24/16/8.  Use 50–200 px.
  * LOD 2 (LOW)   — 12/8/4.   Use below 50 px.

Tessellation cost is paid 3× on load (cheap — typical files have few
solids). Render picks per-frame via `compute_mesh_lod` from each mesh's
`world_aabb` projected diagonal. When a level is missing (e.g. a solid
entity that only emits LOD 0), the selector walks down to the nearest
available slot.

API ripples:
  * New `LodConfig` (HIGH/MID/LOW) drives `tess_sat`, `tess_cone_face`,
    `tess_sphere_face`, `tess_torus_face`.
  * `tessellate_solid3d / _region / _body` now return `MeshLodSet`.
  * `MeshLodSet { lods: Vec<MeshModel>, world_aabb }` + `from_single`
    constructor so interactive truck-based commands (BOX/CYLINDER/etc.)
    that only generate one tessellation stay one-line.
  * GPU side gets `MeshLodGpu { lods: Vec<MeshGpu>, world_aabb }` and
    a `mesh_lod_levels` per-frame index vector.
  * STL/STEP export pulls slot 0 explicitly so the on-disk geometry
    isn't downgraded by the view-dependent ladder.

Background-thread lazy generation (per the roadmap) is deferred — the
synchronous eager build is fine for typical CAD files. Switch to a
queue + background tessellator when files with hundreds of solids
become a load-time bottleneck.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-05-15 23:30:13 +03:00
commit 8c08ae104a
8 changed files with 321 additions and 59 deletions

View file

@ -27,6 +27,8 @@ use camera::Camera;
pub use camera::Projection;
pub use hatch_model::HatchModel;
pub use image_model::ImageModel;
pub use mesh_model::MeshLodSet;
#[allow(unused_imports)]
pub use mesh_model::MeshModel;
pub use object::{GripApply, GripDef};
pub use pipeline::uniforms::Uniforms;
@ -69,7 +71,7 @@ pub struct DerivedCaches {
pub local_extent_max: f32,
pub hatches: HashMap<Handle, HatchModel>,
pub images: HashMap<Handle, ImageModel>,
pub meshes: HashMap<Handle, MeshModel>,
pub meshes: HashMap<Handle, MeshLodSet>,
/// Number of entities removed by the corrupt-entity guard during load.
/// Reported back to the UI so the user knows when a file had parser-junk
/// entities silently dropped.
@ -177,7 +179,7 @@ pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches {
.collect();
// meshes (parallel tessellation)
let meshes: HashMap<Handle, MeshModel> = mesh_handles
let meshes: HashMap<Handle, MeshLodSet> = mesh_handles
.par_iter()
.filter_map(|&handle| {
let e = doc.get_entity(handle)?;
@ -414,7 +416,7 @@ pub struct Scene {
/// Cached image models, keyed by geometry_epoch.
image_cache: RefCell<Option<(u64, Arc<Vec<ImageModel>>)>>,
/// Cached mesh models, keyed by geometry_epoch.
mesh_cache: RefCell<Option<(u64, Arc<Vec<MeshModel>>)>>,
mesh_cache: RefCell<Option<(u64, Arc<Vec<MeshLodSet>>)>>,
/// Per-viewport wire cache for paper-space rendering.
/// Maps vp_handle → (geometry_epoch, Arc<Vec<WireModel>>).
viewport_wire_cache: RefCell<HashMap<Handle, (u64, Arc<Vec<WireModel>>)>>,
@ -436,7 +438,7 @@ pub struct Scene {
/// GPU render data for hatch fills, keyed by the DXF entity Handle.
pub hatches: HashMap<Handle, HatchModel>,
/// GPU render data for solid meshes (truck Shell/Solid tessellation).
pub meshes: HashMap<Handle, MeshModel>,
pub meshes: HashMap<Handle, MeshLodSet>,
/// GPU render data for raster images (RasterImage entities), keyed by handle.
pub images: HashMap<Handle, ImageModel>,
/// The viewport that is currently "entered" (MSPACE mode).
@ -1097,7 +1099,7 @@ impl Scene {
arc
}
pub(super) fn meshes_arc(&self) -> Arc<Vec<MeshModel>> {
pub(super) fn meshes_arc(&self) -> Arc<Vec<MeshLodSet>> {
{
let cache = self.mesh_cache.borrow();
if let Some((cached_epoch, ref arc)) = *cache {
@ -3163,12 +3165,18 @@ impl Scene {
let dx = (new[0] - old[0]) as f32;
let dy = (new[1] - old[1]) as f32;
let dz = (new[2] - old[2]) as f32;
if let Some(mesh) = self.meshes.get_mut(&handle) {
for v in &mut mesh.verts {
v[0] += dx;
v[1] += dy;
v[2] += dz;
if let Some(set) = self.meshes.get_mut(&handle) {
for lod in &mut set.lods {
for v in &mut lod.verts {
v[0] += dx;
v[1] += dy;
v[2] += dz;
}
}
set.world_aabb[0] += dx;
set.world_aabb[1] += dy;
set.world_aabb[2] += dx;
set.world_aabb[3] += dy;
}
}
}