Files
OneCAD/crates/kernel/src/untouched_faces_tests.rs
T
bherbruckandClaude Opus 5.5 d019f2d5bf Straight edges stay one edge after cuts (#1049) (#1101)
* Straight edges stay one edge after cuts (#1049)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Robustness goldens: cuts join straight edges along untouched walls (#1049)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Robustness goldens note: what each changed seed shows when replayed on both builds

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:51:07 -04:00

214 lines
11 KiB
Rust

//! A cut or a join never splits faces it doesn't touch: a flat wall with an opening cut into
//! it stays one face with an inner loop, whatever is cut or joined elsewhere afterwards.
use solidcraft_geom::Vec3;
use crate::{Body, BoolOp, FaceSurface, boolean, box_solid, face_surface, fillet, shell};
/// The faces of `b` lying in the plane through `point` with outward normal `normal`.
fn faces_on(b: &Body, normal: Vec3, point: Vec3) -> usize {
(0..b.face_count())
.filter(|&i| match face_surface(b, i) {
Some(FaceSurface::Plane { normal: n, point: p }) => n.dot(normal) > 1. - 1e-9 && (p - point).dot(normal).abs() < 1e-6,
_ => false,
})
.count()
}
/// The five outer sides of the open case: -y, +y, -x, +x and the bottom.
fn sides(w: f64, d: f64) -> [(Vec3, Vec3); 5] {
[
(Vec3::new(0., -1., 0.), Vec3::new(0., -d / 2., 0.)),
(Vec3::new(0., 1., 0.), Vec3::new(0., d / 2., 0.)),
(Vec3::new(-1., 0., 0.), Vec3::new(-w / 2., 0., 0.)),
(Vec3::new(1., 0., 0.), Vec3::new(w / 2., 0., 0.)),
(Vec3::new(0., 0., -1.), Vec3::new(0., 0., 0.)),
]
}
/// A Raspberry Pi case: a 95 x 66 box of height `h`, its upright edges rounded (5 mm) and
/// shelled 2 mm with the top open.
fn case(h: f64) -> Body {
let b = box_solid(Vec3::new(-47.5, -33., 0.), Vec3::new(47.5, 33., h)).unwrap().with_box_certificate(7).unwrap();
let corners = [(47.5, -33.), (-47.5, -33.), (47.5, 33.), (-47.5, 33.)].map(|(x, y)| Vec3::new(x, y, h / 2.));
let rounded = fillet(&b, &corners, 5.).unwrap().with_preserved_planar_sources(&b).unwrap();
shell(&rounded, &[Vec3::new(0., 0., h)], 2.).unwrap().with_shell_certificate(9).unwrap()
}
fn cut(b: &Body, lo: Vec3, hi: Vec3) -> Body {
boolean(b, &box_solid(lo, hi).unwrap(), BoolOp::Cut).unwrap().unwrap()
}
fn assert_sides(b: &Body, h: f64, what: &str) {
for (n, p) in sides(95., 66.) {
assert_eq!(faces_on(b, n, p), 1, "{what} (height {h}): the outer side facing {n:?} is split");
}
}
/// The README demo's case, at the original height and taller: ports through the long side and
/// the end, then vent slots through the floor. Every outer side stays one face, each opening
/// adds exactly its four sides, and every cut reports its exact history (so faces keep their
/// names): a wall with openings meets the openings' floors along several edges.
#[test]
fn pi_case_ports_and_vents_leave_the_walls_whole() {
for h in [24., 32.] {
let mut b = case(h);
let mut faces = b.face_count();
assert_sides(&b, h, "the shelled case");
// USB-C and two micro-HDMI on the long side, sketched on its outer face and cut through
// the wall (both faces of the tool on the wall's).
for (x0, x1) in [(-36.5, -26.5), (-20.5, -12.5), (-7., 1.)] {
b = cut(&b, Vec3::new(x0, -33., 7.5), Vec3::new(x1, -31., 11.5));
faces += 4;
assert!(b.history().is_some(), "port at x {x0}: the cut reports its history");
assert_eq!(b.face_count(), faces, "port at x {x0} (height {h})");
assert_sides(&b, h, "a port on the long side");
}
// The USB / Ethernet opening on the end.
b = cut(&b, Vec3::new(45.5, -25., 8.), Vec3::new(47.5, 25., 22.));
faces += 4;
assert!(b.history().is_some(), "end opening: the cut reports its history");
assert_eq!(b.face_count(), faces, "end opening (height {h})");
assert_sides(&b, h, "the end opening");
// Five vent slots through the floor, from its inner face to the bottom.
for k in 0..5 {
let y = -15.5 + 7. * k as f64;
b = cut(&b, Vec3::new(-22., y, 0.), Vec3::new(6., y + 3., 2.));
faces += 4;
assert!(b.history().is_some(), "vent slot {k}: the cut reports its history");
assert_eq!(b.face_count(), faces, "vent slot {k} (height {h})");
assert_sides(&b, h, "a floor vent");
}
}
}
/// A small deterministic generator (no test dependency needed).
struct Lcg(u64);
impl Lcg {
fn next(&mut self) -> f64 {
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(self.0 >> 11) as f64 / (1u64 << 53) as f64
}
fn range(&mut self, lo: f64, hi: f64) -> f64 {
lo + (hi - lo) * self.next()
}
}
/// Random openings through the walls and floor and bosses joined on the walls (sunk 1 mm into
/// the 2 mm wall), each on its
/// own patch of a side so none meets another: no side ever gains a face, whichever sides the
/// tools touch, and each tool adds exactly the faces it brings (an opening its four sides, a
/// boss its four sides and its end).
#[test]
fn random_cuts_and_joins_never_split_other_faces() {
for seed in 1..=6u64 {
let mut rng = Lcg(seed);
let h = 24. + 8. * (seed % 2) as f64;
let mut b = case(h);
let mut faces = b.face_count();
// Free patches: on the long sides x in [-40, 40] split in four, on the ends y in [-26,
// 26] split in two, on the floor x in [-40, 40] by y in [-26, 26] split in four.
let mut patches: Vec<(usize, usize)> =
(0..4).flat_map(|k| [(0, k), (1, k)]).chain((0..2).flat_map(|k| [(2, k), (3, k)])).chain((0..4).map(|k| (4, k))).collect();
for step in 0..8 {
let pick = (rng.next() * patches.len() as f64) as usize % patches.len();
let (side, k) = patches.swap_remove(pick);
let join = side < 4 && rng.next() < 0.4;
let (lo, hi) = match side {
0 | 1 => {
let x0 = -40. + 20. * k as f64;
let (a, c) = (rng.range(x0 + 1., x0 + 8.), rng.range(x0 + 11., x0 + 19.));
let (z0, z1) = (rng.range(4., h / 2. - 1.), rng.range(h / 2. + 1., h - 4.));
let y: f64 = if side == 0 { -33. } else { 33. };
let (inside, outside) = if join { (y - y.signum(), y + 3. * y.signum()) } else { (y - 2. * y.signum(), y) };
(Vec3::new(a, inside.min(outside), z0), Vec3::new(c, inside.max(outside), z1))
}
2 | 3 => {
let y0 = -26. + 26. * k as f64;
let (a, c) = (rng.range(y0 + 1., y0 + 10.), rng.range(y0 + 15., y0 + 25.));
let (z0, z1) = (rng.range(4., h / 2. - 1.), rng.range(h / 2. + 1., h - 4.));
let x: f64 = if side == 2 { -47.5 } else { 47.5 };
let (inside, outside) = if join { (x - x.signum(), x + 3. * x.signum()) } else { (x - 2. * x.signum(), x) };
(Vec3::new(inside.min(outside), a, z0), Vec3::new(inside.max(outside), c, z1))
}
_ => {
let (x0, y0) = (-40. + 40. * (k % 2) as f64, -26. + 26. * (k / 2) as f64);
let (a, c) = (rng.range(x0 + 2., x0 + 15.), rng.range(x0 + 25., x0 + 38.));
let (e, f) = (rng.range(y0 + 2., y0 + 10.), rng.range(y0 + 15., y0 + 24.));
(Vec3::new(a, e, 0.), Vec3::new(c, f, 2.))
}
};
let tool = box_solid(lo, hi).unwrap();
b = boolean(&b, &tool, if join { BoolOp::Union } else { BoolOp::Cut }).unwrap().unwrap();
faces += if join { 5 } else { 4 };
let what = format!("seed {seed} step {step}: {} on side {side}", if join { "boss" } else { "opening" });
assert_sides(&b, h, &what);
assert_eq!(b.face_count(), faces, "{what} (height {h})");
}
}
}
/// The edges of `b` lying along the line through `a` and `z`.
fn edges_along(b: &Body, a: Vec3, z: Vec3) -> usize {
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()
}
/// 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());
}
}
}
at.values()
.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)
}
_ => false,
})
.count()
}
/// Cuts at several heights through a block's walls leave no vertices along its straight edges
/// (#1049): the edges they never touch stay one edge each, and so does an edge a later join
/// lies beside, instead of being drawn (and met by later features) as many pieces.
#[test]
fn cuts_leave_straight_edges_whole() {
let mut b = box_solid(Vec3::new(0., 0., 0.), Vec3::new(40., 20., 10.)).unwrap().with_box_certificate(7).unwrap();
// Notches through the front wall at different heights, a pocket in the top and a slot
// through the end: each cut is sliced at its own heights.
b = cut(&b, Vec3::new(5., -1., 2.), Vec3::new(10., 3., 4.));
b = cut(&b, Vec3::new(15., -1., 5.), Vec3::new(20., 3., 8.));
b = cut(&b, Vec3::new(25., 8., 6.), Vec3::new(30., 12., 11.));
b = cut(&b, Vec3::new(37., 5., 3.), Vec3::new(41., 15., 7.));
assert!(b.history().is_some(), "the cuts report their history");
assert_eq!(stations(&b), 0, "a straight edge is split at a vertex nothing else uses");
// The block's four upright edges and the top's far edges are one edge each.
for (x, y) in [(0., 0.), (40., 0.), (0., 20.), (40., 20.)] {
assert_eq!(edges_along(&b, Vec3::new(x, y, 0.), Vec3::new(x, y, 10.)), 1, "upright edge at ({x}, {y})");
}
assert_eq!(edges_along(&b, Vec3::new(0., 20., 10.), Vec3::new(40., 20., 10.)), 1, "the top's back edge");
assert_eq!(edges_along(&b, Vec3::new(0., 0., 0.), Vec3::new(0., 20., 0.)), 1, "the bottom's left edge");
// A boss joined on the top beside its back edge, then another cut: still one edge.
b = boolean(&b, &box_solid(Vec3::new(2., 15., 10.), Vec3::new(8., 20., 14.)).unwrap(), BoolOp::Union).unwrap().unwrap();
b = cut(&b, Vec3::new(12., 4., 1.), Vec3::new(14., 21., 9.));
assert_eq!(stations(&b), 0, "after a join and another cut");
assert_eq!(edges_along(&b, Vec3::new(0., 0., 0.), Vec3::new(0., 0., 10.)), 1, "upright edge after a join and a cut");
}