// Triangle mesh model — produced by the kernel Shell/Solid tessellation. // // Stored alongside WireModels in the scene; rendered by the mesh pipeline // (wgpu TriangleList with depth test, flat normals). /// A tessellated triangle mesh ready to upload to the GPU. #[derive(Clone, Debug)] #[allow(dead_code)] pub struct MeshModel { /// Unique identifier (entity handle value as decimal string). pub name: String, /// World-space vertex positions (high half of the double-single pair). pub verts: Vec<[f32; 3]>, /// Low residual paired with `verts` so meshes stay precise at UTM scale. /// Empty = all-zero (legacy / interactive meshes near the origin). pub verts_low: Vec<[f32; 3]>, /// Per-vertex normals (may be empty if not available). pub normals: Vec<[f32; 3]>, /// Triangle indices into `verts` (every 3 values = one triangle). pub indices: Vec, /// Optional AcDbMaterial handle per triangle. Empty means the whole mesh /// uses `MeshLodSet::material`; otherwise each entry aligns with one /// `indices` triplet and overrides the entity material for that face. pub triangle_material_handles: Vec>, /// Optional ACIS face colour per triangle, aligned with `indices` triplets. pub triangle_colors: Vec>, /// RGBA colour in [0, 1]. pub color: [f32; 4], /// Whether this mesh is currently selected. pub selected: bool, } /// 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. /// Kernel-owned source for a view-dependent silhouette. #[derive(Clone, Debug)] pub struct CurvedGen { pub source: kernel::brep::mesh::SilhouetteSource, } #[derive(Clone, Copy, Debug, Default)] pub struct MeshMetrics { pub vertices: usize, pub triangles: usize, pub surface_area: f64, pub volume: f64, pub centroid: [f64; 3], pub moment_of_inertia: [f64; 3], pub principal_directions: [f64; 9], pub principal_moments: [f64; 3], pub product_of_inertia: [f64; 3], pub radii_of_gyration: [f64; 3], } impl MeshMetrics { fn apply_mass_properties(&mut self, properties: kernel::brep::MassProperties) { self.volume = properties.volume; self.centroid = properties.centroid; self.moment_of_inertia = properties.moment_of_inertia; self.principal_directions = properties.principal_directions; self.principal_moments = properties.principal_moments; self.product_of_inertia = properties.product_of_inertia; self.radii_of_gyration = properties.radii_of_gyration; } pub fn translate(&mut self, delta: [f64; 3]) { if self.volume > 1e-18 { self.apply_mass_properties( kernel::brep::MassProperties { volume: self.volume, centroid: self.centroid, moment_of_inertia: self.moment_of_inertia, principal_directions: self.principal_directions, principal_moments: self.principal_moments, product_of_inertia: self.product_of_inertia, radii_of_gyration: self.radii_of_gyration, } .translated(delta), ); } else { for axis in 0..3 { self.centroid[axis] += delta[axis]; } } } } #[derive(Clone, Debug)] pub struct MeshLodSet { pub lods: Vec, /// Effective AcDbMaterial resolved from entity/layer/INSERT inheritance. /// Geometry remains usable when it is absent; the renderer then keeps the /// per-mesh entity colour. pub material: Option, /// Face-level ACIS material overrides, resolved from each LOD's /// `triangle_material_handles`. Only handles actually referenced by the /// tessellation are retained. pub face_materials: rustc_hash::FxHashMap, /// Effective AcDbVisualStyle override resolved from the entity's full, /// face and edge style handles. pub visual_style: Option, /// True only when every source face produced triangles. False keeps /// downstream solid-edit code from treating a display-only partial shell /// as a closed, valid solid. pub complete: bool, /// Feature-edge line list (LOD-independent): pairs of endpoints, high half /// of the double-single. Populated for ACIS solids (the B-rep face-boundary /// edges) so their wireframe shows real edges rather than the triangulation. /// Empty for plain meshes — those fall back to triangle edges at batch time. pub edge_verts: Vec<[f32; 3]>, /// Low residual paired with `edge_verts`. pub edge_verts_low: Vec<[f32; 3]>, /// Kernel sources for per-frame silhouettes. pub curved_gens: Vec, /// Geometry measurements calculated once from the highest available LOD. /// Properties can read these without re-parsing or re-tessellating ACIS on /// the UI thread. pub metrics: MeshMetrics, /// 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], /// World Z extent `[min_z, max_z]`. With `world_aabb` this is the full 3D /// box, which the pick path projects to a screen rect to skip solids whose /// footprint isn't under the cursor (O(solids) instead of ray-testing every /// triangle). `verts` carry only the high half of the double-single /// position, so the bound is f32-precise — fine for a conservative cull. pub z_aabb: [f32; 2], /// Immutable block-local geometry shared by every INSERT instance of this /// block entity. Top-level meshes leave this empty. pub instance_source: Option>, /// Accumulated block-local → world transform for this rendered instance. pub instance_transform: Option, /// Parent INSERT selected for this rendered block instance. pub instance_handle: Option, /// Effective colour after INSERT inheritance, without copying the mesh. pub instance_color: Option<[f32; 4]>, /// Precise world bounds used by the interaction index. pub instance_aabb: Option<[f64; 6]>, } #[derive(Clone, Debug)] pub struct MeshInstanceSource { pub handle: codec::Handle, pub lods: Vec, pub edge_verts: Vec<[f32; 3]>, pub edge_verts_low: Vec<[f32; 3]>, pub curved_gens: Vec, } /// 3D bounds of every LOD's vertices: `([min_x, min_y, max_x, max_y], [min_z, max_z])`. pub fn compute_mesh_aabb(lods: &[MeshModel]) -> ([f32; 4], [f32; 2]) { let (mut min_x, mut min_y, mut min_z) = (f32::INFINITY, f32::INFINITY, f32::INFINITY); let (mut max_x, mut max_y, mut max_z) = (f32::NEG_INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY); for lod in lods { for &[x, y, z] in &lod.verts { if !x.is_finite() || !y.is_finite() || !z.is_finite() { continue; } min_x = min_x.min(x); min_y = min_y.min(y); min_z = min_z.min(z); max_x = max_x.max(x); max_y = max_y.max(y); max_z = max_z.max(z); } } ([min_x, min_y, max_x, max_y], [min_z, max_z]) } fn compute_mesh_metrics(lods: &[MeshModel]) -> MeshMetrics { let Some(mesh) = lods.iter().find(|mesh| !mesh.indices.is_empty()) else { return MeshMetrics::default(); }; let kernel_mesh = kernel::brep::Mesh { positions: mesh .verts .iter() .enumerate() .map(|(index, high)| { let low = mesh.verts_low.get(index).copied().unwrap_or([0.0; 3]); [ high[0] as f64 + low[0] as f64, high[1] as f64 + low[1] as f64, high[2] as f64 + low[2] as f64, ] }) .collect(), normals: Vec::new(), triangles: mesh .indices .chunks_exact(3) .map(|triangle| { [ triangle[0] as usize, triangle[1] as usize, triangle[2] as usize, ] }) .collect(), }; let surface = kernel_mesh.surface_properties(); let mut metrics = MeshMetrics { vertices: mesh.verts.len(), triangles: mesh.indices.len() / 3, surface_area: surface.map_or(0.0, |value| value.0), centroid: surface.map_or([0.0; 3], |value| value.1), ..MeshMetrics::default() }; if let Some(properties) = kernel_mesh.inertial_properties() { metrics.apply_mass_properties(properties); } metrics } impl MeshLodSet { pub fn apply_mass_properties(&mut self, properties: kernel::brep::MassProperties) { self.metrics.apply_mass_properties(properties); } /// Build a set from its LODs, computing the 3D AABB. pub fn from_lods(lods: Vec) -> Self { let (world_aabb, z_aabb) = compute_mesh_aabb(&lods); let metrics = compute_mesh_metrics(&lods); Self { lods, material: None, face_materials: rustc_hash::FxHashMap::default(), visual_style: None, complete: true, edge_verts: Vec::new(), edge_verts_low: Vec::new(), curved_gens: Vec::new(), metrics, world_aabb, z_aabb, instance_source: None, instance_transform: None, instance_handle: None, instance_color: None, instance_aabb: None, } } /// Wrap a single MeshModel as a one-LOD set. Used by interactive /// commands that only produce one tessellation (e.g. the kernel-based /// BOX/CYLINDER creation). The LOD selector will pick slot 0 for /// every zoom level. pub fn from_single(mesh: MeshModel) -> Self { Self::from_lods(vec![mesh]) } /// Recompute `world_aabb` / `z_aabb` after the LODs' vertices were rewritten /// (relative-to-eye re-split, INSERT transform). pub fn recompute_aabb(&mut self) { let (xy, z) = compute_mesh_aabb(&self.lods); self.world_aabb = xy; self.z_aabb = z; } pub fn prepare_instance_source(&mut self, handle: codec::Handle) { self.instance_source = Some(std::sync::Arc::new(MeshInstanceSource { handle, lods: self.lods.clone(), edge_verts: self.edge_verts.clone(), edge_verts_low: self.edge_verts_low.clone(), curved_gens: self.curved_gens.clone(), })); self.instance_transform = None; self.instance_handle = None; self.instance_color = None; self.instance_aabb = None; } pub fn geometry_lods(&self) -> &[MeshModel] { self.instance_source .as_ref() .map_or(self.lods.as_slice(), |source| source.lods.as_slice()) } pub fn geometry_edges(&self) -> (&[[f32; 3]], &[[f32; 3]]) { self.instance_source.as_ref().map_or( (self.edge_verts.as_slice(), self.edge_verts_low.as_slice()), |source| ( source.edge_verts.as_slice(), source.edge_verts_low.as_slice(), ), ) } pub fn entity_handle(&self) -> Option { self.instance_handle.or_else(|| { self.lods .first() .and_then(|mesh| mesh.name.parse::().ok()) .map(codec::Handle::new) }) } pub fn display_color(&self) -> Option<[f32; 4]> { self.instance_color.or_else(|| { self.geometry_lods() .iter() .find(|mesh| !mesh.indices.is_empty()) .map(|mesh| mesh.color) }) } } /// Max B-rep edge pairs read from one solid for snapping. Curved-edge /// tessellations fan out into thousands of joints; the cap keeps the /// interaction index and the per-move snap scan bounded. pub const MAX_SOLID_SNAP_EDGES: usize = 2048; /// Distinct B-rep edge segments from a high/low double-single line list /// (mesh `edge_verts` pairs + `edge_verts_low`), feeding solid vertex /// (endpoints) and edge-midpoint (centres) snaps. /// /// Degenerate (zero-length) and non-finite pairs are dropped, and identical /// pairs collapse regardless of endpoint order. Past the cap the output is /// stride-sampled so coverage degrades gracefully instead of dropping every /// edge past some cutoff. pub fn solid_edge_segments( edge_verts: &[[f32; 3]], edge_verts_low: &[[f32; 3]], ) -> Vec<(glam::DVec3, glam::DVec3)> { fn point(high: [f32; 3], low: [f32; 3]) -> Option { let p = glam::DVec3::new( high[0] as f64 + low[0] as f64, high[1] as f64 + low[1] as f64, high[2] as f64 + low[2] as f64, ); p.is_finite().then_some(p) } fn key(p: &glam::DVec3) -> [u64; 3] { [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()] } let mut seen = std::collections::HashSet::new(); let mut out = Vec::new(); for index in 0..edge_verts.len() / 2 { let high = [edge_verts[2 * index], edge_verts[2 * index + 1]]; let low = [ edge_verts_low.get(2 * index).copied().unwrap_or([0.0; 3]), edge_verts_low .get(2 * index + 1) .copied() .unwrap_or([0.0; 3]), ]; let (Some(a), Some(b)) = (point(high[0], low[0]), point(high[1], low[1])) else { continue; }; if a.distance_squared(b) <= 1e-12 { continue; } let (ka, kb) = (key(&a), key(&b)); let ordered = if ka <= kb { (ka, kb) } else { (kb, ka) }; if !seen.insert(ordered) { continue; } out.push((a, b)); } if out.len() > MAX_SOLID_SNAP_EDGES { let step = out.len().div_ceil(MAX_SOLID_SNAP_EDGES); out = out.into_iter().step_by(step).collect(); } out } #[cfg(test)] mod solid_snap_tests { use super::solid_edge_segments; fn pt(x: f32, y: f32, z: f32) -> [f32; 3] { [x, y, z] } #[test] fn shared_corners_keep_both_segments() { // Two edges sharing the corner (1,0,0): both pairs survive, the // corner itself dedups later at attach time, not here. let edges = vec![ pt(0.0, 0.0, 0.0), pt(1.0, 0.0, 0.0), pt(1.0, 0.0, 0.0), pt(1.0, 1.0, 0.0), ]; let low = vec![[0.0f32; 3]; 4]; let segs = solid_edge_segments(&edges, &low); assert_eq!(segs.len(), 2, "shared corner collapsed segments: {segs:?}"); } #[test] fn degenerate_and_non_finite_pairs_are_dropped() { let edges = vec![ pt(0.0, 0.0, 0.0), pt(f32::NAN, 0.0, 0.0), pt(1.0, 0.0, 0.0), pt(1.0, 0.0, 0.0), // degenerate: zero length pt(2.0, 0.0, 0.0), pt(3.0, 0.0, f32::INFINITY), pt(4.0, 0.0, 0.0), pt(5.0, 0.0, 0.0), ]; let low = vec![[0.0f32; 3]; 8]; let segs = solid_edge_segments(&edges, &low); assert_eq!(segs.len(), 1, "expected only the valid pair: {segs:?}"); assert!(segs[0].0.is_finite() && segs[0].1.is_finite()); } #[test] fn output_is_capped_with_coverage() { let mut edges = Vec::new(); for k in 0..2200 { let x = 100.0 + k as f32; edges.push(pt(x, 0.0, 0.0)); edges.push(pt(x, 1.0, 0.0)); } let low = vec![[0.0f32; 3]; 4400]; let segs = solid_edge_segments(&edges, &low); assert!( segs.len() <= super::MAX_SOLID_SNAP_EDGES, "cap exceeded: {}", segs.len() ); assert!( segs.len() >= super::MAX_SOLID_SNAP_EDGES / 2, "stride sampling dropped too much coverage: {}", segs.len() ); // The first pair survives sampling (index 0 is kept). assert_eq!(segs[0].0, glam::DVec3::new(100.0, 0.0, 0.0)); } }