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 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.7 committed 2026-05-15 23:30:13 +03:00
1 parent e7675be0bc
commit 8c08ae104a
8 files changed
+321 -59

No files matched your search

+5 -5
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 = [0.6f32, 0.6, 0.8, 1.0]; // default colour; command embedded it
let _ = color; // color is captured inside mesh_fn let _ = color; // color is captured inside mesh_fn
if let Some(mesh) = mesh_fn(name) { 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.tabs[i].dirty = true;
self.command_line.push_output("Solid created."); self.command_line.push_output("Solid created.");
@@ -1126,7 +1126,7 @@ impl H7CAD {
let new_entity = empty_solid3d(); let new_entity = empty_solid3d();
let new_handle = self.tabs[i].scene.add_entity(new_entity); let new_handle = self.tabs[i].scene.add_entity(new_entity);
mesh.name = format!("{}", new_handle.value()); 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.tabs[i].dirty = true;
self.command_line.push_output("EXTRUDE: solid created."); self.command_line.push_output("EXTRUDE: solid created.");
} else { } else {
@@ -1201,7 +1201,7 @@ impl H7CAD {
let new_entity = empty_solid3d(); let new_entity = empty_solid3d();
let new_handle = self.tabs[i].scene.add_entity(new_entity); let new_handle = self.tabs[i].scene.add_entity(new_entity);
mesh.name = format!("{}", new_handle.value()); 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.tabs[i].dirty = true;
self.command_line self.command_line
.push_output(&format!("REVOLVE: solid created ({:.0}°).", angle_deg)); .push_output(&format!("REVOLVE: solid created ({:.0}°).", angle_deg));
@@ -1359,7 +1359,7 @@ impl H7CAD {
let new_entity = empty_solid3d(); let new_entity = empty_solid3d();
let new_handle = self.tabs[i].scene.add_entity(new_entity); let new_handle = self.tabs[i].scene.add_entity(new_entity);
mesh.name = format!("{}", new_handle.value()); 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.tabs[i].dirty = true;
self.command_line.push_output("SWEEP: solid created."); self.command_line.push_output("SWEEP: solid created.");
} else { } else {
@@ -1443,7 +1443,7 @@ impl H7CAD {
let new_entity = empty_solid3d(); let new_entity = empty_solid3d();
let new_handle = self.tabs[i].scene.add_entity(new_entity); let new_handle = self.tabs[i].scene.add_entity(new_entity);
mesh.name = format!("{}", new_handle.value()); 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.tabs[i].dirty = true;
self.command_line.push_output(&format!( self.command_line.push_output(&format!(
"LOFT: solid created from {} profiles.", "LOFT: solid created from {} profiles.",
+20 -5
View File
@@ -382,8 +382,15 @@ impl H7CAD {
Message::StlExportPath(Some(path)) => { Message::StlExportPath(Some(path)) => {
// Re-build STL bytes (we can't easily pass them through the message). // Re-build STL bytes (we can't easily pass them through the message).
let i = self.active_tab; let i = self.active_tab;
let meshes: Vec<crate::scene::mesh_model::MeshModel> = // STL gets the highest-resolution LOD (slot 0) so the
self.tabs[i].scene.meshes.values().cloned().collect(); // 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(); let mesh_refs: Vec<&crate::scene::mesh_model::MeshModel> = meshes.iter().collect();
match crate::io::stl::build_stl(&mesh_refs) { match crate::io::stl::build_stl(&mesh_refs) {
Some(bytes) => match std::fs::write(&path, bytes) { Some(bytes) => match std::fs::write(&path, bytes) {
@@ -428,8 +435,13 @@ impl H7CAD {
Message::StepExportPath(Some(path)) => { Message::StepExportPath(Some(path)) => {
let i = self.active_tab; let i = self.active_tab;
let meshes: Vec<crate::scene::mesh_model::MeshModel> = // Export uses LOD 0 (full resolution); see StlExportPath above.
self.tabs[i].scene.meshes.values().cloned().collect(); 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(); let mesh_refs: Vec<&crate::scene::mesh_model::MeshModel> = meshes.iter().collect();
match crate::io::step::build_step(&mesh_refs) { match crate::io::step::build_step(&mesh_refs) {
Some(text) => match std::fs::write(&path, text.as_bytes()) { Some(text) => match std::fs::write(&path, text.as_bytes()) {
@@ -490,7 +502,10 @@ impl H7CAD {
let entity = empty_solid3d(); let entity = empty_solid3d();
let handle = self.tabs[i].scene.add_entity(entity); let handle = self.tabs[i].scene.add_entity(entity);
if !handle.is_null() { 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.tabs[i].dirty = true;
self.command_line.push_output(&format!( self.command_line.push_output(&format!(
"IMPORTOBJ: imported \"{}\" as mesh.", "IMPORTOBJ: imported \"{}\" as mesh.",
+47
View File
@@ -26,3 +26,50 @@ impl MeshModel {
pub const WHITE: [f32; 4] = [1.00, 1.00, 1.00, 1.0]; pub const WHITE: [f32; 4] = [1.00, 1.00, 1.00, 1.0];
pub const SELECTED: [f32; 4] = [0.15, 0.55, 1.00, 1.0]; pub const SELECTED: [f32; 4] = [0.15, 0.55, 1.00, 1.0];
} }
/// Bundle of mesh tessellations at different sampling densities, picked
/// per frame by the render pipeline based on the projected pixel size of
/// `world_aabb`. Phase 3.4 LOD ladder:
///
/// | LOD | Source | Use when projected diagonal |
/// |-----|------------|------------------------------|
/// | 0 | HIGH | > 200 px |
/// | 1 | MID (½) | 50–200 px |
/// | 2 | LOW (¼) | < 50 px |
///
/// `lods` holds up to one MeshModel per LOD level (high → low). Empty
/// slots fall back to the nearest available LOD at render time.
#[derive(Clone, Debug)]
pub struct MeshLodSet {
pub lods: Vec<MeshModel>,
/// World XY AABB `[min_x, min_y, max_x, max_y]` of the mesh — used
/// by the per-frame LOD selector to compute the projected pixel
/// diagonal.
pub world_aabb: [f32; 4],
}
impl MeshLodSet {
/// Wrap a single MeshModel as a one-LOD set. Used by interactive
/// commands that only produce one tessellation (e.g. truck-based
/// BOX/CYLINDER creation). The LOD selector will pick slot 0 for
/// every zoom level.
pub fn from_single(mesh: MeshModel) -> Self {
let mut min_x = f32::INFINITY;
let mut min_y = f32::INFINITY;
let mut max_x = f32::NEG_INFINITY;
let mut max_y = f32::NEG_INFINITY;
for &[x, y, _] in &mesh.verts {
if !x.is_finite() || !y.is_finite() {
continue;
}
if x < min_x { min_x = x; }
if y < min_y { min_y = y; }
if x > max_x { max_x = x; }
if y > max_y { max_y = y; }
}
Self {
lods: vec![mesh],
world_aabb: [min_x, min_y, max_x, max_y],
}
}
}
+18 -10
View File
@@ -27,6 +27,8 @@ use camera::Camera;
pub use camera::Projection; pub use camera::Projection;
pub use hatch_model::HatchModel; pub use hatch_model::HatchModel;
pub use image_model::ImageModel; pub use image_model::ImageModel;
pub use mesh_model::MeshLodSet;
#[allow(unused_imports)]
pub use mesh_model::MeshModel; pub use mesh_model::MeshModel;
pub use object::{GripApply, GripDef}; pub use object::{GripApply, GripDef};
pub use pipeline::uniforms::Uniforms; pub use pipeline::uniforms::Uniforms;
@@ -69,7 +71,7 @@ pub struct DerivedCaches {
pub local_extent_max: f32, pub local_extent_max: f32,
pub hatches: HashMap<Handle, HatchModel>, pub hatches: HashMap<Handle, HatchModel>,
pub images: HashMap<Handle, ImageModel>, 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. /// 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 /// Reported back to the UI so the user knows when a file had parser-junk
/// entities silently dropped. /// entities silently dropped.
@@ -177,7 +179,7 @@ pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches {
.collect(); .collect();
// meshes (parallel tessellation) // meshes (parallel tessellation)
let meshes: HashMap<Handle, MeshModel> = mesh_handles let meshes: HashMap<Handle, MeshLodSet> = mesh_handles
.par_iter() .par_iter()
.filter_map(|&handle| { .filter_map(|&handle| {
let e = doc.get_entity(handle)?; let e = doc.get_entity(handle)?;
@@ -414,7 +416,7 @@ pub struct Scene {
/// Cached image models, keyed by geometry_epoch. /// Cached image models, keyed by geometry_epoch.
image_cache: RefCell<Option<(u64, Arc<Vec<ImageModel>>)>>, image_cache: RefCell<Option<(u64, Arc<Vec<ImageModel>>)>>,
/// Cached mesh models, keyed by geometry_epoch. /// 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. /// Per-viewport wire cache for paper-space rendering.
/// Maps vp_handle → (geometry_epoch, Arc<Vec<WireModel>>). /// Maps vp_handle → (geometry_epoch, Arc<Vec<WireModel>>).
viewport_wire_cache: RefCell<HashMap<Handle, (u64, 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. /// GPU render data for hatch fills, keyed by the DXF entity Handle.
pub hatches: HashMap<Handle, HatchModel>, pub hatches: HashMap<Handle, HatchModel>,
/// GPU render data for solid meshes (truck Shell/Solid tessellation). /// 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. /// GPU render data for raster images (RasterImage entities), keyed by handle.
pub images: HashMap<Handle, ImageModel>, pub images: HashMap<Handle, ImageModel>,
/// The viewport that is currently "entered" (MSPACE mode). /// The viewport that is currently "entered" (MSPACE mode).
@@ -1097,7 +1099,7 @@ impl Scene {
arc 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(); let cache = self.mesh_cache.borrow();
if let Some((cached_epoch, ref arc)) = *cache { if let Some((cached_epoch, ref arc)) = *cache {
@@ -3163,12 +3165,18 @@ impl Scene {
let dx = (new[0] - old[0]) as f32; let dx = (new[0] - old[0]) as f32;
let dy = (new[1] - old[1]) as f32; let dy = (new[1] - old[1]) as f32;
let dz = (new[2] - old[2]) as f32; let dz = (new[2] - old[2]) as f32;
if let Some(mesh) = self.meshes.get_mut(&handle) { if let Some(set) = self.meshes.get_mut(&handle) {
for v in &mut mesh.verts { for lod in &mut set.lods {
v[0] += dx; for v in &mut lod.verts {
v[1] += dy; v[0] += dx;
v[2] += dz; 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;
} }
} }
} }
+22 -1
View File
@@ -7,7 +7,7 @@
// ------ // ------
// 40 B / vertex // 40 B / vertex
use crate::scene::mesh_model::MeshModel; use crate::scene::mesh_model::{MeshLodSet, MeshModel};
use iced::wgpu; use iced::wgpu;
use iced::wgpu::util::DeviceExt; use iced::wgpu::util::DeviceExt;
@@ -55,6 +55,27 @@ pub struct MeshGpu {
pub index_count: u32, pub index_count: u32,
} }
/// GPU-side bundle of MeshLodSet — one MeshGpu per available LOD plus
/// the world-XY AABB needed to pick a level per frame.
pub struct MeshLodGpu {
pub lods: Vec<MeshGpu>,
pub world_aabb: [f32; 4],
}
impl MeshLodGpu {
pub fn new(device: &wgpu::Device, set: &MeshLodSet) -> Self {
Self {
lods: set
.lods
.iter()
.filter(|m| !m.indices.is_empty())
.map(|m| MeshGpu::new(device, m))
.collect(),
world_aabb: set.world_aabb,
}
}
}
impl MeshGpu { impl MeshGpu {
pub fn new(device: &wgpu::Device, mesh: &MeshModel) -> Self { pub fn new(device: &wgpu::Device, mesh: &MeshModel) -> Self {
let has_normals = mesh.normals.len() == mesh.verts.len(); let has_normals = mesh.normals.len() == mesh.verts.len();
+102 -11
View File
@@ -12,14 +12,14 @@ use iced::{Rectangle, Size};
pub use face3d_gpu::Face3DGpu; pub use face3d_gpu::Face3DGpu;
pub use hatch_gpu::HatchGpu; pub use hatch_gpu::HatchGpu;
pub use image_gpu::ImageGpu; pub use image_gpu::ImageGpu;
pub use mesh_gpu::MeshGpu; pub use mesh_gpu::MeshLodGpu;
pub use uniforms::Uniforms; pub use uniforms::Uniforms;
pub use viewcube::ViewCubePipeline; pub use viewcube::ViewCubePipeline;
pub use wire_gpu::WireGpu; pub use wire_gpu::WireGpu;
use crate::scene::hatch_model::HatchModel; use crate::scene::hatch_model::HatchModel;
use crate::scene::image_model::ImageModel; use crate::scene::image_model::ImageModel;
use crate::scene::mesh_model::MeshModel; use crate::scene::mesh_model::MeshLodSet;
use crate::scene::wire_model::WireModel; use crate::scene::wire_model::WireModel;
/// MSAA sample count for the main drawing pipelines. /// MSAA sample count for the main drawing pipelines.
@@ -70,7 +70,11 @@ pub struct Pipeline {
gpu_images: Vec<ImageGpu>, gpu_images: Vec<ImageGpu>,
/// Pixel scissor rects [x, y, w, h] for viewport-clipped images. Recomputed each frame. /// Pixel scissor rects [x, y, w, h] for viewport-clipped images. Recomputed each frame.
image_pixel_scissors: Vec<Option<[u32; 4]>>, image_pixel_scissors: Vec<Option<[u32; 4]>>,
gpu_meshes: Vec<MeshGpu>, gpu_meshes: Vec<MeshLodGpu>,
/// Per-mesh LOD level (0=high, 1=mid, 2=low) picked each frame from
/// the projected pixel diagonal. Mirrors `hatch_pixel_scissors` —
/// recomputed in `compute_mesh_lod`.
mesh_lod_levels: Vec<usize>,
/// Batched 3DFACE fill (all faces in one buffer) and edges (merged wire). /// Batched 3DFACE fill (all faces in one buffer) and edges (merged wire).
gpu_face3d_fill: Option<Face3DGpu>, gpu_face3d_fill: Option<Face3DGpu>,
gpu_face3d_edges: Vec<WireGpu>, gpu_face3d_edges: Vec<WireGpu>,
@@ -607,6 +611,7 @@ impl Pipeline {
gpu_images: vec![], gpu_images: vec![],
image_pixel_scissors: vec![], image_pixel_scissors: vec![],
gpu_meshes: vec![], gpu_meshes: vec![],
mesh_lod_levels: vec![],
gpu_face3d_fill: None, gpu_face3d_fill: None,
gpu_face3d_edges: vec![], gpu_face3d_edges: vec![],
viewcube, viewcube,
@@ -697,11 +702,23 @@ impl Pipeline {
self.gpu_face3d_edges = WireGpu::from_batch(device, face3d_wires); self.gpu_face3d_edges = WireGpu::from_batch(device, face3d_wires);
} }
pub fn upload_meshes(&mut self, device: &wgpu::Device, meshes: &[MeshModel]) { pub fn upload_meshes(&mut self, device: &wgpu::Device, meshes: &[MeshLodSet]) {
self.gpu_meshes = meshes self.gpu_meshes = meshes
.iter() .iter()
.filter(|m| !m.indices.is_empty()) .filter(|s| s.lods.iter().any(|m| !m.indices.is_empty()))
.map(|m| MeshGpu::new(device, m)) .map(|s| MeshLodGpu::new(device, s))
.collect();
}
/// Per-frame mesh LOD selector. Picks slot 0/1/2 based on the
/// projected pixel diagonal of each mesh's `world_aabb` (Phase 3.4
/// ladder: >200 px → 0, 50–200 → 1, <50 → 2). Falls back to the
/// nearest available lower slot when a level wasn't generated.
pub fn compute_mesh_lod(&mut self, view_proj: glam::Mat4, clip_w: u32, clip_h: u32) {
self.mesh_lod_levels = self
.gpu_meshes
.iter()
.map(|m| pick_mesh_lod(m, view_proj, clip_w, clip_h))
.collect(); .collect();
} }
@@ -887,12 +904,22 @@ impl Pipeline {
pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0); pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0);
pass.set_pipeline(&self.mesh_pipeline); pass.set_pipeline(&self.mesh_pipeline);
pass.set_bind_group(0, &self.uniform_bind_group, &[]); pass.set_bind_group(0, &self.uniform_bind_group, &[]);
for mesh in &self.gpu_meshes { for (i, set) in self.gpu_meshes.iter().enumerate() {
if mesh.index_count > 0 { let level = self
pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..)); .mesh_lod_levels
pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32); .get(i)
pass.draw_indexed(0..mesh.index_count, 0, 0..1); .copied()
.unwrap_or(0)
.min(set.lods.len().saturating_sub(1));
let Some(mesh) = set.lods.get(level) else {
continue;
};
if mesh.index_count == 0 {
continue;
} }
pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..mesh.index_count, 0, 0..1);
} }
} }
@@ -1184,6 +1211,70 @@ impl Pipeline {
} }
} }
/// Project a mesh's `world_aabb` and pick a LOD slot:
/// diagonal > 200 px → 0 (HIGH)
/// diagonal 50–200 px → 1 (MID)
/// diagonal < 50 px → 2 (LOW)
/// When the picked slot is missing from `gpu_meshes[i].lods`, walks down
/// to the next available coarser/finer level so the mesh always renders.
fn pick_mesh_lod(
mesh: &MeshLodGpu,
view_proj: glam::Mat4,
clip_w: u32,
clip_h: u32,
) -> usize {
let diag_px = aabb_diagonal_pixels(mesh.world_aabb, view_proj, clip_w, clip_h);
let target = if diag_px > 200.0 {
0
} else if diag_px > 50.0 {
1
} else {
2
};
// Walk down to nearest available LOD (some entities won't have all 3).
for level in (0..=target).rev() {
if mesh.lods.get(level).is_some() {
return level;
}
}
0
}
fn aabb_diagonal_pixels(
aabb: [f32; 4],
view_proj: glam::Mat4,
clip_w: u32,
clip_h: u32,
) -> f32 {
let [x0, y0, x1, y1] = aabb;
if !x0.is_finite() || !y0.is_finite() || !x1.is_finite() || !y1.is_finite() {
return f32::INFINITY;
}
let w = clip_w as f32;
let h = clip_h as f32;
let corners = [
view_proj.project_point3(glam::Vec3::new(x0, y0, 0.0)),
view_proj.project_point3(glam::Vec3::new(x1, y0, 0.0)),
view_proj.project_point3(glam::Vec3::new(x0, y1, 0.0)),
view_proj.project_point3(glam::Vec3::new(x1, y1, 0.0)),
];
let mut min_px = f32::INFINITY;
let mut max_px = f32::NEG_INFINITY;
let mut min_py = f32::INFINITY;
let mut max_py = f32::NEG_INFINITY;
for c in &corners {
let px = (c.x + 1.0) * 0.5 * w;
let py = (1.0 - c.y) * 0.5 * h;
if px < min_px { min_px = px; }
if px > max_px { max_px = px; }
if py < min_py { min_py = py; }
if py > max_py { max_py = py; }
}
let dx = max_px - min_px;
let dy = max_py - min_py;
(dx * dx + dy * dy).sqrt()
}
/// Return `true` when the world-XY AABB's screen-space size is below the /// Return `true` when the world-XY AABB's screen-space size is below the
/// given pixel threshold. Used by LOD passes (hatch skip, etc.) to drop /// given pixel threshold. Used by LOD passes (hatch skip, etc.) to drop
/// draw calls that wouldn't contribute a visible pixel. /// draw calls that wouldn't contribute a visible pixel.
+3 -2
View File
@@ -14,7 +14,7 @@ use std::sync::Arc;
use super::pipeline::viewcube::{hover_id, VIEWCUBE_PX}; use super::pipeline::viewcube::{hover_id, VIEWCUBE_PX};
use super::pipeline::Pipeline; use super::pipeline::Pipeline;
use super::tessellate; use super::tessellate;
use super::{HatchModel, ImageModel, MeshModel, Scene, Uniforms, WireModel}; use super::{HatchModel, ImageModel, MeshLodSet, Scene, Uniforms, WireModel};
// ── PaperViewportPipeline / PaperViewportPrimitive ──────────────────────── // ── PaperViewportPipeline / PaperViewportPrimitive ────────────────────────
// //
@@ -86,7 +86,7 @@ pub struct Primitive {
/// Wipeout fills — rendered in a separate pass AFTER wires. /// Wipeout fills — rendered in a separate pass AFTER wires.
pub(super) wipeout_hatches: Arc<Vec<HatchModel>>, pub(super) wipeout_hatches: Arc<Vec<HatchModel>>,
pub(super) images: Arc<Vec<ImageModel>>, pub(super) images: Arc<Vec<ImageModel>>,
pub(super) meshes: Arc<Vec<MeshModel>>, pub(super) meshes: Arc<Vec<MeshLodSet>>,
pub(super) uniforms: Uniforms, pub(super) uniforms: Uniforms,
/// Camera rotation matrix derived from the quaternion. /// Camera rotation matrix derived from the quaternion.
/// Used by the ViewCube pipeline — no gimbal lock. /// Used by the ViewCube pipeline — no gimbal lock.
@@ -148,6 +148,7 @@ impl shader::Primitive for Primitive {
pipeline.compute_wipeout_scissors(self.uniforms.view_proj, clip_size.width, clip_size.height); pipeline.compute_wipeout_scissors(self.uniforms.view_proj, clip_size.width, clip_size.height);
pipeline.compute_image_scissors(self.uniforms.view_proj, clip_size.width, clip_size.height); pipeline.compute_image_scissors(self.uniforms.view_proj, clip_size.width, clip_size.height);
pipeline.compute_hatch_lod(self.uniforms.view_proj, clip_size.width, clip_size.height); pipeline.compute_hatch_lod(self.uniforms.view_proj, clip_size.width, clip_size.height);
pipeline.compute_mesh_lod(self.uniforms.view_proj, clip_size.width, clip_size.height);
if self.show_viewcube { if self.show_viewcube {
pipeline.viewcube.upload( pipeline.viewcube.upload(
queue, queue,
+104 -25
View File
@@ -20,18 +20,54 @@ use acadrust::entities::acis::{
}; };
use acadrust::entities::{Body, Region, Solid3D}; use acadrust::entities::{Body, Region, Solid3D};
use crate::scene::mesh_model::MeshModel; use crate::scene::mesh_model::{MeshLodSet, MeshModel};
// Number of arc segments per full circle for curved surface sampling. /// Per-LOD sampling density. Higher values = finer mesh = more triangles.
const CIRC_SEGS: usize = 48; #[derive(Copy, Clone, Debug)]
// Grid resolution for sphere / torus latitude / longitude subdivision. pub struct LodConfig {
const GRID_U: usize = 32; /// Arc segments per full circle for curved-surface sampling.
const GRID_V: usize = 16; pub circ_segs: usize,
/// Longitudinal grid count for sphere / torus surfaces.
pub grid_u: usize,
/// Latitudinal grid count for sphere / torus surfaces.
pub grid_v: usize,
}
impl LodConfig {
/// LOD 0 — full resolution. The pre-Phase-3.4 baseline.
pub const HIGH: LodConfig = LodConfig {
circ_segs: 48,
grid_u: 32,
grid_v: 16,
};
/// LOD 1 — half-resolution. Use between ~50–200 px projected diagonal.
pub const MID: LodConfig = LodConfig {
circ_segs: 24,
grid_u: 16,
grid_v: 8,
};
/// LOD 2 — quarter-resolution. Use below ~50 px.
pub const LOW: LodConfig = LodConfig {
circ_segs: 12,
grid_u: 8,
grid_v: 4,
};
/// Returns the three LOD configs in `[high, mid, low]` order — matches
/// the `MeshLodSet::lods` slot ordering.
pub const fn all() -> [LodConfig; 3] {
[Self::HIGH, Self::MID, Self::LOW]
}
}
// ── Public entry point ──────────────────────────────────────────────────────── // ── Public entry point ────────────────────────────────────────────────────────
/// Tessellate a SAT document into mesh buffers — shared by all ACIS entities. /// Tessellate a SAT document into mesh buffers — shared by all ACIS entities.
fn tessellate_sat(sat: &SatDocument, name: String, color: [f32; 4]) -> Option<MeshModel> { fn tessellate_sat(
sat: &SatDocument,
name: String,
color: [f32; 4],
lod: LodConfig,
) -> Option<MeshModel> {
let mut verts: Vec<[f32; 3]> = Vec::new(); let mut verts: Vec<[f32; 3]> = Vec::new();
let mut normals: Vec<[f32; 3]> = Vec::new(); let mut normals: Vec<[f32; 3]> = Vec::new();
let mut indices: Vec<u32> = Vec::new(); let mut indices: Vec<u32> = Vec::new();
@@ -49,17 +85,17 @@ fn tessellate_sat(sat: &SatDocument, name: String, color: [f32; 4]) -> Option<Me
} }
"cone-surface" => { "cone-surface" => {
if let Some(cone) = SatConeSurface::from_record(surf_rec) { if let Some(cone) = SatConeSurface::from_record(surf_rec) {
tess_cone_face(sat, &face, &cone, &mut verts, &mut normals, &mut indices); tess_cone_face(sat, &face, &cone, lod, &mut verts, &mut normals, &mut indices);
} }
} }
"sphere-surface" => { "sphere-surface" => {
if let Some(sphere) = SatSphereSurface::from_record(surf_rec) { if let Some(sphere) = SatSphereSurface::from_record(surf_rec) {
tess_sphere_face(&sphere, &mut verts, &mut normals, &mut indices); tess_sphere_face(&sphere, lod, &mut verts, &mut normals, &mut indices);
} }
} }
"torus-surface" => { "torus-surface" => {
if let Some(torus) = SatTorusSurface::from_record(surf_rec) { if let Some(torus) = SatTorusSurface::from_record(surf_rec) {
tess_torus_face(&torus, &mut verts, &mut normals, &mut indices); tess_torus_face(&torus, lod, &mut verts, &mut normals, &mut indices);
} }
} }
_ => {} _ => {}
@@ -78,6 +114,46 @@ fn tessellate_sat(sat: &SatDocument, name: String, color: [f32; 4]) -> Option<Me
}) })
} }
/// Tessellate a SAT document at all three LODs and bundle them into a
/// `MeshLodSet` ready for the render pipeline to pick a level per frame.
fn tessellate_sat_lods(
sat: &SatDocument,
name: String,
color: [f32; 4],
) -> Option<MeshLodSet> {
let configs = LodConfig::all();
let mut lods: Vec<MeshModel> = Vec::with_capacity(3);
for lod in configs {
if let Some(m) = tessellate_sat(sat, name.clone(), color, lod) {
lods.push(m);
}
}
if lods.is_empty() {
return None;
}
let world_aabb = mesh_aabb(&lods[0]);
Some(MeshLodSet { lods, world_aabb })
}
/// World-XY AABB of the mesh — used by the render-pipeline LOD selector
/// to pick a level based on projected pixel diagonal.
fn mesh_aabb(mesh: &MeshModel) -> [f32; 4] {
let mut min_x = f32::INFINITY;
let mut min_y = f32::INFINITY;
let mut max_x = f32::NEG_INFINITY;
let mut max_y = f32::NEG_INFINITY;
for &[x, y, _] in &mesh.verts {
if !x.is_finite() || !y.is_finite() {
continue;
}
if x < min_x { min_x = x; }
if y < min_y { min_y = y; }
if x > max_x { max_x = x; }
if y > max_y { max_y = y; }
}
[min_x, min_y, max_x, max_y]
}
fn parse_acis( fn parse_acis(
sat_fn: impl FnOnce() -> Option<SatDocument>, sat_fn: impl FnOnce() -> Option<SatDocument>,
is_binary: bool, is_binary: bool,
@@ -92,40 +168,40 @@ fn parse_acis(
None None
} }
/// Tessellate a `Region` entity (2D planar ACIS body) into a `MeshModel`. /// Tessellate a `Region` entity (2D planar ACIS body) at all three LOD levels.
pub fn tessellate_region(region: &Region, color: [f32; 4]) -> Option<MeshModel> { pub fn tessellate_region(region: &Region, color: [f32; 4]) -> Option<MeshLodSet> {
let sat = parse_acis( let sat = parse_acis(
|| region.parse_sat(), || region.parse_sat(),
region.acis_data.is_binary, region.acis_data.is_binary,
&region.acis_data.sab_data, &region.acis_data.sab_data,
)?; )?;
let name = region.common.handle.value().to_string(); let name = region.common.handle.value().to_string();
tessellate_sat(&sat, name, color) tessellate_sat_lods(&sat, name, color)
} }
/// Tessellate a `Body` entity (3D ACIS body) into a `MeshModel`. /// Tessellate a `Body` entity (3D ACIS body) at all three LOD levels.
pub fn tessellate_body(body: &Body, color: [f32; 4]) -> Option<MeshModel> { pub fn tessellate_body(body: &Body, color: [f32; 4]) -> Option<MeshLodSet> {
let sat = parse_acis( let sat = parse_acis(
|| body.parse_sat(), || body.parse_sat(),
body.acis_data.is_binary, body.acis_data.is_binary,
&body.acis_data.sab_data, &body.acis_data.sab_data,
)?; )?;
let name = body.common.handle.value().to_string(); let name = body.common.handle.value().to_string();
tessellate_sat(&sat, name, color) tessellate_sat_lods(&sat, name, color)
} }
/// Tessellate a `Solid3D` entity into a GPU-ready `MeshModel`. /// Tessellate a `Solid3D` entity at all three LOD levels.
/// ///
/// Returns `None` when the entity has no parseable SAT data or produces no /// Returns `None` when the entity has no parseable SAT data or produces no
/// triangles (e.g. the solid uses only unsupported surface types). /// triangles (e.g. the solid uses only unsupported surface types).
pub fn tessellate_solid3d(solid: &Solid3D, color: [f32; 4]) -> Option<MeshModel> { pub fn tessellate_solid3d(solid: &Solid3D, color: [f32; 4]) -> Option<MeshLodSet> {
let sat = parse_acis( let sat = parse_acis(
|| solid.parse_sat(), || solid.parse_sat(),
solid.acis_data.is_binary, solid.acis_data.is_binary,
&solid.acis_data.sab_data, &solid.acis_data.sab_data,
)?; )?;
let name = solid.common.handle.value().to_string(); let name = solid.common.handle.value().to_string();
tessellate_sat(&sat, name, color) tessellate_sat_lods(&sat, name, color)
} }
// ── Topology helpers ────────────────────────────────────────────────────────── // ── Topology helpers ──────────────────────────────────────────────────────────
@@ -262,6 +338,7 @@ fn tess_cone_face(
sat: &SatDocument, sat: &SatDocument,
face: &SatFace, face: &SatFace,
cone: &SatConeSurface, cone: &SatConeSurface,
lod: LodConfig,
verts: &mut Vec<[f32; 3]>, verts: &mut Vec<[f32; 3]>,
normals: &mut Vec<[f32; 3]>, normals: &mut Vec<[f32; 3]>,
indices: &mut Vec<u32>, indices: &mut Vec<u32>,
@@ -285,8 +362,8 @@ fn tess_cone_face(
let (h_min, h_max, theta_min, theta_max, full_circle) = let (h_min, h_max, theta_min, theta_max, full_circle) =
angular_range(cx, cy, cz, axis, u_dir, v_dir, &poly); angular_range(cx, cy, cz, axis, u_dir, v_dir, &poly);
let segs_u = CIRC_SEGS; let segs_u = lod.circ_segs;
let segs_v = segs_u / 4; // height subdivisions let segs_v = (segs_u / 4).max(1); // height subdivisions
let theta_span = if full_circle { let theta_span = if full_circle {
TAU TAU
@@ -416,6 +493,7 @@ fn angular_range(
fn tess_sphere_face( fn tess_sphere_face(
sphere: &SatSphereSurface, sphere: &SatSphereSurface,
lod: LodConfig,
verts: &mut Vec<[f32; 3]>, verts: &mut Vec<[f32; 3]>,
normals: &mut Vec<[f32; 3]>, normals: &mut Vec<[f32; 3]>,
indices: &mut Vec<u32>, indices: &mut Vec<u32>,
@@ -428,8 +506,8 @@ fn tess_sphere_face(
let u_dir = norm3([ux, uy, uz]); let u_dir = norm3([ux, uy, uz]);
let v_dir = cross3(pole, u_dir); let v_dir = cross3(pole, u_dir);
let nu = GRID_U; let nu = lod.grid_u.max(3);
let nv = GRID_V; let nv = lod.grid_v.max(2);
for j in 0..nv { for j in 0..nv {
let phi0 = std::f64::consts::PI * (j as f64 / nv as f64); // 0..π let phi0 = std::f64::consts::PI * (j as f64 / nv as f64); // 0..π
@@ -481,6 +559,7 @@ fn sphere_dir(pole: [f64; 3], u_dir: [f64; 3], v_dir: [f64; 3], theta: f64, phi:
fn tess_torus_face( fn tess_torus_face(
torus: &SatTorusSurface, torus: &SatTorusSurface,
lod: LodConfig,
verts: &mut Vec<[f32; 3]>, verts: &mut Vec<[f32; 3]>,
normals: &mut Vec<[f32; 3]>, normals: &mut Vec<[f32; 3]>,
indices: &mut Vec<u32>, indices: &mut Vec<u32>,
@@ -494,8 +573,8 @@ fn tess_torus_face(
let major_r = torus.major_radius(); let major_r = torus.major_radius();
let minor_r = torus.minor_radius(); let minor_r = torus.minor_radius();
let nu = GRID_U; // around the tube let nu = lod.grid_u.max(3); // around the tube
let nv = GRID_V; // around the torus let nv = lod.grid_v.max(3); // around the torus
for j in 0..nv { for j in 0..nv {
let phi0 = TAU * (j as f64 / nv as f64); let phi0 = TAU * (j as f64 / nv as f64);