Compare commits

...

1 Commits

Author SHA1 Message Date
Brennen Herbruck 5774d27c2f Mesher: flat faces are their boundary alone, not a grid capped at a fifth of the body
A planar face is exact for any triangulation of its boundary, so it needs no interior
cells. The planner capped every face, flat ones included, at a fifth of the body's
extent, so a 20 x 10 x 5 box came out as 124 triangles instead of 12.

A flat face (a plane, or a spline lying in one) now asks for no size. An edge between
flat faces is one segment; an edge beside a curved face still takes the curved face's
size, so both faces use the same nodes and the mesh stays watertight. Curved faces keep
the cap, the aspect limit and the grid limits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 23:04:49 -05:00
4 changed files with 66 additions and 42 deletions
+4 -7
View File
@@ -1185,11 +1185,8 @@ mod tests {
}
/// OBJ shares the vertices faces meet at and keeps the body name as written, spaces included.
/// The 10 × 20 × 30 box meshes as the planner's grids, no side longer than a fifth of its
/// extent (the 37.4 mm diagonal): its edges in 2, 3 and 5 pieces, so its faces are grids of
/// 2 × 3, 2 × 5 and 3 × 5 cells, 2 · (6 + 10 + 15) · 2 = 124 triangles. Welded, their
/// corners are the 3 · 4 · 6 = 72 nodes of the box's lattice less the 1 · 2 · 4 inside it: 64
/// positions (unwelded, 372), and one normal per face.
/// The 10 × 20 × 30 box meshes as two triangles a face (a flat face needs no interior
/// nodes), 12 triangles on its 8 corners (unwelded, 36), and one normal per face.
#[test]
fn obj_export_is_welded_and_keeps_names() {
let (_, m) = model();
@@ -1200,10 +1197,10 @@ mod tests {
let o = String::from_utf8(export(&st, &[], Format::Obj, "t").unwrap()).unwrap();
let count = |p: &str| o.lines().filter(|l| l.starts_with(p)).count();
assert!(o.contains("\no Red Block\n"), "{o}");
assert_eq!((count("v "), count("vn "), count("f ")), (64, 6, 124), "{o}");
assert_eq!((count("v "), count("vn "), count("f ")), (8, 6, 12), "{o}");
let ids: Vec<usize> =
o.lines().filter(|l| l.starts_with("f ")).flat_map(|l| l.split([' ', '/']).skip(1)).filter_map(|x| x.parse().ok()).collect();
assert!(ids.iter().all(|i| (1..=64).contains(i)));
assert!(ids.iter().all(|i| (1..=8).contains(i)));
}
/// The OBJ export carries the colours of the bodies: the mesh file names the material
+15 -7
View File
@@ -179,7 +179,12 @@ pub(crate) fn plan(b: &brep::Brep, tol: f64, turn: Option<size::Turn>) -> Result
.into_iter()
.collect::<Result<_>>()?;
let face_h = |f: usize| sizes.get(f).map_or(rules.max_len, |(h, _)| *h);
let edge_h: Vec<f64> = edge_faces.iter().map(|fs| fs.iter().map(|&f| face_h(f)).fold(rules.max_len, f64::min)).collect();
// A flat face asks for no size (infinite): an edge between flat faces is whole, one beside
// a curved face takes the curved face's size, so both meet at the same nodes.
let edge_h: Vec<f64> = edge_faces
.iter()
.map(|fs| if fs.is_empty() { rules.max_len } else { fs.iter().map(|&f| face_h(f)).fold(f64::INFINITY, f64::min) })
.collect();
let jobs: Vec<(usize, f64)> = edge_h.into_iter().enumerate().collect();
let solved = b.edges.iter().filter(|e| matches!(e.curve, Curve::SurfaceIntersection { .. })).count();
let mut nodes: Vec<edges::Nodes> = crate::par::map("edge nodes", &jobs, solved >= PARALLEL_CURVES || jobs.len() >= PARALLEL_EDGES, |(e, h)| {
@@ -1027,15 +1032,18 @@ fn plan_face(
// Constrained Delaunay in (u, v) scaled to millimetres: equilateral seeds of side `h`,
// refined where triangles leave the surface, are too long or bend too much, then shaped by
// Ruppert refinement.
let planar = matches!(s, EvalSurface::Plane { .. });
let bare = planar && h.is_infinite();
let polys: Vec<&[[f64; 2]]> = ranges.iter().filter_map(|r| uvs.get(r.clone())).collect();
let step = [h / speed[0], h / speed[1]];
let delaunay = || {
let seeds = cdt::lattice(bbox, step, &polys, speed);
// A flat face with no size asked of it: the boundary nodes alone, no interior seeds.
let seeds = if bare { Vec::new() } else { cdt::lattice(bbox, step, &polys, speed) };
let eval = |u: f64, v: f64| s.derivs(u, v).map(|(p, su, sv)| (p, su.cross(sv).normalized()));
let sag = cdt::Sag { eval: &eval, tol, h, turn: rules.turn, scale: speed };
let fill = cdt::Fill {
seeds: &seeds,
refine: Some((sag, MAX_REFINE)),
refine: if bare { None } else { Some((sag, MAX_REFINE)) },
shape: !matches!(s, EvalSurface::Plane { .. }),
lenient: lenient == Lenient::Yes,
};
@@ -1043,7 +1051,6 @@ fn plan_face(
};
// Planar and curved faces alike: a structured grid where the boundary runs along the
// parameter box (a rectangle, a cylinder half), a Coons grid on four corners, else Delaunay.
let planar = matches!(s, EvalSurface::Plane { .. });
// The lenient fill is the constrained Delaunay alone: the caller names the face in the stats.
let (extra, triangles) = if lenient == Lenient::Yes {
delaunay()?
@@ -1051,7 +1058,7 @@ fn plan_face(
let mut us = uv::divide(s, 0, bbox[0], bbox[1], rules, h, MAX_LINES);
let mut vs = uv::divide(s, 1, bbox[1], bbox[0], rules, h, MAX_LINES);
grid::thin(&mut us, &mut vs, grid::MAX_FACE_GRID);
let structured = match (ranges.len(), if seamed { None } else { grid::quad(&uvs) }) {
let structured = match (ranges.len(), if seamed || bare { None } else { grid::quad(&uvs) }) {
(1, Some(q)) => {
// Interior lines: the boundary's own where opposite sides agree (refined where the
// surface turns more between them inside the face), else the division.
@@ -1076,7 +1083,8 @@ fn plan_face(
_ => None,
};
let structured = match structured {
None if ranges.len() == 1 && !seamed => {
// A flat face with no size asked of it is its boundary alone (the Delaunay below).
None if ranges.len() == 1 && !seamed && !bare => {
let fine = |a: [f64; 2], b: [f64; 2]| uv::fine_between(s, rules, h, a, b);
coons::coons(&uvs, &fixed, speed, grid::MAX_FACE_GRID, h, Some(&fine))
}
@@ -1108,7 +1116,7 @@ fn plan_face(
let trimmed = if seamed || strip {
None
} else if planar {
if ranges.len() == 1 { plan_pieces(&face) } else { None }
if ranges.len() == 1 && h.is_finite() { plan_pieces(&face) } else { None }
} else {
plan_pieces(&face).or_else(|| {
let traces = motorcycle::traces(&uvs, &ranges, speed, 0.25 * h);
+12 -7
View File
@@ -1,7 +1,9 @@
//! Target triangle size: one side length per face, so triangles come out about equilateral
//! (well-shaped triangles shade evenly) and as small as the surface needs.
//!
//! A face's size is the smallest of the body's longest side, the length over which the surface
//! A flat face (a plane, or a spline lying in one) has no size: any triangulation of its
//! boundary is exact, so it gets no interior cells and its straight edges no extra nodes.
//! A curved face's size is the smallest of the body's longest side, the length over which the surface
//! normal turns the caller's facet angle (when one is given, see [`Turn`]), and the chord that
//! sags the tolerance; an edge takes the smaller size of its two faces, so a curved face and
//! its flat neighbour meet at the same spacing.
@@ -107,11 +109,12 @@ pub(crate) fn extent(points: impl Iterator<Item = Vec3>) -> f64 {
/// so the size is never larger than the face needs).
pub(crate) fn face_size(s: &EvalSurface, dom: &Domain, rules: &Rules) -> (f64, [f64; 2]) {
if let EvalSurface::Plane { du, dv, .. } = s {
return (rules.max_len, [du.len(), dv.len()]);
return (f64::INFINITY, [du.len(), dv.len()]);
}
let (Some(ru), Some(rv)) = (dom.range[0], dom.range[1]) else { return (rules.max_len, [1.0, 1.0]) };
let flat = planar(s);
// A flat spline (sheet-metal walls, chamfers): no curvature to look for, only the speeds.
let samples = if planar(s) { 3 } else { SAMPLES };
let samples = if flat { 3 } else { SAMPLES };
let normal = |u: f64, v: f64| -> Option<(Vec3, Vec3, [f64; 2])> {
let (p, su, sv) = s.derivs(u, v)?;
Some((p, su.cross(sv).normalized()?, [su.len(), sv.len()]))
@@ -141,7 +144,9 @@ pub(crate) fn face_size(s: &EvalSurface, dom: &Domain, rules: &Rules) -> (f64, [
}
}
let speed = if count > 0 { [speed[0] / count as f64, speed[1] / count as f64] } else { [1.0, 1.0] };
(rules.for_curvature(k_max), speed.map(|x| if x.is_finite() && x > 0.0 { x } else { 1.0 }))
// A flat face is exact for any triangulation of its boundary: no length cap.
let size = if flat { f64::INFINITY } else { rules.for_curvature(k_max) };
(size, speed.map(|x| if x.is_finite() && x > 0.0 { x } else { 1.0 }))
}
/// A spline surface whose control points lie in one plane, with positive weights: the
@@ -211,7 +216,7 @@ mod tests {
assert!(!planar(&patch(0.5)));
let rules = Rules::new(0.01, 100.0, THIRTY);
let (h, speed) = face_size(&patch(0.0), &super::super::uv::domain(&patch(0.0)), &rules);
assert_eq!(h, rules.max_len);
assert_eq!(h, f64::INFINITY);
assert!((speed[0] - 4.0).abs() < 1e-9 && (speed[1] - 3.0).abs() < 1e-9, "{speed:?}");
// The twisted patch (a hyperbolic paraboloid) has curvature: a smaller side.
assert!(face_size(&patch(0.5), &super::super::uv::domain(&patch(0.5)), &rules).0 < rules.max_len);
@@ -232,9 +237,9 @@ mod tests {
}
#[test]
fn a_plane_takes_the_longest_side() {
fn a_plane_has_no_size_cap() {
let s = EvalSurface::Plane { origin: Vec3::ZERO, du: Vec3::X, dv: Vec3::Y };
let rules = Rules::new(0.01, 100.0, THIRTY);
assert_eq!(face_size(&s, &super::super::uv::domain(&s), &rules).0, 20.0);
assert_eq!(face_size(&s, &super::super::uv::domain(&s), &rules).0, f64::INFINITY);
}
}
+35 -21
View File
@@ -69,31 +69,45 @@ fn cut(a: &Body, b: &Body) -> Body {
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());
}
}
/// A flat face is exact for any triangulation of its boundary, so a box is two triangles a
/// face whatever its size (it was a grid capped at a fifth of the body's diagonal: 124
/// triangles for 20 x 10 x 5).
#[test]
fn a_box_is_two_triangles_a_face() {
for (name, size) in [("20 x 10 x 5", Vec3::new(20.0, 10.0, 5.0)), ("40 x 30 x 20", Vec3::new(40.0, 30.0, 20.0))] {
let b = ok(crate::box_solid(Vec3::ZERO, size));
let (m, s) = plan_body(&b, 0.05);
assert_eq!(s.planar, 6);
assert_watertight(&m, name);
assert_eq!(m.triangles.len(), 12, "{name}: {:?}", (0..6).map(|i| m.tri_face.iter().filter(|t| **t == i).count()).collect::<Vec<_>>());
assert!(max_chord(&b, &m) <= 1e-9, "{name}: flat faces are exact");
}
least
}
/// Flat faces beside curved ones stay joined: the straight edge between a plane and a fillet
/// takes the fillet's spacing, and the plane's boundary uses those nodes.
#[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));
fn flat_faces_beside_curved_ones_stay_watertight_and_within_the_chord() {
let tol = 0.01;
let bx = ok(crate::box_solid(Vec3::ZERO, Vec3::new(20.0, 10.0, 5.0)));
let rounded = ok(crate::fillet(&bx, &[Vec3::new(10.0, 0.0, 5.0), Vec3::new(20.0, 5.0, 5.0), Vec3::new(10.0, 10.0, 5.0), Vec3::new(0.0, 5.0, 5.0)], 1.5));
let pin = ok(crate::cylinder(Vec3::new(10.0, 5.0, -1.0), Vec3::Z, 2.0, 7.0));
let holed = cut(&bx, &pin);
for (name, body) in [("filleted box", &rounded), ("box with a hole", &holed)] {
let m = closed_display_mesh(name, body, tol);
let chord = max_chord(body, &m);
assert!(chord <= tol * 1.05, "{name}: chord {chord}");
// The flat faces carry no interior nodes: far fewer triangles than a grid at the
// curved neighbours' spacing would give.
let (flat, curved) = (0..body.face_count()).fold((0, 0), |(f, c), i| {
let n = m.tri_face.iter().filter(|t| **t as usize == i).count();
match crate::face_surface(body, i) {
Some(crate::FaceSurface::Plane { .. }) => (f + n, c),
_ => (f, c + n),
}
});
assert!(flat < curved, "{name}: {flat} flat vs {curved} curved triangles");
}
}
#[test]