From 15bb64c2ab39fc1a1eb61c067a54f9a1c2b9248b Mon Sep 17 00:00:00 2001 From: CypherNaugh_0x <9931495+CypherNaught-0x@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:50:20 +0200 Subject: [PATCH] Reject chamfer extensions beyond trimmed rim faces --- ROADMAP.md | 2 +- crates/engine/src/tests.rs | 3 +- crates/kernel/src/blend.rs | 88 ++++++++++++++++++++++++++++++++++++-- crates/kernel/src/tests.rs | 23 +++++++++- 4 files changed, 110 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 48e91e46..8a73d86b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c16791c8..66933dba 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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); diff --git a/crates/kernel/src/blend.rs b/crates/kernel/src/blend.rs index dcc2f80f..497d7b10 100644 --- a/crates/kernel/src/blend.rs +++ b/crates/kernel/src/blend.rs @@ -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] { diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index 474cb40a..65cd107c 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -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() {