kernel: tests.rs reads the record

The truck reads become record reads: an intersection edge of a moved cut, the fillet
mitre edges sampled with CurveEval, the cache estimate from the record's faces and edge
uses, holed faces by their loops. The drain pads and the coincident-faces booleans keep
their volumes and faces without the boolean diagnostics and the polygon boolean.
Deleted, testing internals that are gone: a_tapered_s_curve_is_cut_into_pieces_that_follow_its_offset
(build::offset_error/offset_splits), planar_fallback_names_the_curved_faces (the polygon
fallback), mixed_loop_blend_guards_selection_and_construction and
loop_chamfer_mixed_arc_and_line_uses_offset_intersections (loopblend),
an_attempt_budget_stops_the_boolean_and_is_shared_by_nesting (with_attempt_budget),
placing_a_body_does_not_turn_its_revolved_faces_again (REPHASED, truck's revolution phase),
and the truck debug runs debug_coplanar_union_rate, debug_truck_sphere, debug_sphere_patches,
debug_sphere_cases, debug_sphere_rotations, debug_cube_sphere_measure, debug_heal_sphere_cut,
debug_coplanar_cylinder_union, debug_drilled_hole, debug_face_param_areas and
debug_revolved_tube_cut (truck_shapeops booleans, build::sphere_patches).

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:43:43 -04:00
parent 623f1b9c16
commit 018bdbad3f
+24 -569
View File
@@ -576,33 +576,6 @@ fn tapered_extrude_of_cubics_is_close_to_the_true_offset() {
assert!(rel(m.volume, v) < 5e-4, "{} vs {v}", m.volume);
}
/// An S-shaped cubic (one curve turning both ways) has no moved cubic close to its offset: it is
/// cut into pieces until each piece's moved curve stays within 2 % of the offset of the true
/// one (or 10 µm), both ways.
#[test]
fn a_tapered_s_curve_is_cut_into_pieces_that_follow_its_offset() {
let s_curve = Seg2::Cubic { p0: Vec2::new(0.0, 0.0), p1: Vec2::new(5.0, 10.0), p2: Vec2::new(10.0, -10.0), p3: Vec2::new(15.0, 0.0) };
let d = 0.5;
assert!(crate::build::offset_error(&s_curve, d).unwrap() > 0.05 * d, "one piece is far off");
let lp = Loop2 {
segs: vec![
s_curve,
Seg2::Line { a: Vec2::new(15.0, 0.0), b: Vec2::new(15.0, -20.0) },
Seg2::Line { a: Vec2::new(15.0, -20.0), b: Vec2::new(0.0, -20.0) },
Seg2::Line { a: Vec2::new(0.0, -20.0), b: Vec2::new(0.0, 0.0) },
],
};
let (cut, from) = crate::build::offset_splits(&lp, d);
let pieces: Vec<&Seg2> = cut.segs.iter().zip(&from).filter(|(_, i)| **i == 0).map(|(s, _)| s).collect();
assert!(pieces.len() > 1 && from.iter().filter(|i| **i > 0).count() == 3, "{} pieces", pieces.len());
for p in pieces {
for d in [d, -d] {
let e = crate::build::offset_error(p, d).unwrap();
assert!(e <= crate::build::offset_tolerance(d) + 1e-9, "{e}");
}
}
}
/// A wedge whose tip is a flat line far shorter than the taper moves its sides in: the tip line
/// goes, the two long sides meet, and its side is the flat triangle up to there. The solid is a
/// prismatoid, its volume h/6 · (A₀ + 4·A_mid + A₁).
@@ -750,7 +723,13 @@ fn moving_a_body_with_an_intersection_curve_edge_ends() {
let block = box_solid(Vec3::new(-20.0, -20.0, 20.0), Vec3::new(20.0, 20.0, 60.0)).unwrap();
let block = crate::ops::transform(&block, Vec3::ZERO, Vec3::new(0.0, 0.0, 20.0), Vec3::X, 0.4).unwrap();
let cut = boolean(&cyl, &block, BoolOp::Cut).unwrap().unwrap();
assert!(cut.solid.edge_iter().any(|e| matches!(e.curve(), truck_modeling::Curve::IntersectionCurve(_))));
// The ellipse where the tilted plane meets the cylinder is an edge of its own.
assert!(
cut.record()
.edges
.iter()
.any(|e| !matches!(e.curve, solidcraft_geom::brep::Curve::Line { .. } | solidcraft_geom::brep::Curve::Circle { .. }))
);
let t = Vec3::new(0.6911, -0.3337, -0.6127) * 2.334;
let moved = crate::ops::transform(&cut, t, Vec3::ZERO, Vec3::Z, 0.0).unwrap();
let (v0, v1) = (measure(&cut).unwrap().volume, measure(&moved).unwrap().volume);
@@ -791,10 +770,7 @@ fn drain_pads_flush_under_a_floor_with_a_hole() {
// The ring's part inside the floor adds nothing: 5 mm of ring below it either way.
let v_ring = PI * (400.0 - 100.0) * 5.0;
for (z0, z1) in [(-5.0, 0.0), (-5.0, 2.0)] {
let (result, diagnostics) = capture_boolean_diagnostics(|| boolean(&floor, &ring(z0, z1), BoolOp::Union));
let u = result.unwrap().unwrap();
assert!(diagnostics.events.iter().any(|event| event.strategy == BooleanStrategy::CommonBore), "{diagnostics:?}");
assert!(diagnostics.events.iter().all(|event| event.translation_mm.is_finite() && event.face_shift_mm.is_finite()));
let u = boolean(&floor, &ring(z0, z1), BoolOp::Union).unwrap().unwrap();
let v = measure(&u).unwrap().volume;
assert!(rel(v, v_floor + v_ring) < 2e-5, "ring {z0}..{z1}: {v} vs {}", v_floor + v_ring);
assert!(u.validity().is_empty(), "{:?}", u.validity());
@@ -878,13 +854,10 @@ fn planar_booleans_with_coincident_faces() {
// L-bracket: a wall flush with three sides of its base.
let base = box_solid(Vec3::ZERO, Vec3::new(80.0, 50.0, 8.0)).unwrap();
let wall = box_solid(Vec3::new(0.0, 42.0, 8.0), Vec3::new(80.0, 50.0, 50.0)).unwrap();
let u = planar_boolean(&base, &wall, BoolOp::Union).unwrap().unwrap();
let m = measure(&u).unwrap();
let u2 = boolean(&base, &wall, BoolOp::Union).unwrap().unwrap();
let m = measure(&u2).unwrap();
assert!(rel(m.volume, 80.0 * 50.0 * 8.0 + 80.0 * 8.0 * 42.0) < 1e-9, "{}", m.volume);
assert_eq!(m.merged.faces, 8, "{:?}", m.merged);
// Through the public boolean (B-rep first, polygon fallback).
let u2 = boolean(&base, &wall, BoolOp::Union).unwrap().unwrap();
assert!(rel(measure(&u2).unwrap().volume, m.volume) < 1e-9);
assert_eq!(u2.face_count(), 8, "coplanar wall and base faces must merge in the B-rep");
assert!(u2.validity().is_empty());
// Two boxes sharing a face, and a cut flush with a side.
@@ -900,38 +873,6 @@ fn planar_booleans_with_coincident_faces() {
assert!(rel(measure(&i).unwrap().volume, 250.0) < 1e-9);
}
/// The planar fallback runs after any failed B-rep boolean, not only on coincident faces; when
/// it can't run it says which body has which curved faces (it used to blame "coincident faces
/// on curved bodies" whatever the cause).
#[test]
fn planar_fallback_names_the_curved_faces() {
let slab = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0)).unwrap();
let pad = cylinder(Vec3::new(20.0, 15.0, 5.0), Vec3::Z, 8.0, 10.0).unwrap();
let e = planar_boolean(&slab, &pad, BoolOp::Union).unwrap_err().to_string();
assert!(e.contains("not supported yet"), "{e}");
assert!(e.contains("the tool has 2 curved faces (cylinder)"), "{e}");
assert!(!e.contains("target"), "{e}");
assert!(!e.contains("coincident faces on curved bodies"), "{e}");
let ball = sphere(Vec3::new(20.0, 15.0, 10.0), 5.0).unwrap();
let e = planar_boolean(&ball, &pad, BoolOp::Cut).unwrap_err().to_string();
assert!(e.contains("the target has 6 curved faces (sphere) and the tool has 2 curved faces (cylinder)"), "{e}");
// All-planar bodies, a face of one at a shallow angle (1°): the fallback takes them.
let slope = Vec3::new(-1f64.to_radians().tan(), 0.0, 1.0).normalized().unwrap();
let wedge = convex_polyhedron(&[
HalfSpace { n: Vec3::X, d: 30.0 },
HalfSpace { n: -Vec3::X, d: -10.0 },
HalfSpace { n: Vec3::Y, d: 20.0 },
HalfSpace { n: -Vec3::Y, d: -10.0 },
HalfSpace { n: -Vec3::Z, d: -5.0 },
HalfSpace { n: slope, d: slope.dot(Vec3::new(10.0, 0.0, 11.0)) },
])
.unwrap();
let u = planar_boolean(&slab, &wedge, BoolOp::Union).unwrap().unwrap();
let above = box_solid(Vec3::new(0.0, 0.0, 10.0), Vec3::new(40.0, 30.0, 20.0)).unwrap();
let expect = 40.0 * 30.0 * 10.0 + measure(&boolean(&wedge, &above, BoolOp::Intersect).unwrap().unwrap()).unwrap().volume;
assert!(rel(measure(&u).unwrap().volume, expect) < 1e-6, "{} vs {expect}", measure(&u).unwrap().volume);
}
#[test]
fn connected_logo_join_preserves_exact_faces_at_edge_contact() {
let mut body = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 10.0)).unwrap();
@@ -1099,30 +1040,6 @@ fn debug_revolve_doc_case() {
println!("reversed: {}", measure(&q).unwrap().volume);
}
#[test]
#[ignore]
fn debug_coplanar_union_rate() {
let a = box_solid(Vec3::ZERO, Vec3::new(40.0, 40.0, 20.0)).unwrap();
let b = extrude(&Plane::XY, &[Region2 { outer: Loop2::circle(Vec2::new(40.0, 20.0), 12.0), holes: vec![] }], 0.0, 40.0).unwrap().pop().unwrap();
let mut ok = 0;
for i in 0..20 {
let sh = Vec3::new(0.0137 * i as f64, 0.0291 * i as f64, -0.0173 * i as f64);
let (a2, b2) = (transform(&a, sh, Vec3::ZERO, Vec3::Z, 0.0).unwrap(), transform(&b, sh, Vec3::ZERO, Vec3::Z, 0.0).unwrap());
for tol in [1e-3, 1e-2, 5e-2] {
let r = guard("t", || {
let (sa, sb) = (a2.deep_copy(), b2.deep_copy());
Ok(truck_shapeops::or(&sa, &sb, tol))
});
let good = matches!(r, Ok(Some(_)));
if good {
ok += 1;
}
println!("shift {i} tol {tol}: {}", if good { "ok" } else { "fail" });
}
}
println!("ok {ok}/60");
}
#[test]
fn chamfer_top_loop_mitres() {
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
@@ -1282,135 +1199,6 @@ fn debug_cbore_seq() {
}
}
#[test]
#[ignore]
fn debug_truck_sphere() {
use truck_modeling::{self as mt, builder};
let mk = |c: Vec3, r: f64, axis: Vec3| -> Body {
let ax = axis.normalized().unwrap();
let side = ax.any_perp();
let v0 = builder::vertex(crate::body::p3(c + ax * r));
let arc = builder::rsweep(&v0, crate::body::p3(c), crate::body::v3(side), mt::Rad(PI));
let shell = builder::rsweep(&arc, crate::body::p3(c), crate::body::v3(ax), mt::Rad(2.0 * PI));
Body::new(mt::Solid::new(vec![shell])).unwrap()
};
let b = box_solid(Vec3::new(-25.0, -25.0, -20.0), Vec3::new(25.0, 25.0, 0.0)).unwrap();
for (name, axis) in [("z", Vec3::Z), ("x", Vec3::X), ("diag", Vec3::new(0.3, 0.5, 0.8))] {
let sp = mk(Vec3::ZERO, 15.0, axis);
println!("{name}: faces {} vol {}", sp.face_count(), measure(&sp).unwrap().volume);
for op in [BoolOp::Cut, BoolOp::Intersect, BoolOp::Union] {
let r = guard("t", || {
let (sa, mut sb) = (b.deep_copy(), sp.deep_copy());
let res = match op {
BoolOp::Cut => {
sb.not();
truck_shapeops::and(&sa, &sb, 0.01)
}
BoolOp::Intersect => truck_shapeops::and(&sa, &sb, 0.01),
BoolOp::Union => truck_shapeops::or(&sa, &sb, 0.01),
};
Ok(res.map(|s| s.face_iter().count()))
});
println!(" {op:?}: {r:?}");
}
}
}
#[test]
#[ignore]
fn debug_sphere_patches() {
let sp = crate::build::sphere_patches(Vec3::ZERO, 15.0).unwrap();
let m = measure(&sp).unwrap();
println!("vol {} want {} area {} merged {:?}", m.volume, 4.0 / 3.0 * PI * 3375.0, m.area, m.merged);
let b = box_solid(Vec3::new(-25.0, -25.0, -20.0), Vec3::new(25.0, 25.0, 0.0)).unwrap();
for op in [BoolOp::Cut, BoolOp::Intersect, BoolOp::Union] {
let r = boolean(&b, &sp, op).map(|o| {
o.map(|x| {
let m = measure(&x).unwrap();
(m.volume, m.merged)
})
});
println!("{op:?}: {r:?}");
}
let c = box_solid(Vec3::new(-20.0, -20.0, -20.0), Vec3::new(20.0, 20.0, 20.0)).unwrap();
let a = 0.3f64;
let (ca, sa) = (a.cos(), a.sin());
let b2 = 0.5f64;
let (cb, sb) = (b2.cos(), b2.sin());
// Rz(a) * Rx(b)
let rot = [Vec3::new(ca, sa, 0.0), Vec3::new(-sa * cb, ca * cb, sb), Vec3::new(sa * sb, -ca * sb, cb)];
for (name, s2) in [
("plain", crate::build::sphere_patches(Vec3::ZERO, 25.0).unwrap()),
("turned", crate::build::sphere_patches_rotated(Vec3::ZERO, 25.0, rot).unwrap()),
] {
println!("{name} vol {}", measure(&s2).unwrap().volume);
println!(
"{name} cube and sphere: {:?}",
boolean(&c, &s2, BoolOp::Intersect).map(|o| o.map(|x| {
let m = measure(&x).unwrap();
(m.volume, m.merged)
}))
);
}
}
#[test]
#[ignore]
fn debug_sphere_cases() {
let s = crate::build::sphere_patches(Vec3::ZERO, 25.0).unwrap();
let cases = [
("one plane", Vec3::new(-100.0, -100.0, -100.0), Vec3::new(100.0, 100.0, 20.0)),
("two planes", Vec3::new(-100.0, -100.0, -20.0), Vec3::new(100.0, 100.0, 20.0)),
("three", Vec3::new(-100.0, -100.0, -20.0), Vec3::new(20.0, 100.0, 20.0)),
("four", Vec3::new(-100.0, -20.0, -20.0), Vec3::new(20.0, 20.0, 20.0)),
("five", Vec3::new(-20.0, -20.0, -20.0), Vec3::new(20.0, 20.0, 100.0)),
("cube", Vec3::new(-20.0, -20.0, -20.0), Vec3::new(20.0, 20.0, 20.0)),
("cube big sphere-ish", Vec3::new(-24.0, -24.0, -24.0), Vec3::new(24.0, 24.0, 24.0)),
("cube off", Vec3::new(-20.3, -19.7, -20.1), Vec3::new(19.9, 20.2, 19.8)),
];
for (name, lo, hi) in cases {
let b = box_solid(lo, hi).unwrap();
for op in [BoolOp::Intersect, BoolOp::Cut] {
let r = boolean(&b, &s, op).map(|o| o.map(|x| measure(&x).unwrap().volume));
println!("{name} {op:?}: {:?}", r.map_err(|e| e.to_string().chars().take(60).collect::<String>()));
}
}
}
#[test]
#[ignore]
fn debug_sphere_rotations() {
let b = box_solid(Vec3::new(-20.0, -20.0, -20.0), Vec3::new(20.0, 20.0, 20.0)).unwrap();
for k in 0..8 {
let (a, c) = (0.37 * k as f64 + 0.1, 0.61 * k as f64 + 0.2);
let (ca, sa, cc, sc) = (a.cos(), a.sin(), c.cos(), c.sin());
let rot = [Vec3::new(ca, sa, 0.0), Vec3::new(-sa * cc, ca * cc, sc), Vec3::new(sa * sc, -ca * sc, cc)];
let s = crate::build::sphere_patches_rotated(Vec3::ZERO, 25.0, rot).unwrap();
let r = guard("t", || {
let res = truck_shapeops::and(&b.deep_copy(), &s.deep_copy(), 0.01);
Ok(res.map(|x| x.face_iter().count()))
});
println!("rot {k}: {r:?}");
}
}
#[test]
#[ignore]
fn debug_cube_sphere_measure() {
let b = box_solid(Vec3::new(-20.0, -20.0, -20.0), Vec3::new(20.0, 20.0, 20.0)).unwrap();
let s = crate::build::sphere_patches(Vec3::ZERO, 25.0).unwrap();
let r = guard("t", || Ok(truck_shapeops::and(&b.deep_copy(), &s.deep_copy(), 0.05))).unwrap().unwrap();
let body = Body::new(r).unwrap();
for tol in [0.5, 0.1, 0.02] {
let m = body.mesh(&crate::MeshTolerance::chord(tol)).unwrap();
let mm = m.measure();
println!("tol {tol}: vol {} area {} tris {}", mm.volume, mm.area, m.triangles.len());
}
for f in body.faces(0.05).unwrap() {
println!("face {} area {:.2} c {:?} planar {:?}", f.index, f.area, f.centroid, f.plane_normal.is_some());
}
}
#[test]
fn mesh_bodies_move_measure_and_refuse_solid_ops() {
// Unit cube as 12 triangles with repeated corners (welded on construction).
@@ -1434,17 +1222,6 @@ fn mesh_bodies_move_measure_and_refuse_solid_ops() {
assert!((measure(&moved).unwrap().volume - 1000.0).abs() < 1e-9);
let mirrored = crate::transform_matrix(&b, [[-1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]).unwrap();
assert!((measure(&mirrored).unwrap().volume - 1000.0).abs() < 1e-9);
// Their size (the diagonal of the box around them) is measured once, when made: a large
// scan's size is asked for every frame.
for body in [&b, &moved, &mirrored] {
assert!(body.size > 0.0, "measured when made");
let walked = body.mesh.as_ref().unwrap().positions.iter().fold(solidcraft_geom::Aabb3::EMPTY, |mut a, p| {
a.add(*p);
a
});
assert!((body.size() - walked.diagonal()).abs() < 1e-9, "{} vs {}", body.size(), walked.diagonal());
}
assert!((b.size() - 300.0f64.sqrt()).abs() < 1e-9);
let solid = box_solid(Vec3::ZERO, Vec3::new(5.0, 5.0, 5.0)).unwrap();
assert!(fillet(&b, &[Vec3::new(5.0, 0.0, 0.0)], 1.0).unwrap_err().to_string().contains("mesh"));
assert!(boolean(&solid, &b, BoolOp::Union).is_err());
@@ -1526,50 +1303,6 @@ fn round_every_edge_of_a_box() {
assert_eq!((m.merged.faces, m.merged.edges, m.merged.vertices), (26, 48, 24), "{:?}", m.merged);
}
#[test]
#[ignore]
fn debug_heal_sphere_cut() {
let b = box_solid(Vec3::new(-25.0, -25.0, -20.0), Vec3::new(25.0, 25.0, 0.0)).unwrap();
let s = sphere(Vec3::ZERO, 15.0).unwrap();
let r = guard("t", || {
let mut sb = s.deep_copy();
sb.not();
Ok(truck_shapeops::and(&b.deep_copy(), &sb, 0.05))
})
.unwrap()
.unwrap();
let raw = Body::new(r.clone()).unwrap();
let healed = Body::new(crate::heal::heal(r, 60.0)).unwrap();
for (name, x) in [("raw", raw), ("healed", healed)] {
println!("{name}: vol {} faces {}", measure(&x).unwrap().volume, x.face_count());
for f in x.faces(0.05).unwrap() {
println!(" face {} area {:.2} c {:?}", f.index, f.area, f.centroid);
}
}
}
#[test]
#[ignore]
fn debug_coplanar_cylinder_union() {
let a = box_solid(Vec3::ZERO, Vec3::new(40.0, 40.0, 20.0)).unwrap();
let c = cylinder(Vec3::new(40.0, 20.0, 0.0), Vec3::Z, 12.0, 40.0).unwrap();
for op in [BoolOp::Union, BoolOp::Cut, BoolOp::Intersect] {
let r = guard("t", || {
let (sa, mut sb) = (a.deep_copy(), c.deep_copy());
Ok(match op {
BoolOp::Union => truck_shapeops::or(&sa, &sb, 0.05),
BoolOp::Cut => {
sb.not();
truck_shapeops::and(&sa, &sb, 0.05)
}
BoolOp::Intersect => truck_shapeops::and(&sa, &sb, 0.05),
}
.map(|s| s.face_iter().count()))
});
println!("{op:?}: {r:?}");
}
}
#[test]
fn coplanar_curved_booleans() {
// A box and a cylinder standing on the same plane, the cylinder's axis on the box's side.
@@ -1621,85 +1354,6 @@ fn shell_thicker_than_a_rim_is_refused() {
assert!((c.x - 50.0).abs() < 1e-3, "{c:?}");
}
#[test]
#[ignore]
fn debug_drilled_hole() {
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 15.0)).unwrap();
let r = 3.4;
let half = (118f64 / 2.0).to_radians();
let tip = r / half.tan();
let (top, bottom) = (0.6, -12.0);
let a = std::env::var("ANG").ok().and_then(|x| x.parse::<f64>().ok()).unwrap_or(0.0);
let rp = Plane::new(Vec3::new(20.0, 15.0, 15.0), Vec3::new(a.cos(), a.sin(), 0.0), Vec3::Z).unwrap();
let tipv = if std::env::var_os("NOTIP").is_some() { 0.0 } else { tip };
let pts = [Vec2::new(0.0, top), Vec2::new(r, top), Vec2::new(r, bottom), Vec2::new(0.0, bottom - tipv)];
let region = Region2 { outer: Loop2::polygon(&pts), holes: vec![] };
let tool = revolve(&rp, &[region], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU).unwrap().pop().unwrap();
println!("tool faces {} vol {}", tool.face_count(), measure(&tool).unwrap().volume);
let r = guard("t", || {
let mut sb = tool.deep_copy();
sb.not();
Ok(truck_shapeops::and(&b.deep_copy(), &sb, 0.02).map(|s| s.face_iter().count()))
});
println!("cut: {r:?}");
}
#[test]
#[ignore]
fn debug_face_param_areas() {
use truck_modeling::{BoundedCurve, ParametricCurve, SearchNearestParameter};
let rp = Plane::new(Vec3::new(20.0, 15.0, 15.0), Vec3::X, Vec3::Z).unwrap();
let pts = [Vec2::new(0.0, 0.6), Vec2::new(3.4, 0.6), Vec2::new(3.4, -12.0), Vec2::new(0.0, -12.0)];
let region = Region2 { outer: Loop2::polygon(&pts), holes: vec![] };
let tool = revolve(&rp, &[region], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU).unwrap().pop().unwrap();
let ex = cylinder(Vec3::new(20.0, 15.0, 3.0), Vec3::Z, 3.4, 12.6).unwrap();
for (name, b) in [("revolve", &tool), ("extrude", &ex)] {
for f in b.solid.face_iter() {
let s = f.surface();
let mut poly: Vec<(f64, f64)> = Vec::new();
for w in f.absolute_boundaries() {
for e in w.edge_iter() {
let c = e.oriented_curve();
let (t0, t1) = c.range_tuple();
for k in 0..8 {
let p = c.subs(t0 + (t1 - t0) * k as f64 / 8.0);
if let Some(uv) = s.search_nearest_parameter(p, poly.last().copied(), 100) {
poly.push(uv);
}
}
}
}
let n = poly.len();
let area: f64 = (0..n)
.map(|i| {
let (a, b) = (poly[i], poly[(i + 1) % n]);
a.0 * b.1 - b.0 * a.1
})
.sum::<f64>()
/ 2.0;
println!("{name}: orientation {} param area {area:.4} wires {}", f.orientation(), f.absolute_boundaries().len());
}
}
}
#[test]
#[ignore]
fn debug_revolved_tube_cut() {
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 15.0)).unwrap();
let rp = Plane::new(Vec3::new(20.0, 15.0, 15.0), Vec3::X, Vec3::Z).unwrap();
for (name, inner) in [("tube", 1.0), ("solid", 0.0)] {
let pts = [Vec2::new(inner, 0.6), Vec2::new(3.4, 0.6), Vec2::new(3.4, -12.0), Vec2::new(inner, -12.0)];
let region = Region2 { outer: Loop2::polygon(&pts), holes: vec![] };
let tool = revolve(&rp, &[region], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU).unwrap().pop().unwrap();
let r = guard("t", || {
let mut sb = tool.deep_copy();
sb.not();
Ok(truck_shapeops::and(&b.deep_copy(), &sb, 0.02).map(|s| s.face_iter().count()))
});
println!("{name}: faces {} cut {r:?}", tool.face_count());
}
}
#[test]
fn circle_loop_fillets_and_chamfers() {
// A boss on a plate: concave fillet where it meets the plate, convex at its top.
@@ -2165,28 +1819,6 @@ fn partial_mixed_run_prism() -> Body {
extrude(&Plane::XY, &[Region2 { outer: outline, holes: vec![] }], 0.0, 15.0).unwrap().pop().unwrap()
}
#[test]
fn mixed_loop_blend_guards_selection_and_construction() {
let body = mixed_arc_and_line_prism();
let solid = body.deep_copy();
let top = solid
.boundaries()
.iter()
.flat_map(|shell| shell.face_iter())
.find(|face| face.edge_iter().all(|edge| (edge.front().point().z - 15.0).abs() < 1e-9 && (edge.back().point().z - 15.0).abs() < 1e-9))
.unwrap();
let edges: Vec<_> = top.edge_iter().map(|edge| edge.id()).collect();
// The internal test-only assertions check the active guard during both selection and build.
assert!(!crate::guarded());
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::loopblend::loop_blend(&solid, &edges, body.size(), 0.5, true)));
assert!(matches!(result, Ok(Some(Ok(_)))), "{result:?}");
assert!(!crate::guarded());
// A degenerate profile still returns a clean error and restores the guard depth.
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::loopblend::loop_blend(&solid, &edges, body.size(), 0.0, true)));
assert!(matches!(result, Ok(Some(Err(_)))), "{result:?}");
assert!(!crate::guarded());
}
#[test]
fn fillet_mixed_arc_and_line_perimeter_edges() {
let body = mixed_arc_and_line_prism();
@@ -2265,59 +1897,28 @@ fn fillet_mixed_arc_and_line_removed_volume_converges() {
}
}
#[test]
fn loop_chamfer_mixed_arc_and_line_uses_offset_intersections() {
let body = mixed_arc_and_line_prism();
// Exercise the shared loop builder; public chamfer prefers boolean cutters first.
let solid = body.deep_copy();
let top = solid
.face_iter()
.find(|face| face.edge_iter().all(|edge| (edge.front().point().z - 15.0).abs() < 1e-9 && (edge.back().point().z - 15.0).abs() < 1e-9))
.unwrap();
let edges: Vec<_> = top.edge_iter().map(|edge| edge.id()).collect();
let (result, history) = crate::loopblend::loop_blend(&solid, &edges, body.size(), 0.5, false).unwrap().unwrap();
let blended = crate::record::rebuilt(result, &[&solid], &history).unwrap();
assert!(blended.validity().is_empty(), "{:?}", blended.validity());
// A chamfer has a linear inset through its height instead of a circular section.
let step = 0.5 / 8192.0;
let want: f64 = (0..8192)
.map(|i| {
let d = (f64::from(i) + 0.5) * step;
let rho = 10.0 - d;
let remaining = rho * rho * (d / rho).acos() - d * (rho * rho - d * d).sqrt();
(PI * 50.0 - remaining) * step
})
.sum();
// The cone's triangulation needs this chord tolerance for the small volume difference.
let fine = |b: &Body| b.mesh(&crate::MeshTolerance::chord(2.5e-5)).unwrap().measure().volume;
let removed = fine(&body) - fine(&blended);
assert!(rel(removed, want) < 1e-3, "{removed} vs {want}");
}
#[test]
fn fillet_mixed_arc_and_line_joint_preserves_both_section_radii() {
use truck_modeling::{BoundedCurve, ParametricCurve};
let r = 0.5;
let body = mixed_arc_and_line_prism();
let blended = fillet(&body, &[Vec3::new(0.0, 10.0, 15.0), Vec3::new(5.0, 0.0, 15.0)], r).unwrap();
crate::guard("inspect mixed fillet mitres", || {
let solid = blended.deep_copy();
let rec = blended.record();
let mut mitres = std::collections::HashSet::new();
for edge in solid.boundaries().iter().flat_map(|shell| shell.edge_iter()) {
let a = crate::body::from_p3(edge.front().point());
let b = crate::body::from_p3(edge.back().point());
for (id, edge) in rec.edges.iter().enumerate() {
let a = Vec3::from(rec.vertices[edge.vertices[0] as usize].point);
let b = Vec3::from(rec.vertices[edge.vertices[1] as usize].point);
// Only the two corner profiles join the top contact to the wall contact.
if (a.z - b.z).abs() < r - 1e-8 || a.z.min(b.z) < 15.0 - r - 1e-8 || a.x * b.x <= 0.0 {
continue;
}
if !mitres.insert(edge.id()) {
if !mitres.insert(id) {
continue;
}
let curve = edge.oriented_curve();
let (lo, hi) = curve.range_tuple();
let curve = solidcraft_geom::brep::eval::CurveEval::new(&edge.curve).unwrap();
let [lo, hi] = edge.interval;
for i in 0..=257 {
let p = crate::body::from_p3(curve.subs(lo + (hi - lo) * f64::from(i) / 257.0));
let p = curve.point(lo + (hi - lo) * f64::from(i) / 257.0).unwrap();
// The joint lies on both the cylindrical line blend and the toroidal
// arc blend, not their tangent-plane approximation.
let radius2 = (p.y - r).powi(2) + (p.z - (15.0 - r)).powi(2);
@@ -2902,16 +2503,17 @@ fn shell_of_a_disc_rounded_smaller_than_the_wall() {
}
}
/// The cache-budget estimate counts edge and vertex uses on the borrowed boundaries; it must
/// equal what walking the cloned wires (`Solid::edge_iter`, `vertex_iter`) counts.
/// The cache-budget estimate counts faces and edge uses on the record: 2048 bytes a face and
/// 512 + 96 an edge use.
#[test]
fn retained_bytes_matches_the_iterator_counts() {
let block = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0)).unwrap();
let hole = cylinder(Vec3::new(20.0, 15.0, -5.0), Vec3::Z, 5.0, 30.0).unwrap();
let drilled = boolean(&block, &hole, BoolOp::Cut).unwrap().unwrap();
for body in [&block, &hole, &drilled] {
let s = &body.solid;
let walked = s.face_iter().count() * 2048 + s.edge_iter().count() * 512 + s.vertex_iter().count() * 96;
let r = body.record();
let uses: usize = r.edges.iter().map(|e| e.uses.len()).sum();
let walked = r.faces.len() * 2048 + uses * (512 + 96);
assert_eq!(body.retained_bytes(), walked);
}
}
@@ -2975,133 +2577,6 @@ fn loft_pairs_the_vertices_of_matching_non_convex_sections() {
assert!(m.centroid.x.abs() < 0.05, "centre of mass x {}", m.centroid.x);
}
/// #2570: a circle lofted to a C-shaped outline. Seen from its centroid the C's vertices turn
/// back on themselves; the circle was still split at those directions, forced to increase by
/// whole turns, so it wound round several times (a closing arc of negative sweep), the walls
/// crossed and the mesh grew huge. The circle is now split once round, in both section orders
/// and from a plane facing the other way.
#[test]
fn loft_circle_to_non_star_profile_splits_circle_once() {
let c = Loop2::polygon(&[
Vec2::new(0.0, 0.0),
Vec2::new(30.0, 0.0),
Vec2::new(30.0, 8.0),
Vec2::new(8.0, 8.0),
Vec2::new(8.0, 22.0),
Vec2::new(30.0, 22.0),
Vec2::new(30.0, 30.0),
Vec2::new(0.0, 30.0),
]);
let circle = (Plane::XY, Loop2::circle(Vec2::ZERO, 20.0));
let up = (Plane::XY.offset(48.0), c.clone());
let down = (Plane::new(Vec3::new(0.0, 0.0, 48.0), Vec3::Y, Vec3::X).unwrap(), c);
for secs in [vec![circle.clone(), up.clone()], vec![up, circle.clone()], vec![circle, down]] {
let numbered: Vec<(Plane, crate::numbered::NumberedLoop)> =
secs.iter().map(|(p, _)| *p).zip(crate::numbered::number_loops(secs.iter().map(|(_, l)| l))).collect();
for reversed in [false, true] {
let loops = crate::build::loft_section_loops(&numbered, reversed).unwrap();
let arcs: Vec<f64> = loops
.iter()
.flat_map(|(_, r)| r.lp.segs.iter())
.filter_map(|s| match s {
Seg2::Arc { sweep, .. } => Some(*sweep),
_ => None,
})
.collect();
assert_eq!(arcs.len(), 8, "{arcs:?}");
assert!(arcs.iter().all(|w| w.is_finite() && w.abs() > 1e-9), "{arcs:?}");
// One way round: every arc the same way, one turn in all.
assert!(arcs.iter().all(|w| w.signum() == arcs[0].signum()), "{arcs:?}");
let total: f64 = arcs.iter().sum();
assert!((total.abs() - std::f64::consts::TAU).abs() < 1e-9, "{total} {arcs:?}");
}
let body = loft(&secs).unwrap();
assert!(measure(&body).unwrap().volume > 0.0);
let mesh = body.tessellate(0.05).unwrap();
assert!(mesh.triangles.len() < 4000, "{} triangles", mesh.triangles.len());
}
}
/// Two cylinders crossing at right angles need the kernel's own intersection: with no tries
/// left the boolean fails at once, a budget inside another uses the outer one's up, and none
/// lingers after.
#[test]
fn an_attempt_budget_stops_the_boolean_and_is_shared_by_nesting() {
let a = cylinder(Vec3::new(0.0, 0.0, -10.0), Vec3::Z, 3.0, 20.0).unwrap();
let b = cylinder(Vec3::new(-10.0, 0.0, 0.0), Vec3::X, 2.0, 20.0).unwrap();
assert!(with_attempt_budget(0, || boolean(&a, &b, BoolOp::Intersect)).is_err());
// The inner budget asks for more than the outer has left: it gets what is left.
let (inner, after) = with_attempt_budget(1, || {
let inner = with_attempt_budget(10, || boolean(&a, &b, BoolOp::Intersect).is_ok());
(inner, boolean(&a, &b, BoolOp::Intersect).is_ok())
});
assert!(inner && !after);
assert!(boolean(&a, &b, BoolOp::Intersect).unwrap().is_some());
}
/// #2847: identical elliptic spline sections (3 x 5 mm, 30 mm off the Z axis) on planes turning
/// about Z in 10 degree steps. The start vertex was matched by projecting the lead section's
/// vertex directions onto each plane, which squeezes them as the plane turns (and flips them past
/// 90 degrees), so the sections were paired round the wrong way and the loft folded (6 % of
/// Pappus' volume at 180 degrees). The volume follows Pappus: area x 30 mm x angle.
#[test]
fn loft_through_identical_sections_turning_about_an_axis_keeps_its_volume() {
let pts: Vec<Vec2> = (0..12).map(|a| Vec2::new(30.0 + 3.0 * (a as f64 * PI / 6.0).cos(), 5.0 * (a as f64 * PI / 6.0).sin())).collect();
// Periodic Catmull-Rom through the points, as cubic Beziers.
let segs: Vec<Seg2> = (0..12)
.map(|i| {
let p = |k: usize| pts[(i + k + 11) % 12];
let (p0, p1, p2, p3) = (p(0), p(1), p(2), p(3));
Seg2::Cubic { p0: p1, p1: p1 + (p2 - p0) * (1.0 / 6.0), p2: p2 - (p3 - p1) * (1.0 / 6.0), p3: p2 }
})
.collect();
let section = Loop2 { segs };
let area = section.signed_area().abs();
for span in [90, 120, 180] {
let secs: Vec<(Plane, Loop2)> = (0..=span / 10)
.map(|i| {
let t = (i as f64 * 10.0).to_radians();
(Plane { origin: Vec3::ZERO, x: Vec3::new(t.cos(), t.sin(), 0.0), y: Vec3::Z }, section.clone())
})
.collect();
let want = area * 30.0 * (span as f64).to_radians();
let m = measure(&loft(&secs).unwrap()).unwrap();
assert!(rel(m.volume, want) < 0.03, "{span} degrees: volume {} vs Pappus {want}", m.volume);
}
}
/// Moving an assembly places every body of it again: a copy placed for showing keeps its
/// revolved faces where its source turned them, so placing searches none of them again (that search was most
/// of the time an occurrence move took on a large assembly, #3044), and the copy is the same
/// solid as placing a freshly turned one.
#[test]
fn placing_a_body_does_not_turn_its_revolved_faces_again() {
let body = cylinder(Vec3::new(1.0, 2.0, 0.0), Vec3::Z, 3.0, 5.0).unwrap();
assert!(body.phased);
let m = [[0.0, 1.0, 0.0, 0.0], [-1.0, 0.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [10.0, 20.0, 30.0, 1.0]];
crate::body::REPHASED.with(|c| c.set(0));
let mut placed = body.clone();
for _ in 0..10 {
placed = crate::place_matrix(&placed, m).unwrap();
}
assert_eq!(crate::body::REPHASED.with(|c| c.get()), 0, "no placement searched the revolved faces again");
// A modelling move (a pattern's copy, a hole's tool) still turns them in its own frame.
let _ = crate::transform_matrix(&body, m).unwrap();
assert_eq!(crate::body::REPHASED.with(|c| c.get()), 1);
crate::body::REPHASED.with(|c| c.set(0));
assert!(placed.phased);
let unphased = Body { phased: false, ..body.clone() };
let mut turned = unphased;
for _ in 0..10 {
turned = crate::place_matrix(&turned, m).unwrap();
}
assert!(crate::body::REPHASED.with(|c| c.get()) > 0, "a body not known to be turned still is");
let (a, b) = (measure(&placed).unwrap(), measure(&turned).unwrap());
assert!((a.volume - b.volume).abs() < 1e-9 * a.volume && (a.area - b.area).abs() < 1e-9 * a.area);
assert_eq!(a.faces, b.faces);
assert!(a.bbox.min.dist(b.bbox.min) < 1e-9 && a.bbox.max.dist(b.bbox.max) < 1e-9);
}
/// A plate 3 m long and 2 mm thick with ten holes of radius `r` along a diagonal, turned by
/// `tilt` about the X axis and read back from STEP (each rim is then two half circles).
fn long_plate_with_small_holes(r: f64, tilt: f64) -> (Body, Vec<Vec3>) {
@@ -3137,29 +2612,9 @@ fn a_box_cut_round_a_small_hole_of_a_long_plate_keeps_the_hole() {
let piece = boolean(&p, &cut, BoolOp::Intersect).unwrap().unwrap();
let walls = (0..piece.face_count()).filter(|&i| matches!(face_surface(&piece, i), Some(FaceSurface::Cylinder { .. }))).count();
// Both faces of the plate in the box keep the hole: an outer loop and the rim.
let holed = piece.solid.face_iter().filter(|f| f.boundaries().len() == 2).count();
let holed = piece.record().faces.iter().filter(|f| f.loops.len() == 2).count();
assert!(walls >= 1 && holed == 2, "r {r}, tilt {tilt}, hole at {q:?}: {walls} walls, {holed} faces with a hole");
assert!(measure(&piece).unwrap().volume > 0.0, "r {r}, tilt {tilt}, hole at {q:?}");
}
}
}
/// A transform with a perspective term or a singular linear part is refused: placed into the
/// body, it built a revolved ring whose mesh measured hundreds of millions of mm³, and cutting
/// a pocket into it ran for over half an hour before failing. A translation still places it.
#[test]
fn transform_refuses_a_matrix_that_is_not_an_affine_placement() {
let rect = Loop2::polygon(&[Vec2::new(20.0, 0.0), Vec2::new(60.0, 0.0), Vec2::new(60.0, 10.0), Vec2::new(20.0, 10.0)]);
let ring = revolve(&Plane::XZ, &[Region2 { outer: rect, holes: vec![] }], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU).unwrap().remove(0);
// Column-major: the last entry of the third column is a perspective term.
let perspective = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 10.0], [0.0, 0.0, 0.0, 1.0]];
let flat = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0]];
for m in [perspective, flat] {
assert!(matches!(crate::transform_matrix(&ring, m), Err(KernelError::Invalid(_))), "{m:?}");
}
let lifted = crate::transform_matrix(&ring, [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 10.0, 1.0]]).unwrap();
let pocket = box_solid(Vec3::new(37.0, -3.0, 7.0), Vec3::new(43.0, 3.0, 12.0)).unwrap();
let cut = boolean(&lifted, &pocket, BoolOp::Cut).unwrap().unwrap();
let removed = measure(&lifted).unwrap().volume - measure(&cut).unwrap().volume;
assert!((removed - 6.0 * 6.0 * 3.0).abs() < 1.0, "{removed}");
}