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:
1 parent
b85ccd62cf
commit
1467ad1d89
11 files changed
+721
-32
No files matched your search
+117
-2
@@ -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()))
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user