Merge pull request #102 from CypherNaught-0x/fix/chamfer-sloped-rims
Fix chamfers on sloped end faces and enforce rim clearance
This commit is contained in:
4 files changed
+219
-13
No files matched your search
+2
-1
@@ -66,7 +66,8 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge,
|
||||
rounds next to earlier rounds of another radius, and smooth corner patches where three rounds
|
||||
meet (a sphere for equal radii); straight edges (convex or concave) between planar faces with perpendicular
|
||||
planar end faces; whole smooth loops of a planar face, picked whole or by one edge (tangent
|
||||
planar end faces (equal-distance chamfers also meet sloped planar end faces, including inner
|
||||
basin rims within the rim width); whole smooth loops of a planar face, picked whole or by one edge (tangent
|
||||
chain), including circles and walls at any angle (boss bases and tops, bores, pocket floors,
|
||||
plate outlines), loops with sharp corners between straight edges (the blends meet in mitres), runs of
|
||||
neighbouring edges along a planar face's loop (two top edges of a box: mitred, ending square;
|
||||
|
||||
@@ -1650,6 +1650,35 @@ fn unitless_parameters_used_as_lengths_take_the_design_units() {
|
||||
assert!((max(0) - 50.8).abs() < 1e-9 && (max(1) - 50.8).abs() < 1e-9 && (max(2) - 10.0).abs() < 1e-9, "{m}");
|
||||
}
|
||||
|
||||
/// A chamfer on a tapered pocket survives parameter edits and undo/redo.
|
||||
#[test]
|
||||
fn chamfer_sloped_basin_rim_recomputes() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 30}));
|
||||
for (name, z, p0, p1) in [("Rim", 30, [20, 4], [80, 50]), ("Floor", 10, [30, 20], [70, 40])] {
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": z, "name": name}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": p0, "p1": p1}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
}
|
||||
run(&mut s, "solid.loft", json!({"sections": [{"sketch": "Rim"}, {"sketch": "Floor"}], "operation": "cut"}));
|
||||
run(&mut s, "parameters.add", json!({"name": "rim_chamfer", "expression": "1 mm"}));
|
||||
let base = volume(&mut s);
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[50, 4, 30]], "distance": "rim_chamfer"}));
|
||||
let expected = |size: f64| {
|
||||
let wall_slope: f64 = (20.0 - 4.0) / (30.0 - 10.0); // horizontal run / vertical drop
|
||||
let drop = size / (1.0 + wall_slope.powi(2)).sqrt();
|
||||
base - 0.5 * size * drop * (60.0 - drop / 3.0)
|
||||
};
|
||||
assert!(rel(volume(&mut s), expected(1.0)) < 1e-8);
|
||||
let edited = run(&mut s, "parameters.change", json!({"name": "rim_chamfer", "expression": "2 mm"}));
|
||||
assert_eq!(edited["errors"].as_array().unwrap().len(), 0, "{edited}");
|
||||
assert!(rel(volume(&mut s), expected(2.0)) < 1e-8);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(rel(volume(&mut s), expected(1.0)) < 1e-8);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(rel(volume(&mut s), expected(2.0)) < 1e-8);
|
||||
}
|
||||
|
||||
/// Chamfers with two distances or a distance and an angle.
|
||||
#[test]
|
||||
fn unequal_chamfers() {
|
||||
|
||||
+144
-12
@@ -1,8 +1,8 @@
|
||||
//! Edge fillets and chamfers as a local B-rep operation.
|
||||
//!
|
||||
//! Supported: a straight, convex edge between two planar faces whose two end vertices each
|
||||
//! touch exactly one more planar face perpendicular to the edge (the common case for edges of
|
||||
//! extruded and box-like parts). The two side faces are trimmed back, the end faces get an arc
|
||||
//! Supported locally: a straight edge between two planar faces whose two end vertices each
|
||||
//! touch exactly one more planar face. Fillets require perpendicular end faces; chamfers
|
||||
//! intersect their offset rails with the end planes, including sloped walls. The end faces get an arc
|
||||
//! (fillet) or a line (chamfer) at the corner, and a cylindrical (or planar) face is inserted.
|
||||
//! The result is exact. Edges meeting at an already blended corner, curved edges and other
|
||||
//! configurations are reported as not supported yet.
|
||||
@@ -131,6 +131,60 @@ fn setback(r: f64, phi: f64, shape: Shape) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// An extended corner edge must stay in the trimmed side face, not just its infinite
|
||||
/// supporting plane. Reject crossings and contact with any boundary after the old corner,
|
||||
/// including holes. Curved boundaries are sampled more finely than the clearance tolerance.
|
||||
fn chamfer_extension_fits(face: &mt::Face, p: Vec3, q: Vec3, tol: f64) -> bool {
|
||||
use mt::{BoundedCurve, ParameterDivision1D};
|
||||
// truck requires a curve sampling tolerance of at least 1e-6. Keep the
|
||||
// boundary clearance four times larger than the chord approximation error.
|
||||
let tol = tol.max(4e-6);
|
||||
let (Some(x), Some(n)) = ((q - p).normalized(), plane_normal(face)) else { return false };
|
||||
let y = n.cross(x);
|
||||
let len = p.dist(q);
|
||||
if !len.is_finite() || len <= tol {
|
||||
return false;
|
||||
}
|
||||
let mut inside = false;
|
||||
for edge in face.edge_iter() {
|
||||
let points = if matches!(edge.curve(), mt::Curve::Line(_)) {
|
||||
vec![edge.front().point(), edge.back().point()]
|
||||
} else {
|
||||
let curve = edge.curve();
|
||||
curve.parameter_division(curve.range_tuple(), tol * 0.25).1
|
||||
};
|
||||
if points.len() < 2 || points.len() > 100_000 {
|
||||
return false;
|
||||
}
|
||||
for pair in points.windows(2) {
|
||||
let [a, b] = pair else { return false };
|
||||
let a = from_p3(*a) - p;
|
||||
let b = from_p3(*b) - p;
|
||||
let (ax, ay, bx, by) = (a.dot(x), a.dot(y), b.dot(x), b.dot(y));
|
||||
if ![ax, ay, bx, by].iter().all(|v| v.is_finite()) {
|
||||
return false;
|
||||
}
|
||||
// Contact/overlap with the extension, except its original endpoint.
|
||||
if (ay.abs() <= tol && by.abs() <= tol && ax.max(bx) > tol && ax.min(bx) <= len + tol)
|
||||
|| [(ax, ay), (bx, by)].iter().any(|(u, v)| *u > tol && *u <= len + tol && v.abs() <= tol)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (ay > 0.0) != (by > 0.0) {
|
||||
let hit = ax - ay * (bx - ax) / (by - ay);
|
||||
if hit > tol && hit <= len + tol {
|
||||
return false;
|
||||
}
|
||||
// Even-odd containment of q over all loops (outer boundary and holes).
|
||||
if hit > len {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
inside
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn blend_geometry(
|
||||
solid: &Solid,
|
||||
@@ -153,6 +207,8 @@ fn blend_geometry(
|
||||
g: usize,
|
||||
e1: mt::Edge,
|
||||
e2: mt::Edge,
|
||||
a: Vec3,
|
||||
b: Vec3,
|
||||
}
|
||||
let mut ends: Vec<End> = Vec::new();
|
||||
for (v, p) in [(&v0, p0), (&v1, p1)] {
|
||||
@@ -161,9 +217,19 @@ fn blend_geometry(
|
||||
let [g] = others[..] else { return Err(unsupported("each end of the edge must touch exactly one more face")) };
|
||||
let gf = faces.get(g).ok_or_else(|| KernelError::Failed("face".into()))?;
|
||||
let ng = plane_normal(gf).ok_or_else(|| unsupported("the faces at the edge ends must be planar"))?;
|
||||
if ng.dot(d).abs() < 1.0 - 1e-9 {
|
||||
if shape == Shape::Round && ng.dot(d).abs() < 1.0 - 1e-9 {
|
||||
return Err(unsupported("the faces at the edge ends must be perpendicular to the edge"));
|
||||
}
|
||||
if ng.dot(d).abs() < 1e-9 {
|
||||
return Err(unsupported("an end face parallel to the edge"));
|
||||
}
|
||||
// Each rail is parallel to the original edge and set back by s in its side
|
||||
// face. Slide its endpoint along d until it lies on the actual end plane.
|
||||
let endpoint = |t: Vec3| {
|
||||
let q = p + t * s;
|
||||
if shape == Shape::Flat { q - d * (ng.dot(t) * s / ng.dot(d)) } else { q }
|
||||
};
|
||||
let (a, b) = (endpoint(t1), endpoint(t2));
|
||||
// Edges at v shared by g with f1 and with f2.
|
||||
let shared = |fi: usize| -> Result<mt::Edge> {
|
||||
let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?;
|
||||
@@ -172,26 +238,46 @@ fn blend_geometry(
|
||||
.ok_or_else(|| unsupported("unexpected corner topology"))
|
||||
};
|
||||
let (e1, e2) = (shared(i1)?, shared(i2)?);
|
||||
for (e, t) in [(&e1, t1), (&e2, t2)] {
|
||||
for (e, q, t, fi) in [(&e1, a, t1, i1), (&e2, b, t2, i2)] {
|
||||
if !matches!(e.curve(), mt::Curve::Line(_)) {
|
||||
return Err(unsupported("the edges at the corners must be straight"));
|
||||
}
|
||||
let other = if e.front() == v { vtx(e.back()) } else { vtx(e.front()) };
|
||||
let along = (other - p).dot(t);
|
||||
if (other - p - t * along).len() > tol * 100.0 + 1e-7 || along <= s + tol {
|
||||
let delta = other - p;
|
||||
if shape == Shape::Round {
|
||||
// Fillets retain the square-end check: the original corner edge
|
||||
// must run in the setback direction and have enough length left.
|
||||
let along = delta.dot(t);
|
||||
if (delta - t * along).len() > tol * 100.0 + 1e-7 || along <= s + tol {
|
||||
return Err(unsupported("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let len = delta.len();
|
||||
let dir = delta.normalized().ok_or_else(|| unsupported("zero-length corner edge"))?;
|
||||
let along = (q - p).dot(dir);
|
||||
// An inner rim extends the neighbouring rim edge away from its other
|
||||
// vertex. This is valid only within the trimmed side face.
|
||||
if (q - p - dir * along).len() > tol * 100.0 + 1e-7
|
||||
|| along >= len - tol
|
||||
|| (along < 0.0 && !faces.get(fi).is_some_and(|f| chamfer_extension_fits(f, p, q, tol)))
|
||||
{
|
||||
return Err(unsupported("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
}
|
||||
ends.push(End { v: v.clone(), g, e1, e2 });
|
||||
ends.push(End { v: v.clone(), g, e1, e2, a, b });
|
||||
}
|
||||
let [end0, end1] = &ends[..] else { return Err(KernelError::Failed("ends".into())) };
|
||||
if end0.g == end1.g {
|
||||
return Err(unsupported("both ends touch the same face"));
|
||||
}
|
||||
let a0 = builder::vertex(p3(p0 + t1 * s));
|
||||
let b0 = builder::vertex(p3(p0 + t2 * s));
|
||||
let a1 = builder::vertex(p3(p1 + t1 * s));
|
||||
let b1 = builder::vertex(p3(p1 + t2 * s));
|
||||
if (end1.a - end0.a).dot(d) <= tol || (end1.b - end0.b).dot(d) <= tol {
|
||||
return Err(unsupported("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
let a0 = builder::vertex(p3(end0.a));
|
||||
let b0 = builder::vertex(p3(end0.b));
|
||||
let a1 = builder::vertex(p3(end1.a));
|
||||
let b1 = builder::vertex(p3(end1.b));
|
||||
// Corner curves on the end faces (A → B) and the blend surface. The arc centre is r from
|
||||
// both faces (inside the material for a convex edge, outside for a concave one); the arc's
|
||||
// middle is the point nearest the old edge.
|
||||
@@ -861,3 +947,49 @@ fn chamfer_tool_sides(cur: &Body, p: Vec3, s: f64, sides: Option<(ChamferSide, b
|
||||
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()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use solvecraft_geom::{Loop2, Plane, Region2, Vec2};
|
||||
|
||||
#[test]
|
||||
fn chamfer_extension_cannot_cross_a_hole_and_reenter_the_face() {
|
||||
let rectangle = |a, b, c, d| Loop2::polygon(&[Vec2::new(a, b), Vec2::new(c, b), Vec2::new(c, d), Vec2::new(a, d)]);
|
||||
let region =
|
||||
Region2 { outer: rectangle(0.0, 0.0, 10.0, 10.0), holes: vec![rectangle(4.0, 4.0, 6.0, 6.0), Loop2::circle(Vec2::new(4.0, 1.5), 0.25)] };
|
||||
for plane in [Plane::XY, Plane::XZ] {
|
||||
let face = crate::build::face(&plane, ®ion).unwrap();
|
||||
let p = plane.to_world(Vec2::new(4.0, 4.0));
|
||||
let fits = |y| chamfer_extension_fits(&face, p, plane.to_world(Vec2::new(4.0, y)), 1e-6);
|
||||
assert!(fits(2.0));
|
||||
assert!(!fits(1.5)); // endpoint in the circular hole
|
||||
assert!(!fits(0.5)); // endpoint in material, but the extension crossed the hole
|
||||
assert!(!fits(-1e8)); // an arbitrarily long slide cannot escape the face
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_chamfer_intersects_sloped_end_planes() {
|
||||
for slope in [-0.5, 0.5] {
|
||||
let region = Region2 {
|
||||
outer: Loop2::polygon(&[Vec2::ZERO, Vec2::new(40.0, 0.0), Vec2::new(40.0 - slope * 20.0, 20.0), Vec2::new(slope * 20.0, 20.0)]),
|
||||
holes: vec![],
|
||||
};
|
||||
let body = crate::extrude(&Plane::XY, &[region], 0.0, 20.0).unwrap().pop().unwrap();
|
||||
let pick = [Vec3::new(20.0, 0.0, 20.0)];
|
||||
let result = blend(&body, &pick, 2.0, Shape::Flat, "chamfer").unwrap();
|
||||
let removed = 2.0 * (40.0 - 2.0 * slope * 2.0 / 3.0);
|
||||
let actual = crate::measure(&body).unwrap().volume - crate::measure(&result).unwrap().volume;
|
||||
assert!((actual - removed).abs() < 1e-6, "{actual} vs {removed}");
|
||||
assert!(result.validity().is_empty());
|
||||
// The new corner vertices lie on both original end planes.
|
||||
for point in [Vec3::new(slope * 2.0, 2.0, 20.0), Vec3::new(40.0 - slope * 2.0, 2.0, 20.0)] {
|
||||
assert!(result.solid.vertex_iter().any(|v| vtx(&v).dist(point) < 1e-8));
|
||||
}
|
||||
assert!(blend(&body, &pick, 25.0, Shape::Flat, "chamfer").is_err());
|
||||
// Oblique fillet caps need elliptical trims, which this local path does not build.
|
||||
assert!(blend(&body, &pick, 2.0, Shape::Round, "fillet").is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -435,6 +435,50 @@ fn chamfer_top_loop_mitres() {
|
||||
assert_eq!(m.merged.faces, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chamfer_sloped_basin_rim() {
|
||||
let slab = box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap();
|
||||
let rectangle = |x0, y0, x1, y1| Loop2::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)]);
|
||||
let pocket =
|
||||
loft(&[(Plane::XY.offset(30.0), rectangle(20.0, 4.0, 80.0, 50.0)), (Plane::XY.offset(10.0), rectangle(30.0, 20.0, 70.0, 40.0))]).unwrap();
|
||||
let basin = boolean(&slab, &pocket, BoolOp::Cut).unwrap().unwrap();
|
||||
let result = chamfer(&basin, &[Vec3::new(50.0, 4.0, 30.0)], 2.0).unwrap();
|
||||
// The removed triangular section has area s² sin(phi)/2. Its length varies
|
||||
// linearly between the sloped end walls, so its centroid gives the mean length.
|
||||
let wall_slope: f64 = (20.0 - 4.0) / (30.0 - 10.0); // horizontal run / vertical drop
|
||||
let drop = 2.0 / (1.0 + wall_slope.powi(2)).sqrt();
|
||||
let removed = drop * (60.0 - drop / 3.0);
|
||||
let before = measure(&basin).unwrap();
|
||||
let after = measure(&result).unwrap();
|
||||
assert!(rel(after.volume, before.volume - removed) < 1e-8, "{} vs {}", after.volume, before.volume - removed);
|
||||
assert_eq!(after.merged.faces, before.merged.faces + 1);
|
||||
assert_eq!(result.lumps().unwrap().len(), 1);
|
||||
assert!(result.validity().is_empty());
|
||||
let mesh = result.tessellate(0.01).unwrap();
|
||||
assert!(!mesh.contains(Vec3::new(50.0, 3.5, 29.9)));
|
||||
assert!(mesh.contains(Vec3::new(50.0, 3.5, 28.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chamfer_wider_than_sloped_basin_rim_is_refused() {
|
||||
for (margin, distance) in [(1.0, 2.0), (1.0, 3.0), (0.5, 4.0), (1.0, 1.0)] {
|
||||
let slab = box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap();
|
||||
let rectangle = |x0, y0, x1, y1| Loop2::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)]);
|
||||
let pocket =
|
||||
loft(&[(Plane::XY.offset(30.0), rectangle(20.0, margin, 80.0, 50.0)), (Plane::XY.offset(10.0), rectangle(30.0, 20.0, 70.0, 40.0))])
|
||||
.unwrap();
|
||||
let basin = boolean(&slab, &pocket, BoolOp::Cut).unwrap().unwrap();
|
||||
let before = measure(&basin).unwrap();
|
||||
let error = chamfer(&basin, &[Vec3::new(50.0, margin, 30.0)], distance).unwrap_err();
|
||||
assert!(error.to_string().contains("larger than the neighbouring faces"), "{margin}, {distance}: {error}");
|
||||
assert_eq!(measure(&basin).unwrap().volume, before.volume);
|
||||
// A smaller chamfer on the same thin rim is still supported.
|
||||
let smaller = chamfer(&basin, &[Vec3::new(50.0, margin, 30.0)], margin * 0.5).unwrap();
|
||||
assert!(smaller.validity().is_empty());
|
||||
assert!(measure(&smaller).unwrap().volume < before.volume);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn debug_chamfer_cut() {
|
||||
|
||||
Reference in new issue
Block a user