mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-04 02:55:54 +00:00
kernel: placement, history, mesh-memo, display and issue tests read the record
- placement: a rigid placement moves the record and nothing else (regions, shells, loops with their pcurves, edges and their uses equal; every vertex moved; the identity's history), on a cylinder whose wall's loop uses its seam twice. - issue 2005, 616, 674, 1038, 1510, join between caps, regularization protection, history, mesh memo, display mesh: the truck reads become record reads (shells closed by edge uses, faces by the record's surfaces, vertices by the record's points, the mesher's own boundary budget); the nearly-in-plane slab of 1510 is an extruded record body. - Deleted, testing internals that are gone: issue 616's glue_joins_every_face_the_pieces_share (coplanar::glue) and the_retry_guard_refuses_changes_away_from_the_tool (boolean_guard::check_retry); issue 674's partial_contact_does_not_join_tangent_or_separated_faces (coplanar::imprint_union); issue 1038's hinted_division_matches_the_search_from_nothing (truck's vendored parameter_division); issue 1510's a_face_close_to_the_plane_but_off_it_is_not_moved (ops::off_plane). The boolean-diagnostics assertions (no retry, no tool nudge) went with capture_boolean_diagnostics; the rest of each test is kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017itGh5fpMEousnZ91cYcVt
This commit is contained in:
@@ -294,7 +294,7 @@ fn a_face_crowded_with_holes_gets_a_coarser_angle_and_bounded_work() {
|
||||
let coarse = b.mesh(&crate::MeshTolerance::chord(tol)).unwrap();
|
||||
let round = b.mesh(&crate::MeshTolerance { chord: tol, angle: Some(DISPLAY_ANGLE) }).unwrap();
|
||||
let boundary = |m: &Mesh, z: f64| m.edges.iter().filter(|e| e.iter().all(|p| (p.z - z).abs() < 1e-6)).map(|e| e.len() - 1).sum::<usize>();
|
||||
let budget = truck_meshalgo::tessellation::Deflection::MAX_ANGLE_BOUNDARY;
|
||||
let budget = crate::tess::size::MAX_ANGLE_BOUNDARY;
|
||||
for z in [0.0, 2.0] {
|
||||
// At the chord tolerance alone each rim is divided only as far as a face can be bounded
|
||||
// by it (two segments, a line, are divided again).
|
||||
|
||||
@@ -33,7 +33,7 @@ fn a_transform_keeps_every_face_and_edge_in_place() {
|
||||
assert!(rot(before).dist(after) < 1e-6, "{before:?} → {after:?}");
|
||||
}
|
||||
// Each edge separates the same faces it did.
|
||||
let (ea, em) = (super::edge_faces(&a.solid), super::edge_faces(&moved.solid));
|
||||
let (ea, em) = (super::edge_faces(a.record()), super::edge_faces(moved.record()));
|
||||
assert_eq!(ea, em);
|
||||
assert!(h.edges.iter().all(|e| e.faces.len() == 2 && e.origins.len() == 1));
|
||||
// The history's edges are the tessellation's edges, bounding the same faces.
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
//! #1038: a small hole on a large curved face divides its intersection edges from each point's
|
||||
//! neighbour (the vendored `IntersectionCurve::parameter_division`) instead of searching both
|
||||
//! surfaces from nothing for every point. These pin the hinted division to the search from
|
||||
//! nothing, so a refresh of the vendored crate can't quietly lose the speedup or change a body.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
//! #1038: a small hole on a large curved face cuts cleanly, its rim on the face an intersection
|
||||
//! edge.
|
||||
|
||||
use solidcraft_geom::brep::Curve;
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
|
||||
use truck_modeling as mt;
|
||||
use truck_modeling::{BoundedCurve, ParameterDivision1D};
|
||||
|
||||
use crate::{Body, BoolOp, build::extrude, cylinder, ops::boolean};
|
||||
|
||||
@@ -24,14 +19,15 @@ fn holed() -> (Body, Body) {
|
||||
(block, cut)
|
||||
}
|
||||
|
||||
fn intersection_curves(body: &Body) -> Vec<mt::Curve> {
|
||||
body.solid.edge_iter().map(|e| e.curve()).filter(|c| matches!(c, mt::Curve::IntersectionCurve(_))).collect()
|
||||
}
|
||||
|
||||
fn timed<T>(f: impl Fn() -> T) -> (T, Duration) {
|
||||
let start = Instant::now();
|
||||
let out = f();
|
||||
(out, start.elapsed())
|
||||
/// The edges that are intersection curves: neither lines nor conics (a hole's rim on a spline
|
||||
/// face is a spline).
|
||||
fn intersection_curves(body: &Body) -> Vec<Curve> {
|
||||
body.record()
|
||||
.edges
|
||||
.iter()
|
||||
.map(|e| e.curve.clone())
|
||||
.filter(|c| matches!(c, Curve::Spline { .. } | Curve::RationalSpline { .. } | Curve::SurfaceIntersection { .. }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -49,53 +45,3 @@ fn a_small_hole_in_a_large_spline_face_cuts_cleanly() {
|
||||
let removed = std::f64::consts::PI * 0.25 * 40.0;
|
||||
assert!((v0 - v1 - removed).abs() < 0.05 * removed, "removed {} of {removed}", v0 - v1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hinted_division_matches_the_search_from_nothing_and_is_faster() {
|
||||
let (_, cut) = holed();
|
||||
let (mut hinted_time, mut scratch_time) = (Duration::ZERO, Duration::ZERO);
|
||||
for curve in intersection_curves(&cut) {
|
||||
let mt::Curve::IntersectionCurve(ic) = &curve else { continue };
|
||||
let range = ic.range_tuple();
|
||||
for tol in [0.01, 0.001] {
|
||||
let ((hp, hq), ht) = timed(|| ic.parameter_division(range, tol));
|
||||
let ((sp, _), st) = timed(|| mt::algo::curve::parameter_division(ic, range, tol));
|
||||
hinted_time += ht;
|
||||
scratch_time += st;
|
||||
// The same division up to rounding: the hinted projections converge to within the
|
||||
// Newton tolerance of the searched ones, so a split the chord test only just asks for
|
||||
// can come out one way or the other, but no more than that.
|
||||
assert_eq!((hp.first(), hp.last()), (sp.first(), sp.last()), "tol {tol}");
|
||||
assert!(hp.len().abs_diff(sp.len()) <= 2, "tol {tol}: {} points hinted, {} from nothing", hp.len(), sp.len());
|
||||
// Every hinted point is the curve's own point at its parameter.
|
||||
for (t, h) in hp.iter().zip(&hq) {
|
||||
let s = ic.search_triple(*t).unwrap().0;
|
||||
assert!(mt::MetricSpace::distance(*h, s) < 1e-6, "tol {tol}, t {t}: {h:?} vs {s:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("hinted {hinted_time:?}, from nothing {scratch_time:?}");
|
||||
// The hinted path skips each point's grid search of the B-spline face; a loose bound, so a
|
||||
// loaded runner doesn't trip it, but one a fallback to the search for every point would.
|
||||
assert!(hinted_time * 2 < scratch_time, "hinted {hinted_time:?}, from nothing {scratch_time:?}");
|
||||
}
|
||||
|
||||
/// #4623: dividing an intersection edge at a tolerance finer than truck's 1e-6 (or one that is
|
||||
/// not a number) panicked in truck's tolerance assertion, and the join that asked for it failed.
|
||||
/// It divides at 1e-6 instead.
|
||||
#[test]
|
||||
fn dividing_an_intersection_edge_finer_than_truck_allows_divides_at_its_tolerance() {
|
||||
let (_, cut) = holed();
|
||||
let curves = intersection_curves(&cut);
|
||||
assert!(!curves.is_empty());
|
||||
for curve in curves {
|
||||
let mt::Curve::IntersectionCurve(ic) = &curve else { continue };
|
||||
let range = ic.range_tuple();
|
||||
let at_truck = ic.parameter_division(range, 1e-6);
|
||||
for tol in [5e-7, 1e-12, 0.0, -1.0, f64::NAN] {
|
||||
let got = std::panic::catch_unwind(|| ic.parameter_division(range, tol));
|
||||
let Ok(got) = got else { panic!("dividing at {tol:e} panicked") };
|
||||
assert_eq!(got, at_truck, "tolerance {tol:e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,11 +24,10 @@ fn a_split_in_the_plane_of_a_face_keeps_all_of_the_body() {
|
||||
let b = plate(width);
|
||||
let v = b.volume_at(1e-3).unwrap();
|
||||
let plane = Plane::from_normal(Vec3::new(0.0, 0.0, 1.5), Vec3::Z).unwrap();
|
||||
let (parts, d) = crate::capture_boolean_diagnostics(|| split_by_plane(&b, &plane));
|
||||
let parts = parts.unwrap();
|
||||
let parts = split_by_plane(&b, &plane).unwrap();
|
||||
assert_eq!(parts.len(), 2, "{width}");
|
||||
let vs: Vec<f64> = parts.iter().map(|p| p.volume_at(1e-3).unwrap()).collect();
|
||||
assert!((vs.iter().sum::<f64>() - v).abs() < 1e-6 * v, "{width}: {vs:?} against {v}; {:?}", d.summary());
|
||||
assert!((vs.iter().sum::<f64>() - v).abs() < 1e-6 * v, "{width}: {vs:?} against {v}");
|
||||
// Above: the plate from 1.5 to 2.5 less the hole; below: less the hole and the groove.
|
||||
let (r, h) = (2.5f64, width / 2.0);
|
||||
let hole = std::f64::consts::PI * r * r;
|
||||
@@ -36,44 +35,31 @@ fn a_split_in_the_plane_of_a_face_keeps_all_of_the_body() {
|
||||
let groove = width * 20.0 - (h * (r * r - h * h).sqrt() + r * r * (h / r).asin());
|
||||
let want = [800.0 - hole, 1.5 * (800.0 - hole - groove)];
|
||||
for (got, want) in vs.iter().zip(want) {
|
||||
assert!((got - want).abs() < 0.05, "{width}: {vs:?} against {want}; {:?}", d.summary());
|
||||
assert!((got - want).abs() < 0.05, "{width}: {vs:?} against {want}");
|
||||
}
|
||||
// Both parts end in the plane, not off it.
|
||||
let z: Vec<(f64, f64)> = parts.iter().map(|p| crate::measure(p).map(|m| (m.bbox.min.z, m.bbox.max.z)).unwrap()).collect();
|
||||
assert!((z[0].0 - 1.5).abs() < 1e-6 && (z[1].1 - 1.5).abs() < 1e-6, "{width}: {z:?}; {:?}", d.summary());
|
||||
assert!((z[0].0 - 1.5).abs() < 1e-6 && (z[1].1 - 1.5).abs() < 1e-6, "{width}: {z:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A slab whose bottom rises 0.03 mm over its 700 mm length, its plane through the origin: its
|
||||
/// normal is within 1e-9 of the split plane's, but it doesn't lie in the plane, so it is not
|
||||
/// moved, and the part on the upper side is the whole slab.
|
||||
/// normal is within 1e-9 of the split plane's, but it doesn't lie in the plane, so the part on
|
||||
/// the upper side is the whole slab.
|
||||
#[test]
|
||||
fn a_face_only_nearly_in_the_plane_is_not_moved() {
|
||||
use truck_modeling as mt;
|
||||
let v = |x: f64, y: f64, z: f64| mt::builder::vertex(crate::body::p3(Vec3::new(x, y, z)));
|
||||
let p = [v(0.0, 0.0, 0.0), v(0.0, 100.0, 0.0), v(700.0, 100.0, 0.03), v(700.0, 0.0, 0.03)];
|
||||
let wire: mt::Wire = (0..4).map(|i| mt::builder::line(&p[i], &p[(i + 1) % 4])).collect();
|
||||
let surface = mt::Plane::new(crate::body::p3(Vec3::ZERO), crate::body::p3(Vec3::Y), crate::body::p3(Vec3::new(700.0, 0.0, 0.03)));
|
||||
let bottom = mt::Face::new(vec![wire], mt::Surface::Plane(surface));
|
||||
let b = Body::new(mt::builder::tsweep(&bottom, mt::Vector3::new(0.0, 0.0, 10.0))).unwrap();
|
||||
let x = Vec3::new(700.0, 0.0, 0.03).normalized().unwrap();
|
||||
let bottom = Plane::new(Vec3::ZERO, x, Vec3::Y).unwrap();
|
||||
let outer = solidcraft_geom::Loop2::polygon(&[
|
||||
solidcraft_geom::Vec2::new(0.0, 0.0),
|
||||
solidcraft_geom::Vec2::new(Vec3::new(700.0, 0.0, 0.03).len(), 0.0),
|
||||
solidcraft_geom::Vec2::new(Vec3::new(700.0, 0.0, 0.03).len(), 100.0),
|
||||
solidcraft_geom::Vec2::new(0.0, 100.0),
|
||||
]);
|
||||
let b = crate::extrude(&bottom, &[solidcraft_geom::Region2 { outer, holes: vec![] }], 0.0, 10.0).unwrap().pop().unwrap();
|
||||
let plane = Plane::from_normal(Vec3::ZERO, Vec3::Z).unwrap();
|
||||
assert!(crate::ops::off_plane(&b, &plane, true).is_none());
|
||||
let v0 = b.volume_at(1e-2).unwrap();
|
||||
let up = crate::ops::half_space(&b, &plane, true).unwrap().0.unwrap();
|
||||
let v1 = up.volume_at(1e-2).unwrap();
|
||||
assert!((v1 - v0).abs() < 1e-3 * v0, "{v1} against {v0}");
|
||||
}
|
||||
|
||||
/// A box whose top face is 3e-5 above the plane, within the boolean's tolerance but off it: the
|
||||
/// face bounds a real sliver, and it is not moved.
|
||||
#[test]
|
||||
fn a_face_close_to_the_plane_but_off_it_is_not_moved() {
|
||||
let b = box_solid(Vec3::new(0.0, 0.0, -10.0), Vec3::new(700.0, 100.0, 3e-5)).unwrap();
|
||||
let plane = Plane::from_normal(Vec3::ZERO, Vec3::Z).unwrap();
|
||||
assert!(crate::ops::off_plane(&b, &plane, true).is_none());
|
||||
assert!(crate::ops::off_plane(&b, &plane, false).is_none());
|
||||
// A face in the plane is moved.
|
||||
let plate = plate(4.0);
|
||||
let at = Plane::from_normal(Vec3::new(0.0, 0.0, 1.5), Vec3::Z).unwrap();
|
||||
assert!(crate::ops::off_plane(&plate, &at, true).is_some() && crate::ops::off_plane(&plate, &at, false).is_some());
|
||||
}
|
||||
|
||||
@@ -33,7 +33,8 @@ fn tube(body: &Body, volume: f64, genus: usize) {
|
||||
assert!((m.volume - volume).abs() < volume * 0.01, "{m:?}, expected {volume}");
|
||||
// A closed orientable tube has Euler characteristic 2 - 2g. Coincident caps gave 2.
|
||||
assert_eq!(m.vertices + m.faces + 2 * genus, m.edges + 2, "{m:?}");
|
||||
assert!(body.solid.boundaries().iter().all(|s| s.extract_boundaries().is_empty()));
|
||||
// Every shell closed: each edge bounds two faces.
|
||||
assert!(body.record().edges.iter().all(|e| e.uses.len() >= 2));
|
||||
let h = body.history().expect("sweep provenance");
|
||||
assert!(h.faces.iter().all(|f| f.origins.iter().all(|o| matches!(o, crate::history::FaceOrigin::Profile { .. }))));
|
||||
}
|
||||
|
||||
@@ -54,7 +54,11 @@ fn moved_outside(from: &Mesh, onto: &Mesh, region: &Aabb3, tol: f64) -> Option<V
|
||||
/// (a slit, a moved face) and none lost, outside the tool's box.
|
||||
fn assert_local(target: &Body, tool: &Body, joined: &Body, what: &str) {
|
||||
let tol = 1e-3;
|
||||
let (mt, mtool, mj) = (target.mesh(&crate::MeshTolerance::chord(0.01)).unwrap(), tool.mesh(&crate::MeshTolerance::chord(0.01)).unwrap(), joined.mesh(&crate::MeshTolerance::chord(0.01)).unwrap());
|
||||
let (mt, mtool, mj) = (
|
||||
target.mesh(&crate::MeshTolerance::chord(0.01)).unwrap(),
|
||||
tool.mesh(&crate::MeshTolerance::chord(0.01)).unwrap(),
|
||||
joined.mesh(&crate::MeshTolerance::chord(0.01)).unwrap(),
|
||||
);
|
||||
let mut region = mtool.bounds();
|
||||
region.min -= Vec3::new(0.01, 0.01, 0.01);
|
||||
region.max += Vec3::new(0.01, 0.01, 0.01);
|
||||
@@ -64,19 +68,17 @@ fn assert_local(target: &Body, tool: &Body, joined: &Body, what: &str) {
|
||||
|
||||
/// The four ribs of the issue, 1.6 × 6 × 11.7, sit on the plate (z = 2.4), over the skirt, flush
|
||||
/// with the window's sides. Each join adds exactly what lies outside the skirt, changes nothing
|
||||
/// else (it used to leave a slit through the far wall), and needs no displacement of the tool.
|
||||
/// else (it used to leave a slit through the far wall).
|
||||
#[test]
|
||||
fn ribs_joined_flush_to_the_lid_change_nothing_else() {
|
||||
let mut b = lid();
|
||||
assert!((volume(&b) - 6703.5222).abs() < 1e-3, "{}", volume(&b));
|
||||
for c in [[102.8, 14., 2.4], [102.8, 48., 2.4], [172.4, 14., 2.4], [172.4, 48., 2.4]] {
|
||||
let tool = bx(c, [1.6, 6., 11.7]);
|
||||
let (r, report) = capture_boolean_diagnostics(|| boolean(&b, &tool, BoolOp::Union));
|
||||
let r = r.unwrap().unwrap();
|
||||
let r = boolean(&b, &tool, BoolOp::Union).unwrap().unwrap();
|
||||
// 1.6 × 6 × 11.7 less the 1.35 × 6 × 3 inside the skirt.
|
||||
assert!((volume(&r) - volume(&b) - 88.02).abs() < 1e-6, "{c:?}: {}", volume(&r) - volume(&b));
|
||||
assert!(!report.events.iter().any(|e| e.strategy == BooleanStrategy::ToolNudge), "{c:?}: {report:?}");
|
||||
assert_eq!(r.solid.boundaries().len(), 1);
|
||||
assert_eq!(r.shell_count(), 1);
|
||||
assert_local(&b, &tool, &r, &format!("{c:?}"));
|
||||
b = r;
|
||||
}
|
||||
@@ -84,40 +86,6 @@ fn ribs_joined_flush_to_the_lid_change_nothing_else() {
|
||||
assert!((bounds.max.z - 14.1).abs() < 1e-9, "the ribs keep their height: {}", bounds.max.z);
|
||||
}
|
||||
|
||||
/// A plane cut through a frame meets it in two faces; gluing the halves back joins both (one
|
||||
/// left over was the slit of #616).
|
||||
#[test]
|
||||
fn glue_joins_every_face_the_pieces_share() {
|
||||
let frame = boolean(&bx([0., 0., 0.], [40., 30., 5.]), &bx([5., 5., -1.], [30., 20., 7.]), BoolOp::Cut).unwrap().unwrap();
|
||||
let low = boolean(&frame, &bx([-1., -1., -1.], [42., 16., 7.]), BoolOp::Intersect).unwrap().unwrap();
|
||||
let high = boolean(&frame, &bx([-1., 15., -1.], [42., 16., 7.]), BoolOp::Intersect).unwrap().unwrap();
|
||||
let glued = crate::coplanar::glue(&low, &high).unwrap().unwrap();
|
||||
assert_eq!(glued.solid.boundaries().len(), 1);
|
||||
assert!((volume(&glued) - volume(&frame)).abs() < 1e-6);
|
||||
assert!((measure(&glued).unwrap().area - measure(&frame).unwrap().area).abs() < 1e-6, "no faces left inside");
|
||||
}
|
||||
|
||||
/// The guard refuses a result that changed the target away from the tool, or lost volume in a
|
||||
/// join, and accepts the right one.
|
||||
#[test]
|
||||
fn the_retry_guard_refuses_changes_away_from_the_tool() {
|
||||
let target = bx([0., 0., 0.], [40., 30., 5.]);
|
||||
let tool = bx([0., 0., 5.], [4., 4., 4.]);
|
||||
let right = boolean(&target, &tool, BoolOp::Union).unwrap().unwrap();
|
||||
assert!(crate::boolean_guard::check_retry(&target, &tool, BoolOp::Union, Some(&right)).is_ok());
|
||||
// A notch far from the tool (volume still up: the tool adds more than the notch takes).
|
||||
let notched = boolean(&right, &bx([30., 10., 4.], [2., 2., 2.]), BoolOp::Cut).unwrap().unwrap();
|
||||
let e = crate::boolean_guard::check_retry(&target, &tool, BoolOp::Union, Some(¬ched)).unwrap_err().to_string();
|
||||
assert!(e.contains("away from the tool") && e.contains("refused"), "{e}");
|
||||
// A join that lost volume.
|
||||
let e = crate::boolean_guard::check_retry(&target, &tool, BoolOp::Union, Some(&bx([0., 0., 0.], [40., 30., 4.]))).unwrap_err().to_string();
|
||||
assert!(e.contains("volume"), "{e}");
|
||||
// A cut that comes out empty while the tool covers only part of the target.
|
||||
assert!(crate::boolean_guard::check_retry(&target, &tool, BoolOp::Cut, None).is_err());
|
||||
assert!(crate::boolean_guard::check_retry(&tool, &target, BoolOp::Cut, None).is_err());
|
||||
assert!(crate::boolean_guard::check_retry(&tool, &bx([-1., -1., 4.], [6., 6., 6.]), BoolOp::Cut, None).is_ok());
|
||||
}
|
||||
|
||||
/// Random small boxes joined flush to the walls and floor of a shelled box with rounded corners
|
||||
/// (sitting on the floor, against a wall, flush with a wall's top or into its corner), and of
|
||||
/// the same box with a window through its floor (on the ledge beside the window, flush with its
|
||||
|
||||
@@ -5,11 +5,15 @@ use std::f64::consts::PI;
|
||||
|
||||
#[test]
|
||||
fn split_contact_arc_crossing_one_edge_twice_preserves_the_body() {
|
||||
use truck_modeling::Surface;
|
||||
let a = box_solid(Vec3::ZERO, Vec3::new(25.0, 47.5, 37.5)).unwrap();
|
||||
let b = cylinder(Vec3::new(0.0, 7.5, 0.0), -Vec3::X, 5.0, 2.5).unwrap();
|
||||
let face = a.faces(0.01).unwrap().iter().position(|f| f.plane_normal.is_some_and(|n| n.dot(-Vec3::X) > 0.99)).unwrap();
|
||||
let side = b.solid.face_iter().position(|f| !matches!(f.surface(), Surface::Plane(_))).unwrap();
|
||||
let side = b
|
||||
.record()
|
||||
.faces
|
||||
.iter()
|
||||
.position(|f| !matches!(f.surface, solidcraft_geom::brep::Surface::Plane { .. } | solidcraft_geom::brep::Surface::PlaneThrough { .. }))
|
||||
.unwrap();
|
||||
let r = split_faces(&a, &[face], &SplitTool::Face { body: &b, face: side }).unwrap();
|
||||
assert_eq!(r.face_count(), a.face_count() + 1);
|
||||
assert!(r.validity().is_empty(), "{:?}", r.validity());
|
||||
@@ -19,15 +23,6 @@ fn split_contact_arc_crossing_one_edge_twice_preserves_the_body() {
|
||||
assert!((before.area - after.area).abs() < 1e-7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_contact_does_not_join_tangent_or_separated_faces() {
|
||||
let a = box_solid(Vec3::ZERO, Vec3::new(25.0, 47.5, 37.5)).unwrap();
|
||||
for z in [-5.0, -6.0] {
|
||||
let b = cylinder(Vec3::new(0.0, 7.5, z), -Vec3::X, 5.0, 2.5).unwrap();
|
||||
assert!(crate::coplanar::imprint_union(&a, &b, &|_| true).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_contact_retains_an_authored_split_away_from_the_contact() {
|
||||
let a = box_solid(Vec3::ZERO, Vec3::new(25.0, 47.5, 37.5)).unwrap();
|
||||
|
||||
@@ -9,7 +9,6 @@ use std::f64::consts::PI;
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use crate::body::from_p3;
|
||||
use crate::*;
|
||||
|
||||
/// A 40 × 20 × 6 plate (z 5 … 11) with a 14 × 10 × 6 leaf under its back edge, 1 mm into it:
|
||||
@@ -23,7 +22,7 @@ fn plate_with_leaf() -> Body {
|
||||
/// Barrels along x with their axis on the leaf's back bottom edge (y 20, z 0): radius 5 (its top
|
||||
/// meets the plate's back edge) and 4.9, ends flush with both of the leaf's sides, with one, and
|
||||
/// running on past both under the plate. A quarter of the barrel lies in the leaf (where the
|
||||
/// leaf is). Each join is exact: no retry, one solid, and every vertex on a level along x of the
|
||||
/// leaf is). Each join is exact: one solid, and every vertex on a level along x of the
|
||||
/// plate, the leaf or the barrel.
|
||||
#[test]
|
||||
fn a_barrel_between_the_sides_of_a_leaf_joins_exactly() {
|
||||
@@ -31,16 +30,14 @@ fn a_barrel_between_the_sides_of_a_leaf_joins_exactly() {
|
||||
for (r, x0, x1) in [(5., 10., 24.), (4.9, 10., 24.), (5., 10., 23.), (5., 8.5, 25.5)] {
|
||||
let what = format!("radius {r}, x {x0} … {x1}");
|
||||
let barrel = cylinder(Vec3::new(x0, 20., 0.), Vec3::X, r, x1 - x0).unwrap();
|
||||
let (joined, d) = capture_boolean_diagnostics(|| boolean(&part, &barrel, BoolOp::Union));
|
||||
let joined = joined.unwrap().unwrap();
|
||||
assert!(d.events.is_empty(), "{what}: {d:?}");
|
||||
let joined = boolean(&part, &barrel, BoolOp::Union).unwrap().unwrap();
|
||||
assert_eq!(joined.shell_count(), 1, "{what}");
|
||||
assert!(joined.validity().is_empty(), "{what}: {:?}", joined.validity());
|
||||
let inside = (x1.min(24.) - x0.max(10.)) / 4.;
|
||||
let want = 5500. + PI * r * r * (x1 - x0 - inside);
|
||||
let v = joined.mesh(&crate::MeshTolerance::chord(1e-4)).unwrap().measure().volume;
|
||||
assert!((v - want).abs() < want * 1e-5, "{what}: volume {v} vs {want}");
|
||||
for p in joined.solid.vertex_iter().map(|v| from_p3(v.point())) {
|
||||
for p in joined.record().vertices.iter().map(|v| Vec3::from(v.point)) {
|
||||
assert!([0., 10., 24., 40., x0, x1].iter().any(|x| (p.x - x).abs() < 1e-9), "{what}: a vertex at {p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,8 @@ fn a_copy_with_another_solid_makes_its_own_meshes() {
|
||||
let kept = plate().keeping_meshes();
|
||||
let other = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
|
||||
let mut swapped = kept.clone();
|
||||
swapped.solid = other.solid.clone();
|
||||
swapped.record = other.record.clone();
|
||||
swapped.smlib = other.smlib.clone();
|
||||
let _ = kept.mesh(&crate::MeshTolerance::chord(0.5)).unwrap();
|
||||
let m = swapped.mesh(&crate::MeshTolerance::chord(0.5)).unwrap();
|
||||
assert_eq!(m.triangles.len(), other.mesh(&crate::MeshTolerance::chord(0.5)).unwrap().triangles.len());
|
||||
|
||||
@@ -1,64 +1,41 @@
|
||||
use super::*;
|
||||
use crate::body::{p3, v3};
|
||||
use mt::builder;
|
||||
use crate::history::BodyHistory;
|
||||
|
||||
fn assert_topology(source: &Body, placed: &Body, matrix: [[f64; 4]; 4]) {
|
||||
use std::collections::HashMap;
|
||||
let mut vertices = HashMap::new();
|
||||
let mut edges = HashMap::new();
|
||||
for (a, b) in source.solid.face_iter().zip(placed.solid.face_iter()) {
|
||||
assert_eq!(a.orientation(), b.orientation());
|
||||
let aw = a.absolute_boundaries();
|
||||
let bw = b.absolute_boundaries();
|
||||
assert_eq!(aw.len(), bw.len());
|
||||
for (a, b) in aw.iter().zip(bw.iter()) {
|
||||
assert_eq!(a.len(), b.len());
|
||||
for (a, b) in a.edge_iter().zip(b.edge_iter()) {
|
||||
assert_eq!(a.orientation(), b.orientation());
|
||||
assert_ne!(a.id(), b.id());
|
||||
assert_eq!(*edges.entry(a.id()).or_insert(b.id()), b.id());
|
||||
for (a, b) in [(a.absolute_front(), b.absolute_front()), (a.absolute_back(), b.absolute_back())] {
|
||||
assert_ne!(a.id(), b.id());
|
||||
assert_eq!(*vertices.entry(a.id()).or_insert(b.id()), b.id());
|
||||
let p = from_p3(a.point());
|
||||
let expected = Vec3::new(
|
||||
matrix[0][0] * p.x + matrix[1][0] * p.y + matrix[2][0] * p.z + matrix[3][0],
|
||||
matrix[0][1] * p.x + matrix[1][1] * p.y + matrix[2][1] * p.z + matrix[3][1],
|
||||
matrix[0][2] * p.x + matrix[1][2] * p.y + matrix[2][2] * p.z + matrix[3][2],
|
||||
);
|
||||
assert!(from_p3(b.point()).dist(expected) < 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(edges.len(), source.solid.edge_iter().map(|e| e.id()).collect::<std::collections::HashSet<_>>().len());
|
||||
/// `p` moved by the column-major affine `matrix`.
|
||||
fn moved(matrix: [[f64; 4]; 4], p: [f64; 3]) -> Vec3 {
|
||||
Vec3::new(
|
||||
matrix[0][0] * p[0] + matrix[1][0] * p[1] + matrix[2][0] * p[2] + matrix[3][0],
|
||||
matrix[0][1] * p[0] + matrix[1][1] * p[1] + matrix[2][1] * p[2] + matrix[3][1],
|
||||
matrix[0][2] * p[0] + matrix[1][2] * p[1] + matrix[2][2] * p[2] + matrix[3][2],
|
||||
)
|
||||
}
|
||||
|
||||
/// A rigid placement moves the record and nothing else: the same regions, shells, faces' loops
|
||||
/// (edges, directions and pcurves: each surface moves with its parameters), edges' ends and uses;
|
||||
/// every vertex moved by the motion; and the history the identity's.
|
||||
fn assert_topology(source: &Body, placed: &Body, matrix: [[f64; 4]; 4]) {
|
||||
let (a, b) = (source.record(), placed.record());
|
||||
assert_eq!(a.regions, b.regions);
|
||||
assert_eq!(a.shells, b.shells);
|
||||
assert_eq!(a.faces.len(), b.faces.len());
|
||||
for (fa, fb) in a.faces.iter().zip(&b.faces) {
|
||||
assert_eq!(fa.loops, fb.loops);
|
||||
assert_eq!(fa.domain, fb.domain);
|
||||
}
|
||||
assert_eq!(a.edges.len(), b.edges.len());
|
||||
for (ea, eb) in a.edges.iter().zip(&b.edges) {
|
||||
assert_eq!((ea.vertices, &ea.uses, ea.interval), (eb.vertices, &eb.uses, eb.interval));
|
||||
}
|
||||
assert_eq!(a.vertices.len(), b.vertices.len());
|
||||
for (va, vb) in a.vertices.iter().zip(&b.vertices) {
|
||||
assert!(Vec3::from(vb.point).dist(moved(matrix, va.point)) < 1e-9);
|
||||
}
|
||||
assert_eq!(placed.history(), Some(&<crate::history::History as BodyHistory>::identity(source)));
|
||||
}
|
||||
|
||||
/// A cylinder whose wall is one face with a seam: the seam edge is used twice by the wall's loop.
|
||||
fn periodic_cylinder() -> Body {
|
||||
let b0 = builder::vertex(p3(Vec3::new(10., 0., 0.)));
|
||||
let b1 = builder::vertex(p3(Vec3::new(-10., 0., 0.)));
|
||||
let t0 = builder::vertex(p3(Vec3::new(10., 0., 20.)));
|
||||
let t1 = builder::vertex(p3(Vec3::new(-10., 0., 20.)));
|
||||
let bottom: mt::Wire =
|
||||
vec![builder::circle_arc(&b0, &b1, p3(Vec3::new(0., 10., 0.))), builder::circle_arc(&b1, &b0, p3(Vec3::new(0., -10., 0.)))].into();
|
||||
let top: mt::Wire =
|
||||
vec![builder::circle_arc(&t0, &t1, p3(Vec3::new(0., 10., 20.))), builder::circle_arc(&t1, &t0, p3(Vec3::new(0., -10., 20.)))].into();
|
||||
let seam = builder::line(&b0, &t0);
|
||||
let mut boundary: Vec<_> = bottom.edge_iter().cloned().collect();
|
||||
boundary.push(seam.clone());
|
||||
boundary.extend(top.inverse().edge_iter().cloned());
|
||||
boundary.push(seam.inverse());
|
||||
let wall = mt::Face::new_unchecked(
|
||||
vec![boundary.into()],
|
||||
mt::Surface::RevolutedCurve(mt::Processor::new(mt::RevolutedCurve::by_revolution(
|
||||
seam.inverse().oriented_curve(),
|
||||
p3(Vec3::ZERO),
|
||||
v3(Vec3::Z),
|
||||
))),
|
||||
);
|
||||
let lower = builder::try_attach_plane(&[bottom.inverse()]).unwrap();
|
||||
let upper = builder::try_attach_plane(&[top]).unwrap();
|
||||
Body::new(Solid::new_unchecked(vec![vec![wall, lower, upper].into()])).unwrap()
|
||||
crate::cylinder(Vec3::ZERO, Vec3::Z, 10.0, 20.0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -68,9 +45,14 @@ fn periodic_step_seam_survives_identity_rotation_and_translation() {
|
||||
let imported = crate::step_import(&bytes).unwrap();
|
||||
assert!(imported.warnings.is_empty(), "{:?}", imported.warnings);
|
||||
let body = &imported.bodies.first().unwrap().body;
|
||||
assert!(body.solid.face_iter().any(|f| f.absolute_boundaries().iter().any(|w| !w.is_simple())));
|
||||
let source_vertices: Vec<_> = body.solid.vertex_iter().map(|v| from_p3(v.point())).collect();
|
||||
let source_faces = body.solid.face_iter().count();
|
||||
// A face whose loop uses an edge twice (the seam).
|
||||
let seamed = body.record().faces.iter().any(|f| {
|
||||
f.loops.iter().any(|l| match l {
|
||||
solidcraft_geom::brep::Loop::Edges { edges } => edges.iter().any(|u| edges.iter().filter(|w| w.edge == u.edge).count() > 1),
|
||||
solidcraft_geom::brep::Loop::Vertex { .. } => false,
|
||||
})
|
||||
});
|
||||
assert!(seamed);
|
||||
let unchanged = crate::step_export(&[body], "source snapshot").unwrap();
|
||||
let before = body.mesh(&crate::MeshTolerance::chord(1e-3)).unwrap().measure().volume;
|
||||
assert!((before - 2000. * std::f64::consts::PI).abs() < 1.);
|
||||
@@ -81,8 +63,6 @@ fn periodic_step_seam_survives_identity_rotation_and_translation() {
|
||||
] {
|
||||
let moved = transform_matrix(body, matrix).unwrap();
|
||||
assert_topology(body, &moved, matrix);
|
||||
assert_eq!(moved.solid.face_iter().count(), source_faces);
|
||||
assert_eq!(moved.solid.vertex_iter().count(), source_vertices.len());
|
||||
let after = moved.mesh(&crate::MeshTolerance::chord(1e-3)).unwrap().measure().volume;
|
||||
assert!((after - before).abs() < 1e-5, "{after} vs {before}");
|
||||
let roundtrip = crate::step_import(&crate::step_export(&[&moved], "placement regression").unwrap()).unwrap();
|
||||
|
||||
@@ -15,13 +15,7 @@ fn stamped_shell_keeps_authored_painted_and_split_boundaries() {
|
||||
let ordinary = cup(&stock);
|
||||
assert!(!ordinary.regularization_protected);
|
||||
let painted = stock.clone().with_paint(Some(Paint { opacity: 1., faces: vec![FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }] }));
|
||||
let side = stock
|
||||
.solid
|
||||
.face_iter()
|
||||
.enumerate()
|
||||
.find(|(i, _)| stock.planar_face(*i).is_some_and(|p| p.normal().dot(Vec3::X) > 0.999 && p.origin.x == 20.))
|
||||
.unwrap()
|
||||
.0;
|
||||
let side = (0..stock.face_count()).find(|i| stock.planar_face(*i).is_some_and(|p| p.normal().dot(Vec3::X) > 0.999 && p.origin.x == 20.)).unwrap();
|
||||
let split = crate::split_faces(&stock, &[side], &crate::SplitTool::Plane(Plane { origin: Vec3::new(0., 0., 8.), ..Plane::XY })).unwrap();
|
||||
assert!(split.split_lines().is_some());
|
||||
for protected in [painted, split] {
|
||||
|
||||
Reference in New Issue
Block a user