Reject chamfer extensions beyond trimmed rim faces
This commit is contained in:
1 parent
9c6f9410d2
commit
15bb64c2ab
4 files changed
+110
-6
No files matched your search
+1
-1
@@ -67,7 +67,7 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
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 (equal-distance chamfers also meet sloped planar end faces, including inner
|
||||
basin rims); whole smooth loops of a planar face, picked whole or by one edge (tangent
|
||||
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;
|
||||
|
||||
@@ -1663,7 +1663,8 @@ fn chamfer_sloped_basin_rim_recomputes() {
|
||||
let base = volume(&mut s);
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[50, 4, 30]], "distance": "rim_chamfer"}));
|
||||
let expected = |size: f64| {
|
||||
let drop = size / 1.64_f64.sqrt();
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
@@ -184,18 +238,30 @@ fn blend_geometry(
|
||||
.ok_or_else(|| unsupported("unexpected corner topology"))
|
||||
};
|
||||
let (e1, e2) = (shared(i1)?, shared(i2)?);
|
||||
for (e, q) in [(&e1, a), (&e2, b)] {
|
||||
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 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; passing that other vertex is not.
|
||||
if (q - p - dir * along).len() > tol * 100.0 + 1e-7 || along >= len - tol || (shape == Shape::Round && along <= tol) {
|
||||
// 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"));
|
||||
}
|
||||
}
|
||||
@@ -887,6 +953,22 @@ 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] {
|
||||
|
||||
@@ -445,7 +445,8 @@ fn chamfer_sloped_basin_rim() {
|
||||
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 drop = 2.0 / 1.64_f64.sqrt();
|
||||
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();
|
||||
@@ -458,6 +459,26 @@ fn chamfer_sloped_basin_rim() {
|
||||
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