fix: cuts no longer split walls they don't touch (#670)

A cut built slab by slab (the exact prism boolean) sewed the result from horizontal
slabs and only joins merged the coplanar pieces back. So a floor vent cut through the
Raspberry Pi case split the long wall into strips at the ports' tops and bottoms
(87 faces instead of 79 at 32 mm), and at 24 mm the end opening split the end wall.

- Prism booleans merge coplanar neighbours for every operation (faces only: edges,
  curves and surfaces stay exact), keeping each face's construction source where all
  its pieces share it, Split Face lines and regularization protection. Painted or
  protected operands keep their authored pieces as before.
- The prism history now tells apart a wall's several edges with its caps (a wall
  with openings meets each opening's floor), so cuts beside existing openings keep
  exact history and faces keep their names.
- Tests: the Pi case's faces pinned after every feature at 24, at 24 changed to 32,
  and built at 32 (each outer side one face); random openings and bosses on a
  shelled case never split another side; every corpus design rebuilds a parameter
  edit like a fresh build and reverts exactly.
- Action matrix baseline: demo/pi-case re-blessed (fewer, merged faces; no
  regressions), demo/pi-case-0.8 recorded, and 88 new passes on
  regressions/lid_ribs_flush_with_window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-13 10:14:52 -04:00
1 parent 9ba72e118b
commit 1e4387f3ea
8 files changed
+1277 -376

No files matched your search

