Shell: an open face next to a concave edge no longer cuts the wall past it (#1495)

The pushed-out cavity is trimmed so that past an open face's plane it only reaches through the open face itself.
This commit is contained in:
H. Conrad authored and GitHub committed 2026-10-15 22:19:04 -04:00
1 parent 723c7b61c4
commit 2141a3802a
2 files changed
+143

No files matched your search

@@ -0,0 +1,58 @@
//! A shell whose open face meets a concave edge must not cut into the wall past that edge.
//!
//! Lower box `[0, 25] × [0, 17.5] × [0, 35]`, top box `[7.5, 22.5] × [0, w] × [35, 42.5]` with
//! `w ≤ 17.5`, and the shell opens the top box's left face (x = 7.5). That face's bottom edge is
//! concave: the lower box's top runs on from it. With walls `t` thick, the cavity is
//!
//! - in the lower box: `[t, 25 − t] × [t, 17.5 − t] × [t, 35 − t]`
//! - in the top box, out to the open face: `[7.5, 22.5 − t] × [t, w − t] × [35 − t, 42.5 − t]`
//!
//! The shell's volume is the solid's volume less those two boxes. The open face used to be
//! pushed out past the lower box's top, cutting a strip through that wall.
use serde_json::json;
use solidcraft_engine::Session;
fn run(s: &mut Session, id: &str, p: serde_json::Value) {
if let Err(e) = s.execute(id, &p) {
panic!("{id} {p}: {e}");
}
}
fn shell_volume(w: f64, t: f64) -> f64 {
let solid = 25.0 * 17.5 * 35.0 + 15.0 * w * 7.5;
let lower = (25.0 - 2.0 * t) * (17.5 - 2.0 * t) * (35.0 - 2.0 * t);
let upper = (15.0 - t) * (w - 2.0 * t) * 7.5;
solid - lower - upper
}
fn shelled(w: f64, t: f64) -> Session {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"height": 35.0, "length": 25.0, "width": 17.5}));
run(&mut s, "solid.box", json!({"corner": [7.5, 0.0, 35.0], "height": 7.5, "length": 15.0, "operation": "join", "width": w}));
run(&mut s, "solid.shell", json!({"faces": [[7.5, w / 2.0, 38.75]], "thickness": t}));
s
}
fn check(s: &Session, want: f64) {
let errors: Vec<_> = s.model.results.iter().filter_map(|r| r.error.clone()).collect();
assert!(errors.is_empty(), "{errors:?}");
let st = s.model.state();
assert_eq!(st.bodies.len(), 1, "one body");
let b = &st.bodies[0].body;
assert!(b.validity().is_empty(), "{:?}", b.validity());
assert_eq!(b.shell_count(), 1, "one shell");
let got = solidcraft_kernel::measure(b).map(|m| m.volume).unwrap_or(f64::NAN);
assert!((got - want).abs() < 1e-6 * want, "shell volume {got}, want {want}");
}
#[test]
fn open_face_flush_with_the_back_keeps_the_wall_past_its_concave_edge() {
for t in [1.0, 1.5] {
check(&shelled(17.5, t), shell_volume(17.5, t));
}
}
#[test]
fn open_face_inside_the_footprint_keeps_the_wall_past_its_concave_edge() {
check(&shelled(15.0, 1.5), shell_volume(15.0, 1.5));
}
+85
View File
@@ -561,6 +561,14 @@ fn shell_opened(b: &Body, healed: &Body, open: &[Vec3], opened: &[usize], thickn
Ok(c) => c, Ok(c) => c,
Err(e) => return shell_by_opening(healed, opened, thickness, margin).map_err(|_| e), Err(e) => return shell_by_opening(healed, opened, thickness, margin).map_err(|_| e),
}; };
let cavity = match opening_overreach(healed, &cavity, opened, thickness, margin) {
Ok(Some(trimmed)) => trimmed,
Ok(None) => cavity,
Err(e) => {
log::warn!("shell: could not trim the opening to the open faces ({e}); the wall next to it may be cut");
cavity
}
};
let r = crate::ops::boolean(healed, &cavity, crate::BoolOp::Cut); let r = crate::ops::boolean(healed, &cavity, crate::BoolOp::Cut);
if r.is_err() if r.is_err()
&& let Ok(x) = shell_by_opening(healed, opened, thickness, margin) && let Ok(x) = shell_by_opening(healed, opened, thickness, margin)
@@ -605,6 +613,83 @@ fn shell_opened(b: &Body, healed: &Body, open: &[Vec3], opened: &[usize], thickn
/// A shell made from the cavity with every face moved in by the thickness (open faces too), /// A shell made from the cavity with every face moved in by the thickness (open faces too),
/// joined with each open face's moved copy swept out through the body. No history: the /// joined with each open face's moved copy swept out through the body. No history: the
/// opening's walls, swept from the cavity's edges, come from nothing of `b`. /// opening's walls, swept from the cavity's edges, come from nothing of `b`.
/// The cavity with each open face pushed out (`cavity`), trimmed so that beyond an open face's
/// plane it only reaches through the open face itself; `None` when it already does.
///
/// The pushed-out face carries the cavity past every edge of the open face. Past a convex edge
/// that is outside the body, but past a concave one (a box on a box, its side face open where it
/// meets the lower box's top) it runs on into the neighbouring wall and cuts a strip through it,
/// as wide as the push. The trimmed cavity is the shell's cavity with every face moved in,
/// together with the pushed-out cavity's parts that are on the body's side of every open face's
/// plane or in front of that open face.
fn opening_overreach(healed: &Body, cavity: &Body, opened: &[usize], thickness: f64, margin: f64) -> Result<Option<Body>> {
let size = healed.size();
let tol = size * 1e-6 + 1e-9;
let faces: Vec<mt::Face> = healed.solid.face_iter().cloned().collect();
// Each open face's plane (outward normal, offset) and its triangles.
let mesh = healed.tessellate((size * 1e-3).max(1e-3))?;
let mut open: Vec<(&mt::Face, Vec3, f64, Vec<[Vec3; 3]>)> = Vec::new();
for fi in opened {
let Some(f) = faces.get(*fi) else { return Ok(None) };
let mt::Surface::Plane(pl) = f.oriented_surface() else { return Ok(None) };
let n = pl.normal();
let Some(n) = Vec3::new(n.x, n.y, n.z).normalized() else { return Ok(None) };
let d = n.dot(from_p3(pl.origin()));
let tris: Vec<[Vec3; 3]> =
mesh.triangles.iter().zip(&mesh.tri_face).filter(|(_, f)| **f as usize == *fi).filter_map(|(t, _)| mesh.tri(t)).collect();
if tris.is_empty() {
return Ok(None);
}
open.push((f, n, d, tris));
}
// Only a corner of the cavity past an open face's plane and not in front of that face can be
// in a wall; with none, the cavity is right as it is.
let overreach = cavity.solid.vertex_iter().any(|v| {
let q = from_p3(v.point());
open.iter().any(|(_, n, d, tris)| {
let h = n.dot(q) - d;
h > tol && {
let on = q - *n * h;
!tris.iter().any(|[a, b, c]| point_tri(on, *a, *b, *c) < size * 1e-4 + 1e-6)
}
})
});
if !overreach {
return Ok(None);
}
let closed = offset_planar(healed, |_, _| -thickness)?;
// Keep: on the body's side of each open plane, or in front of the open face.
let reach = size * 4.0;
let mut keep: Option<Body> = None;
for (f, n, d, _) in &open {
let c = *n * *d;
let mut hs = vec![HalfSpace { n: *n, d: *d }];
for axis in [Vec3::X, Vec3::Y, Vec3::Z] {
hs.push(HalfSpace { n: axis, d: axis.dot(c) + reach });
hs.push(HalfSpace { n: axis * -1.0, d: -axis.dot(c) + reach });
}
let (side, _) = polyhedron(&hs)?;
// In front of the face: its prism, from half a wall inside the body (overlapping `side`
// rather than touching it) to past the pushed-out face.
let inset = thickness * 0.5;
let prism = guard("shell opening prism", || {
let start = builder::translated(*f, v3(*n * -inset));
let solid: Solid = builder::tsweep(&start, v3(*n * (inset + margin * 2.0)));
Ok(solid)
})?;
let prism = Body::new(prism)?;
let k = crate::ops::boolean(&side, &prism, crate::BoolOp::Union)?.ok_or_else(|| KernelError::Failed("shell: opening".into()))?;
keep = Some(match keep {
None => k,
Some(acc) => crate::ops::boolean(&acc, &k, crate::BoolOp::Intersect)?.ok_or_else(|| KernelError::Failed("shell: opening".into()))?,
});
}
let Some(keep) = keep else { return Ok(None) };
let kept = crate::ops::boolean(cavity, &keep, crate::BoolOp::Intersect)?.ok_or_else(|| KernelError::Failed("shell: opening".into()))?;
let trimmed = crate::ops::boolean(&closed, &kept, crate::BoolOp::Union)?.ok_or_else(|| KernelError::Failed("shell: opening".into()))?;
Ok(Some(trimmed))
}
fn shell_by_opening(b: &Body, opened: &[usize], thickness: f64, reach: f64) -> Result<Body> { fn shell_by_opening(b: &Body, opened: &[usize], thickness: f64, reach: f64) -> Result<Body> {
let closed = offset_planar(b, |_, _| -thickness)?; let closed = offset_planar(b, |_, _| -thickness)?;
let faces: Vec<mt::Face> = closed.solid.face_iter().cloned().collect(); let faces: Vec<mt::Face> = closed.solid.face_iter().cloned().collect();