diff --git a/ROADMAP.md b/ROADMAP.md index 3a7621ea..48e91e46 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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); 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 2495c83c..c16791c8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1648,6 +1648,34 @@ 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 drop = size / 1.64_f64.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() { diff --git a/crates/kernel/src/blend.rs b/crates/kernel/src/blend.rs index 8472bd09..dcc2f80f 100644 --- a/crates/kernel/src/blend.rs +++ b/crates/kernel/src/blend.rs @@ -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. @@ -153,6 +153,8 @@ fn blend_geometry( g: usize, e1: mt::Edge, e2: mt::Edge, + a: Vec3, + b: Vec3, } let mut ends: Vec = Vec::new(); for (v, p) in [(&v0, p0), (&v1, p1)] { @@ -161,9 +163,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 { let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?; @@ -172,26 +184,34 @@ 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) in [(&e1, a), (&e2, b)] { 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; + 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) { 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 +881,33 @@ 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 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()); + } + } +} diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index 6a5946bb..474cb40a 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -435,6 +435,29 @@ 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 drop = 2.0 / 1.64_f64.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] #[ignore] fn debug_chamfer_cut() {