Per-face target side lengths, the caller's facet angle, refined grid lines, and fixes for underflowing coordinates and straight edges ending exactly on their vertex. Nothing drawn changes yet. Refs #303 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
282 lines
13 KiB
Rust
282 lines
13 KiB
Rust
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<T>(r: crate::Result<T>) -> 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=<dir> 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("<svg xmlns='http://www.w3.org/2000/svg' width='1600' height='860' style='background:#fff'>");
|
||
let label = |x: f64, text: String| format!("<text x='{x}' y='40' font-family='sans-serif' font-size='26'>{text}</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 += "</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::<f64>(), *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<String> = q.iter().map(|(x, y)| format!("{x:.1},{y:.1}")).collect();
|
||
out += &format!(
|
||
"<polygon points='{}' fill='rgb({},{},{})' stroke='#1d3557' stroke-width='0.5'/>",
|
||
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);
|
||
}
|