@@ -0,0 +1,203 @@
//! A parameter change gives the same topology as building at the new value from scratch, and
//! changing it back gives the original topology: no stale splits, no faces added to regions
//! nothing touched. Over the design corpus (`tests/corpus`, `tests/action_matrix/designs`), and
//! the README demo's Raspberry Pi case pinned step by step.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use solidcraft_engine::Session;
use solidcraft_engine::doc::kernel::{FaceSurface, face_surface};
fn root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().unwrap()
}
/// A folder of its own for test `name` (tests run side by side).
fn scratch_dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("solidcraft-rebuild-topology-{name}-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d
}
/// A body's topology: its face and edge counts, and how many faces lie in each plane.
#[derive(Debug, PartialEq, Eq)]
struct Topology {
faces: usize,
edges: usize,
planes: BTreeMap<[i64; 4], usize>,
}
/// Every solid body's topology, by name.
fn topology(s: &Session) -> BTreeMap<String, Topology> {
let st = s.world_state();
st.bodies
.iter()
.filter(|b| !b.body.is_mesh())
.map(|b| {
let mut planes = BTreeMap::new();
for i in 0..b.body.face_count() {
if let Some(FaceSurface::Plane { normal, point }) = face_surface(&b.body, i) {
let key = [normal.x, normal.y, normal.z, normal.dot(point) / 100.].map(|x| (x * 1e4).round() as i64);
*planes.entry(key).or_insert(0) += 1;
}
}
(b.name.clone(), Topology { faces: b.body.face_count(), edges: b.body.edge_count(), planes })
})
.collect()
}
fn open(path: &Path) -> Session {
let mut s = Session::default();
s.execute("doc.open", &json!({"path": path.to_string_lossy()})).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
s
}
/// The design built from scratch: saved as it is (timeline only, no cached geometry) and
/// opened again.
fn rebuilt(s: &Session, path: &Path) -> Session {
let mut t = s.scratch();
t.execute("file.save", &json!({"path": path.to_string_lossy(), "format": "json"})).unwrap();
open(path)
}
fn errors(s: &Session) -> Vec<u64> {
s.model.results.iter().filter(|r| r.error.is_some()).map(|r| r.id).collect()
}
/// The corpus's designs, as sessions.
fn designs() -> Vec<(String, Session)> {
let root = root();
let mut out = Vec::new();
for dir in ["apps/solidcraft-cli/tests/corpus", "tests/action_matrix/designs"] {
let mut files: Vec<PathBuf> = std::fs::read_dir(root.join(dir))
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "solidcraft" || x == "json") && !p.to_string_lossy().ends_with(".expected.json"))
.collect();
files.sort();
for p in files {
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
let s = if p.extension().is_some_and(|x| x == "solidcraft") {
open(&p)
} else {
let mut s = Session::default();
let script: Value = serde_json::from_slice(&std::fs::read(&p).unwrap()).unwrap();
s.run_script(&script).unwrap_or_else(|e| panic!("{name}: {e}"));
if s.active_sketch.is_some() {
let _ = s.execute("sketch.finish", &json!({}));
}
s
};
out.push((name, s));
}
}
out
}
/// For each design and its first length parameter that edits cleanly (the user's first): the parameter
/// scaled by 1.1 gives what a fresh build at that value gives, and setting it back gives the
/// original topology.
#[test]
fn a_parameter_edit_rebuilds_like_a_fresh_build_and_reverts_exactly() {
let dir = scratch_dir("corpus");
let mut problems = Vec::new();
let mut checked = 0;
for (name, s) in designs() {
let original = topology(&s);
let before = errors(&s);
let mut params: Vec<_> = s.doc.params.iter().filter(|p| p.unit == "mm").cloned().collect();
params.sort_by_key(|p| p.model);
let mut tried = 0;
for p in params {
if tried == 1 {
break;
}
let mut t = s.scratch();
let expr = format!("({}) * 1.1", p.expr);
if t.execute("parameters.change", &json!({"name": p.name, "expression": expr})).is_err() || errors(&t) != before {
continue;
}
tried += 1;
checked += 1;
let edited = topology(&t);
let fresh = topology(&rebuilt(&t, &dir.join(format!("{name}-{}.solidcraft", p.name))));
if edited != fresh {
problems.push(format!("{name}: {} = {expr}: edited {edited:?}\n fresh {fresh:?}", p.name));
}
t.execute("parameters.change", &json!({"name": p.name, "expression": p.expr})).unwrap();
let back = topology(&t);
if back != original {
problems.push(format!("{name}: {} back to {}: {back:?}\n original {original:?}", p.name, p.expr));
}
}
}
let _ = std::fs::remove_dir_all(&dir);
assert!(checked >= 8, "only {checked} parameter edits checked");
assert!(problems.is_empty(), "{}", problems.join("\n"));
}
/// The README demo's Raspberry Pi case (`examples/demo/enclosure_ui_mcp.py --story pi`): its
/// face count after each feature, and each outer side (the four walls and the bottom) one face
/// throughout: (a) built at height 24, (b) built at 24 and changed to 32 in Parameters, (c)
/// built at 32. The port cut-outs and the floor vents used to split the long wall (or the end
/// wall at 24) into strips along the openings' tops and bottoms.
#[test]
fn pi_case_faces_per_step_at_both_heights() {
// Sketch1 (1) .. RectangularPattern2 (14): the faces once features 1..=k are built.
const FACES: [(usize, usize); 13] =
[(2, 6), (3, 10), (4, 19), (5, 19), (6, 25), (7, 43), (8, 43), (9, 55), (10, 55), (11, 59), (12, 59), (13, 63), (14, 79)];
let dir = scratch_dir("pi");
let design: Value = serde_json::from_slice(&std::fs::read(root().join("tests/action_matrix/designs/pi-case.solidcraft")).unwrap()).unwrap();
let at = |height: &str, file: &str| {
let mut d = design.clone();
for p in d["params"].as_array_mut().unwrap() {
if p["name"] == "height" {
p["expr"] = json!(height);
}
}
let path = dir.join(file);
std::fs::write(&path, serde_json::to_vec(&d).unwrap()).unwrap();
open(&path)
};
let a = at("24 mm", "pi-24.solidcraft");
let mut b = at("24 mm", "pi-24-to-32.solidcraft");
b.execute("parameters.change", &json!({"name": "height", "expression": "32 mm"})).unwrap();
let c = at("32 mm", "pi-32.solidcraft");
let _ = std::fs::remove_dir_all(&dir);
let mut per_case = Vec::new();
for (case, s, h) in [("(a) at 24", a, 24.), ("(b) 24 changed to 32", b, 32.), ("(c) at 32", c, 32.)] {
assert!(errors(&s).is_empty(), "{case}: features fail: {:?}", errors(&s));
let mut steps = Vec::new();
for (k, want) in FACES {
let mut t = s.scratch();
t.execute("timeline.rollback", &json!({"position": k})).unwrap();
let st = t.world_state();
let body = st.bodies.iter().find(|b| b.name == "Body1").unwrap();
assert_eq!(body.body.face_count(), want, "{case}: faces after feature {k}");
// Each outer side: the faces in its plane.
for (n, d) in [([0., -1., 0.], 33.), ([0., 1., 0.], 33.), ([-1., 0., 0.], 47.5), ([1., 0., 0.], 47.5), ([0., 0., -1.], 0.)] {
let on = (0..body.body.face_count())
.filter(|&i| match face_surface(&body.body, i) {
Some(FaceSurface::Plane { normal, point }) => {
(normal.x - n[0]).abs() + (normal.y - n[1]).abs() + (normal.z - n[2]).abs() < 1e-6 && (normal.dot(point) - d).abs() < 1e-6
}
_ => false,
})
.count();
assert_eq!(on, 1, "{case}: after feature {k} the outer side facing {n:?} is {on} faces");
}
let bbox_top = body.mesh().bounds().max.z;
assert!((bbox_top - h).abs() < 1e-6, "{case}: height {bbox_top}");
steps.push(topology(&t));
}
per_case.push((case, steps));
}
// (b) and (c): the same topology after every feature.
let (b, c) = (&per_case[1].1, &per_case[2].1);
for (k, (x, y)) in b.iter().zip(c).enumerate() {
assert_eq!(x, y, "after feature {}: (b) differs from (c)", k + 2);
}
}
+29
View File
@@ -146,6 +146,35 @@ pub(crate) fn heal_body(body: &Body, solid: Solid, size: f64, keep: &[Vec<Vec3>]
heal_build(solid, size, keep, faces, edges, counts)
}
/// A prism boolean's result `body` with the coplanar pieces its slabs leave merged (nothing
/// else healed: its edges are exact as they are), its history carried through, and what else it carries kept: each face's
/// construction source where every piece merged into it had that same one, its Split Face
/// lines and its regularization protection.
pub(crate) fn heal_prism_result(body: &Body, size: f64, keep: &[Vec<Vec3>], counts: &[(usize, usize)]) -> Result<Body> {
let (faces, edges): (Option<Vec<FaceProv>>, Option<Vec<Vec<EdgeOrigin>>>) =
body.history().map(|h| (h.faces.clone(), h.edges.iter().map(|e| e.origins.clone()).collect())).unzip();
let (healed, map) = crate::heal::merge_faces_traced((*body.solid).clone(), size, keep);
let sources = match (body.face_sources(), &map) {
(Some(sources), Some(m)) => Some(
m.faces
.iter()
.map(|under| {
let mut each = under.iter().map(|i| sources.get(*i).cloned().flatten());
let first = each.next().flatten()?;
each.all(|s| s.as_ref() == Some(&first)).then_some(first)
})
.collect::<Vec<_>>(),
),
_ => None,
};
let carried = faces.zip(edges).zip(map).and_then(|((f, e), m)| m.carry(&f, &e));
let (faces, edges) = carried.unzip();
let mut out = record::build(healed, counts, faces, edges.as_ref())?;
out = out.with_face_sources(sources.map(std::sync::Arc::new)).inherit_regularization_protection(body);
out.splits = body.splits.clone();
Ok(out)
}
/// `body` healed as the local modifiers take it (coplanar neighbours as one face, as after a
/// join; faces split on purpose kept apart), with its history over `[body]`.
pub(crate) fn healed(body: &Body) -> Result<Body> {
+15 -8
View File
@@ -476,7 +476,7 @@ fn planar_surfaces(shell: &mt::Shell, tol: f64) -> Option<mt::Shell> {
/// Heal a solid; returns the input unchanged when nothing applies or healing fails.
#[cfg(test)]
pub fn heal(solid: Solid, size: f64) -> Solid {
healed(solid, size, &[], false).0
healed(solid, size, &[], false, false).0
}
/// What healing made each face and edge of its result from.
@@ -535,14 +535,21 @@ impl HealMap {
/// along lines they were split along on purpose (`keep`), with what each face and edge of the
/// result was made from (`None` when that could not be followed).
pub(crate) fn heal_traced(solid: Solid, size: f64, keep: &[Vec<Vec3>]) -> (Solid, Option<HealMap>) {
healed(solid, size, keep, true)
healed(solid, size, keep, true, false)
}
/// [`heal_traced`] that only merges coplanar neighbouring faces, leaving every edge, curve and
/// surface as it is: for results whose geometry is already exact (a prism boolean's), where
/// joining edges would change more than the faces split in pieces.
pub(crate) fn merge_faces_traced(solid: Solid, size: f64, keep: &[Vec<Vec3>]) -> (Solid, Option<HealMap>) {
healed(solid, size, keep, true, true)
}
/// The healed solid, and what it was made from when `traced`.
fn healed(solid: Solid, size: f64, keep: &[Vec<Vec3>], traced: bool) -> (Solid, Option<HealMap>) {
fn healed(solid: Solid, size: f64, keep: &[Vec<Vec3>], traced: bool, faces_only: bool) -> (Solid, Option<HealMap>) {
// Edges a boolean made are intersection curves, which panic where they no longer
// evaluate; the solid then stays as it is.
match crate::guard("heal", || Ok(heal_shells(&solid, size, keep, traced))) {
match crate::guard("heal", || Ok(heal_shells(&solid, size, keep, traced, faces_only))) {
Ok(Some((healed, map))) => (healed, map),
_ => {
let map = if traced { crate::guard("heal map", || Ok(HealMap::identity(&solid))).ok() } else { None };
@@ -588,7 +595,7 @@ impl Trace {
}
}
fn heal_shells(solid: &Solid, size: f64, keep: &[Vec<Vec3>], traced: bool) -> Option<(Solid, Option<HealMap>)> {
fn heal_shells(solid: &Solid, size: f64, keep: &[Vec<Vec3>], traced: bool, faces_only: bool) -> Option<(Solid, Option<HealMap>)> {
let tol = (size * 1e-7).max(1e-9);
let mut shells = Vec::new();
let mut changed = false;
@@ -603,11 +610,11 @@ fn heal_shells(solid: &Solid, size: f64, keep: &[Vec<Vec3>], traced: bool) -> Op
let mut from: Vec<Vec<usize>> = (first..first + count).map(|i| vec![i]).collect();
first += count;
// Faces come out in place, one for one, except where coplanar ones merge.
if let Some(m) = planar_surfaces(&cur, (size * 1e-7).max(1e-9)) {
if !faces_only && let Some(m) = planar_surfaces(&cur, (size * 1e-7).max(1e-9)) {
cur = m;
changed = true;
}
if let Some(m) = exact_edges(&cur, (size * 1e-5).max(1e-7), &mut trace) {
if !faces_only && let Some(m) = exact_edges(&cur, (size * 1e-5).max(1e-7), &mut trace) {
cur = m;
changed = true;
}
@@ -616,7 +623,7 @@ fn heal_shells(solid: &Solid, size: f64, keep: &[Vec<Vec3>], traced: bool) -> Op
from = groups.iter().map(|g| g.iter().filter_map(|i| from.get(*i)).flatten().copied().collect()).collect();
changed = true;
}
if let Some(m) = merge_collinear(&cur, &mut trace) {
if !faces_only && let Some(m) = merge_collinear(&cur, &mut trace) {
cur = m;
changed = true;
}
+2
View File
@@ -74,6 +74,8 @@ mod sweep_path;
mod thread_tests;
mod topo;
mod tspline;
#[cfg(test)]
mod untouched_faces_tests;
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet, fillet_each, fillet_max, fillet_max_each};
pub use blend_corner::fillet_radii;
+10 -4
View File
@@ -464,14 +464,20 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
// Prisms along one direction (plates with holes): exact, in 2D (so only the volume
// bounds are checked).
if let Some(Ok(r)) = crate::prism::prism_boolean(a, b, op) {
// Slab-by-slab prism joins sew a closed solid, but leave cap and wall
// pieces along slab boundaries. Merge those coplanar faces just as the
// general B-rep boolean does, retaining intentional Split Face lines.
// Slab-by-slab prism booleans sew a closed solid, but leave cap and wall pieces
// along slab boundaries: a cut along Z through a box whose side wall already has a
// port in it splits that wall into strips at the port's top and bottom, though the
// cut never touches it. Merge those coplanar faces for every operation, just as the
// general B-rep boolean does, retaining intentional Split Face lines. Authored
// boundaries (painted faces, regularization protection) keep their pieces.
let keep: Vec<Vec<Vec3>> = a.split_keep().iter().chain(b.split_keep()).cloned().collect();
let authored = |x: &Body| x.regularization_protected || x.paint().is_some_and(|p| !p.faces.is_empty());
let r = if op == BoolOp::Union {
r.map(|body| guard("heal prism boolean", || recorded::heal_body(&body, (*body.solid).clone(), size, &keep, &counts)).unwrap_or(body))
} else {
} else if authored(a) || authored(b) {
r
} else {
r.map(|body| guard("heal prism boolean", || recorded::heal_prism_result(&body, size, &keep, &counts)).unwrap_or(body))
};
match &r {
Some(body) => {
+42 -6
View File
@@ -235,9 +235,10 @@ impl<'a> Context<'a> {
/// The origins of the edge between the wall over a segment tagged `t` and a cap lying on the
/// operand faces `cap`: the operand's edge between the wall and its own cap there (the very
/// edge the segment was read off, or the one it runs along where its slab ends, when that is
/// one), else where the wall meets the cap (all of the faces it lies on).
fn cap_edge(&self, t: Tag, cap: &BTreeSet<FaceOrigin>) -> Option<Vec<EdgeOrigin>> {
/// edge the segment was read off, or the one it runs along where its slab ends: the one
/// running through `at`, a point of the edge, where the wall meets its caps along several),
/// else where the wall meets the cap (all of the faces it lies on).
fn cap_edge(&self, t: Tag, cap: &BTreeSet<FaceOrigin>, at: Vec3) -> Option<Vec<EdgeOrigin>> {
let (x, w) = (t.operand, t.wall?);
let own: Vec<usize> =
cap.iter().filter_map(|o| if let Face { operand, face } = *o { (operand == x).then_some(face) } else { None }).collect();
@@ -250,6 +251,40 @@ impl<'a> Context<'a> {
match along.as_slice() {
[e] => Some(vec![EdgeOrigin::Edge { operand: x, edge: *e }]),
[] => Some(vec![EdgeOrigin::meet(Self::wall(t)?, Side::new(cap.iter().copied())?)]),
// One wall with openings meets its caps along several edges (a side wall with ports
// cut through it meets the ports' floors at each port): the one this edge lies on,
// else (a new opening beside them) where the wall meets the cap.
many => match self.edge_through(x, many, at)? {
Some(edge) => Some(vec![EdgeOrigin::Edge { operand: x, edge }]),
None => Some(vec![EdgeOrigin::meet(Self::wall(t)?, Side::new(cap.iter().copied())?)]),
},
}
}
/// Of the operand's edges `edges`, the one the point `at` lies on (`Some(None)` when none
/// does; `None` when several do).
fn edge_through(&self, operand: usize, edges: &[usize], at: Vec3) -> Option<Option<usize>> {
if edges.len() > 4096 {
return None;
}
let body = self.operands.get(operand)?;
let order = crate::body::edge_order(body.solid.face_iter()).0;
let mut on = edges.iter().filter(|e| {
order.get(**e).is_some_and(|edge| {
use mt::{BoundedCurve, ParametricCurve};
let c = edge.curve();
let (t0, t1) = c.range_tuple();
// The edge as a fine polyline: a line exactly, an arc within its chords' sag.
let n = if matches!(c, mt::Curve::Line(_)) { 1 } else { 64 };
let pts: Vec<Vec3> = (0..=n).map(|k| from_p3(c.subs(t0 + (t1 - t0) * k as f64 / n as f64))).collect();
let length: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum();
let near = self.tol * 10.0 + if n == 1 { 0.0 } else { length * 2e-3 };
pts.windows(2).any(|w| at.dist_to_segment(w[0], w[1]) <= near)
})
});
match (on.next(), on.next()) {
(Some(e), None) => Some(Some(*e)),
(None, _) => Some(None),
_ => None,
}
}
@@ -412,7 +447,7 @@ impl<'a> Context<'a> {
let o = match (kinds.get(*f)?, kinds.get(*g)?) {
(Kind::Wall(segment), Kind::Cap(end)) | (Kind::Cap(end), Kind::Wall(segment)) => {
let layer = if *end == 0 { 0 } else { layers.len() - 1 };
self.cap_edge(tag(layer, *segment)?, tops.get(*end)?)?
self.cap_edge(tag(layer, *segment)?, tops.get(*end)?, crate::heal::edge_points(e).get(2).copied()?)?
}
(Kind::Wall(..), Kind::Wall(..)) => {
let (s, next, split) = *[e.absolute_front().id(), e.absolute_back().id()].iter().find_map(|v| junctions.get(v))?;
@@ -468,9 +503,10 @@ impl<'a> Context<'a> {
let (front, back) = (from_p3(e.absolute_front().point()), from_p3(e.absolute_back().point()));
let (h1, h2) = (self.plane.height(front), self.plane.height(back));
let (lo, hi) = (h1.min(h2), h1.max(h2));
let middle = || crate::heal::edge_points(e).get(2).copied();
let o = match (x, y) {
(Spec::Wall { .. }, Spec::Cap { .. }) => self.cap_edge(x.end(lo, self.tol)?.tag, cap_of.get(j)?.as_ref()?)?,
(Spec::Cap { .. }, Spec::Wall { .. }) => self.cap_edge(y.end(lo, self.tol)?.tag, cap_of.get(i)?.as_ref()?)?,
(Spec::Wall { .. }, Spec::Cap { .. }) => self.cap_edge(x.end(lo, self.tol)?.tag, cap_of.get(j)?.as_ref()?, middle()?)?,
(Spec::Cap { .. }, Spec::Wall { .. }) => self.cap_edge(y.end(lo, self.tol)?.tag, cap_of.get(i)?.as_ref()?, middle()?)?,
(Spec::Wall { .. }, Spec::Wall { .. }) if hi - lo <= self.tol => {
// One over the other at a level: the lower first.
let (below, above) = match (x.top(lo, self.tol), y.bottom(lo, self.tol)) {
+150
View File
@@ -0,0 +1,150 @@
//! 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})");
}
}
}
File diff suppressed because it is too large. Load diff