kernel: shell, offset faces and thicken through SMLib, with SMLib's history

With SOLIDCRAFT_KERNEL=smlib (the one kernel switch), shell, offset_faces and thicken send
the body to SMLib tagged as operand 0 (offset_backend.rs) and bring the result back with
SMLib's own history, as booleans do. A result SMLib's check refuses is an error.

SMLib's tags travel through the copies its offset makes, so a shell's inner wall and a
thickened sheet's moved copy read as pieces (Split) of the face they came from. A result face
lying more than half the operation's distance from its tagged face is recorded as that face's
offset (FaceOrigin::Offset) and its edges as where its faces meet; a thickened sheet's walls,
new to SMLib, are recorded as made in place of the sheet edge their edges are tagged with, as
truck's thicken records them. Offset faces pick the faces truck's path picks (a round wall's
pieces together) and pass SMLib one point inside each; the moved faces are Modified.

The histories are checked against the geometry as truck's are (modify_history_tests), with
the analytic volumes, the thicken direction, and the refusal of walls past the L-shaped body's
inradius (8.79) that its box (half extent 10) let through before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
This commit is contained in:
jyviko
2026-10-21 08:25:05 -04:00
parent 51211ad1fe
commit 0f3d6f177e
5 changed files with 326 additions and 0 deletions
+1
View File
@@ -98,6 +98,7 @@ mod modify_history_tests;
mod native;
mod numbered;
mod offset;
mod offset_backend;
mod ops;
mod outline;
#[cfg(test)]
+97
View File
@@ -483,3 +483,100 @@ fn chamfer_after_box_cuts() {
}
}
}
/// Shell, offset faces and thicken through SMLib (`crate::offset_backend`), their histories
/// checked against the geometry as truck's are, and their numbers against the analytic ones.
#[cfg(feature = "smlib")]
mod smlib {
use super::*;
use crate::offset_backend;
fn volume(b: &Body) -> f64 {
crate::measure(b).unwrap().volume
}
#[test]
fn shell_lines_the_walls_with_offsets() {
let b = cube();
let top = Vec3::new(20.0, 15.0, 20.0);
let r = offset_backend::shell(&b, &[top], 2.0).unwrap();
// The cavity is 36 x 26 x 18 (the floor 2 thick, the top open).
assert!((volume(&r) - (24000.0 - 36.0 * 26.0 * 18.0)).abs() < 1e-3, "{}", volume(&r));
let h = verified(&r, &b, reach(2.0));
let top = face_at(&b, top);
for f in (0..6).filter(|f| *f != top) {
let walls = with(h, &[FaceOrigin::Offset { operand: 0, face: f }]);
assert_eq!(walls.len(), 1, "{h:?}");
assert_eq!(h.faces[walls[0]].change, Change::Generated);
}
assert!(with(h, &[FaceOrigin::Offset { operand: 0, face: top }]).is_empty());
assert_eq!(changes(h), [0, 6, 0, 0, 5], "{h:?}");
}
#[test]
fn shell_of_a_body_that_is_not_convex() {
let b = ell();
let r = offset_backend::shell(&b, &[Vec3::new(5.0, 5.0, 20.0)], 2.0).unwrap();
let h = verified(&r, &b, reach(2.0 * 1.5));
let offsets = h.faces.iter().filter(|f| matches!(f.origins.as_slice(), [FaceOrigin::Offset { .. }])).count();
assert_eq!(offsets, 7, "{h:?}");
}
/// tangency-labs/solidcore#380: a closed shell whose wall leaves no room inside the body is
/// refused before SMLib's offset sees it, by the body's own inradius (the L's legs are 15
/// wide, the largest ball fits at the inner corner: 15 * sqrt(2) / (1 + sqrt(2)) = 8.79),
/// never by its box (whose half extent, 10, the old guard used).
#[test]
fn a_wall_with_no_room_inside_is_refused() {
let b = ell();
for t in [8.8, 9.5, 12.0] {
let e = offset_backend::shell(&b, &[], t).unwrap_err();
assert!(e.to_string().contains("no room"), "{t}: {e}");
}
let e = offset_backend::shell(&b, &[Vec3::new(5.0, 5.0, 20.0)], 8.8).unwrap_err();
assert!(e.to_string().contains("no room"), "{e}");
}
#[test]
fn offset_moves_a_face() {
let b = ell();
let top = Vec3::new(5.0, 5.0, 20.0);
let healed = crate::boolean_record::healed(&b).unwrap();
let chosen = [face_at(&healed, top)];
let r = offset_backend::offset_faces(&healed, &chosen, 3.0).unwrap();
// The L's plan is 40 x 15 + 15 x 15 = 825; 23 high.
assert!((volume(&r) - 825.0 * 23.0).abs() < 1e-3, "{}", volume(&r));
let h = verified(&r, &healed, Reach { made: 0.0, moved: 3.0 * 1.1 });
assert_eq!(h.faces.len(), healed.face_count());
let moved = with(h, &[face(chosen[0])]);
assert_eq!(moved.len(), 1);
assert_eq!(h.faces[moved[0]].change, Change::Modified);
// Into the body by its whole height: nothing is left behind the face.
let e = offset_backend::offset_faces(&healed, &chosen, -20.0).unwrap_err();
assert!(e.to_string().contains("no room"), "{e}");
}
#[test]
fn thicken_offsets_the_surface() {
let s = crate::patch_region(&Plane::XY, &Region2 { outer: square(0.0, 0.0, 10.0, 10.0), holes: vec![] }).unwrap();
let t = offset_backend::thicken(&s, 2.0, false).unwrap();
assert!((volume(&t) - 200.0).abs() < 1e-6, "{}", volume(&t));
// Along the sheet's normal (+Z).
let m = t.tessellate(TOL).unwrap();
let (lo, hi) = m.positions.iter().fold((f64::MAX, f64::MIN), |(lo, hi), p| (lo.min(p.z), hi.max(p.z)));
assert!(lo.abs() < 1e-9 && (hi - 2.0).abs() < 1e-9, "z {lo}..{hi}");
let h = verified(&t, &s, reach(2.0));
assert_eq!(with(h, &[face(0)]).len(), 1, "{h:?}");
assert_eq!(with(h, &[FaceOrigin::Offset { operand: 0, face: 0 }]).len(), 1, "{h:?}");
assert_eq!(blends(h).len(), 4, "{h:?}");
let both = offset_backend::thicken(&s, 2.0, true).unwrap();
let m = both.tessellate(TOL).unwrap();
let (lo, hi) = m.positions.iter().fold((f64::MAX, f64::MIN), |(lo, hi), p| (lo.min(p.z), hi.max(p.z)));
assert!((lo + 1.0).abs() < 1e-9 && (hi - 1.0).abs() < 1e-9, "z {lo}..{hi}");
let h = verified(&both, &s, reach(1.0));
assert_eq!(with(h, &[FaceOrigin::Offset { operand: 0, face: 0 }]).len(), 2, "{h:?}");
assert_eq!(blends(h).len(), 4, "{h:?}");
let against = offset_backend::thicken(&s, -2.0, false).unwrap();
assert!((volume(&against) - 200.0).abs() < 1e-6);
}
}
+216
View File
@@ -0,0 +1,216 @@
//! Shell, offset faces and thicken through SMLib, when [`crate::backend`] is SMLib (the one
//! kernel setting, `SOLIDCRAFT_KERNEL`): the body goes to SMLib as its schema 4 record,
//! tagged as operand 0, SMLib runs the operation, and the result comes back with SMLib's own
//! history, renumbered into the body's and the result's face and edge order (as
//! `crate::smlib_boolean` does for booleans). A result SMLib's own check refuses is an
//! error, never a body.
//!
//! SMLib's tags travel through the copies its offset makes, so a shell's inner wall and a
//! thickened sheet's moved copy carry the tag of the face they were made from, and SMLib reads
//! both that face and its copy as pieces of it (Split). Which is which is a matter of where they
//! lie: the copy is the operation's distance from the face, the face itself on it. So a result
//! face farther from its tagged face than half that distance is recorded as its offset
//! ([`FaceOrigin::Offset`]), and its edges as where its faces meet. A face SMLib makes new along
//! a sheet's open edge (a thickened sheet's wall) is recorded as made in place of that edge, as
//! truck's thicken records it.
#![cfg(feature = "smlib")]
use std::collections::{BTreeMap, BTreeSet};
use solidcraft_geom::Vec3;
use solidcraft_smlib::{Brep, CheckLevel, Error as SmError};
use crate::body::Body;
use crate::history::{Change, EdgeOrigin, FaceOrigin, History};
use crate::native::{body_from_smlib_ordered, smlib_body_ordered};
use crate::{KernelError, Result, record};
fn failed(op: &str, what: &str, e: impl std::fmt::Display) -> KernelError {
KernelError::Failed(format!("SMLib {op}: {what}: {e}"))
}
/// The error for SMLib's refusal of `op`, in the words of the operation.
fn refused(op: &str, e: SmError) -> KernelError {
match e {
SmError::NoRoom => KernelError::Invalid(format!("{op}: the wall is too thick: it leaves no room inside the body")),
SmError::NoMatch { point } => KernelError::Invalid(format!("{op}: no face at selection point {}", point + 1)),
SmError::Ambiguous { point } => KernelError::Invalid(format!("{op}: selection point {} is as near to two faces", point + 1)),
e => failed(op, "operation", e),
}
}
/// How far a selection point may be from the face it picks: the kernel's own picking reach.
fn pick_tolerance(b: &Body) -> f64 {
b.size() * 1e-3 + 1e-6
}
fn points(p: &[Vec3]) -> Vec<[f64; 3]> {
p.iter().map(|v| [v.x, v.y, v.z]).collect()
}
/// Runs `op` on `b` in SMLib, tagged as operand 0, and brings the result back with SMLib's
/// history over `[b]` (none where an order cannot be matched).
fn run(name: &str, b: &Body, op: impl FnOnce(&Brep) -> std::result::Result<Brep, SmError>) -> Result<(Body, Option<History>)> {
b.require_brep(name)?;
let (sb, ob) = smlib_body_ordered(b)?;
let tagged = sb.tagged(0).map_err(|e| failed(name, "tag", e))?;
let r = op(&tagged).map_err(|e| refused(name, e))?;
let check = r.check(CheckLevel::All).map_err(|e| failed(name, "check", e))?;
if !check.is_valid() {
let input = sb.check(CheckLevel::All).is_ok_and(|c| !c.is_valid());
let what = if input { "SMLib's check refuses the result (the body already fails it)" } else { "SMLib's check refuses the result" };
return Err(failed(name, what, check));
}
let (body, or) = body_from_smlib_ordered(&r)?;
let history = match (ob, or) {
(Some(ob), Some(or)) => r.history().map_err(|e| failed(name, "history", e))?.history(&[ob], &or).map(Some).unwrap_or_else(|e| {
log::debug!(target: "smlib", "{name} history: {e}");
None
}),
_ => None,
};
Ok((body, history))
}
/// [`crate::shell`] through SMLib: walls of `thickness` inside, open at the faces nearest to
/// `open`.
pub(crate) fn shell(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
if !(thickness.is_finite() && thickness > 1e-6) {
return Err(KernelError::Invalid("shell thickness must be positive".into()));
}
let tol = pick_tolerance(b);
let pts = points(open);
let (r, h) = run("shell", b, |s| s.shell(&pts, tol, -thickness))?;
let h = h.and_then(|h| with_offsets(&r, b, h, thickness));
Ok(r.with_checked_history(h, &record::counts(&[b])))
}
/// [`crate::offset_faces`] through SMLib: the faces `chosen` (indices in `b`'s face order)
/// moved `distance` along their outward normals. The moved faces are the faces themselves,
/// moved (Modified).
pub(crate) fn offset_faces(b: &Body, chosen: &[usize], distance: f64) -> Result<Body> {
let at = face_points(b, chosen)?;
let tol = pick_tolerance(b);
let pts = points(&at);
let (r, h) = run("offset faces", b, |s| s.offset_faces(&pts, tol, distance))?;
let h = h.map(|mut h| {
for f in &mut h.faces {
if f.change == Change::Kept && f.origins.iter().any(|o| matches!(o, FaceOrigin::Face { face, .. } if chosen.contains(face))) {
f.change = Change::Modified;
}
}
h
});
Ok(r.with_checked_history(h, &record::counts(&[b])))
}
/// [`crate::thicken`] through SMLib: the sheet `b` grown by `thickness` along its faces'
/// normals, or half each way when `symmetric`.
pub(crate) fn thicken(b: &Body, thickness: f64, symmetric: bool) -> Result<Body> {
if !(thickness.is_finite() && thickness.abs() > 1e-9) {
return Err(KernelError::Invalid("thickness must be non-zero".into()));
}
let (r, h) = run("thicken", b, |s| s.thicken(thickness, symmetric))?;
let step = if symmetric { thickness.abs() / 2.0 } else { thickness.abs() };
// The walls first, while the sheet's edges still carry their tags on both copies.
let h = h.map(with_edge_walls).and_then(|h| with_offsets(&r, b, h, step));
Ok(r.with_checked_history(h, &record::counts(&[b])))
}
/// One point inside each face `faces` of `b`: the centroid of its largest triangle.
fn face_points(b: &Body, faces: &[usize]) -> Result<Vec<Vec3>> {
let mesh = b.tessellate((b.size() * 1e-3).max(1e-3))?;
faces
.iter()
.map(|f| {
mesh.triangles
.iter()
.zip(&mesh.tri_face)
.filter(|(_, t)| **t as usize == *f)
.filter_map(|(t, _)| mesh.tri(t))
.max_by(|x, y| area(x).total_cmp(&area(y)))
.map(|[p, q, s]| (p + q + s) * (1.0 / 3.0))
.ok_or_else(|| KernelError::Failed(format!("offset faces: face {f} has no triangles")))
})
.collect()
}
fn area([a, b, c]: &[Vec3; 3]) -> f64 {
(*b - *a).cross(*c - *a).len()
}
/// `h` over `[input]` with each result face that lies `distance` (more than half of it) from
/// the input face it is tagged with recorded as that face's offset, the faces left on their
/// input face Split only where several still share it, and every edge of an offset face as
/// where its faces meet. `None` where a face cannot be measured.
fn with_offsets(result: &Body, input: &Body, mut h: History, distance: f64) -> Option<History> {
let chord = (input.size().max(result.size()) * 1e-3).min(distance / 8.0).max(1e-6);
let source = input.tessellate(chord).ok()?;
let made = result.tessellate(chord).ok()?;
let at = |mesh: &solidcraft_geom::Mesh, face: usize| -> Vec<[Vec3; 3]> {
mesh.triangles.iter().zip(&mesh.tri_face).filter(|(_, t)| **t as usize == face).filter_map(|(t, _)| mesh.tri(t)).collect()
};
let mut offset = vec![false; h.faces.len()];
for (i, f) in h.faces.iter_mut().enumerate() {
let [FaceOrigin::Face { operand: 0, face }] = f.origins[..] else { continue };
let tris = at(&made, i);
let p = tris.iter().max_by(|x, y| area(x).total_cmp(&area(y))).map(|[a, b, c]| (*a + *b + *c) * (1.0 / 3.0))?;
let under = at(&source, face);
let d = under.iter().map(|[a, b, c]| crate::boolean_guard::point_triangle_dist(p, *a, *b, *c)).fold(f64::INFINITY, f64::min);
if !d.is_finite() {
return None;
}
if d > distance / 2.0 {
*f = record::generated(vec![FaceOrigin::Offset { operand: 0, face }]);
offset[i] = true;
}
}
// Faces still on an input face: Split only where several share it.
let mut uses: BTreeMap<FaceOrigin, usize> = BTreeMap::new();
for f in &h.faces {
if let [o @ FaceOrigin::Face { .. }] = f.origins[..] {
*uses.entry(o).or_default() += 1;
}
}
for f in &mut h.faces {
if f.change == Change::Split
&& let [o @ FaceOrigin::Face { .. }] = f.origins[..]
&& uses.get(&o) == Some(&1)
{
f.change = Change::Modified;
}
}
let faces = h.faces.clone();
for e in &mut h.edges {
if e.faces.iter().any(|f| offset.get(*f).copied().unwrap_or(false)) {
e.origins = record::meeting(&e.faces, &faces);
}
}
Some(h)
}
/// `h` with each face new to the operation that stands on exactly one input edge (its edges
/// tagged with that edge, a thickened sheet's wall) recorded as made in place of that edge.
fn with_edge_walls(mut h: History) -> History {
let mut along: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); h.faces.len()];
for e in &h.edges {
for o in &e.origins {
if let EdgeOrigin::Edge { operand: 0, edge } = o {
for f in &e.faces {
if let Some(s) = along.get_mut(*f) {
s.insert(*edge);
}
}
}
}
}
for (f, edges) in h.faces.iter_mut().zip(&along) {
if f.origins.is_empty()
&& let [edge] = edges.iter().copied().collect::<Vec<_>>()[..]
{
*f = record::generated(vec![FaceOrigin::Edge { operand: 0, edge }]);
}
}
h
}
+8
View File
@@ -192,6 +192,10 @@ fn cavity_history(poly: &Body, on: &[usize], groups: &[Vec<usize>]) -> Option<Hi
/// Hollow a convex planar body: walls of `thickness` inside, faces near `open` removed. The
/// result's history is over `[b]`: the inner walls are offsets of the faces they line.
pub fn shell(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
#[cfg(feature = "smlib")]
if crate::backend()? == crate::Backend::Smlib {
return crate::offset_backend::shell(b, open, thickness);
}
guard("shell", || shell_impl(b, open, thickness)?.with_shell_planar_sources(b, thickness, open))
}
@@ -875,6 +879,10 @@ pub fn offset_faces(b: &Body, at: &[Vec3], distance: f64) -> Result<Body> {
let run = |body: &Body| -> Result<Body> {
let healed = crate::boolean_record::healed(body)?.with_split_lines(body.splits.clone());
let chosen = faces_at(&healed, at)?;
#[cfg(feature = "smlib")]
if crate::backend()? == crate::Backend::Smlib {
return crate::offset_backend::offset_faces(&healed, &chosen, distance).map(|r| record::after(r, &healed, &record::counts(&[body])));
}
match offset_planar(&healed, |fi, _| if chosen.contains(&fi) { distance } else { 0.0 }) {
Ok(r) => Ok(record::after(r, &healed, &record::counts(&[body]))),
// A piece of a split face (its neighbour stays): the piece swept along its normal
+4
View File
@@ -833,6 +833,10 @@ const THICKEN_TOLERANCE: f64 = 0.01;
/// agree).
pub fn thicken(body: &Body, thickness: f64, symmetric: bool) -> Result<Body> {
body.require_brep("thicken")?;
#[cfg(feature = "smlib")]
if crate::backend()? == crate::Backend::Smlib {
return crate::offset_backend::thicken(body, thickness, symmetric);
}
if !(thickness.is_finite() && thickness.abs() > 1e-9) {
return Err(KernelError::Invalid("thickness must be non-zero".into()));
}