// B-rep construction for the Model tab's primitives, plus tessellation into // the renderer's `MeshLodSet`. // // The bodies come from the geometry kernel, which builds each primitive the // way ACIS records it: an analytic surface with singular vertices where the // surface has them, rather than a mesh or a spline that happens to look like // one. That is what lets a solid built here be written back out as exact // geometry instead of a facetted approximation — see `acis_bridge`. // // Everything is oriented Z-up with the footprint on the z = base plane, to // match acadrust's `acis::primitives`. // // The resulting `Body` is cached per entity handle on the Scene so the // Design-group boolean tools can run on it. use cadkernel::brep::{self, Body}; use crate::scene::model::mesh_model::{MeshLodSet, MeshModel}; /// What counts as the same point when the kernel checks a body over. const TOL: f64 = 1e-9; fn tessellation(body: &Body) -> brep::mesh::BodyMesh { brep::mesh::tessellate( body, brep::mesh::TessellationTolerance::new( cadkernel::tessellation::DEFAULT_ANGLE, TOL, ), ) } /// Axis-aligned box from its center and full extents. pub fn box_solid(center: [f64; 3], length: f64, width: f64, height: f64) -> Option { brep::make::cuboid( [ center[0] - length / 2.0, center[1] - width / 2.0, center[2] - height / 2.0, ], [length, width, height], ) } /// Right triangular prism (wedge): right-triangle cross-section in XZ, /// extruded along Y. `origin` is the min corner, ramp rising in +X/+Z. pub fn wedge_solid(origin: [f64; 3], length: f64, width: f64, height: f64) -> Option { brep::make::wedge(origin, length, width, height) } /// Solid cylinder standing on the z = base plane. pub fn cylinder_solid(center: [f64; 3], radius: f64, height: f64) -> Option { brep::make::cylinder(center, radius, height) } /// Solid cone standing on the z = base plane, apex `height` above it. pub fn cone_solid(center: [f64; 3], radius: f64, height: f64) -> Option { brep::make::cone(center, radius, height) } /// Solid sphere about `center`. pub fn sphere_solid(center: [f64; 3], radius: f64) -> Option { brep::make::sphere(center, radius) } /// Solid torus in the z = base plane (tube revolved about the Z axis). pub fn torus_solid(center: [f64; 3], major: f64, minor: f64) -> Option { brep::make::torus(center, major, minor) } /// Solid pyramid on a regular polygon of `sides` corners. pub fn pyramid_solid(center: [f64; 3], radius: f64, height: f64, sides: usize) -> Option { brep::make::pyramid(center, radius, height, sides) } // ── Placement ─────────────────────────────────────────────────────────────── /// Moves a body by a rigid transform, given as three axes and an origin. /// /// The Model tab builds every primitive in its own upright frame and then /// puts it on the working plane, which is the only reason this exists. A /// body carries analytic surfaces, so moving it moves their frames rather /// than any points. pub fn placed( body: &Body, x: [f64; 3], y: [f64; 3], z: [f64; 3], origin: [f64; 3], ) -> Option { brep::transform( body, &brep::Placement { x_axis: x, y_axis: y, z_axis: z, origin, }, ) } /// Turns a body about one of the world axes, through the point `about`. pub fn turned(body: &Body, axis: usize, angle: f64, about: [f64; 3]) -> Option { let (sin, cos) = angle.sin_cos(); // The rotation's columns, written out per axis rather than assembled from // a general formula: three cases are shorter than the axis-angle one and // there is nothing to get subtly wrong in them. let (x, y, z) = match axis { 0 => ([1.0, 0.0, 0.0], [0.0, cos, sin], [0.0, -sin, cos]), 1 => ([cos, 0.0, -sin], [0.0, 1.0, 0.0], [sin, 0.0, cos]), _ => ([cos, sin, 0.0], [-sin, cos, 0.0], [0.0, 0.0, 1.0]), }; placed(body, x, y, z, about_origin(x, y, z, about)) } /// Reflects a body in the plane across one of the world axes, through `about`. /// /// The kernel puts the mirrored solid back the right way out; a reflection /// left alone lights black. pub fn mirrored(body: &Body, axis: usize, about: [f64; 3]) -> Option { let mut columns = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; columns[axis][axis] = -1.0; let [x, y, z] = columns; placed(body, x, y, z, about_origin(x, y, z, about)) } /// Moves a body by a transform given as a column-major 4×4, which is what a /// frame-to-frame solve produces. pub fn by_matrix(body: &Body, matrix: [f64; 16]) -> Option { placed( body, [matrix[0], matrix[1], matrix[2]], [matrix[4], matrix[5], matrix[6]], [matrix[8], matrix[9], matrix[10]], [matrix[12], matrix[13], matrix[14]], ) } /// Where a transform's origin has to sit for it to act about `about` rather /// than about the world origin: `about − M·about`. fn about_origin(x: [f64; 3], y: [f64; 3], z: [f64; 3], about: [f64; 3]) -> [f64; 3] { let mut origin = about; for axis in 0..3 { origin[axis] -= x[axis] * about[0] + y[axis] * about[1] + z[axis] * about[2]; } origin } /// The box a body occupies, from its mesh. pub fn extent(body: &Body) -> Option<([f64; 3], [f64; 3])> { let mesh = tessellation(body).mesh; if mesh.positions.is_empty() { return None; } let mut low = [f64::INFINITY; 3]; let mut high = [f64::NEG_INFINITY; 3]; for point in &mesh.positions { for axis in 0..3 { low[axis] = low[axis].min(point[axis]); high[axis] = high[axis].max(point[axis]); } } Some((low, high)) } /// Where an axis-aligned plane cuts a body, as line segments. /// /// Taken off the mesh rather than the surfaces: a section of a cone by a /// slanted plane is a conic, of a torus a quartic, and the answer wanted here /// is a set of Line entities either way. Each triangle the plane crosses /// contributes the one segment where it does. pub fn section(body: &Body, axis: usize, value: f64) -> Vec<([f64; 3], [f64; 3])> { let mesh = tessellation(body).mesh; let mut out = Vec::new(); for triangle in &mesh.triangles { let corners: Vec<[f64; 3]> = triangle.iter().map(|i| mesh.positions[*i]).collect(); // Where each edge of the triangle meets the plane. A triangle with a // corner exactly on it contributes that corner twice, which collapses // to nothing and is dropped below. let mut hits: Vec<[f64; 3]> = Vec::new(); for step in 0..3 { let (from, to) = (corners[step], corners[(step + 1) % 3]); let (a, b) = (from[axis] - value, to[axis] - value); if (a > 0.0) == (b > 0.0) || a == b { continue; } let along = a / (a - b); hits.push([ from[0] + (to[0] - from[0]) * along, from[1] + (to[1] - from[1]) * along, from[2] + (to[2] - from[2]) * along, ]); } if hits.len() == 2 { let span = (hits[0][0] - hits[1][0]).abs() + (hits[0][1] - hits[1][1]).abs() + (hits[0][2] - hits[1][2]).abs(); if span > 1e-9 { out.push((hits[0], hits[1])); } } } out } // ── Edge extraction (pick geometry + wireframe overlay) ───────────────────── /// Tessellate the solid's B-rep edges into acadrust `Wire`s. Stored on the /// `Solid3D`/result entity for picking. pub fn edge_wires(body: &Body) -> Vec { use acadrust::types::Vector3; tessellation(body) .edges .into_iter() .map(|edge| { acadrust::entities::Wire::from_points( edge.positions .into_iter() .map(|p| Vector3::new(p[0], p[1], p[2])) .collect(), ) }) .collect() } // ── Boolean operations ────────────────────────────────────────────────────── /// Which CSG to apply. Mirrors `model::boolean_cmd::BoolOp` but kept local so /// this scene module has no dependency on the UI module. #[derive(Clone, Copy)] pub enum Bool { Union, Subtract, Intersect, } /// Combine two solids. `Subtract` removes `b` from `a`. /// /// `None` when the kernel refuses — a face pair it has no closed form for, a /// cut it cannot make. It refuses rather than returning a solid with a wall /// missing, and passing that on unchanged is the point: a half-done boolean /// looks finished. pub fn boolean(op: Bool, a: &Body, b: &Body) -> Option { let how = match op { Bool::Union => brep::Operation::Union, Bool::Subtract => brep::Operation::Difference, Bool::Intersect => brep::Operation::Intersection, }; brep::combine(a.clone(), b.clone(), how, TOL).ok() } // ── Tessellation ──────────────────────────────────────────────────────────── /// Tessellate a `Body` into a single-LOD `MeshLodSet` (world-space, before /// world_offset is applied by the caller). pub fn mesh_from_solid(body: &Body, color: [f32; 4]) -> Option { let tessellation = tessellation(body); let silhouette = tessellation.silhouette_source(); let mesh = tessellation.mesh; if mesh.is_empty() { return None; } // The renderer holds each position as a coarse float plus a fine // correction, so a survey coordinate keeps its last millimetres instead // of losing them to f32. let mut verts = Vec::with_capacity(mesh.positions.len()); let mut verts_low = Vec::with_capacity(mesh.positions.len()); for point in &mesh.positions { let high = [point[0] as f32, point[1] as f32, point[2] as f32]; verts.push(high); verts_low.push([ (point[0] - high[0] as f64) as f32, (point[1] - high[1] as f64) as f32, (point[2] - high[2] as f64) as f32, ]); } let normals = mesh .normals .iter() .map(|n| [n[0] as f32, n[1] as f32, n[2] as f32]) .collect(); let indices = mesh .triangles .iter() .flat_map(|t| [t[0] as u32, t[1] as u32, t[2] as u32]) .collect(); let mut set = MeshLodSet::from_single(MeshModel { name: String::new(), verts, verts_low, normals, indices, triangle_material_handles: Vec::new(), triangle_colors: Vec::new(), color, selected: false, }); for edge in tessellation.edges { for segment in edge.positions.windows(2) { for point in segment { let high = [point[0] as f32, point[1] as f32, point[2] as f32]; set.edge_verts.push(high); set.edge_verts_low.push([ (point[0] - high[0] as f64) as f32, (point[1] - high[1] as f64) as f32, (point[2] - high[2] as f64) as f32, ]); } } } set.complete = tessellation.missing_faces.is_empty(); set.curved_gens.push(super::mesh_model::CurvedGen { source: silhouette }); Some(set) } /// The middle of a body, for a caller needing a point to turn or scale about. /// /// Read off the mesh rather than `body_bounds`, which refuses a face that /// wraps a closed surface — a sphere is one such face and has no box at all. pub fn centre(body: &Body) -> Option<[f64; 3]> { let mesh = tessellation(body).mesh; if mesh.positions.is_empty() { return None; } let mut low = [f64::INFINITY; 3]; let mut high = [f64::NEG_INFINITY; 3]; for point in &mesh.positions { for axis in 0..3 { low[axis] = low[axis].min(point[axis]); high[axis] = high[axis].max(point[axis]); } } Some([ (low[0] + high[0]) * 0.5, (low[1] + high[1]) * 0.5, (low[2] + high[2]) * 0.5, ]) } /// How much a body encloses, from its mesh. /// /// The divergence theorem over triangles wound outwards, which is what makes /// it a check rather than only a measurement: a solid built inside out /// reports a negative volume rather than a plausible one, and one missing a /// face reports far too little. Nothing in the app measures volume yet, so it /// exists to test with. #[cfg(test)] pub fn volume(body: &Body) -> f64 { use cadkernel::space::Vec3; let mesh = tessellation(body).mesh; let Some(middle) = centre(body) else { return 0.0; }; // About the body's own middle: at survey coordinates the tetrahedra // reaching back to the origin are enormous and nearly cancel, and a // cubic millimetre read off a sum of billions is noise. let middle = Vec3::from(middle); mesh.triangles .iter() .map(|triangle| { let at = |index: usize| Vec3::from(mesh.positions[triangle[index]]) - middle; at(0).cross(at(1)).dot(at(2)) / 6.0 }) .sum() } #[cfg(test)] mod tests { use super::*; fn tri_count(body: &Body) -> usize { mesh_from_solid(body, [0.7, 0.7, 0.7, 1.0]) .map(|m| m.lods[0].indices.len() / 3) .unwrap_or(0) } #[test] fn all_primitives_triangulate() { let c = [0.0, 0.0, 0.0]; assert!(tri_count(&box_solid(c, 10.0, 10.0, 10.0).unwrap()) >= 12, "box"); assert!(tri_count(&wedge_solid(c, 10.0, 10.0, 10.0).unwrap()) >= 6, "wedge"); assert!(tri_count(&cylinder_solid(c, 5.0, 12.0).unwrap()) > 20, "cylinder"); assert!(tri_count(&cone_solid(c, 5.0, 12.0).unwrap()) > 10, "cone"); assert!(tri_count(&sphere_solid(c, 5.0).unwrap()) > 50, "sphere"); assert!(tri_count(&torus_solid(c, 8.0, 2.0).unwrap()) > 50, "torus"); assert!(tri_count(&pyramid_solid(c, 5.0, 9.0, 6).unwrap()) >= 8, "pyramid"); } #[test] fn every_primitive_is_the_size_it_was_asked_for() { // Triangle counts say a mesh exists; the volume says it is the right // shape and the right way out. A face left out reads far too small // and one wound inwards reads negative, and neither shows up in a // count. use std::f64::consts::PI; let c = [0.0, 0.0, 0.0]; let cases: [(Body, f64); 5] = [ (box_solid(c, 10.0, 4.0, 6.0).unwrap(), 240.0), (cylinder_solid(c, 5.0, 12.0).unwrap(), PI * 25.0 * 12.0), (cone_solid(c, 5.0, 12.0).unwrap(), PI * 25.0 * 12.0 / 3.0), (sphere_solid(c, 5.0).unwrap(), 4.0 / 3.0 * PI * 125.0), (torus_solid(c, 8.0, 2.0).unwrap(), 2.0 * PI * PI * 8.0 * 4.0), ]; for (body, expected) in cases { let got = volume(&body); assert!(got > 0.0, "wound inwards: {got}"); // Close either way, rather than short and never over. A chord does // lie inside the surface it spans, so a convex solid can only read // short — but a torus is not convex, and across the inside of its // tube the chords fall outside the material and add a little. What // is being checked is that the mesh is the shape asked for, and a // per cent covers both. assert!( (got - expected).abs() < 0.01 * expected, "{got} vs {expected}" ); } } #[test] fn booleans_produce_solids() { let a = box_solid([0.0, 0.0, 0.0], 10.0, 10.0, 10.0).unwrap(); let b = box_solid([5.0, 5.0, 5.0], 10.0, 10.0, 10.0).unwrap(); for (op, label) in [ (Bool::Union, "union"), (Bool::Subtract, "subtract"), (Bool::Intersect, "intersect"), ] { let r = boolean(op, &a, &b); let n = r.as_ref().map(tri_count).unwrap_or(0); assert!(r.is_some() && n > 0, "{label} produced nothing"); } } #[test] fn box_exposes_edges() { assert!(edge_wires(&box_solid([0.0, 0.0, 0.0], 10.0, 10.0, 10.0).unwrap()).len() >= 12); } #[test] fn placing_a_body_moves_it_without_changing_its_size() { let body = box_solid([0.0, 0.0, 0.0], 10.0, 4.0, 6.0).unwrap(); // A quarter turn about Z, then five along X. let moved = placed( &body, [0.0, 1.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [5.0, 0.0, 0.0], ) .expect("a turned box"); assert!((volume(&moved) - 240.0).abs() < 1e-6, "{}", volume(&moved)); // Centred on the origin to begin with, so the turn leaves it there // and the move puts it five along x. let middle = centre(&moved).unwrap(); assert!((middle[0] - 5.0).abs() < 1e-9, "{middle:?}"); // And ten along x really did become ten along y. let (low, high) = extent(&moved).unwrap(); assert!((high[1] - low[1] - 10.0).abs() < 1e-9, "{low:?} {high:?}"); assert!((high[0] - low[0] - 4.0).abs() < 1e-9, "{low:?} {high:?}"); } }