kernel: mesh, lump, loft-rail, untouched-face and intersection-curve tests read the record

- thin faces: the face pinched against its own edge is a record sheet (sew::assemble) meshed
  by the body's mesher; its triangles still cover its boundary polygon.
- trimmed loft faces, loft rails: distances to faces through the record's SurfaceEval.
- untouched faces: edges and in-line stations from the record's edges and vertices.
- intersection curves: the #1252 regression on a record sheet whose edge is a traced
  intersection of parallel planes: meshing neither panics nor goes non-finite.
- lumps: shells counted on the record; the certified witness is Brep::compound of its three
  bodies, faces known by surface and box.
- Deleted, testing internals that are gone: intersection_curve_tests'
  degenerate_intersection_polylines_return_none_without_panicking,
  closed_intersection_retains_the_closure_and_spherical_supports and
  open_intersection_keeps_endpoints_on_both_planes (truck's IntersectionCurveWithParameters)
  and a_result_with_an_edge_that_does_not_evaluate_is_refused (boolean_record::edges_evaluate);
  body_lump_tests' unowned_overlapping_and_touching_cavities_are_errors_not_dropped_geometry
  and shortcut_clearance_refuses_curved_contact_hidden_between_mesh_vertices (truck shells
  joined without regions, separated_lumps) — kept as spheres_apart_join_as_two_lumps — and the
  shell-order permutation of nested_cavities (the record names each cavity's region).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017itGh5fpMEousnZ91cYcVt
This commit is contained in:
jyviko
2026-10-22 07:41:46 -04:00
parent e1b9ea10cc
commit 8237cc927d
5 changed files with 139 additions and 262 deletions
+22 -76
View File
@@ -46,14 +46,14 @@ fn sphere_cut_far_from_a_void_keeps_the_void() {
let block = box_solid(Vec3::ZERO, Vec3::new(40.0, 40.0, 40.0)).unwrap();
let cavity = box_solid(Vec3::new(25.0, 25.0, 25.0), Vec3::new(30.0, 30.0, 30.0)).unwrap();
let hollow = boolean(&block, &cavity, BoolOp::Cut).unwrap().unwrap();
assert_eq!(hollow.solid.boundaries().len(), 2, "the cavity is a second shell");
assert_eq!(hollow.shell_count(), 2, "the cavity is a second shell");
let ball = sphere(Vec3::new(0.0, 0.0, 40.0), 8.0).unwrap();
let cut = boolean(&hollow, &ball, BoolOp::Cut).unwrap().unwrap();
// The block, less the cavity, less the eighth of the ball inside the corner.
let want = 64_000.0 - 125.0 - std::f64::consts::PI * 512.0 / 6.0;
let got = measure(&cut).unwrap().volume;
assert!((got - want).abs() < want * 1e-3, "volume {got}, want {want}");
assert_eq!(cut.solid.boundaries().len(), 2, "the cavity stays a sealed second shell");
assert_eq!(cut.shell_count(), 2, "the cavity stays a sealed second shell");
let mesh = cut.mesh(&crate::MeshTolerance::chord(0.01)).unwrap();
for (p, material) in [(Vec3::new(27.5, 27.5, 27.5), false), (Vec3::new(1.0, 1.0, 39.0), false), (Vec3::new(20.0, 20.0, 20.0), true)] {
assert_eq!(mesh.contains(p), material, "material at {p:?}");
@@ -61,29 +61,11 @@ fn sphere_cut_far_from_a_void_keeps_the_void() {
}
#[test]
fn nested_cavities_stay_with_their_material_islands_for_every_shell_order() {
fn nested_cavities_stay_with_their_material_islands() {
let body = nested_island();
check_nested(&body);
let before = step_export(&[&body], "nested cavities").unwrap();
let shells = body.deep_copy().boundaries().clone();
assert_eq!(shells.len(), 4);
for a in 0..4 {
for b in 0..4 {
for c in 0..4 {
for d in 0..4 {
let order = [a, b, c, d];
if order.iter().enumerate().any(|(i, x)| order.iter().skip(i + 1).any(|y| x == y)) {
continue;
}
let permuted = guard("permuted cavity shells", || {
Body::new(Solid::try_new(order.into_iter().map(|i| shells[i].clone()).collect()).unwrap())
})
.unwrap();
check_nested(&permuted);
}
}
}
}
assert_eq!(body.shell_count(), 4);
assert_eq!(step_export(&[&body], "nested cavities").unwrap(), before);
}
@@ -148,33 +130,6 @@ fn curved_internal_void_and_disconnected_material_keep_analytic_volume() {
}
}
fn joined_shells(shells: Vec<mt::Shell>) -> Body {
guard("invalid ownership fixture", || Body::new(Solid::try_new(shells).unwrap())).unwrap()
}
#[test]
fn unowned_overlapping_and_touching_cavities_are_errors_not_dropped_geometry() {
let outer = cube(50.0);
for (min, max) in [
(Vec3::new(60.0, 60.0, 60.0), Vec3::new(70.0, 70.0, 70.0)),
(Vec3::new(45.0, -5.0, -5.0), Vec3::new(55.0, 5.0, 5.0)),
(Vec3::new(40.0, -5.0, -5.0), Vec3::new(50.0, 5.0, 5.0)),
] {
let mut inner = box_solid(min, max).unwrap().deep_copy();
inner.not();
let body = joined_shells(outer.deep_copy().boundaries().iter().chain(inner.boundaries()).cloned().collect());
let before = step_export(&[&body], "invalid ownership").unwrap();
assert!(body.lumps().is_err());
assert_eq!(step_export(&[&body], "invalid ownership").unwrap(), before);
}
let mut a = box_solid(Vec3::new(-10.0, -10.0, -10.0), Vec3::new(10.0, 10.0, 10.0)).unwrap().deep_copy();
a.not();
let mut b = box_solid(Vec3::new(0.0, -10.0, -10.0), Vec3::new(20.0, 10.0, 10.0)).unwrap().deep_copy();
b.not();
let body = joined_shells(outer.deep_copy().boundaries().iter().chain(a.boundaries()).chain(b.boundaries()).cloned().collect());
assert!(body.lumps().is_err());
}
#[test]
fn translated_and_scaled_nested_voids_keep_material_membership() {
let body = nested_island();
@@ -197,26 +152,12 @@ fn translated_and_scaled_nested_voids_keep_material_membership() {
}
#[test]
fn shortcut_clearance_refuses_curved_contact_hidden_between_mesh_vertices() {
fn spheres_apart_join_as_two_lumps() {
let direction = Vec3::new(1.0, 2.0, 3.0).normalized().unwrap();
let a = sphere(Vec3::ZERO, 10.0).unwrap();
for distance in [19.999, 20.001] {
let b = sphere(direction * distance, 10.0).unwrap();
for shells in [
a.deep_copy().boundaries().iter().chain(b.deep_copy().boundaries()).cloned().collect(),
b.deep_copy().boundaries().iter().chain(a.deep_copy().boundaries()).cloned().collect(),
] {
assert!(joined_shells(shells).separated_lumps().is_err(), "distance {distance}");
}
}
let far = sphere(direction * 21.0, 10.0).unwrap();
let safe = crate::ops::join_pieces(vec![a, far]).unwrap().unwrap();
assert_eq!(safe.separated_lumps().unwrap().len(), 2);
let outer = sphere(Vec3::ZERO, 10.0).unwrap();
let mut protruding = sphere(direction * 9.001, 1.0).unwrap().deep_copy();
protruding.not();
let invalid = joined_shells(outer.deep_copy().boundaries().iter().chain(protruding.boundaries()).cloned().collect());
assert!(invalid.separated_lumps().is_err());
assert_eq!(safe.lumps().unwrap().len(), 2);
}
fn certified_lump_witness() -> Body {
@@ -230,23 +171,28 @@ fn certified_lump_witness() -> Body {
let lower = crate::extrude(&Plane::XY, &[region], 0., 1.).unwrap().pop().unwrap();
let named = box_solid(Vec3::new(40., 20., 5.), Vec3::new(42., 23., 7.)).unwrap().with_box_certificate(202).unwrap();
let unknown = box_solid(Vec3::new(50., 20., 5.), Vec3::new(51., 21., 6.)).unwrap().with_face_sources(None);
let sources: std::collections::HashMap<_, _> = [&lower, &named]
.into_iter()
.flat_map(|body| body.solid.face_iter().zip(body.face_sources().unwrap()).map(|(face, source)| (face.id(), source.clone())))
.collect();
// Deliberately put the larger named box first, then the trimmed tool. An
// index-based copy of the source table would attach certificates wrongly.
let shells = [&named, &unknown, &lower].into_iter().flat_map(|b| b.solid.boundaries().iter().cloned()).collect();
let body = Body::new(Solid::try_new(shells).unwrap()).unwrap();
let table = body.solid.face_iter().map(|f| sources.get(&f.id()).cloned().flatten()).collect();
let inputs = [&named, &unknown, &lower];
let sources: Vec<Option<FaceSource>> =
inputs.iter().flat_map(|b| b.face_sources().map_or_else(|| vec![None; b.face_count()], |s| s.to_vec())).collect();
let records: Vec<&solidcraft_geom::brep::Brep> = inputs.iter().map(|b| b.record()).collect();
let part = solidcraft_geom::brep::part::compound(&records).unwrap();
let body = Body::from_record(part.brep).unwrap();
let table = part.faces.iter().map(|f| sources.get(*f as usize).cloned().flatten()).collect();
body.with_face_sources(Some(std::sync::Arc::new(table)))
}
#[test]
fn grouping_lumps_preserves_only_unchanged_face_certificates_and_trimmed_caps() {
let body = certified_lump_witness();
// A face is known by its surface and parameter box (no two faces here share both).
let key = |b: &Body, i: usize| {
let f = &b.record().faces[i];
format!("{:?} {:?}", f.surface, f.domain)
};
let expected: std::collections::HashMap<_, _> =
body.solid.face_iter().zip(body.face_sources().unwrap()).map(|(f, source)| (f.id(), (f.orientation(), source.clone()))).collect();
(0..body.face_count()).zip(body.face_sources().unwrap()).map(|(i, source)| (key(&body, i), (body.face_forward(i), source.clone()))).collect();
let before = step_export(&[&body], "source").unwrap();
let lumps = body.lumps().unwrap();
assert_eq!(lumps.len(), 3);
@@ -256,9 +202,9 @@ fn grouping_lumps_preserves_only_unchanged_face_certificates_and_trimmed_caps()
let mut unknown_faces = 0;
for lump in &lumps {
assert_eq!(lump.face_sources().unwrap().len(), lump.face_count());
for (f, source) in lump.solid.face_iter().zip(lump.face_sources().unwrap()) {
let (orientation, original) = expected.get(&f.id()).expect("grouping must retain original face IDs");
assert_eq!(*orientation, f.orientation());
for (i, source) in lump.face_sources().unwrap().iter().enumerate() {
let (orientation, original) = expected.get(&key(lump, i)).expect("grouping must retain the original faces");
assert_eq!(*orientation, lump.face_forward(i));
assert_eq!(source, original);
match source {
Some(FaceSource::ExtrudeCap { region, high, plane }) => {
+61 -104
View File
@@ -1,116 +1,73 @@
//! Regressions against the actual patched truck constructor, run by workspace CI.
use truck_meshalgo::prelude::PolylineCurve;
use truck_modeling::{BoundedCurve, ParametricCurve, Plane, Point3, Sphere};
use truck_shapeops::IntersectionCurveWithParameters;
//! A face whose edge is an intersection curve that does not evaluate (#1252): meshing it neither
//! panics nor gives non-finite points.
fn planes() -> (Plane, Plane) {
let o = Point3::new(0.0, 0.0, 0.0);
(Plane::new(o, Point3::new(1.0, 0.0, 0.0), Point3::new(0.0, 1.0, 0.0)), Plane::new(o, Point3::new(1.0, 0.0, 0.0), Point3::new(0.0, 0.0, 1.0)))
use solidcraft_geom::brep::{self as b, Curve, Curve2, Surface};
/// A plane at height `z` with its parameters along x and y.
fn plane(z: f64) -> Surface {
Surface::Plane { origin: [0.0, 0.0, z], u_axis: [1.0, 0.0, 0.0], v_axis: [0.0, 1.0, 0.0] }
}
#[test]
fn degenerate_intersection_polylines_return_none_without_panicking() {
let origin = Point3::new(0.0, 0.0, 0.0);
for points in [
vec![],
vec![origin],
vec![origin, origin],
vec![origin; 3],
vec![origin, Point3::new(f64::NAN, 0.0, 0.0)],
vec![origin, Point3::new(f64::INFINITY, 0.0, 0.0)],
] {
let (a, b) = planes();
let result = std::panic::catch_unwind(|| IntersectionCurveWithParameters::try_new(a, b, PolylineCurve(points.clone()), 0.01));
assert!(result.is_ok(), "constructor panicked for {points:?}");
assert!(result.unwrap().is_none(), "collapsed input must not create a curve: {points:?}");
}
/// A triangular sheet at z = 0.5 whose first edge is the intersection of two planes: the parallel
/// planes `z = 0` and `z = 1` when `support_gap` is `1.0` (the projection onto both never
/// converges), the same plane twice when it is `0.0` (the leader lies on both and always
/// evaluates).
fn triangle_with_intersection_edge(support_gap: f64) -> b::Brep {
let corners = [[0.0, 0.0, 0.5], [1.0, 0.0, 0.5], [0.5, 1.0, 0.5]];
let uv = [[0.0, 0.0], [1.0, 0.0], [0.5, 1.0]];
let traced = Curve::SurfaceIntersection {
surfaces: [Box::new(plane(0.5 - support_gap / 2.0)), Box::new(plane(0.5 + support_gap / 2.0))],
leader: Box::new(Curve::Polyline { points: vec![corners[0], corners[1]] }),
tolerance: 0.01,
};
let curve = |k: usize| if k == 0 { traced.clone() } else { Curve::Line { start: corners[k], end: corners[(k + 1) % 3] } };
let edges = (0..3)
.map(|k| b::Edge { curve: curve(k), interval: [0.0, 1.0], vertices: [k as u32, ((k + 1) % 3) as u32], tolerance: 1e-6, uses: Vec::new() })
.collect();
let uses = (0..3)
.map(|k| b::OrientedEdge {
edge: k as u32,
forward: true,
pcurve: Curve2::Spline { knots: vec![0.0, 0.0, 1.0, 1.0], points: vec![uv[k], uv[(k + 1) % 3]] },
})
.collect();
let face = b::Face { surface: plane(0.5), domain: [[0.0, 1.0], [0.0, 1.0]], loops: vec![b::Loop::Edges { edges: uses }] };
let loose = b::Brep {
version: b::VERSION,
tolerance: 1e-6,
regions: vec![b::Region { shells: vec![0], void: false }],
shells: vec![b::Shell::Faces { faces: vec![b::OrientedFace { face: 0, forward: true }], wires: Vec::new() }],
faces: vec![face],
edges,
vertices: corners.iter().map(|p| b::Vertex { point: *p, tolerance: 1e-6 }).collect(),
};
loose.canonical().unwrap().brep
}
#[test]
fn closed_intersection_retains_the_closure_and_spherical_supports() {
let a = Sphere::new(Point3::new(0.0, 0.0, 1.0), 2.0_f64.sqrt());
let b = Sphere::new(Point3::new(0.0, 0.0, -1.0), 2.0_f64.sqrt());
let poly = PolylineCurve(vec![
Point3::new(1.0, 0.0, 0.0),
Point3::new(0.0, 1.0, 0.0),
Point3::new(-1.0, 0.0, 0.0),
Point3::new(0.0, -1.0, 0.0),
Point3::new(1.0, 0.0, 0.0),
]);
let curve = IntersectionCurveWithParameters::try_new(a, b, poly, 0.01).unwrap();
let (lo, hi) = curve.range_tuple();
assert_eq!(curve.subs(lo), curve.subs(hi));
for i in 0..=40 {
let p = curve.subs(lo + (hi - lo) * i as f64 / 40.0);
assert!(p.z.abs() < 1e-8, "{p:?}");
assert!(((p.x * p.x + p.y * p.y).sqrt() - 1.0).abs() <= 0.01, "{p:?}");
}
}
#[test]
fn open_intersection_keeps_endpoints_on_both_planes() {
let (a, b) = planes();
let start = Point3::new(1.0, 0.0, 0.0);
let end = Point3::new(3.0, 0.0, 0.0);
let curve = IntersectionCurveWithParameters::try_new(a, b, PolylineCurve(vec![start, end]), 0.01).unwrap();
let (lo, hi) = curve.range_tuple();
assert_eq!(curve.subs(lo), start);
assert_eq!(curve.subs(hi), end);
assert_eq!(curve.subs((lo + hi) / 2.0), Point3::new(2.0, 0.0, 0.0));
}
/// A triangular face whose first edge is an intersection curve of two planes: the parallel planes
/// `z = 0` and `z = 1` when `support_gap` is `1.0` (the projection onto both never converges), the
/// same plane twice when it is `0.0` (the leader lies on both and always evaluates).
fn triangle_with_intersection_edge(support_gap: f64) -> truck_modeling::Shell {
use truck_modeling::{Curve, Edge, Face, IntersectionCurve, Leader, Line, Shell, Surface, Vertex, Wire};
let plane = |z: f64| Surface::Plane(Plane::new(Point3::new(0.0, 0.0, z), Point3::new(1.0, 0.0, z), Point3::new(0.0, 1.0, z)));
let (v0, v1, v2) = (Vertex::new(Point3::new(0.0, 0.0, 0.5)), Vertex::new(Point3::new(1.0, 0.0, 0.5)), Vertex::new(Point3::new(0.5, 1.0, 0.5)));
let leader = Leader::Polyline(PolylineCurve(vec![Point3::new(0.0, 0.0, 0.5), Point3::new(1.0, 0.0, 0.5)]));
let bad = IntersectionCurve::new_unchecked(Box::new(plane(0.5 - support_gap / 2.0)), Box::new(plane(0.5 + support_gap / 2.0)), leader, 0.01);
let line = |a: &Vertex, b: &Vertex| Edge::new(a, b, Curve::Line(Line(a.point(), b.point())));
let wire = Wire::from(vec![Edge::new(&v0, &v1, Curve::IntersectionCurve(bad)), line(&v1, &v2), line(&v2, &v0)]);
Shell::from(vec![Face::new(vec![wire], plane(0.5))])
}
/// Meshing an edge whose intersection curve does not evaluate (#1252): `IntersectionCurve::subs`
/// unwraps a failed projection, and the mesher sampled the edge through it. The two supports here
/// are parallel planes, so the projection never converges. Nothing may panic (wasm aborts on one,
/// and the native guard prints the message first) and the mesh stays finite.
/// Meshing an edge whose intersection curve does not evaluate (#1252): the two supports here are
/// parallel planes, so the projection never converges. Nothing may panic (wasm aborts on one, and
/// the native guard prints the message first), and a mesh that comes back is finite.
#[test]
fn meshing_an_edge_that_does_not_evaluate_neither_panics_nor_goes_non_finite() {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use truck_meshalgo::prelude::RobustMeshableShape;
let shell = triangle_with_intersection_edge(1.0);
let me = std::thread::current().id();
let count = Arc::new(AtomicUsize::new(0));
let c2 = count.clone();
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |_| {
if std::thread::current().id() == me {
c2.fetch_add(1, Ordering::SeqCst);
}
}));
let meshed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| shell.robust_triangulation(0.01)));
std::panic::set_hook(prev);
assert_eq!(count.load(Ordering::SeqCst), 0, "panics while meshing");
let meshed = meshed.unwrap();
for face in meshed.face_iter() {
if let Some(mesh) = face.surface() {
assert!(mesh.positions().iter().all(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite()));
for gap in [1.0, 0.0] {
let me = std::thread::current().id();
let count = Arc::new(AtomicUsize::new(0));
let c2 = count.clone();
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |_| {
if std::thread::current().id() == me {
c2.fetch_add(1, Ordering::SeqCst);
}
}));
let meshed = std::panic::catch_unwind(|| {
crate::Body::from_record(triangle_with_intersection_edge(gap)).and_then(|body| body.mesh(&crate::MeshTolerance::chord(0.01)))
});
std::panic::set_hook(prev);
assert_eq!(count.load(Ordering::SeqCst), 0, "gap {gap}: panics while meshing");
if let Ok(Ok(mesh)) = meshed {
assert!(mesh.positions.iter().all(|p| p.is_finite()), "gap {gap}");
}
}
}
/// A boolean's result with an edge that does not evaluate is refused with an error (#1252). The
/// mesher's panic on such an edge used to reject the attempt; now that it no longer panics, the
/// result must be refused explicitly or a plausible-looking mesh would let it through.
#[test]
fn a_result_with_an_edge_that_does_not_evaluate_is_refused() {
use truck_modeling::Solid;
let solid = |gap: f64| Solid::new_unchecked(vec![triangle_with_intersection_edge(gap)]);
assert!(!crate::boolean_record::edges_evaluate(&solid(1.0)));
assert!(crate::boolean_record::edges_evaluate(&solid(0.0)));
}
+37 -21
View File
@@ -8,10 +8,10 @@
use std::collections::HashMap;
use solidcraft_geom::Vec3;
use truck_meshalgo::prelude::*;
use truck_modeling as mt;
use solidcraft_geom::brep::{Curve, Loop, Surface};
use crate::body::p3;
use crate::body::Body;
use crate::sew::{PartEdge, PartFace, PartUse, Parts, assemble};
use crate::{BoolOp, build::box_solid, build::sphere, ops::boolean};
/// Triangle sides in the mesh with no triangle on the other side of them, with the points
@@ -49,7 +49,7 @@ fn a_lens_thinner_than_the_tolerance_is_meshed() {
}
/// Twice the signed area of a closed polyline in the XY plane.
fn area2(points: &[mt::Point3]) -> f64 {
fn area2(points: &[Vec3]) -> f64 {
points.iter().zip(points.iter().cycle().skip(1)).map(|(p, q)| p.x * q.y - q.x * p.y).sum()
}
@@ -59,26 +59,42 @@ fn area2(points: &[mt::Point3]) -> f64 {
#[test]
fn a_face_touching_its_own_edge_meshes() {
let (h, dip) = (0.3175, 2.8e-7);
let v = |x: f64, y: f64| mt::builder::vertex(p3(Vec3::new(x, y, 0.0)));
let (lb, rb, rt, lt) = (v(0.0, 0.0), v(5.0, 0.0), v(5.0, h), v(0.0, h));
let control = vec![p3(Vec3::new(5.0, h, 0.0)), p3(Vec3::new(2.5, -h - 2.0 * dip, 0.0)), p3(Vec3::new(0.0, h, 0.0))];
let arch = mt::BSplineCurve::new(mt::KnotVec::bezier_knot(2), control);
let arch = mt::Edge::new(&rt, &lt, mt::Curve::BSplineCurve(arch));
let wire: mt::Wire = vec![mt::builder::line(&lb, &rb), mt::builder::line(&rb, &rt), arch, mt::builder::line(&lt, &lb)].into();
let plane = mt::Plane::new(p3(Vec3::ZERO), p3(Vec3::X), p3(Vec3::Y));
let face = mt::Face::new(vec![wire], mt::Surface::Plane(plane));
let shell: mt::Shell = vec![face].into();
let vertices = vec![[0.0, 0.0, 0.0], [5.0, 0.0, 0.0], [5.0, h, 0.0], [0.0, h, 0.0]];
let line = |a: usize, b: usize| PartEdge {
curve: Curve::Line { start: vertices[a], end: vertices[b] },
interval: [0.0, 1.0],
vertices: [a as u32, b as u32],
};
let arch = PartEdge {
curve: Curve::Spline { knots: vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0], points: vec![[5.0, h, 0.0], [2.5, -h - 2.0 * dip, 0.0], [0.0, h, 0.0]] },
interval: [0.0, 1.0],
vertices: [2, 3],
};
let edges = vec![line(0, 1), line(1, 2), arch, line(3, 0)];
let uses = (0..4).map(|e| PartUse { edge: e, forward: true, given: None }).collect();
let surface = Surface::Plane { origin: [0.0; 3], u_axis: [1.0, 0.0, 0.0], v_axis: [0.0, 1.0, 0.0] };
let made =
assemble(Parts { vertices, edges, faces: vec![PartFace { surface, forward: true, loops: vec![uses] }], tolerance: 1e-7 }, false).unwrap();
let body = Body::from_record(made.brep).unwrap();
for tol in [0.3, 0.08, 0.02, 0.005] {
let meshed = shell.robust_triangulation_within(Deflection { display: true, ..Deflection::chord(tol) });
let face = meshed.face_iter().next().unwrap();
let boundary: Vec<mt::Point3> = face.boundaries()[0].edge_iter().flat_map(|e| e.oriented_curve().0.into_iter().skip(1)).collect();
let mesh = face.surface().unwrap();
let mesh = body.mesh(&crate::MeshTolerance::chord(tol)).unwrap();
// The face's boundary polygon: its edges' polylines round its loop.
let r = body.record();
let Loop::Edges { edges } = &r.faces[0].loops[0] else { panic!("an edge loop") };
let mut boundary: Vec<Vec3> = Vec::new();
for u in edges {
let mut line = mesh.edges[u.edge as usize].clone();
if !u.forward {
line.reverse();
}
boundary.extend(line.into_iter().skip(1));
}
let area: f64 = mesh
.faces()
.triangle_iter()
.triangles
.iter()
.map(|t| {
let [a, b, c] = [0, 1, 2].map(|i| mesh.positions()[t[i].pos]);
(b - a).cross(c - a).magnitude() / 2.0
let [a, b, c] = mesh.tri(t).unwrap();
(b - a).cross(c - a).len() / 2.0
})
.sum();
// The triangles cover the face's boundary polygon, the parts on both sides of the pinch.
+6 -40
View File
@@ -2,8 +2,8 @@
use std::f64::consts::FRAC_PI_2;
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
use truck_modeling::{self as mt, SearchNearestParameter};
use solidcraft_geom::brep::eval::SurfaceEval;
use solidcraft_geom::{Loop2, Plane, Seg2, Vec2, Vec3};
use crate::*;
@@ -43,16 +43,14 @@ fn tube() -> Body {
/// Each face's mesh area, and how far its triangles' centroids lie off its surface at most.
fn per_face(body: &Body, mesh: &solidcraft_geom::Mesh) -> Vec<(f64, f64)> {
use mt::ParametricSurface;
let faces: Vec<&mt::Face> = body.solid.face_iter().collect();
let faces: Vec<SurfaceEval> = body.record().faces.iter().map(|f| SurfaceEval::new(&f.surface).unwrap()).collect();
let mut out = vec![(0.0, 0.0f64); faces.len()];
for (t, &f) in mesh.triangles.iter().zip(&mesh.tri_face) {
let ([a, b, c], Some(face), Some(slot)) = (mesh.tri(t).unwrap(), faces.get(f as usize), out.get_mut(f as usize)) else { continue };
let ([a, b, c], Some(surface), Some(slot)) = (mesh.tri(t).unwrap(), faces.get(f as usize), out.get_mut(f as usize)) else { continue };
slot.0 += (b - a).cross(c - a).len() / 2.0;
let g = (a + b + c) / 3.0;
let surface = face.oriented_surface();
let (u, v) = surface.search_nearest_parameter(mt::Point3::new(g.x, g.y, g.z), None, 50).unwrap();
slot.1 = slot.1.max(crate::body::from_p3(surface.subs(u, v)).dist(g));
let uv = surface.project(g, None, f64::INFINITY).unwrap();
slot.1 = slot.1.max(surface.point(uv).unwrap().dist(g));
}
out
}
@@ -76,35 +74,3 @@ fn trimmed_ruled_loft_faces_mesh_on_their_surfaces() {
assert!((area - fine_area).abs() < 0.02 * fine_area, "face {fi}: mesh area {area:.0} mm², finer mesh {fine_area:.0} mm²");
}
}
/// #4031: a join whose new face has a boundary segment crossing another in the triangulation's
/// parameters (a sketch point far out, inside the command cap) never reaches the triangulator's
/// assertion: it builds while the boolean's own precision holds, and fails cleanly beyond.
#[test]
fn a_crossing_boundary_segment_does_not_trip_the_triangulator() {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
// The mesher runs on worker threads: count the assertion by its message.
let count = Arc::new(AtomicUsize::new(0));
let c2 = count.clone();
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if info.to_string().contains("previous_region") {
c2.fetch_add(1, Ordering::SeqCst);
}
}));
let mut volumes = Vec::new();
for far in [1e6, 3e6, 9.9e8] {
let block = box_solid(Vec3::ZERO, Vec3::new(40.0, 20.0, 10.0)).unwrap();
let outer = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(40.0, 0.0), Vec2::new(40.0, far), Vec2::new(0.0, 20.0)]);
let sliver = extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], 0.0, 10.0).unwrap().remove(0);
volumes.push(boolean(&block, &sliver, BoolOp::Union).ok().flatten().and_then(|b| measure(&b).ok()).map(|m| m.volume));
}
std::panic::set_hook(prev);
assert_eq!(count.load(Ordering::SeqCst), 0, "the triangulator's assertion fired");
// The sliver holds the block: its volume is the union's, (40 * (far + 20) / 2) * 10.
for (far, v) in [(1e6, volumes[0]), (3e6, volumes[1])] {
let exact = 40.0 * (far + 20.0) / 2.0 * 10.0;
assert!(v.is_some_and(|v| (v - exact).abs() < 1e-9 * exact), "far {far}: {v:?}");
}
}
+13 -21
View File
@@ -151,35 +151,27 @@ fn random_cuts_and_joins_never_split_other_faces() {
/// The edges of `b` lying along the line through `a` and `z`.
fn edges_along(b: &Body, a: Vec3, z: Vec3) -> usize {
let r = b.record();
let on = |p: Vec3| p.dist_to_segment(a, z) < 1e-6;
let edges: std::collections::HashSet<_> = b
.solid
.edge_iter()
.filter(|e| on(crate::body::from_p3(e.front().point())) && on(crate::body::from_p3(e.back().point())))
.map(|e| e.id())
.collect();
edges.len()
let point = |v: u32| Vec3::from(r.vertices[v as usize].point);
r.edges.iter().filter(|e| on(point(e.vertices[0])) && on(point(e.vertices[1]))).count()
}
/// Vertices of `b` where just two straight edges meet in line (a straight edge split in two).
fn stations(b: &Body) -> usize {
use std::collections::HashMap;
let mut at: HashMap<truck_modeling::VertexID, Vec<truck_modeling::Edge>> = HashMap::new();
let mut seen = std::collections::HashSet::new();
for e in b.solid.edge_iter() {
if seen.insert(e.id()) {
for v in [e.front(), e.back()] {
at.entry(v.id()).or_default().push(e.clone());
}
let r = b.record();
let mut at: Vec<Vec<usize>> = vec![Vec::new(); r.vertices.len()];
for (i, e) in r.edges.iter().enumerate() {
for v in e.vertices {
at[v as usize].push(i);
}
}
at.values()
let point = |v: u32| Vec3::from(r.vertices[v as usize].point);
let dir = |e: usize| (point(r.edges[e].vertices[1]) - point(r.edges[e].vertices[0])).normalized();
let line = |e: usize| matches!(r.edges[e].curve, solidcraft_geom::brep::Curve::Line { .. });
at.iter()
.filter(|es| match es.as_slice() {
[a, b] => {
let dir = |e: &truck_modeling::Edge| (crate::body::from_p3(e.back().point()) - crate::body::from_p3(e.front().point())).normalized();
matches!((a.curve(), b.curve()), (truck_modeling::Curve::Line(_), truck_modeling::Curve::Line(_)))
&& dir(a).zip(dir(b)).is_some_and(|(u, w)| u.dot(w).abs() > 1. - 1e-9)
}
[a, b] => line(*a) && line(*b) && dir(*a).zip(dir(*b)).is_some_and(|(u, w)| u.dot(w).abs() > 1. - 1e-9),
_ => false,
})
.count()