diff --git a/src/app/cmd_result.rs b/src/app/cmd_result.rs index b4e9265ab..81ae06742 100644 --- a/src/app/cmd_result.rs +++ b/src/app/cmd_result.rs @@ -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.", diff --git a/src/app/update.rs b/src/app/update.rs index 977454778..e4fa529ad 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -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 = - 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 = 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 = - self.tabs[i].scene.meshes.values().cloned().collect(); + // Export uses LOD 0 (full resolution); see StlExportPath above. + let meshes: Vec = 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.", diff --git a/src/scene/mesh_model.rs b/src/scene/mesh_model.rs index 655c30c48..9083f8987 100644 --- a/src/scene/mesh_model.rs +++ b/src/scene/mesh_model.rs @@ -26,3 +26,50 @@ impl MeshModel { 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]; } + +/// 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, + /// 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], + } + } +} diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 3d4df4cbb..25d8eeb4a 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -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, pub images: HashMap, - pub meshes: HashMap, + pub meshes: HashMap, /// 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 = mesh_handles + let meshes: HashMap = 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>)>>, /// Cached mesh models, keyed by geometry_epoch. - mesh_cache: RefCell>)>>, + mesh_cache: RefCell>)>>, /// Per-viewport wire cache for paper-space rendering. /// Maps vp_handle → (geometry_epoch, Arc>). viewport_wire_cache: RefCell>)>>, @@ -436,7 +438,7 @@ pub struct Scene { /// GPU render data for hatch fills, keyed by the DXF entity Handle. pub hatches: HashMap, /// GPU render data for solid meshes (truck Shell/Solid tessellation). - pub meshes: HashMap, + pub meshes: HashMap, /// GPU render data for raster images (RasterImage entities), keyed by handle. pub images: HashMap, /// The viewport that is currently "entered" (MSPACE mode). @@ -1097,7 +1099,7 @@ impl Scene { arc } - pub(super) fn meshes_arc(&self) -> Arc> { + pub(super) fn meshes_arc(&self) -> Arc> { { 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; } } } diff --git a/src/scene/pipeline/mesh_gpu.rs b/src/scene/pipeline/mesh_gpu.rs index 212dd1834..6676e2244 100644 --- a/src/scene/pipeline/mesh_gpu.rs +++ b/src/scene/pipeline/mesh_gpu.rs @@ -7,7 +7,7 @@ // ------ // 40 B / vertex -use crate::scene::mesh_model::MeshModel; +use crate::scene::mesh_model::{MeshLodSet, MeshModel}; use iced::wgpu; use iced::wgpu::util::DeviceExt; @@ -55,6 +55,27 @@ pub struct MeshGpu { 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, + 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 { pub fn new(device: &wgpu::Device, mesh: &MeshModel) -> Self { let has_normals = mesh.normals.len() == mesh.verts.len(); diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 0a332439a..746d88725 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -12,14 +12,14 @@ use iced::{Rectangle, Size}; pub use face3d_gpu::Face3DGpu; pub use hatch_gpu::HatchGpu; pub use image_gpu::ImageGpu; -pub use mesh_gpu::MeshGpu; +pub use mesh_gpu::MeshLodGpu; pub use uniforms::Uniforms; pub use viewcube::ViewCubePipeline; pub use wire_gpu::WireGpu; use crate::scene::hatch_model::HatchModel; use crate::scene::image_model::ImageModel; -use crate::scene::mesh_model::MeshModel; +use crate::scene::mesh_model::MeshLodSet; use crate::scene::wire_model::WireModel; /// MSAA sample count for the main drawing pipelines. @@ -70,7 +70,11 @@ pub struct Pipeline { gpu_images: Vec, /// Pixel scissor rects [x, y, w, h] for viewport-clipped images. Recomputed each frame. image_pixel_scissors: Vec>, - gpu_meshes: Vec, + gpu_meshes: Vec, + /// 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, /// Batched 3DFACE fill (all faces in one buffer) and edges (merged wire). gpu_face3d_fill: Option, gpu_face3d_edges: Vec, @@ -607,6 +611,7 @@ impl Pipeline { gpu_images: vec![], image_pixel_scissors: vec![], gpu_meshes: vec![], + mesh_lod_levels: vec![], gpu_face3d_fill: None, gpu_face3d_edges: vec![], viewcube, @@ -697,11 +702,23 @@ impl Pipeline { 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 .iter() - .filter(|m| !m.indices.is_empty()) - .map(|m| MeshGpu::new(device, m)) + .filter(|s| s.lods.iter().any(|m| !m.indices.is_empty())) + .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(); } @@ -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_pipeline(&self.mesh_pipeline); pass.set_bind_group(0, &self.uniform_bind_group, &[]); - for mesh in &self.gpu_meshes { - if mesh.index_count > 0 { - 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); + for (i, set) in self.gpu_meshes.iter().enumerate() { + let level = self + .mesh_lod_levels + .get(i) + .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 /// given pixel threshold. Used by LOD passes (hatch skip, etc.) to drop /// draw calls that wouldn't contribute a visible pixel. diff --git a/src/scene/render.rs b/src/scene/render.rs index ed6e6a45c..571086860 100644 --- a/src/scene/render.rs +++ b/src/scene/render.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use super::pipeline::viewcube::{hover_id, VIEWCUBE_PX}; use super::pipeline::Pipeline; use super::tessellate; -use super::{HatchModel, ImageModel, MeshModel, Scene, Uniforms, WireModel}; +use super::{HatchModel, ImageModel, MeshLodSet, Scene, Uniforms, WireModel}; // ── PaperViewportPipeline / PaperViewportPrimitive ──────────────────────── // @@ -86,7 +86,7 @@ pub struct Primitive { /// Wipeout fills — rendered in a separate pass AFTER wires. pub(super) wipeout_hatches: Arc>, pub(super) images: Arc>, - pub(super) meshes: Arc>, + pub(super) meshes: Arc>, pub(super) uniforms: Uniforms, /// Camera rotation matrix derived from the quaternion. /// 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_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_mesh_lod(self.uniforms.view_proj, clip_size.width, clip_size.height); if self.show_viewcube { pipeline.viewcube.upload( queue, diff --git a/src/scene/solid3d_tess.rs b/src/scene/solid3d_tess.rs index 472b593f9..b7066acb7 100644 --- a/src/scene/solid3d_tess.rs +++ b/src/scene/solid3d_tess.rs @@ -20,18 +20,54 @@ use acadrust::entities::acis::{ }; 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. -const CIRC_SEGS: usize = 48; -// Grid resolution for sphere / torus latitude / longitude subdivision. -const GRID_U: usize = 32; -const GRID_V: usize = 16; +/// Per-LOD sampling density. Higher values = finer mesh = more triangles. +#[derive(Copy, Clone, Debug)] +pub struct LodConfig { + /// Arc segments per full circle for curved-surface sampling. + 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 ──────────────────────────────────────────────────────── /// Tessellate a SAT document into mesh buffers — shared by all ACIS entities. -fn tessellate_sat(sat: &SatDocument, name: String, color: [f32; 4]) -> Option { +fn tessellate_sat( + sat: &SatDocument, + name: String, + color: [f32; 4], + lod: LodConfig, +) -> Option { let mut verts: Vec<[f32; 3]> = Vec::new(); let mut normals: Vec<[f32; 3]> = Vec::new(); let mut indices: Vec = Vec::new(); @@ -49,17 +85,17 @@ fn tessellate_sat(sat: &SatDocument, name: String, color: [f32; 4]) -> Option { 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" => { 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" => { 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 Option { + let configs = LodConfig::all(); + let mut lods: Vec = 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( sat_fn: impl FnOnce() -> Option, is_binary: bool, @@ -92,40 +168,40 @@ fn parse_acis( None } -/// Tessellate a `Region` entity (2D planar ACIS body) into a `MeshModel`. -pub fn tessellate_region(region: &Region, color: [f32; 4]) -> Option { +/// Tessellate a `Region` entity (2D planar ACIS body) at all three LOD levels. +pub fn tessellate_region(region: &Region, color: [f32; 4]) -> Option { let sat = parse_acis( || region.parse_sat(), region.acis_data.is_binary, ®ion.acis_data.sab_data, )?; 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`. -pub fn tessellate_body(body: &Body, color: [f32; 4]) -> Option { +/// Tessellate a `Body` entity (3D ACIS body) at all three LOD levels. +pub fn tessellate_body(body: &Body, color: [f32; 4]) -> Option { let sat = parse_acis( || body.parse_sat(), body.acis_data.is_binary, &body.acis_data.sab_data, )?; 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 /// triangles (e.g. the solid uses only unsupported surface types). -pub fn tessellate_solid3d(solid: &Solid3D, color: [f32; 4]) -> Option { +pub fn tessellate_solid3d(solid: &Solid3D, color: [f32; 4]) -> Option { let sat = parse_acis( || solid.parse_sat(), solid.acis_data.is_binary, &solid.acis_data.sab_data, )?; let name = solid.common.handle.value().to_string(); - tessellate_sat(&sat, name, color) + tessellate_sat_lods(&sat, name, color) } // ── Topology helpers ────────────────────────────────────────────────────────── @@ -262,6 +338,7 @@ fn tess_cone_face( sat: &SatDocument, face: &SatFace, cone: &SatConeSurface, + lod: LodConfig, verts: &mut Vec<[f32; 3]>, normals: &mut Vec<[f32; 3]>, indices: &mut Vec, @@ -285,8 +362,8 @@ fn tess_cone_face( let (h_min, h_max, theta_min, theta_max, full_circle) = angular_range(cx, cy, cz, axis, u_dir, v_dir, &poly); - let segs_u = CIRC_SEGS; - let segs_v = segs_u / 4; // height subdivisions + let segs_u = lod.circ_segs; + let segs_v = (segs_u / 4).max(1); // height subdivisions let theta_span = if full_circle { TAU @@ -416,6 +493,7 @@ fn angular_range( fn tess_sphere_face( sphere: &SatSphereSurface, + lod: LodConfig, verts: &mut Vec<[f32; 3]>, normals: &mut Vec<[f32; 3]>, indices: &mut Vec, @@ -428,8 +506,8 @@ fn tess_sphere_face( let u_dir = norm3([ux, uy, uz]); let v_dir = cross3(pole, u_dir); - let nu = GRID_U; - let nv = GRID_V; + let nu = lod.grid_u.max(3); + let nv = lod.grid_v.max(2); for j in 0..nv { 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( torus: &SatTorusSurface, + lod: LodConfig, verts: &mut Vec<[f32; 3]>, normals: &mut Vec<[f32; 3]>, indices: &mut Vec, @@ -494,8 +573,8 @@ fn tess_torus_face( let major_r = torus.major_radius(); let minor_r = torus.minor_radius(); - let nu = GRID_U; // around the tube - let nv = GRID_V; // around the torus + let nu = lod.grid_u.max(3); // around the tube + let nv = lod.grid_v.max(3); // around the torus for j in 0..nv { let phi0 = TAU * (j as f64 / nv as f64);