use std::collections::HashMap; use solidcraft_geom::{Mesh, Vec3}; use super::*; use crate::{Body, BoolOp}; /// Every triangle edge, with vertices merged by exact position, is shared by exactly two /// triangles: the mesh is closed and its faces meet exactly (no cracks, no T-junctions). fn assert_watertight(m: &Mesh, name: &str) { // By value: −0.0 and 0.0 are one position (adding 0.0 makes −0.0 into 0.0). let key = |i: u32| m.positions.get(i as usize).map(|p| [(p.x + 0.0).to_bits(), (p.y + 0.0).to_bits(), (p.z + 0.0).to_bits()]); let mut uses: HashMap<([u64; 3], [u64; 3]), i32> = HashMap::new(); for t in &m.triangles { for e in 0..3 { let (Some(a), Some(b)) = (key(t[e]), key(t[(e + 1) % 3])) else { panic!("{name}: bad index") }; if a == b { continue; } // Directed: a closed, consistently oriented mesh uses each edge once each way. *uses.entry((a, b)).or_default() += 1; *uses.entry((b, a)).or_default() -= 1; } } let open: Vec<_> = uses.iter().filter(|(_, n)| **n != 0).collect(); assert!(open.is_empty(), "{name}: {} unmatched directed edges, e.g. {:?}", open.len(), open.first()); } fn plan_body(b: &Body, tol: f64) -> (Mesh, PlanStats) { match plan(&b.solid, tol, Some(crate::tess::size::Turn { angle: crate::DISPLAY_ANGLE, floor: tol * 1e-3 })) { Ok((p, stats)) => (p.evaluate(), stats), Err(e) => panic!("plan: {e}"), } } fn check(name: &str, b: &Body, tol: f64, volume: f64) -> PlanStats { let (m, stats) = plan_body(b, tol); assert_watertight(&m, name); let got = m.measure().volume; // Chord error tol over the surface: the volume is short by at most about area · tol. let area = m.measure().area; assert!((got - volume).abs() <= area * tol * 1.5 + 1e-9, "{name}: volume {got} vs {volume} (area {area}, tol {tol})"); // Outward normals: the divergence theorem gives a positive volume only if they are. assert!(got > 0.0, "{name}: inside out"); let faces = b.solid.face_iter().count(); assert_eq!(stats.planar + stats.structured + stats.delaunay, faces, "{name}: every face planned"); stats } fn ok(r: crate::Result) -> T { match r { Ok(v) => v, Err(e) => panic!("{e}"), } } fn cut(a: &Body, b: &Body) -> Body { match crate::boolean(a, b, BoolOp::Cut) { Ok(Some(r)) => r, other => panic!("cut: {other:?}"), } } const PI: f64 = std::f64::consts::PI; /// Smallest corner angle (degrees) over the mesh. fn min_angle(m: &Mesh) -> f64 { let mut least = 180.0f64; for t in &m.triangles { let [a, b, c] = t.map(|i| m.positions[i as usize]); for (p, q, r) in [(a, b, c), (b, c, a), (c, a, b)] { let (u, v) = (q - p, r - p); if let (Some(u), Some(v)) = (u.normalized(), v.normalized()) { least = least.min(u.dot(v).clamp(-1.0, 1.0).acos().to_degrees()); } } } least } #[test] fn a_box_is_a_regular_grid_on_every_face() { // 40 × 30 × 20: sides at most 1/5 of the diagonal (10.77 mm), so 4 × 3 × 2 cells. let b = ok(crate::box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0))); let (m, s) = plan_body(&b, 0.05); assert_eq!(s.planar, 6); assert_watertight(&m, "box"); let cells = 2 * (4 * 3 + 4 * 2 + 3 * 2); assert_eq!(m.triangles.len(), 2 * cells, "two triangles per grid cell"); assert!(min_angle(&m) > 40.0, "{}", min_angle(&m)); } #[test] fn primitives_are_watertight_with_the_right_volume() { for tol in [0.05, 0.005] { let s = check("box", &ok(crate::box_solid(Vec3::ZERO, Vec3::new(10.0, 6.0, 4.0))), tol, 240.0); assert_eq!(s, PlanStats { planar: 6, structured: 0, delaunay: 0 }); let s = check("cylinder", &ok(crate::cylinder(Vec3::new(1.0, 2.0, 3.0), Vec3::new(0.0, 1.0, 1.0), 2.5, 7.0)), tol, PI * 2.5 * 2.5 * 7.0); assert_eq!(s.delaunay, 0, "cylinder walls are structured"); let s = check("torus", &ok(crate::torus(Vec3::ZERO, 6.0, 1.5)), tol, 2.0 * PI * PI * 6.0 * 1.5 * 1.5); assert_eq!(s.delaunay, 0, "torus patches are structured"); } } #[test] fn holes_and_trimmed_faces_are_watertight() { let tol = 0.01; let bx = ok(crate::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 4.0))); let pin = ok(crate::cylinder(Vec3::new(5.0, 5.0, -1.0), Vec3::Z, 2.0, 6.0)); check("box with a hole", &cut(&bx, &pin), tol, 400.0 - PI * 4.0 * 4.0); // A cylinder with a cross hole: trimmed cylinder halves (Delaunay) and intersection-curve walls. let cyl = ok(crate::cylinder(Vec3::new(5.0, 5.0, -1.0), Vec3::Z, 2.0, 6.0)); let cross = ok(crate::cylinder(Vec3::new(5.0, -1.0, 2.0), Vec3::Y, 1.0, 12.0)); let body = cut(&cyl, &cross); let (m, stats) = plan_body(&body, tol); assert_watertight(&m, "cross hole"); assert!(stats.delaunay > 0, "{stats:?}"); // Against the reference mesher's volume (no closed form for the two-cylinder intersection). let Ok(reference) = body.tessellate(tol * 0.2) else { panic!("reference mesh") }; let (a, b) = (m.measure().volume, reference.measure().volume); assert!((a - b).abs() < 0.01 * b, "cross hole volume {a} vs {b}"); } #[test] fn faces_and_edges_keep_the_body_order() { let body = ok(crate::cylinder(Vec3::ZERO, Vec3::Z, 2.0, 5.0)); let (m, _) = plan_body(&body, 0.01); let Ok(reference) = body.tessellate(0.01) else { panic!("reference mesh") }; assert_eq!(m.edges.len(), reference.edges.len()); assert_eq!(m.edge_faces, reference.edge_faces); // Same faces in the same order: each face's area agrees with the reference mesher's. let face_area = |m: &Mesh, f: u32| -> f64 { m.triangles .iter() .zip(&m.tri_face) .filter(|(_, tf)| **tf == f) .map(|(t, _)| { let p = |i: u32| m.positions.get(i as usize).copied().unwrap_or_default(); (p(t[1]) - p(t[0])).cross(p(t[2]) - p(t[0])).len() * 0.5 }) .sum() }; for f in 0..body.solid.face_iter().count() as u32 { let (a, b) = (face_area(&m, f), face_area(&reference, f)); assert!((a - b).abs() < 0.01 * b.max(1e-9), "face {f}: {a} vs {b}"); } // Each edge polyline ends on the same points as the reference's. for (a, b) in m.edges.iter().zip(&reference.edges) { assert!(a.first().zip(b.first()).is_some_and(|(p, q)| p.dist(*q) < 1e-9)); assert!(a.last().zip(b.last()).is_some_and(|(p, q)| p.dist(*q) < 1e-9)); } } /// Side-by-side wireframes, reference mesher (left) and plan (right): /// `SOLIDCRAFT_TESS_SVG= cargo test -p solidcraft-kernel render_wireframes -- --ignored`. #[test] #[ignore] fn render_wireframes() { let Ok(dir) = std::env::var("SOLIDCRAFT_TESS_SVG") else { return }; let tol = 0.02; let bx = ok(crate::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 4.0))); let pin = ok(crate::cylinder(Vec3::new(5.0, 5.0, -1.0), Vec3::Z, 2.0, 6.0)); let cross = ok(crate::cylinder(Vec3::new(5.0, -1.0, 2.0), Vec3::Y, 1.0, 12.0)); let filleted = ok(crate::fillet(&bx, &[Vec3::new(5.0, 0.0, 4.0), Vec3::new(10.0, 5.0, 4.0), Vec3::new(5.0, 10.0, 4.0), Vec3::new(0.0, 5.0, 4.0)], 1.5)); let shapes = [ ("box_hole", cut(&bx, &pin)), ("cylinder", pin.clone()), ("cross_hole", cut(&pin, &cross)), ("torus", ok(crate::torus(Vec3::ZERO, 6.0, 1.5))), ("fillet", filleted), ]; for (name, body) in shapes { let t0 = std::time::Instant::now(); let Ok(old) = body.tessellate(tol) else { panic!("reference") }; let t_old = t0.elapsed().as_secs_f64() * 1e3; let t0 = std::time::Instant::now(); let new = match plan(&body.solid, tol, Some(crate::tess::size::Turn { angle: crate::DISPLAY_ANGLE, floor: tol * 1e-3 })) { Ok((p, st)) => Some((p.evaluate(), st)), Err(e) => { println!("{name}: no plan ({e})"); None } }; let t_new = t0.elapsed().as_secs_f64() * 1e3; let mut svg = String::from(""); let label = |x: f64, text: String| format!("{text}"); svg += &label(20.0, format!("meshalgo (Delaunay): {} tris, {t_old:.1} ms", old.triangles.len())); svg += &draw(&old, 0.0); if let Some((m, st)) = &new { svg += &label( 820.0, format!("plan: {} tris, {t_new:.1} ms [{} planar, {} grid, {} cdt]", m.triangles.len(), st.planar, st.structured, st.delaunay), ); svg += &draw(m, 800.0); } svg += ""; let _ = std::fs::write(format!("{dir}/{name}.svg"), svg); } } /// Isometric, painter-sorted, flat-shaded triangles with their edges, in an 800×800 panel. fn draw(m: &Mesh, x0: f64) -> String { let (c30, s30) = (30f64.to_radians().cos(), 30f64.to_radians().sin()); let proj = |p: Vec3| -> (f64, f64, f64) { // Turn 45° about Z, tilt 30° about X; depth along the view. let (x, y) = ((p.x - p.y) * std::f64::consts::FRAC_1_SQRT_2, (p.x + p.y) * std::f64::consts::FRAC_1_SQRT_2); (x, p.z * c30 - y * s30, y * c30 + p.z * s30) }; let pts: Vec<(f64, f64, f64)> = m.positions.iter().map(|p| proj(*p)).collect(); let (mut lo, mut hi) = ((f64::MAX, f64::MAX), (f64::MIN, f64::MIN)); for p in &pts { lo = (lo.0.min(p.0), lo.1.min(p.1)); hi = (hi.0.max(p.0), hi.1.max(p.1)); } let k = 700.0 / (hi.0 - lo.0).max(hi.1 - lo.1).max(1e-9); let sx = |p: (f64, f64, f64)| (x0 + 50.0 + (p.0 - lo.0) * k, 800.0 - (p.1 - lo.1) * k); let mut tris: Vec<(f64, [u32; 3], u32)> = m.triangles.iter().zip(&m.tri_face).map(|(t, f)| (t.iter().map(|&i| pts[i as usize].2).sum::(), *t, *f)).collect(); tris.sort_by(|a, b| b.0.total_cmp(&a.0)); let light = Vec3::new(0.3, -0.5, 0.8).normalized().unwrap_or(Vec3::new(0.0, 0.0, 1.0)); let mut out = String::new(); let tint = [(1.0, 1.0, 1.15), (1.15, 1.0, 1.0), (1.0, 1.15, 1.0), (1.1, 1.1, 0.9), (0.9, 1.1, 1.1), (1.1, 0.9, 1.1)]; for (_, t, f) in tris { let (tr, tg, tb) = tint[f as usize % tint.len()]; let [a, b, c] = t.map(|i| m.positions[i as usize]); let n = (b - a).cross(c - a).normalized().unwrap_or(Vec3::new(0.0, 0.0, 1.0)); let shade = (90.0 + 150.0 * n.dot(light).max(0.0)) as u8; let q: Vec<(f64, f64)> = t.iter().map(|&i| sx(pts[i as usize])).collect(); // Back faces away (screen y points down, so outward triangles wind clockwise here). if (q[1].0 - q[0].0) * (q[2].1 - q[0].1) - (q[2].0 - q[0].0) * (q[1].1 - q[0].1) > 0.0 { continue; } let pp: Vec = q.iter().map(|(x, y)| format!("{x:.1},{y:.1}")).collect(); out += &format!( "", pp.join(" "), (f64::from(shade) * tr).min(255.0) as u8, (f64::from(shade) * tg).min(255.0) as u8, (f64::from(shade) * tb).min(255.0) as u8, ); } out } /// Blind drilled holes (a box cut by a revolved drill with a 118° point, as the hole feature /// builds it): the point's apex is a pole of the cone faces, two copies of one boundary node. /// The plan is closed and has no triangle on both copies (no zero-area triangles), also where /// such a triangle carried a boundary side of the cone. #[test] fn a_drill_point_apex_leaves_no_zero_area_triangles() { use solidcraft_geom::{Loop2, Plane, Region2, Vec2}; // (box size, hole position, radius, depth) let cases = [(Vec3::new(40.0, 30.0, 15.0), Vec3::new(20.0, 15.0, 15.0), 3.4, 8.0), (Vec3::new(60.0, 40.0, 15.0), Vec3::new(50.0, 20.0, 15.0), 3.0, 10.0)]; for (size, at, r, depth) in cases { let bx = ok(crate::box_solid(Vec3::ZERO, size)); let tip = r / (118f64 / 2.0).to_radians().tan(); let Some(rp) = Plane::new(at, Vec3::X, Vec3::Z) else { panic!("plane") }; let pts = [Vec2::new(0.0, 0.6), Vec2::new(r, 0.6), Vec2::new(r, -depth), Vec2::new(0.0, -depth - tip)]; let region = Region2 { outer: Loop2::polygon(&pts), holes: vec![] }; let Some(drill) = ok(crate::revolve(&rp, &[region], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU)).pop() else { panic!("drill") }; let body = cut(&bx, &drill); let name = format!("hole r {r} depth {depth}"); let (m, _) = plan_body(&body, 0.02); assert_watertight(&m, &name); for t in &m.triangles { let [a, b, c] = t.map(|i| m.positions[i as usize]); assert!((b - a).cross(c - a).len() > 1e-12, "{name}: zero-area triangle {a:?} {b:?} {c:?}"); } } } #[test] fn a_placed_body_meshes_like_the_original() { let b = ok(crate::box_solid(Vec3::ZERO, Vec3::new(2.0, 3.0, 4.0))); let placed = ok(crate::transform(&b, Vec3::new(15.0, -2.0, 8.0), Vec3::ZERO, Vec3::X, 0.7)); let (m, _) = plan_body(&b, 0.005); let (p, _) = plan_body(&placed, 0.005); assert_eq!(m.tri_face, p.tri_face); }