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; // 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.",
+20 -5
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.",
+47
View File
@@ -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<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 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;
}
}
}
+22 -1
View File
@@ -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<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 {
pub fn new(device: &wgpu::Device, mesh: &MeshModel) -> Self {
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 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<ImageGpu>,
/// Pixel scissor rects [x, y, w, h] for viewport-clipped images. Recomputed each frame.
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).
gpu_face3d_fill: Option<Face3DGpu>,
gpu_face3d_edges: Vec<WireGpu>,
@@ -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.
+3 -2
View File
@@ -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<Vec<HatchModel>>,
pub(super) images: Arc<Vec<ImageModel>>,
pub(super) meshes: Arc<Vec<MeshModel>>,
pub(super) meshes: Arc<Vec<MeshLodSet>>,
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,
+104 -25
View File
@@ -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<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 normals: Vec<[f32; 3]> = 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" => {
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<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(
sat_fn: impl FnOnce() -> Option<SatDocument>,
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<MeshModel> {
/// Tessellate a `Region` entity (2D planar ACIS body) at all three LOD levels.
pub fn tessellate_region(region: &Region, color: [f32; 4]) -> Option<MeshLodSet> {
let sat = parse_acis(
|| region.parse_sat(),
region.acis_data.is_binary,
&region.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<MeshModel> {
/// Tessellate a `Body` entity (3D ACIS body) at all three LOD levels.
pub fn tessellate_body(body: &Body, color: [f32; 4]) -> Option<MeshLodSet> {
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<MeshModel> {
pub fn tessellate_solid3d(solid: &Solid3D, color: [f32; 4]) -> Option<MeshLodSet> {
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<u32>,
@@ -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<u32>,
@@ -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<u32>,
@@ -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);