M1: rectangular/circular patterns and mirror of features, construction planes (offset, at angle), split body by plane, chamfers by mitred cutters

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-07 18:29:51 +00:00
1 parent b85ccd62cf
commit 1467ad1d89
11 files changed
+721 -32

No files matched your search

+117 -2
View File
@@ -301,7 +301,122 @@ pub fn fillet(body: &Body, edges: &[Vec3], radius: f64) -> Result<Body> {
blend(body, edges, radius, Shape::Round, "fillet")
}
/// Equal-distance chamfer of the edges nearest to the given points.
/// Equal-distance chamfer of the edges nearest to the given points. Each edge is cut with a
/// prism whose only face inside the body is the chamfer plane (so neighbouring chamfers meet in
/// a mitre); the local operation is the fallback.
pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result<Body> {
blend(body, edges, distance, Shape::Flat, "chamfer")
if !(distance.is_finite() && distance > 1e-6 && distance < 1e6) {
return Err(KernelError::Invalid("chamfer size must be positive".into()));
}
if edges.is_empty() || edges.len() > 1000 {
return Err(KernelError::Invalid("select 1…1000 edges".into()));
}
// All cutters at once (their union mitres the corners), else one edge at a time.
let tools: Result<Vec<Body>> = edges.iter().enumerate().map(|(i, p)| chamfer_tool(body, *p, distance, i)).collect();
if let Ok(tools) = tools {
let mut acc: Option<Body> = None;
let mut ok = true;
for t in tools {
let next = match &acc {
None => Some(t),
Some(a) => crate::ops::boolean(a, &t, crate::BoolOp::Union).ok().flatten(),
};
if next.is_none() {
ok = false;
break;
}
acc = next;
}
if ok
&& let Some(tool) = acc
&& let Ok(Some(r)) = crate::ops::boolean(body, &tool, crate::BoolOp::Cut)
{
return Ok(r);
}
}
let mut cur = body.clone();
for p in edges {
cur = match chamfer_tool(&cur, *p, distance, 0).and_then(|t| crate::ops::boolean(&cur, &t, crate::BoolOp::Cut)) {
Ok(Some(b)) => b,
_ => blend(&cur, std::slice::from_ref(p), distance, Shape::Flat, "chamfer")?,
};
}
Ok(cur)
}
/// Geometry of a straight convex edge between two planar faces.
struct EdgeFrame {
p0: Vec3,
p1: Vec3,
d: Vec3,
n1: Vec3,
n2: Vec3,
t1: Vec3,
t2: Vec3,
}
fn edge_frame(cur: &Body, p: Vec3) -> Result<EdgeFrame> {
let size = cur.size();
let tol = (size * 2e-3).max(1e-3);
let Some((idx, dist)) = cur.nearest_edge(p, tol)? else { return Err(KernelError::Invalid("the body has no edges".into())) };
if dist > size * 0.05 + 1e-3 {
return Err(KernelError::Invalid(format!("no edge near {:?}", [p.x, p.y, p.z])));
}
let solid = &*cur.solid;
let edge = Body::unique_edges(solid).into_iter().nth(idx).ok_or_else(|| KernelError::Invalid("edge index".into()))?;
if !matches!(edge.curve(), mt::Curve::Line(_)) {
return Err(unsupported("only straight edges"));
}
let faces: Vec<&mt::Face> = solid.face_iter().filter(|f| f.edge_iter().any(|e| e.id() == edge.id())).collect();
let [f1, f2] = faces[..] else { return Err(unsupported("the edge is not between two faces")) };
let (Some(n1), Some(n2)) = (plane_normal(f1), plane_normal(f2)) else { return Err(unsupported("both faces next to the edge must be planar")) };
let (p0, p1) = (vtx(edge.absolute_front()), vtx(edge.absolute_back()));
let d = (p1 - p0).normalized().ok_or_else(|| KernelError::Failed("zero-length edge".into()))?;
let mut t1 = n1.cross(d);
if t1.dot(n2) > 0.0 {
t1 = -t1;
}
let mut t2 = n2.cross(d);
if t2.dot(n1) > 0.0 {
t2 = -t2;
}
if n1.dot(t2) > 1e-9 || n2.dot(t1) > 1e-9 || t1.dot(t2) < -1.0 + 1e-6 {
return Err(unsupported("only convex edges"));
}
Ok(EdgeFrame { p0, p1, d, n1, n2, t1, t2 })
}
/// The prism that cuts a chamfer of size `s` on the edge nearest `p`. `k` varies the parts that
/// stay outside the body so that neighbouring cutters don't share edges.
pub(crate) fn chamfer_tool(cur: &Body, p: Vec3, s: f64, k: usize) -> Result<Body> {
let f = edge_frame(cur, p)?;
let size = cur.size();
let len = f.p0.dist(f.p1);
let ext = (s * 2.0).max(size * 0.01) * (1.0 + 0.093 * (k % 5) as f64);
// Cross-section at p0 - d*ext, in a plane with normal d.
let base = f.p0 - f.d * ext;
let plane = solvecraft_geom::Plane::from_normal(base, f.d).ok_or_else(|| KernelError::Failed("chamfer plane".into()))?;
let a = base + f.t1 * s;
let b = base + f.t2 * s;
let ab = (a - b).normalized().ok_or_else(|| KernelError::Failed("chamfer".into()))?;
let out = (f.n1 + f.n2).normalized().ok_or_else(|| KernelError::Failed("chamfer".into()))?;
let vary = 1.0 + 0.137 * (k % 7) as f64;
let reach = (s * 4.0 + size * 0.02) * vary;
let a_ext = a + ab * (s * 0.5 + reach * 0.05);
let b_ext = b - ab * (s * 0.5 + reach * 0.07);
let e_far = base + out * reach;
// The material beyond both ends must be empty (the cutter runs past them).
let mid = (a + b + base) / 3.0;
for probe in [mid + f.d * (ext * 0.5), mid + f.d * (len + ext * 1.5)] {
if cur.contains(probe) {
return Err(unsupported("chamfers ending at a concave corner"));
}
}
let loc: Vec<solvecraft_geom::Vec2> = [a_ext, b_ext, e_far].iter().map(|q| plane.to_local(*q)).collect();
let mut lp = solvecraft_geom::Loop2::polygon(&loc);
if lp.signed_area() < 0.0 {
lp = lp.reversed();
}
let region = solvecraft_geom::Region2 { outer: lp, holes: vec![] };
crate::build::extrude(&plane, &[region], 0.0, len + 2.0 * ext)?.pop().ok_or_else(|| KernelError::Failed("chamfer tool".into()))
}
+1 -1
View File
@@ -24,7 +24,7 @@ pub use blend::{chamfer, fillet};
pub use body::{Body, EdgeInfo, FaceInfo};
pub use build::{box_solid, cylinder, extrude, extrude_tapered, revolve, sphere, torus};
pub use measure::{BodyMeasure, measure};
pub use ops::{BoolOp, boolean, transform};
pub use ops::{BoolOp, boolean, split_by_plane, transform, transform_matrix};
pub use step::step_export;
pub use topo::{TopoCounts, merged_topology, seam_flags};
+43
View File
@@ -84,6 +84,20 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
Err(KernelError::Failed(format!("boolean {op:?}: {last}")))
}
/// Affine transform by a column-major 4×4 matrix (rotations, translations, reflections).
pub fn transform_matrix(body: &Body, m: [[f64; 4]; 4]) -> Result<Body> {
if m.iter().flatten().any(|x| !x.is_finite()) {
return Err(KernelError::Invalid("non-finite transform".into()));
}
guard("transform", || {
let mat = mt::Matrix4::new(
m[0][0], m[0][1], m[0][2], m[0][3], m[1][0], m[1][1], m[1][2], m[1][3], m[2][0], m[2][1], m[2][2], m[2][3], m[3][0], m[3][1], m[3][2],
m[3][3],
);
Body::new(mt::builder::transformed(&body.deep_copy(), mat))
})
}
/// Rigid transform: rotate about `axis` through `origin` by `angle` radians, then translate.
pub fn transform(body: &Body, translate: Vec3, origin: Vec3, axis: Vec3, angle: f64) -> Result<Body> {
if !(translate.is_finite() && origin.is_finite() && angle.is_finite()) {
@@ -101,3 +115,32 @@ pub fn transform(body: &Body, translate: Vec3, origin: Vec3, axis: Vec3, angle:
Body::new(s)
})
}
/// Split a body with a plane: the parts on the positive and the negative side of the plane
/// (only the non-empty ones).
pub fn split_by_plane(body: &Body, plane: &solvecraft_geom::Plane) -> Result<Vec<Body>> {
let size = body.size();
let mut bb = solvecraft_geom::Aabb3::EMPTY;
for v in body.solid.vertex_iter() {
bb.add(crate::body::from_p3(v.point()));
}
let reach = (bb.diagonal() + plane.origin.dist(bb.center())) * 2.0 + size;
let c = plane.to_local(bb.center());
let sq = solvecraft_geom::Region2 {
outer: solvecraft_geom::Loop2::polygon(&[
solvecraft_geom::Vec2::new(c.x - reach, c.y - reach),
solvecraft_geom::Vec2::new(c.x + reach, c.y - reach),
solvecraft_geom::Vec2::new(c.x + reach, c.y + reach),
solvecraft_geom::Vec2::new(c.x - reach, c.y + reach),
]),
holes: vec![],
};
let mut out = Vec::new();
for (lo, hi) in [(0.0, reach), (-reach, 0.0)] {
let half = crate::build::extrude(plane, std::slice::from_ref(&sq), lo, hi)?.pop().ok_or_else(|| KernelError::Failed("half space".into()))?;
if let Some(part) = boolean(body, &half, BoolOp::Intersect)? {
out.push(part);
}
}
Ok(out)
}
+31
View File
@@ -264,3 +264,34 @@ fn debug_coplanar_union_rate() {
}
println!("ok {ok}/60");
}
#[test]
fn chamfer_top_loop_mitres() {
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
let edges = [Vec3::new(0.0, 15.0, 20.0), Vec3::new(20.0, 0.0, 20.0), Vec3::new(40.0, 15.0, 20.0), Vec3::new(20.0, 30.0, 20.0)];
let c = chamfer(&b, &edges, 2.0).unwrap();
let m = measure(&c).unwrap();
assert!(rel(m.volume, 23730.666667) < 1e-6, "{}", m.volume);
assert_eq!(m.merged.faces, 10);
}
#[test]
#[ignore]
fn debug_chamfer_cut() {
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
// Prism along x: triangle in the YZ plane beyond the top-front edge.
let plane = Plane { origin: Vec3::new(-5.0, 0.0, 0.0), x: Vec3::Y, y: Vec3::Z };
let tri = Loop2::polygon(&[Vec2::new(2.3, 20.3), Vec2::new(-0.3, 17.7), Vec2::new(10.0, 30.0)]);
let tri = if tri.signed_area() < 0.0 { tri.reversed() } else { tri };
let tool = extrude(&plane, &[Region2 { outer: tri, holes: vec![] }], 0.0, 50.0).unwrap().pop().unwrap();
println!("tool vol {}", measure(&tool).unwrap().volume);
let r = boolean(&b, &tool, BoolOp::Cut).unwrap().unwrap();
let m = measure(&r).unwrap();
println!("cut: {} faces {} {:?}", m.volume, m.faces, m.merged);
for f in r.faces(0.01).unwrap() {
println!("face {} area {:.3} c {:?} n {:?}", f.index, f.area, f.centroid, f.plane_normal);
}
for tol in [0.1, 0.01, 0.001] {
println!("tess {tol}: {}", r.tessellate(tol).unwrap().measure().volume);
}
}