Compare commits

...

2 Commits

Author SHA1 Message Date
Brennen Herbruck 74aac4598e solidcore-tess: fmt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 21:51:09 -05:00
Brennen Herbruck ec4b968763 solidcore-tess: a flat face asks for no size, so a box meshes as 12 triangles
A plane, or a spline lying in one, is exact for any triangulation of its boundary. The planner
capped every face, flat ones included, at a fifth of the body's extent, so a box got interior
cells and extra edge nodes. A flat face now has 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 share nodes,
and a plane is its boundary nodes alone. Curved faces keep the cap and aspect limits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 21:51:02 -05:00
3 changed files with 49 additions and 38 deletions
+22 -5
View File
@@ -474,12 +474,19 @@ fn plan_at(b: &brep::Brep, tol: f64, turn: Option<size::Turn>) -> Result<(TessPl
.map(|(fi, r)| refused.take(fi, r).flatten())
.collect();
let face_h = |f: usize| sizes.get(f).and_then(Option::as_ref).map(|s| s.h);
// 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. An edge that
// bounds no face keeps the longest side.
let edge_h: Vec<f64> = edge_faces
.iter()
.map(|fs| {
fs.iter()
.filter_map(|&f| face_h(f))
.fold(rules.max_len, f64::min)
if fs.is_empty() {
rules.max_len
} else {
fs.iter()
.filter_map(|&f| face_h(f))
.fold(f64::INFINITY, f64::min)
}
})
.collect();
let jobs: Vec<(usize, f64)> = edge_h.into_iter().enumerate().collect();
@@ -2124,8 +2131,15 @@ fn plan_face(
// refined where triangles leave the surface, are too long or bend too much, then shaped by
// Ruppert refinement.
let step = [h / speed[0], h / speed[1]];
// A flat face with no size asked of it is its boundary nodes alone: no interior seeds, no
// refinement.
let bare = matches!(s, EvalSurface::Plane { .. }) && h.is_infinite();
let delaunay = || -> std::result::Result<_, FaceError> {
let seeds = cdt::lattice(bbox, step, &polys, speed);
let seeds = if bare {
Vec::new()
} else {
cdt::lattice(bbox, step, &polys, speed)
};
let eval = |u: f64, v: f64| sag_eval(s, u, v);
let sag = cdt::Sag {
eval: &eval,
@@ -2136,7 +2150,7 @@ fn plan_face(
};
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,
};
@@ -2150,6 +2164,9 @@ fn plan_face(
// The lenient fill is the constrained Delaunay alone: the caller names the face in the stats.
let (extra, triangles) = if lenient == Lenient::Yes {
delaunay()?
} else if bare {
stats.planar += 1;
delaunay()?
} else {
// A point of the box outside the face's loops is not the face's (as for the size
// samples above); one they cannot tell about, past MAX_PERIODS, refuses the face.
+15 -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.
@@ -141,7 +143,7 @@ pub(crate) fn face_size(s: &EvalSurface, dom: &Domain, rules: &Rules) -> FaceSiz
};
if let EvalSurface::Plane { du, dv, .. } = s {
return plain(
rules.max_len,
f64::INFINITY,
[
kernel::record_vec3(*du).len(),
kernel::record_vec3(*dv).len(),
@@ -153,7 +155,8 @@ pub(crate) fn face_size(s: &EvalSurface, dom: &Domain, rules: &Rules) -> FaceSiz
};
let mut unevaluated: Vec<[f64; 2]> = Vec::new();
// A flat spline (sheet-metal walls, chamfers): no curvature to look for, only the speeds.
let samples = if planar(s) { 3 } else { SAMPLES };
let flat = planar(s);
let samples = if flat { 3 } else { SAMPLES };
// The point, the unit normal (`None` at a pole, where it is undefined) and the speeds.
let normal = |u: f64, v: f64| -> Result<(Vec3, Option<Vec3>, [f64; 2]), [f64; 2]> {
let (p, su, sv) = s.derivs(u, v).map(kernel::record_triple).ok_or([u, v])?;
@@ -203,7 +206,12 @@ pub(crate) fn face_size(s: &EvalSurface, dom: &Domain, rules: &Rules) -> FaceSiz
[1.0, 1.0]
};
FaceSize {
h: rules.for_curvature(k_max),
// A flat face is exact for any triangulation of its boundary: no length cap.
h: if flat {
f64::INFINITY
} else {
rules.for_curvature(k_max)
},
speed: speed.map(|x| if x.is_finite() && x > 0.0 { x } else { 1.0 }),
unevaluated,
}
@@ -348,7 +356,7 @@ mod tests {
&solidcore::eval::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:?}"
@@ -387,7 +395,7 @@ mod tests {
}
#[test]
fn a_plane_takes_the_longest_side() {
fn a_plane_asks_for_no_side() {
let s = EvalSurface::Plane {
origin: solidcore::Vec3::ZERO,
du: solidcore::Vec3::X,
@@ -396,7 +404,7 @@ mod tests {
let rules = Rules::new(0.01, 100.0, THIRTY);
assert_eq!(
face_size(&s, &solidcore::eval::uv::domain(&s), &rules).h,
20.0
f64::INFINITY
);
}
}
+12 -26
View File
@@ -155,31 +155,17 @@ fn ok<T>(r: Result<T>) -> T {
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
}
/// A plane is exact for any triangulation of its boundary: it asks for no size, a box's edges
/// are whole, and each face is its four corners, two triangles. Whatever the box's size.
#[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 = fixture("box_40x30x20");
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 a_box_is_twelve_triangles_whatever_its_size() {
for name in ["box_10x6x4", "box_40x30x20"] {
let b = fixture(name);
let (m, s) = plan_body(&b, 0.05);
assert_eq!(s.planar, 6, "{name}");
assert_watertight(&m, name);
assert_eq!(m.triangles.len(), 12, "{name}");
}
}
#[test]
@@ -2155,8 +2141,8 @@ fn the_spline_coil_keeps_its_triangles() {
let name = "smlib_coil_lower_half";
let (b, size) = (fixture(name), size_of(name));
for (t, want) in [
(MeshTolerance::display_of(size), 30_776),
(MeshTolerance::export_of(size), 30_808),
(MeshTolerance::display_of(size), 30_630),
(MeshTolerance::export_of(size), 30_662),
] {
let (p, _) = plan(&b, &t, size).unwrap();
let (m, _) = p.evaluate();