Pipe (solid or hollow, along sketch curves), Bounding Solid, Scale (uniform or per axis), Offset Face (planar faces), physical materials with mass in Measure; sweeps keep profile holes

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-07 23:00:57 +00:00
1 parent 37f4e4e49e
commit 840eddb875
9 files changed
+395 -24

No files matched your search

+26 -18
View File
@@ -554,28 +554,36 @@ pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result<Body>
if path.is_empty() || path.len() > 1000 {
return Err(KernelError::Invalid("the path needs 1…1000 segments".into()));
}
let w0 = wire(plane, &region.outer.ccw())?;
// The outer loop and each hole, swept along the path; the ends are capped with holes.
let mut starts = vec![wire(plane, &region.outer.ccw())?];
for h in &region.holes {
starts.push(wire(plane, &h.ccw().reversed())?);
}
guard("sweep", || {
let mut shell = mt::Shell::new();
let mut cur = w0.clone();
for seg in path {
let mut part: mt::Shell = match *seg {
PathSeg::Line { a, b } => builder::tsweep(&cur, v3(b - a)),
PathSeg::Arc { center, axis, angle, .. } => {
let ax = axis.normalized().ok_or_else(|| KernelError::Invalid("arc axis".into()))?;
builder::rsweep(&cur, p3(center), v3(ax), mt::Rad(angle))
}
};
let next = part
.extract_boundaries()
.into_iter()
.find(|w| w.edge_iter().all(|e| !cur.edge_iter().any(|c| c.id() == e.id())))
.ok_or_else(|| KernelError::Failed("sweep: lost the profile".into()))?;
shell.append(&mut part);
cur = next.inverse();
let mut ends = Vec::new();
for w0 in &starts {
let mut cur = w0.clone();
for seg in path {
let mut part: mt::Shell = match *seg {
PathSeg::Line { a, b } => builder::tsweep(&cur, v3(b - a)),
PathSeg::Arc { center, axis, angle, .. } => {
let ax = axis.normalized().ok_or_else(|| KernelError::Invalid("arc axis".into()))?;
builder::rsweep(&cur, p3(center), v3(ax), mt::Rad(angle))
}
};
let next = part
.extract_boundaries()
.into_iter()
.find(|w| w.edge_iter().all(|e| !cur.edge_iter().any(|c| c.id() == e.id())))
.ok_or_else(|| KernelError::Failed("sweep: lost the profile".into()))?;
shell.append(&mut part);
cur = next.inverse();
}
ends.push(cur);
}
let faces: Vec<mt::Face> = shell.face_iter().cloned().collect();
cap_and_close(faces, std::slice::from_ref(&w0), std::slice::from_ref(&cur))
cap_and_close(faces, &starts, &ends)
})
}
+1 -1
View File
@@ -34,7 +34,7 @@ pub use measure::{BodyMeasure, measure};
pub use meshbody::{MAX_TRIANGLES, mesh_body};
pub use ops::{BoolOp, boolean, split_by_plane, transform, transform_matrix};
pub use polybool::planar_boolean;
pub use polyhedron::{HalfSpace, convex_polyhedron, draft, shell};
pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell};
pub use step::step_export;
pub use step_in::{ImportedBody, StepImport, StepNode, step_import, step_import_shared};
pub use topo::{CylinderFace, cylinder_face_at};
+3 -3
View File
@@ -69,15 +69,15 @@ fn mismatch(a: &solvecraft_geom::Mesh, b: &solvecraft_geom::Mesh, result: &solve
Some(bad as f64 / n.max(1) as f64)
}
/// Boolean of two bodies. The result may be empty (`Ok(None)`) for a cut that removes
/// everything or an intersection of disjoint bodies. Results are checked against volume bounds
/// and retried with shifted copies and other tolerances when they fail or look wrong.
thread_local! {
/// Set while a boolean runs on bodies whose coincident faces were pushed apart (no
/// second round of pushing).
static APART: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// Boolean of two bodies. The result may be empty (`Ok(None)`) for a cut that removes
/// everything or an intersection of disjoint bodies. Results are checked against volume bounds
/// and retried with shifted copies and other tolerances when they fail or look wrong.
pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
a.require_brep("a boolean")?;
b.require_brep("a boolean")?;
+30
View File
@@ -460,3 +460,33 @@ fn solve3(m: [[f64; 3]; 3], r: [f64; 3]) -> Option<[f64; 3]> {
}
Some(out)
}
/// Move the planar faces at the given points along their outward normals by `distance`
/// (negative: into the body). The body's other faces follow (its topology is kept).
pub fn offset_faces(b: &Body, at: &[Vec3], distance: f64) -> Result<Body> {
b.require_brep("offset faces")?;
if !distance.is_finite() || distance.abs() > 1e6 {
return Err(KernelError::Invalid("offset distance".into()));
}
let healed = Body::new(crate::heal::heal(b.deep_copy(), b.size()))?;
let size = healed.size();
let mesh = healed.tessellate((size * 1e-3).max(1e-3))?;
let mut chosen: Vec<usize> = Vec::new();
for p in at {
let near = mesh
.triangles
.iter()
.zip(&mesh.tri_face)
.filter_map(|(t, f)| mesh.tri(t).map(|[x, y, z]| (point_tri(*p, x, y, z), *f as usize)))
.min_by(|a, c| a.0.total_cmp(&c.0));
match near {
Some((d, f)) if d < size * 1e-3 + 1e-6 => {
if !chosen.contains(&f) {
chosen.push(f);
}
}
_ => return Err(KernelError::Invalid(format!("no face at {:?}", [p.x, p.y, p.z]))),
}
}
offset_planar(&healed, |fi, _| if chosen.contains(&fi) { distance } else { 0.0 })
}