From 67c79ec91ce9db42383f8e61f571f2274e3328a8 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Fri, 9 Oct 2026 22:22:43 +0000 Subject: [PATCH] Lips on rounded, shelled rims: the band along the rim's edge is raised from the body's own faces (kernel::raise_band: the rim face split along the band's exact offset edge, the band's walls swept up from the walls below, a top), since a join can't merge walls that continue each other; grooves cut with a band reaching into the free side so no wall coincides; tests against the band's area times its height Fixes #29 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT --- crates/doc/src/eval_more.rs | 23 ++- crates/engine/src/tests/robustness.rs | 30 ++++ crates/kernel/src/lib.rs | 2 + crates/kernel/src/lip.rs | 223 ++++++++++++++++++++++++++ crates/kernel/src/offset.rs | 2 +- crates/kernel/src/tests.rs | 21 +++ 6 files changed, 297 insertions(+), 4 deletions(-) create mode 100644 crates/kernel/src/lip.rs diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index 320ccbe0..9006f74e 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -783,11 +783,28 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap, f: &F return Err(DocError::Invalid("pick the rim face of a shelled body (a face with an inner and an outer edge)".into())); }; let bw = if *groove { w + g } else { w }; - // The band along the chosen edge, inside the rim face. + // A lip: the band raised from the rim's own faces (its walls continue the walls + // below, which a join can't do: they would coincide). + if !*groove + && let Ok(b) = kernel::raise_band(&mb.body, *face, w, h, *outside) + && let Some(slot) = st.bodies.iter_mut().find(|x| x.name == target) + { + *slot = ModelBody::new(target.clone(), b, slot.feature); + return Ok(()); + } + // The band along the chosen edge, inside the rim face. A groove's cutter reaches past + // the edge into the free side (the cavity, or outside), so no wall coincides. + let free = if *groove { bw } else { 0.0 }; let band = if *outside { - Region2 { outer: Loop2::polygon(&outer).ccw(), holes: vec![Loop2::polygon(&offset_left(&outer, bw)).ccw().reversed()] } + Region2 { + outer: Loop2::polygon(&offset_left(&outer, -free)).ccw(), + holes: vec![Loop2::polygon(&offset_left(&outer, bw)).ccw().reversed()], + } } else { - Region2 { outer: Loop2::polygon(&offset_left(&inner, -bw)).ccw(), holes: vec![Loop2::polygon(&inner).ccw().reversed()] } + Region2 { + outer: Loop2::polygon(&offset_left(&inner, -bw)).ccw(), + holes: vec![Loop2::polygon(&offset_left(&inner, free)).ccw().reversed()], + } }; let delta = (h * 0.05).clamp(1e-3, 0.5); if *groove { diff --git a/crates/engine/src/tests/robustness.rs b/crates/engine/src/tests/robustness.rs index 41d3070d..dc9bac2d 100644 --- a/crates/engine/src/tests/robustness.rs +++ b/crates/engine/src/tests/robustness.rs @@ -280,3 +280,33 @@ fn lip_after_boss() { } } } + +/// A lip, a groove and an outside lip on the rim of a rounded, shelled enclosure (#29: the +/// lip's walls continue the shell's walls, which a boolean join couldn't do). +#[test] +fn lip_on_a_rounded_shelled_rim() { + use std::f64::consts::PI; + let base = json!([ + {"command": "solid.box", "params": {"corner": [-40, -30, 0], "length": 80, "width": 60, "height": 30}}, + {"command": "solid.fillet", "params": {"edges": [[40, 30, 15], [-40, 30, 15], [-40, -30, 15], [40, -30, 15]], "radius": 6}}, + {"command": "solid.shell", "params": {"faces": [[0, 0, 30]], "thickness": 2}} + ]); + let v0 = total_volume(&script(base.clone())); + let ar = |w: f64, h: f64, r: f64| w * h - (4.0 - PI) * r * r; + let inside = ar(78.0, 58.0, 5.0) - ar(76.0, 56.0, 4.0); + for (extra, want) in [ + (json!({}), v0 + inside * 1.5), + (json!({"side": "outside"}), v0 + (ar(80.0, 60.0, 6.0) - ar(78.0, 58.0, 5.0)) * 1.5), + // (The groove's band comes from the rim's meshed outline: within its sag.) + (json!({"type": "groove"}), v0 - inside * 1.5), + ] { + let mut p = json!({"face": [0, 29, 30], "width": 1, "height": 1.5}); + if let (Some(o), Some(e)) = (p.as_object_mut(), extra.as_object()) { + o.extend(e.clone()); + } + let mut steps = base.as_array().cloned().unwrap_or_default(); + steps.push(json!({"command": "plastic.lip", "params": p})); + let v = total_volume(&script(Value::Array(steps))); + assert!((v - want).abs() < 1e-3 * want, "{extra}: {v} vs {want}"); + } +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 388b5668..d40ff5e8 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -26,6 +26,7 @@ mod heal; mod helix; mod iges_in; mod iges_out; +mod lip; mod loopblend; mod measure; mod meshbody; @@ -56,6 +57,7 @@ pub use delete_face::delete_faces; pub use helix::{loft_to_point, sweep_helix, sweep_helix_mesh}; pub use iges_in::{MAX_IGES_BYTES, iges_import, iges_to_step}; pub use iges_out::iges_export_bodies; +pub use lip::raise_band; pub use measure::{BodyMeasure, measure}; pub use meshbody::{MAX_TRIANGLES, mesh_body}; pub use offset::with_same_surface; diff --git a/crates/kernel/src/lip.rs b/crates/kernel/src/lip.rs new file mode 100644 index 00000000..edef8544 --- /dev/null +++ b/crates/kernel/src/lip.rs @@ -0,0 +1,223 @@ +//! Raising a band of a planar face (a plastic part's lip): the band along one of the face's +//! boundary loops, a set width into the face, is pushed out along the face's normal. Built +//! directly from the body's faces (the band's walls continue the walls below, which a boolean +//! union can't join: their faces would coincide). + +use solvecraft_geom::Vec3; +use truck_modeling::{self as mt, builder}; + +use crate::body::{Body, Solid, from_p3, p3, v3}; +use crate::{KernelError, Result, guard}; + +fn fail(m: &str) -> KernelError { + KernelError::Failed(format!("raising a band: {m}")) +} + +/// A boundary edge of the face, as its ends and (for an arc) a point halfway. +#[derive(Clone, Copy)] +struct Piece { + a: Vec3, + b: Vec3, + mid: Option, +} + +/// The planar face of `body` at `at`: its index and outward normal. +fn face_at(body: &Body, at: Vec3) -> Result<(usize, Vec3)> { + let size = body.size(); + let mesh = body.tessellate((size * 1e-3).max(1e-3))?; + let near = mesh + .triangles + .iter() + .zip(&mesh.tri_face) + .filter_map(|(t, f)| { + let [a, b, c] = mesh.tri(t)?; + let n = (b - a).cross(c - a).normalized()?; + let q = at - n * (at - a).dot(n); + let inside = [(a, b), (b, c), (c, a)].iter().all(|(u, v)| (*v - *u).cross(q - *u).dot(n) >= -1e-9); + inside.then_some(((at - a).dot(n).abs(), *f as usize)) + }) + .min_by(|x, y| x.0.total_cmp(&y.0)); + let (d, f) = near.ok_or_else(|| fail("no face there"))?; + if d > size * 1e-3 { + return Err(fail("no face there")); + } + let face = body.solid.face_iter().nth(f).ok_or_else(|| fail("face"))?; + let mt::Surface::Plane(pl) = face.oriented_surface() else { return Err(fail("the face must be planar")) }; + let n = pl.normal(); + Ok((f, Vec3::new(n.x, n.y, n.z).normalized().ok_or_else(|| fail("normal"))?)) +} + +/// The face's loop moved `w` into the face (to the left of its edges, as a face's boundary has +/// its inside on the left), corners mitred where the offsets don't already meet. +fn offset_loop(pieces: &[Piece], n: Vec3, w: f64, tol: f64) -> Result> { + let left = |a: Vec3, b: Vec3| n.cross(b - a).normalized(); + let mut out: Vec = Vec::new(); + for p in pieces { + out.push(match p.mid { + None => { + let l = left(p.a, p.b).ok_or_else(|| fail("zero edge"))?; + Piece { a: p.a + l * w, b: p.b + l * w, mid: None } + } + Some(m) => { + // Each point moves square to the arc (along its radius), to the left. + let (la, lm, lb) = + (left(p.a, m).ok_or_else(|| fail("arc"))?, left(p.a, p.b).ok_or_else(|| fail("arc"))?, left(m, p.b).ok_or_else(|| fail("arc"))?); + let c = circle_centre(p.a, m, p.b).ok_or_else(|| fail("arc"))?; + let radial = |q: Vec3, hint: Vec3| { + let r = (q - c).normalized().unwrap_or(hint); + if r.dot(hint) >= 0.0 { r } else { -r } + }; + let _ = (la, lb); + Piece { a: p.a + radial(p.a, lm) * w, b: p.b + radial(p.b, lm) * w, mid: Some(m + radial(m, lm) * w) } + } + }); + } + // Joints: where the offsets don't meet (sharp corners of lines), the lines are extended or + // cut back to their crossing. + let k = out.len(); + for i in 0..k { + let j = (i + 1) % k; + let (Some(pi), Some(pj)) = (out.get(i).copied(), out.get(j).copied()) else { continue }; + if pi.b.dist(pj.a) < tol { + if let Some(x) = out.get_mut(j) { + x.a = pi.b; + } + continue; + } + match (pi.mid, pj.mid) { + (None, None) => { + let (d1, d2) = (pi.b - pi.a, pj.b - pj.a); + let w0 = pj.a - pi.a; + let cr = d1.cross(d2); + let den = cr.len2(); + if den < 1e-24 { + return Err(fail("parallel edges at a corner")); + } + let t = w0.cross(d2).dot(cr) / den; + let x = pi.a + d1 * t; + if let Some(e) = out.get_mut(i) { + e.b = x; + } + if let Some(e) = out.get_mut(j) { + e.a = x; + } + } + _ => return Err(fail("an arc meeting another edge at a corner")), + } + } + Ok(out) +} + +fn circle_centre(a: Vec3, m: Vec3, b: Vec3) -> Option { + let (u, v) = (b - a, m - a); + let w = u.cross(v); + let d = 2.0 * w.len2(); + if d < 1e-300 { + return None; + } + Some(a + (v.cross(w) * u.len2() + w.cross(u) * v.len2()) * (1.0 / d)) +} + +/// Raise the band of the planar face at `at` that runs `width` wide along its outer loop +/// (`outside`) or its inner loop, by `height` along the face's normal. +pub fn raise_band(body: &Body, at: Vec3, width: f64, height: f64, outside: bool) -> Result { + body.require_brep("a lip")?; + if !(width > 1e-6 && height > 1e-6 && width.is_finite() && height.is_finite()) { + return Err(KernelError::Invalid("the band's width and height must be positive".into())); + } + if body.solid.boundaries().len() != 1 { + return Err(fail("a body in one piece")); + } + let size = body.size(); + let tol = (size * 1e-7).max(1e-9) * 100.0; + let (fi, n) = face_at(body, at)?; + let faces: Vec = body.solid.face_iter().cloned().collect(); + let f = faces.get(fi).ok_or_else(|| fail("face"))?; + let wires = f.boundaries(); + if wires.len() != 2 { + return Err(fail("the face must have an outer and one inner loop (a rim)")); + } + // Outer loop: turning positively about n. + let turn = |w: &mt::Wire| -> f64 { + let pts: Vec = w.vertex_iter().map(|v| from_p3(v.point())).collect(); + let mut s = Vec3::ZERO; + for (k, p) in pts.iter().enumerate() { + if let Some(q) = pts.get((k + 1) % pts.len()) { + s += p.cross(*q); + } + } + s.dot(n) + }; + let (w0, w1) = (wires.first().ok_or_else(|| fail("loop"))?, wires.get(1).ok_or_else(|| fail("loop"))?); + let (outer, inner) = if turn(w0) >= turn(w1) { (w0, w1) } else { (w1, w0) }; + let (along, other) = if outside { (outer, inner) } else { (inner, outer) }; + let pieces: Vec = along + .edge_iter() + .map(|e| { + let (a, b) = (from_p3(e.front().point()), from_p3(e.back().point())); + match crate::offset::arc_mid(e, size * 1e-6) { + Some(None) => Ok(Piece { a, b, mid: None }), + Some(Some(m)) => Ok(Piece { a, b, mid: Some(m) }), + None => Err(fail("edges must be lines or arcs")), + } + }) + .collect::>()?; + let moved = offset_loop(&pieces, n, width, tol)?; + guard("lip", || { + // The band's other edge, on the face. + let verts: Vec = moved.iter().map(|p| builder::vertex(p3(p.a))).collect(); + let k = verts.len(); + let mut cedges: Vec = Vec::with_capacity(k); + for (i, p) in moved.iter().enumerate() { + let (Some(a), Some(b)) = (verts.get(i), verts.get((i + 1) % k)) else { return Err(fail("vertex")) }; + cedges.push(match p.mid { + None => builder::line(a, b), + Some(m) => builder::circle_arc(a, b, p3(m)), + }); + } + let c: mt::Wire = cedges.into(); + // The rest of the face: its other loop, and the band's edge as a loop of its own. + // (The band's edge runs the face's way round: a hole when the band follows the inner + // loop, the outer loop when it follows the outer.) + let rest = mt::Face::try_new(vec![other.clone(), c.clone()], f.oriented_surface()).map_err(|e| fail(&e.to_string()))?; + // Walls: the face's loop and the band's edge swept out; the band's top. + let up = v3(n * height); + let wall_along: mt::Shell = builder::tsweep(along, up); + let wall_c: mt::Shell = builder::tsweep(&c, up); + let top_of = |sh: &mt::Shell| -> Vec { + sh.extract_boundaries().into_iter().filter(|w| w.vertex_iter().all(|v| (from_p3(v.point()) - at).dot(n) > height * 0.5)).collect() + }; + let flipped = |sh: &mt::Shell, yes: bool| -> mt::Shell { + if yes { sh.face_iter().map(|x| x.inverse()).collect::>().into() } else { sh.clone() } + }; + let mut last = String::new(); + for flip in 0..8u32 { + let (wa, wc) = (flipped(&wall_along, flip & 1 == 1), flipped(&wall_c, flip & 2 == 2)); + let mut tops = top_of(&wa); + tops.extend(top_of(&wc)); + if tops.len() != 2 { + return Err(fail("the band's top")); + } + // The top closes both walls: its loops run against theirs. + let tops: Vec = tops.iter().map(|w| w.inverse()).collect(); + let Ok(top) = builder::try_attach_plane(&tops) else { continue }; + let top = if flip & 4 == 4 { top.inverse() } else { top }; + let mut all: Vec = faces.iter().enumerate().filter(|(i, _)| *i != fi).map(|(_, x)| x.clone()).collect(); + all.push(rest.clone()); + all.extend(wa.face_iter().cloned()); + all.extend(wc.face_iter().cloned()); + all.push(top); + match Solid::try_new(vec![all.into()]) { + Ok(s) => { + let b = Body::new(s)?; + if b.validity().is_empty() { + return Ok(b); + } + last = b.validity().join("; "); + } + Err(e) => last = e.to_string(), + } + } + Err(fail(&last)) + }) +} diff --git a/crates/kernel/src/offset.rs b/crates/kernel/src/offset.rs index c72ebfea..57ca3d3e 100644 --- a/crates/kernel/src/offset.rs +++ b/crates/kernel/src/offset.rs @@ -423,7 +423,7 @@ fn span_step(ns: &[Vec3], s: &[f64]) -> Option { } /// A line or circular arc through an edge (by samples): `None` for other curves. -fn arc_mid(e: &mt::Edge, tol: f64) -> Option> { +pub(crate) fn arc_mid(e: &mt::Edge, tol: f64) -> Option> { use mt::{BoundedCurve, ParametricCurve}; let c = e.curve(); let (t0, t1) = c.range_tuple(); diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index f2e23857..10e7dd7e 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -1393,3 +1393,24 @@ fn perf_measure_5000_faces() { measure(&b).unwrap(); assert!(t.elapsed().as_secs_f64() < 1.5, "{:?}", t.elapsed()); } + +/// A lip on a rounded, shelled rim (#29): the band along the rim's inner (or outer) edge raised, +/// its walls continuing the walls below. Exactly the band's area times its height is added. +#[test] +fn raise_band_on_a_rounded_rim() { + let b = box_solid(Vec3::new(-40.0, -30.0, 0.0), Vec3::new(40.0, 30.0, 30.0)).unwrap(); + let f = + fillet(&b, &[Vec3::new(40.0, 30.0, 15.0), Vec3::new(-40.0, 30.0, 15.0), Vec3::new(-40.0, -30.0, 15.0), Vec3::new(40.0, -30.0, 15.0)], 6.0) + .unwrap(); + let sh = shell(&f, &[Vec3::new(0.0, 0.0, 30.0)], 2.0).unwrap(); + let v0 = measure(&sh).unwrap().volume; + // A rounded rectangle's area. + let ar = |w: f64, h: f64, r: f64| w * h - (4.0 - PI) * r * r; + for outside in [false, true] { + let lip = crate::lip::raise_band(&sh, Vec3::new(0.0, 29.0, 30.0), 1.0, 1.5, outside).unwrap(); + assert!(lip.validity().is_empty(), "{:?}", lip.validity()); + let band = if outside { ar(80.0, 60.0, 6.0) - ar(78.0, 58.0, 5.0) } else { ar(78.0, 58.0, 5.0) - ar(76.0, 56.0, 4.0) }; + let v = measure(&lip).unwrap().volume; + assert!((v - (v0 + band * 1.5)).abs() < 1e-5 * v, "outside {outside}: {v} vs {}", v0 + band * 1.5); + } +}