Compare commits

...

1 Commits

Author SHA1 Message Date
bherbruck a2eb31816c ops: offset surfaces, each face on its exact offset, joined where they meet (#3138)
SolidCraft's Surface > Offset offsets each face as a sheet and assembles the
records in the app before stitching (surface_offset.rs, surfaces::offset_faces).
offset_surface copies each picked face out on its own (copy_faces_from, its
attributes with it), offsets it with offset_sheet, copies the sheets into one
body and stitches them at the caller's tolerance: neighbours meeting smoothly
keep their shared edge, across a sharp edge they come apart, and faces that
close make a solid. An offset that would fold is the kernel's refusal.
history::recorded_into gives the new body's history against its source, so
each face is the OffsetOf the face it came from.

Tests (exact measures): a cube's top 5 up is area 100 at z = 15, the top's
offset; two faces across a sharp edge come apart (2 faces, 8 edges); a zero
distance copies (area 200); a cylinder side r 10 out 2 is area 2π·12·20 with its
seam kept, and refused at -10.5; a ball r 10 out 2 closes into the ball of
radius 12 (area 4π·144, volume 4π·1728/3), refused at -10.

Fixes #3138.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 15:50:17 -05:00
5 changed files with 291 additions and 1 deletions
+29
View File
@@ -140,6 +140,35 @@ pub fn recorded(
Ok(history)
}
/// `op`, a call that makes a new body from `body` (and leaves `body` as it was, such as
/// [`crate::offset_surface`]), run with `body`'s faces and edges tagged by their record indices
/// (`order`), and the new body with its [`History`] relative to `body`, in the new body's record
/// order. The tags and the offset roles are removed from both bodies before this returns,
/// whether `op` succeeds or not.
///
/// # Errors
/// `op`'s error; the kernel's, from the tags or the new body's record.
pub fn recorded_into(
body: &Body,
order: &Order,
made: Made<'_>,
op: impl FnOnce() -> Result<Body>,
) -> Result<(Body, History)> {
tag(body, order)?;
let done = op().and_then(|new| {
let (_, out) = new.to_record()?;
let history = read(&new.lock(), &out, made);
let cleared = clear(&mut new.lock());
let history = history?;
cleared?;
Ok((new, history))
});
let cleared = clear(&mut body.lock());
let result = done?;
cleared?;
Ok(result)
}
/// Every face and edge of `order` tagged with its index, after every ops tag the body carried
/// is gone.
fn tag(body: &Body, order: &Order) -> Result<()> {
+1 -1
View File
@@ -71,7 +71,7 @@ pub use measure::{
point_distance, Distance, EdgePoint, FaceCentroid, GapSample, MassPropertiesEstimate, Normal,
PointClass,
};
pub use offset::{draft, offset_faces, offset_sheet, shell, thicken};
pub use offset::{draft, offset_faces, offset_sheet, offset_surface, shell, thicken};
pub use order::Order;
pub use step_assembly::{placed_bodies, PlacedBody};
+3
View File
@@ -419,5 +419,8 @@ pub fn draft(
})?
}
mod surface;
pub use surface::offset_surface;
#[cfg(test)]
mod tests;
+142
View File
@@ -0,0 +1,142 @@
//! Offset surfaces: faces of a body copied out at a signed distance along their normals, as a
//! new body. Each face goes onto its surface's offset on its own, the kernel's offset of the face
//! as a sheet ([`super::offset_sheet`]); the offsets are then joined and stitched where they meet
//! within the caller's tolerance, so neighbours moved to the same place along their shared edge
//! (meeting smoothly) keep sharing it, and across a sharp edge they come apart. Faces that close
//! up make a solid. An offset the kernel cannot make (one that would fold a face over) is its
//! refusal, never a fitted surface. Ops sequences: every face, offset and stitch is the
//! kernel's.
use solidcore::geom::tol::USE_DEFAULT;
use solidcore::topology::brep::p2::{copy_source_ref, CopyNotifyState};
use solidcore::topology::brep::p3::{brep_copy_mapped, new_brep};
use solidcore::topology::brep::p7::copy_faces_from;
use solidcore::topology::loopuse::notify_ref;
use solidcore::topology::{Brep, FaceId};
use solidcore::EntityRef;
use super::{offset_sheet, MAX_DISTANCE};
use crate::body::Body;
use crate::error::status;
use crate::order::{self, Order};
use crate::session::run_on;
use crate::split::copies_of;
use crate::stitch::stitch;
use crate::{Error, Result};
/// Record faces `faces` of `body` (through `order`, each once) offset by `distance` along their
/// outward normals (negative: against them) into one new body, joined where their offsets'
/// edges lie within `tolerance` of each other; with whether the result closes into a solid
/// ([`stitch`]'s). A distance of zero copies the faces. `body` is unchanged; the faces and edges
/// of the result carry what their sources carry (an application's provenance,
/// [`crate::history::recorded_into`]).
///
/// # Errors
/// [`Error::Argument`] for no face, an index past the faces or repeated, a distance not finite
/// or past [`MAX_DISTANCE`], a tolerance not finite and positive; the kernel's refusals (an
/// offset that would fold a face, [`Error::Empty`] for one that leaves nothing); those of
/// [`stitch`].
pub fn offset_surface(
body: &Body,
order: &Order,
faces: &[usize],
distance: f64,
tolerance: f64,
) -> Result<(Body, bool)> {
if faces.is_empty()
|| !(distance.is_finite() && distance.abs() <= MAX_DISTANCE)
|| !(tolerance.is_finite() && tolerance > 0.0)
{
return Err(Error::Argument);
}
let mut picked: Vec<FaceId> = Vec::new();
for &i in faces {
let f = order::face(order, i)?;
if picked.contains(&f) {
return Err(Error::Argument);
}
picked.push(f);
}
let mut sheets = Vec::new();
for f in picked {
let sheet = face_sheet(body, f)?;
if distance != 0.0 {
offset_sheet(&sheet, distance)?;
}
sheets.push(sheet);
}
stitch(joined(&sheets)?, tolerance)
}
/// Face `f` of `body` copied out as a sheet of its own, with what it and its edges carry.
fn face_sheet(body: &Body, f: FaceId) -> Result<Body> {
run_on("offset_surface", &[("brep", body)], |k| {
let (mut whole, src) = {
let g = body.lock();
if !g.face_list().contains(&f) {
return Ok(Err(Error::Argument));
}
let (whole, map) = brep_copy_mapped(k, &g)?;
let face = |e| match e {
EntityRef::Face(x) => Some(x),
_ => None,
};
let Some([src]) = copies_of(&map, &[f], EntityRef::Face, face)
.and_then(|v| <[FaceId; 1]>::try_from(v).ok())
else {
return Ok(Err(Error::Argument));
};
(whole, src)
};
let mut w = new_brep(k, USE_DEFAULT, USE_DEFAULT)?;
w.set_tolerance(whole.tolerance());
match copied_into(k, &mut whole, &[src], &mut w) {
Ok(1) => {}
Ok(_) => return Ok(Err(Error::Empty)),
Err(e) => return Ok(Err(status(e))),
}
Ok(Ok(Body::new(w)))
})?
}
/// The faces of `sheets` copied into one body, each sheet's topology kept.
fn joined(sheets: &[Body]) -> Result<Body> {
let inputs: Vec<(&'static str, &Body)> = sheets.iter().map(|s| ("sheet", s)).collect();
run_on("offset_surface", &inputs, |k| {
let mut dest = new_brep(k, USE_DEFAULT, USE_DEFAULT)?;
for s in sheets {
let mut g = s.lock();
let faces = g.face_list().to_vec();
if let Err(e) = copied_into(k, &mut g, &faces, &mut dest) {
return Ok(Err(status(e)));
}
}
Ok(Ok(Body::new(dest)))
})?
}
/// `faces` of `src` copied into `dest` through the merge, with both bodies' attributes in the
/// copy's notifications so each copy carries what its source carries; how many faces it made.
fn copied_into(
k: &mut solidcore::Kernel,
src: &mut Brep,
faces: &[FaceId],
dest: &mut Brep,
) -> solidcore::Result<usize> {
let mut state = CopyNotifyState::with_attributes_of(src, dest)?;
let this_face = |x: FaceId| notify_ref(EntityRef::Face(x));
let copied = copy_faces_from(
k,
src,
faces,
dest,
true,
&copy_source_ref,
&this_face,
&mut state.env(None),
);
state.restore_attributes(src, dest)?;
let (made, _) = copied?;
dest.refresh_cached_topology();
Ok(made.len())
}
+116
View File
@@ -0,0 +1,116 @@
//! Offset surfaces (tangency-labs/solidcore#3138): faces copied out at a distance, each on its
//! surface's exact offset, joined where they meet; the cases of SolidCraft's
//! `surface_offset.rs`, on closed forms measured on the exact B-rep.
#![allow(
clippy::unwrap_used,
clippy::disallowed_methods // the closed forms' oracle, not ops geometry
)]
use std::f64::consts::PI;
use solidcore_ops::history::{recorded_into, Change, FaceOrigin, Made};
use solidcore_ops::{make_box, make_cylinder, make_sphere, offset_surface, session, Body, Order};
fn all_fixes() {
session::set_thread_fixes(solidcore::kernel::UpstreamFixes::NAMES.iter().copied()).unwrap();
}
fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs().max(1e-9)
}
fn face_at(b: &Body, o: &Order, p: [f64; 3]) -> usize {
b.face_at(o, p, 1e-6).unwrap().unwrap()
}
fn edges(b: &Body) -> usize {
b.to_record().unwrap().1.edges.len()
}
/// Planes move exactly: a 10 cube's top 5 up is a 10 × 10 square at z = 15, the offset of the
/// top. Two faces across a sharp edge come apart (8 edges, none shared); a zero distance copies.
#[test]
fn planes_move_and_sharp_edges_come_apart() {
all_fixes();
let b = make_box([0.0; 3], [10.0; 3]).unwrap();
let (_, o) = b.to_record().unwrap();
let top = face_at(&b, &o, [5.0, 5.0, 10.0]);
let (up, h) = recorded_into(&b, &o, Made::Offset, || {
offset_surface(&b, &o, &[top], 5.0, 0.01).map(|(r, _)| r)
})
.unwrap();
assert!(rel(up.area(1e-8).unwrap(), 100.0) < 1e-9);
let (lo, hi) = up.bounds().unwrap();
assert!(
(lo[2] - 15.0).abs() < 1e-9 && (hi[2] - 15.0).abs() < 1e-9,
"{lo:?} {hi:?}"
);
assert_eq!(h.faces.len(), 1);
assert_eq!(h.faces[0].change, Change::Generated);
assert_eq!(h.faces[0].origins, vec![FaceOrigin::OffsetOf(top)]);
let side = face_at(&b, &o, [5.0, 0.0, 5.0]);
let (two, closed) = offset_surface(&b, &o, &[top, side], 1.0, 0.01).unwrap();
assert!(!closed);
assert_eq!(
(two.to_record().unwrap().1.faces.len(), edges(&two)),
(2, 8)
);
let (copy, _) = offset_surface(&b, &o, &[top, side], 0.0, 0.01).unwrap();
assert!(rel(copy.area(1e-8).unwrap(), 200.0) < 1e-9);
// the body itself is unchanged
assert!(rel(b.volume(1e-8).unwrap(), 1000.0) < 1e-9);
}
/// A cylinder's side (r 10, h 20, one face closed round its seam) moved out by 2 keeps its seam
/// joined, with radius 12: area 2π·12·20. Moved in past its axis: refused.
#[test]
fn a_cylinder_side_changes_radius() {
all_fixes();
let c = make_cylinder([0.0; 3], 10.0, 20.0).unwrap();
let (_, o) = c.to_record().unwrap();
let d = 10.0 / 2f64.sqrt();
let side = face_at(&c, &o, [d, d, 10.0]);
assert_eq!(
face_at(&c, &o, [-d, -d, 10.0]),
side,
"the side is one face"
);
let (out, _) = offset_surface(&c, &o, &[side], 2.0, 0.01).unwrap();
assert!(rel(out.area(1e-8).unwrap(), 2.0 * PI * 12.0 * 20.0) < 1e-7);
let (joined, _) = offset_surface(&c, &o, &[side], 0.0, 0.01).unwrap();
assert_eq!(
edges(&out),
edges(&joined),
"the side still closes on its seam"
);
assert!(offset_surface(&c, &o, &[side], -10.5, 0.01).is_err());
}
/// A ball (r 10) moved out by 2: its faces meet smoothly, so their offsets share every edge and
/// close into the ball of radius 12 (area 4π·144, volume 4π·1728/3); moved in to its centre:
/// refused.
#[test]
fn a_ball_closes_and_is_refused_at_its_centre() {
all_fixes();
let s = make_sphere([0.0; 3], 10.0).unwrap();
let (_, o) = s.to_record().unwrap();
let all: Vec<usize> = (0..o.faces.len()).collect();
let (out, closed) = offset_surface(&s, &o, &all, 2.0, 0.01).unwrap();
assert!(closed, "the offset ball closes into a solid");
assert_eq!(edges(&out), edges(&s));
assert!(rel(out.area(1e-8).unwrap(), 4.0 * PI * 144.0) < 1e-7);
assert!(rel(out.volume(1e-8).unwrap(), 4.0 / 3.0 * PI * 1728.0) < 1e-7);
assert!(offset_surface(&s, &o, &all, -10.0, 0.01).is_err());
}
/// Arguments: no face, a face twice, an index past the faces, a tolerance of 0.
#[test]
fn bad_arguments_are_refused() {
all_fixes();
let b = make_box([0.0; 3], [10.0; 3]).unwrap();
let (_, o) = b.to_record().unwrap();
assert!(offset_surface(&b, &o, &[], 1.0, 0.01).is_err());
assert!(offset_surface(&b, &o, &[0, 0], 1.0, 0.01).is_err());
assert!(offset_surface(&b, &o, &[6], 1.0, 0.01).is_err());
assert!(offset_surface(&b, &o, &[0], 1.0, 0.0).is_err());
}