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

@ -1066,7 +1066,7 @@ impl H7CAD {
let color = [0.6f32, 0.6, 0.8, 1.0]; // default colour; command embedded it
let _ = color; // color is captured inside mesh_fn
if let Some(mesh) = mesh_fn(name) {
self.tabs[i].scene.meshes.insert(handle, mesh);
self.tabs[i].scene.meshes.insert(handle, crate::scene::MeshLodSet::from_single(mesh));
}
self.tabs[i].dirty = true;
self.command_line.push_output("Solid created.");
@ -1126,7 +1126,7 @@ impl H7CAD {
let new_entity = empty_solid3d();
let new_handle = self.tabs[i].scene.add_entity(new_entity);
mesh.name = format!("{}", new_handle.value());
self.tabs[i].scene.meshes.insert(new_handle, mesh);
self.tabs[i].scene.meshes.insert(new_handle, crate::scene::MeshLodSet::from_single(mesh));
self.tabs[i].dirty = true;
self.command_line.push_output("EXTRUDE: solid created.");
} else {
@ -1201,7 +1201,7 @@ impl H7CAD {
let new_entity = empty_solid3d();
let new_handle = self.tabs[i].scene.add_entity(new_entity);
mesh.name = format!("{}", new_handle.value());
self.tabs[i].scene.meshes.insert(new_handle, mesh);
self.tabs[i].scene.meshes.insert(new_handle, crate::scene::MeshLodSet::from_single(mesh));
self.tabs[i].dirty = true;
self.command_line
.push_output(&format!("REVOLVE: solid created ({:.0}°).", angle_deg));
@ -1359,7 +1359,7 @@ impl H7CAD {
let new_entity = empty_solid3d();
let new_handle = self.tabs[i].scene.add_entity(new_entity);
mesh.name = format!("{}", new_handle.value());
self.tabs[i].scene.meshes.insert(new_handle, mesh);
self.tabs[i].scene.meshes.insert(new_handle, crate::scene::MeshLodSet::from_single(mesh));
self.tabs[i].dirty = true;
self.command_line.push_output("SWEEP: solid created.");
} else {
@ -1443,7 +1443,7 @@ impl H7CAD {
let new_entity = empty_solid3d();
let new_handle = self.tabs[i].scene.add_entity(new_entity);
mesh.name = format!("{}", new_handle.value());
self.tabs[i].scene.meshes.insert(new_handle, mesh);
self.tabs[i].scene.meshes.insert(new_handle, crate::scene::MeshLodSet::from_single(mesh));
self.tabs[i].dirty = true;
self.command_line.push_output(&format!(
"LOFT: solid created from {} profiles.",

View file

@ -382,8 +382,15 @@ impl H7CAD {
Message::StlExportPath(Some(path)) => {
// Re-build STL bytes (we can't easily pass them through the message).
let i = self.active_tab;
let meshes: Vec<crate::scene::mesh_model::MeshModel> =
self.tabs[i].scene.meshes.values().cloned().collect();
// STL gets the highest-resolution LOD (slot 0) so the
// exported geometry isn't downgraded by the view-dependent
// mesh LOD ladder used for rendering.
let meshes: Vec<crate::scene::mesh_model::MeshModel> = self.tabs[i]
.scene
.meshes
.values()
.filter_map(|s| s.lods.first().cloned())
.collect();
let mesh_refs: Vec<&crate::scene::mesh_model::MeshModel> = meshes.iter().collect();
match crate::io::stl::build_stl(&mesh_refs) {
Some(bytes) => match std::fs::write(&path, bytes) {
@ -428,8 +435,13 @@ impl H7CAD {
Message::StepExportPath(Some(path)) => {
let i = self.active_tab;
let meshes: Vec<crate::scene::mesh_model::MeshModel> =
self.tabs[i].scene.meshes.values().cloned().collect();
// Export uses LOD 0 (full resolution); see StlExportPath above.
let meshes: Vec<crate::scene::mesh_model::MeshModel> = self.tabs[i]
.scene
.meshes
.values()
.filter_map(|s| s.lods.first().cloned())
.collect();
let mesh_refs: Vec<&crate::scene::mesh_model::MeshModel> = meshes.iter().collect();
match crate::io::step::build_step(&mesh_refs) {
Some(text) => match std::fs::write(&path, text.as_bytes()) {
@ -490,7 +502,10 @@ impl H7CAD {
let entity = empty_solid3d();
let handle = self.tabs[i].scene.add_entity(entity);
if !handle.is_null() {
self.tabs[i].scene.meshes.insert(handle, mesh);
self.tabs[i]
.scene
.meshes
.insert(handle, crate::scene::MeshLodSet::from_single(mesh));
self.tabs[i].dirty = true;
self.command_line.push_output(&format!(
"IMPORTOBJ: imported \"{}\" as mesh.",