Compare commits

...

3 Commits

Author SHA1 Message Date
Brennen Herbruck e241978a15 Tests: side-wall sag checks pick the wall by face, the dense-cluster test keeps its intent
The flat caps of a cylinder are their rim alone now, so a triangle with all corners on the
rim is no proof of a wall triangle: pick the wall's triangles by their face. The hole
placement test asserted the body had twice the face's triangles; it asserts the body has
other faces than the one picked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:36:25 -05:00
Brennen Herbruck 08a65b64ad Mesh counts of a box follow the flat-face mesher: engine export, viewport box and edges
The box exports as 12 triangles (was 124), draws as 12 glass triangles (was 104) and
its imported-mesh outline is 12 segments a box (was 20). Also cargo fmt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:36:25 -05:00
Brennen Herbruck 73441c51f8 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-08 04:36:25 -05:00
10 changed files with 90 additions and 56 deletions
+6 -1
View File
@@ -176,7 +176,12 @@ mod tests {
let sag = mesh
.triangles
.iter()
.map(|t| t.map(|i| mesh.positions[i as usize]))
.zip(&mesh.tri_face)
// The side wall's own triangles: the flat caps have corners on the rim too.
.filter(|(_, f)| {
matches!(solidcraft_kernel::face_surface(&mb.body, **f as usize), Some(solidcraft_kernel::FaceSurface::Cylinder { .. }))
})
.map(|(t, _)| t.map(|i| mesh.positions[i as usize]))
.filter(|t| t.iter().all(|p| (r(*p) - 20.0).abs() < 1e-9))
.map(|[a, b, c]| 20.0 - r((a + b + c) * (1.0 / 3.0)))
.fold(0.0, f64::max);
+3 -4
View File
@@ -1394,12 +1394,11 @@ mod tests {
assert!(bytes.starts_with(signature), "{cmd}");
}
// The triangles `file.export` writes (the 80-byte header holds the file's name), 84 + 50
// bytes each. The mesher lays each planar face out as a regular grid whose sides are at
// most a fifth of the body's diagonal (kernel test `a_box_is_a_regular_grid_on_every_face`):
// 22.9 / 5 = 4.58 mm, so 5 × 3 × 2 cells along 20 × 10 × 5, two triangles each.
// bytes each. A planar face is exact for any triangulation of its outline, so the mesher
// gives each of the box's six faces two triangles (kernel test `a_box_is_two_triangles_a_face`).
s.execute("file.export", &json!({"path": at("box.stl")})).unwrap();
assert_eq!(std::fs::read(at("StlBinary.txt")).unwrap()[80..], std::fs::read(at("box.stl")).unwrap()[80..]);
let triangles = 2 * 2 * (5 * 3 + 5 * 2 + 3 * 2);
let triangles = 6 * 2;
let stl = std::fs::read(at("box.stl")).unwrap();
assert_eq!(u32::from_le_bytes([stl[80], stl[81], stl[82], stl[83]]), u32::try_from(triangles).unwrap());
assert_eq!(stl.len(), 84 + 50 * triangles);
+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
+13 -7
View File
@@ -179,7 +179,10 @@ 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 +1030,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 +1049,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 +1056,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 +1081,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 +1114,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);
}
}
+36 -21
View File
@@ -69,31 +69,46 @@ 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]
+9 -1
View File
@@ -468,7 +468,15 @@ mod tests {
let sag = mesh
.triangles
.iter()
.map(|t| t.map(|i| mesh.positions[i as usize]))
.zip(&mesh.tri_face)
// The side wall's own triangles: the flat caps have corners on the rim too.
.filter(|(_, f)| {
matches!(
solidcraft_engine::kernel::face_surface(&b.body, **f as usize),
Some(solidcraft_engine::kernel::FaceSurface::Cylinder { .. })
)
})
.map(|(t, _)| t.map(|i| mesh.positions[i as usize]))
.filter(|t| t.iter().all(|p| (r(*p) - 10.0).abs() < 1e-9))
.map(|[a, b, c]| 10.0 - r((a + b + c) * (1.0 / 3.0)))
.fold(0.0, f64::max);
@@ -785,7 +785,8 @@ fn cylinder_with_cluster(sketch: Value, at: [f64; 2], view: &str) -> Cluster {
gap.x.max(gap.y).max(gap.z) <= 1e-3
};
let (body_tris, face_tris, near_tris) = (m.triangles.len(), face.len(), face.iter().filter(|t| near_points(t)).count());
assert!(body_tris >= 2 * face_tris, "the body has other faces: {body_tris} and {face_tris} triangles");
// Other faces exist (the flat caps are two triangles each now, so the curved side can be most of the body).
assert!(body_tris > face_tris, "the body has other faces: {body_tris} and {face_tris} triangles");
Cluster { h, many, click, body_tris, face_tris, near_tris }
}
+3 -4
View File
@@ -9179,10 +9179,9 @@ mod tests {
for style in 0..4 {
app.ui.visual_style = style;
let scene = build_scene(&app).merged();
// Twelve edges per placed box, in the saved mesh's pieces: no side longer than a fifth
// of the 30 mm diagonal, so the eight 20 mm edges in four and the four 10 mm ones in
// two, 40 segments.
let lines = if style == 1 { 0 } else { 2 * 40 * crate::gpu::LINE_SIZE };
// Twelve edges per placed box, each one segment (a flat face needs no nodes along its
// edges), 24 segments.
let lines = if style == 1 { 0 } else { 2 * 12 * crate::gpu::LINE_SIZE };
assert_eq!(scene.lines.len(), lines, "style {style}: twelve edges per placed box");
assert_eq!(scene.hidden.len(), if style == 3 { lines } else { 0 });
assert_eq!(scene.tris.is_empty(), style == 2, "wireframe draws only the edges");
@@ -243,9 +243,8 @@ fn an_opacity_class_change_keeps_vertices_and_rewrites_only_the_runs() {
assert!(block.bytes.opaque.is_empty() && !block.bytes.glass.is_empty(), "the triangles moved to the glass runs");
let triangles: u32 = block.bytes.glass.iter().map(|r| r.len() as u32).sum::<u32>() / 3;
assert_eq!(block.bytes.glass.len(), 1, "one body of one class is one run, drawn with no index buffer");
// The planner's grids, no side longer than a fifth of the box's 26.9 mm diagonal: edges in
// 4, 3 and 2 pieces, faces of 4 × 3, 4 × 2 and 3 × 2 cells, 2 · (12 + 8 + 6) · 2 triangles.
assert_eq!(triangles, 104, "every triangle of the box");
// Flat faces are their outline alone: two triangles a face.
assert_eq!(triangles, 12, "every triangle of the box");
let vertex = block.bytes.glass.first().map_or(0, |r| r.start) as usize;
let at = vertex * crate::gpu::TRI_SIZE;
let colour = first_vertex(&second, block.bytes.vertices.get(at..).unwrap_or_default()).1;