diff --git a/ROADMAP.md b/ROADMAP.md index 2d84c32a..017b9183 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -160,7 +160,8 @@ headline counts in-scope tabs only and lists the deferred ones separately. convex planar body or of an extruded part; fillets inside patterns; closed chains of curved edges between any two smooth sides (a branch pipe on a main pipe), by a rolling ball (the blend a rational B-spline through exact arcs), closed or ending at planar faces square to the - edges. + edges, where straight or curved cap edges are trimmed to the contacts (the straight edge + between two overlapping extruded arcs). - Booleans: prisms along one direction (plates with holes, slots, patterns of them, equal sections stacked end to end) are combined exactly in 2D. Qualified axial cuts preserve annular holes with overrun cutters, including inward Press Pull after a joined extrusion; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6627f257..8cdc25a4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -123,6 +123,69 @@ mod mirror_planes; mod primitive_points; mod robustness; +/// Plan area a round of radius `r` fills at the concave corner `x` where circles (c1, r1) and +/// (c2, r2) cross: the triangle of its contacts and the corner, less the three circular segments. +fn round_gap(c1: [f64; 2], r1: f64, c2: [f64; 2], r2: f64, x: [f64; 2], r: f64) -> f64 { + let (a, b) = (r1 + r, r2 + r); + let d = (c2[0] - c1[0]).hypot(c2[1] - c1[1]); + let u = [(c2[0] - c1[0]) / d, (c2[1] - c1[1]) / d]; + let along = (a * a - b * b + d * d) / (2.0 * d); + let side = if (x[0] - c1[0]) * -u[1] + (x[1] - c1[1]) * u[0] < 0.0 { -1.0 } else { 1.0 }; + let off = (a * a - along * along).sqrt() * side; + let f = [c1[0] + u[0] * along - u[1] * off, c1[1] + u[1] * along + u[0] * off]; + let t1 = [c1[0] + (f[0] - c1[0]) * r1 / a, c1[1] + (f[1] - c1[1]) * r1 / a]; + let t2 = [c2[0] + (f[0] - c2[0]) * r2 / b, c2[1] + (f[1] - c2[1]) * r2 / b]; + let angle = |c: [f64; 2], p: [f64; 2], q: [f64; 2]| { + let (v, w) = ([p[0] - c[0], p[1] - c[1]], [q[0] - c[0], q[1] - c[1]]); + ((v[0] * w[0] + v[1] * w[1]) / (v[0].hypot(v[1]) * w[0].hypot(w[1]))).clamp(-1.0, 1.0).acos() + }; + let segment = |r: f64, t: f64| r * r * (t - t.sin()) / 2.0; + let triangle = ((x[0] - t1[0]) * (t2[1] - t1[1]) - (t2[0] - t1[0]) * (x[1] - t1[1])).abs() / 2.0; + triangle - segment(r1, angle(c1, t1, x)) - segment(r2, angle(c2, x, t2)) - segment(r, angle(f, t2, t1)) +} + +/// A profile of two overlapping arcs, extruded: the vertical edges where the two walls meet are +/// straight lines between cylinders, and their rounds end on the caps across curved edges. +#[test] +fn fillet_the_edges_between_two_overlapping_cylinders() { + let (c1, r1) = ([0.0, 0.0], 17.140595088852663); + let (c2, r2) = ([10.715766135516247, 13.378055020402751], 9.666944358807896); + let (p, q) = ([1.7728134670876865, 17.048669520256194], [16.250324167975673, 5.452243981674495]); + let profile = 1087.5018766864137; + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + run(&mut s, "sketch.arc.center_point", json!({"center": c1, "start": p, "end": q})); + run(&mut s, "sketch.arc.center_point", json!({"center": c2, "start": q, "end": p})); + let si = run(&mut s, "sketch.inspect", json!({})); + assert!(rel(si["profiles"][0]["area_mm2"].as_f64().unwrap(), profile) < 1e-9, "{si}"); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance": 10})); + let counts = |s: &mut Session| { + let m = run(s, "inspect.measure", json!({})); + (m["total"]["faces"].as_u64(), m["total"]["edges"].as_u64(), m["total"]["vertices"].as_u64()) + }; + assert_eq!(counts(&mut s), (Some(4), Some(6), Some(4))); + let (gp, gq) = (round_gap(c1, r1, c2, r2, p, 6.0), round_gap(c1, r1, c2, r2, q, 6.0)); + // One round: a blend face; the edge becomes two contact lines and two end arcs. + run(&mut s, "solid.fillet", json!({"edges": [[p[0], p[1], 5]], "radius": 6, "name": "Round"})); + let (v, expect) = (volume(&mut s), (profile + gp) * 10.0); + assert!(rel(v, expect) < 2e-4, "{v} vs {expect}"); + assert_eq!(counts(&mut s), (Some(5), Some(9), Some(6))); + // Both corners in one fillet. + run( + &mut s, + "timeline.redefine", + json!({"feature": "Round", "command": "solid.fillet", "params": {"edges": [[p[0], p[1], 5], [q[0], q[1], 5]], "radius": 6}}), + ); + let (v, expect) = (volume(&mut s), (profile + gp + gq) * 10.0); + assert!(rel(v, expect) < 2e-4, "{v} vs {expect}"); + assert_eq!(counts(&mut s), (Some(6), Some(12), Some(8))); + // A larger round, whose contacts are found on the long arcs from their far ends too. + run(&mut s, "timeline.redefine", json!({"feature": "Round", "command": "solid.fillet", "params": {"edges": [[q[0], q[1], 5]], "radius": 13}})); + let (v, expect) = (volume(&mut s), (profile + round_gap(c1, r1, c2, r2, q, 13.0)) * 10.0); + assert!(rel(v, expect) < 2e-4, "{v} vs {expect}"); +} + /// Sketch a 40 x 30 rectangle with dimensions, extrude 20, fillet a vertical edge, cut a hole. #[test] fn box_fillet_cut_part() { diff --git a/crates/kernel/src/curveblend.rs b/crates/kernel/src/curveblend.rs index 4b57d605..a5ebf5ac 100644 --- a/crates/kernel/src/curveblend.rs +++ b/crates/kernel/src/curveblend.rs @@ -3,7 +3,8 @@ //! blend's radius rolls along the edge touching both sides; its centre traces the spine and its //! contacts the curves where the blend meets each side. The blend face is a rational B-spline //! through the circular cross-sections (exact arcs at the samples), the side faces are trimmed -//! back to the contact curves, and seams that met the edge are shortened to the contacts. +//! back to the contact curves, and seams that met the edge (lines, or arcs of an end face) are +//! shortened to the contacts. use std::collections::HashMap; @@ -176,14 +177,47 @@ fn project(f: &mt::Face, p: Vec3, hint: Option<(f64, f64)>) -> Option<(Vec3, Vec Some((q, Vec3::new(n.x, n.y, n.z).normalized()?, uv)) } +/// Whether `uv` lies within the parameters of the face's surface (unbounded directions pass). +fn in_range(f: &mt::Face, uv: (f64, f64)) -> bool { + use mt::ParametricSurface; + use std::ops::Bound; + let inside = |range: (Bound, Bound), x: f64| { + let slack = |a: f64, b: f64| (b - a).abs() * 1e-6 + 1e-9; + match range { + (Bound::Included(a) | Bound::Excluded(a), Bound::Included(b) | Bound::Excluded(b)) => x >= a - slack(a, b) && x <= b + slack(a, b), + (Bound::Included(a) | Bound::Excluded(a), Bound::Unbounded) => x >= a - 1e-9, + (Bound::Unbounded, Bound::Included(b) | Bound::Excluded(b)) => x <= b + 1e-9, + (Bound::Unbounded, Bound::Unbounded) => true, + } + }; + let (ru, rv) = f.oriented_surface().parameter_range(); + uv.0.is_finite() && uv.1.is_finite() && inside(ru, uv.0) && inside(rv, uv.1) +} + /// Solve for the ball touching both sides near `p`. `extra` adds a third condition (a plane the /// side-k contact must lie in); without it the centre stays in the plane through `p` square to /// `t`. `sigma` is +1 when the ball sits outside the material (concave edge), −1 inside. #[allow(clippy::too_many_arguments)] fn solve(f1: &[&mt::Face], f2: &[&mt::Face], p: Vec3, t: Vec3, r: f64, sigma: f64, extra: Option<(usize, Vec3, Vec3)>, tol: f64) -> Option
{ - // The nearest face of a side (several faces on one smooth surface group). + // The nearest face of a side (several faces on one smooth surface group). Each face's + // search starts from its last answer: the centre moves little between iterations. An + // answer outside the surface's parameters, or farther than that last point (the search + // ran away or settled on the far side), is searched again cold. + let last = std::cell::RefCell::new(HashMap::::new()); let near = |fs: &[&mt::Face], c: Vec3| -> Option<(Vec3, Vec3)> { - fs.iter().filter_map(|f| project(f, c, None).map(|(q, n, _)| (q, n))).min_by(|a, b| a.0.dist(c).total_cmp(&b.0.dist(c))) + fs.iter() + .filter_map(|f| { + let prev = last.borrow().get(&f.id()).copied(); + let warm = + prev.and_then(|(uv, q)| project(f, c, Some(uv)).filter(|(x, _, uv)| in_range(f, *uv) && x.dist(c) <= q.dist(c) + tol * 1e-3)); + let (q, n, uv) = match warm { + Some(x) => x, + None => project(f, c, None)?, + }; + last.borrow_mut().insert(f.id(), (uv, q)); + Some((q, n)) + }) + .min_by(|a, b| a.0.dist(c).total_cmp(&b.0.dist(c))) }; let (q1, n1) = near(f1, p)?; let (q2, n2) = near(f2, p)?; @@ -238,6 +272,50 @@ fn section_controls(s: &Section, round: bool) -> Option<[mt::Vector4; 3]> { Some([h(s.q1, 1.0), h(m, half), h(s.q2, 1.0)]) } +/// A curved seam (an arc of an end face) shortened to the blend's contacts: the part of its +/// curve between the new ends `a` and `b` (`None` keeps that end), which must lie on it, in +/// order, short of each other. +fn trim_curved_seam(seam: &mt::Edge, a: Option, b: Option, size: f64) -> Result { + // A closed seam (a full circle starting and ending at one vertex) has no "between": which + // end a contact replaces, and which way round the kept piece runs, are both ambiguous. + if seam.front() == seam.back() { + return Err(unsupported("a closed curved edge where the blend ends")); + } + use mt::{BoundedCurve, Cut, SearchNearestParameter}; + let mut curve = seam.curve(); + let (lo, hi) = curve.range_tuple(); + let at = |v: &Option, end: f64| -> Result { + let Some(v) = v else { return Ok(end) }; + // Start from the nearest of a few samples: from the far end of a long arc + // the search can settle on the opposite side of the circle. + let q = from_p3(v.point()); + let hint = (0..=32) + .map(|i| lo + (hi - lo) * i as f64 / 32.0) + .min_by(|x, y| { + let d = |t: f64| from_p3(mt::ParametricCurve::subs(&curve, t)).dist(q); + d(*x).total_cmp(&d(*y)) + }) + .unwrap_or(end); + let t = curve.search_nearest_parameter(v.point(), hint, 100).ok_or_else(|| unsupported("the blend is larger than the neighbouring faces"))?; + if !(t.is_finite() && from_p3(mt::ParametricCurve::subs(&curve, t)).dist(from_p3(v.point())) <= size * 1e-5) { + return Err(unsupported("the blend is larger than the neighbouring faces")); + } + Ok(t) + }; + let (ta, tb) = (at(&a, lo)?, at(&b, hi)?); + let margin = (hi - lo) * 1e-9; + if ta < lo - margin || tb > hi + margin || tb - ta < (hi - lo) * 1e-6 { + return Err(unsupported("the blend is larger than the neighbouring faces")); + } + if tb < hi { + let _ = curve.cut(tb); + } + let piece = if ta > lo { curve.cut(ta) } else { curve }; + let a = a.unwrap_or_else(|| seam.front().clone()); + let b = b.unwrap_or_else(|| seam.back().clone()); + mt::Edge::try_new(&a, &b, piece).map_err(|e| KernelError::Failed(format!("curve blend: seam: {e}"))) +} + /// Blend the closed chain of edges (ids in any order) with radius `r`. pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Result { curve_blend_with_limit(solid, ids, size, r, round, MAX_SAMPLES) @@ -598,7 +676,8 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, let mut seam_subst: HashMap = HashMap::new(); for (id, (seam, a, b)) in ends { if !matches!(seam.curve(), mt::Curve::Line(_)) { - return Err(unsupported("a curved edge where the blend ends")); + seam_subst.insert(id, trim_curved_seam(&seam, a, b, size)?); + continue; } // The new ends must stay on the seam, in order, short of each other. let (p0, p1) = (from_p3(seam.front().point()), from_p3(seam.back().point())); diff --git a/crates/kernel/src/curveblend_tests.rs b/crates/kernel/src/curveblend_tests.rs index 941168a3..c7b0f9f1 100644 --- a/crates/kernel/src/curveblend_tests.rs +++ b/crates/kernel/src/curveblend_tests.rs @@ -192,3 +192,110 @@ fn rolling_ball_refinement_cap_returns_an_error_and_preserves_input() { assert_eq!(crate::step_export(&[&body], "unchanged").unwrap(), before); assert_eq!(crate::step_export(&[&Body::new(solid.clone()).unwrap()], "unchanged").unwrap(), input_before); } + +/// Plan-view construction of a round between two overlapping circles (a concave vertical edge +/// where two Z cylinders meet): the ball centre `f`, the circle and fillet arc angles, and the +/// area the round fills in, all independent of the kernel. +struct Gap { + /// Points along the round's arc in plan, contacts included. + arc: Vec, + /// Arc angles on circle 1, circle 2 and the fillet between the corner and the contacts. + angles: [f64; 3], + area: f64, +} + +fn gap(c1: Vec3, r1: f64, c2: Vec3, r2: f64, corner: Vec3, r: f64) -> Gap { + // The ball centre is r from both circles, on the corner's side of the centre line. + let (a, b) = (r1 + r, r2 + r); + let d = c1.dist(c2); + let u = (c2 - c1) * (1.0 / d); + let along = (a * a - b * b + d * d) / (2.0 * d); + let mut side = Vec3::new(-u.y, u.x, 0.0); + if (corner - c1).dot(side) < 0.0 { + side = side * -1.0; + } + let f = c1 + u * along + side * (a * a - along * along).sqrt(); + let (t1, t2) = (c1 + (f - c1) * (r1 / a), c2 + (f - c2) * (r2 / b)); + let angle = |c: Vec3, p: Vec3, q: Vec3| (p - c).normalized().unwrap().dot((q - c).normalized().unwrap()).clamp(-1.0, 1.0).acos(); + let angles = [angle(c1, t1, corner), angle(c2, corner, t2), angle(f, t2, t1)]; + // Shoelace over the three short arcs: circle 1 to the corner, circle 2 on, fillet back. + let mut ring = Vec::new(); + for (c, p, q) in [(c1, t1, corner), (c2, corner, t2), (f, t2, t1)] { + let (p0, q0) = (p - c, q - c); + let (a0, a1) = (p0.y.atan2(p0.x), q0.y.atan2(q0.x)); + let mut sweep = a1 - a0; + while sweep > std::f64::consts::PI { + sweep -= std::f64::consts::TAU; + } + while sweep < -std::f64::consts::PI { + sweep += std::f64::consts::TAU; + } + let rr = p0.len(); + for i in 0..20_000 { + let t = a0 + sweep * i as f64 / 20_000.0; + ring.push(c + Vec3::new(t.cos(), t.sin(), 0.0) * rr); + } + } + let area = ring.iter().zip(ring.iter().cycle().skip(1)).map(|(p, q)| p.x * q.y - q.x * p.y).sum::().abs() * 0.5; + let (p, q) = ((t1 - f).normalized().unwrap(), (t2 - f).normalized().unwrap()); + let arc = (0..=8).map(|i| angles[2] * i as f64 / 8.0).map(|t| f + (p * ((angles[2] - t).sin()) + q * t.sin()) * (r / angles[2].sin())).collect(); + Gap { arc, angles, area } +} + +#[test] +fn rolling_ball_along_a_straight_edge_between_cylinders_trims_curved_end_seams() { + // Two Z cylinders crossing square to each other: the vertical lines where they meet are + // concave edges between two curved faces, and the blend ends on the planar caps, where the + // seams are arcs. + let (r1, r2, h, r) = (17.0, 9.5, 10.0, 6.0); + let (c1, c2) = (Vec3::ZERO, Vec3::new(r1, r2, 0.0)); + let a = cylinder(c1, Vec3::Z, r1, h).unwrap(); + let b = cylinder(c2, Vec3::Z, r2, h).unwrap(); + let body = boolean(&a, &b, BoolOp::Union).unwrap().unwrap(); + // The corner: one of the two points where the circles cross. + let d = c1.dist(c2); + let u = (c2 - c1) * (1.0 / d); + let along = (r1 * r1 - r2 * r2 + d * d) / (2.0 * d); + let corner = c1 + u * along + Vec3::new(-u.y, u.x, 0.0) * (r1 * r1 - along * along).sqrt(); + let g = gap(c1, r1, c2, r2, corner, r); + let blend = fillet(&body, &[corner + Vec3::Z * (h / 2.0)], r).unwrap(); + assert!(blend.validity().is_empty(), "{:?}", blend.validity()); + // One blend face; the edge is replaced by two contact lines and two end arcs. + let (before, after) = (crate::measure(&body).unwrap(), crate::measure(&blend).unwrap()); + assert_eq!((after.faces, after.edges, after.vertices), (before.faces + 1, before.edges + 3, before.vertices + 2), "{before:?} -> {after:?}"); + // The round fills the gap over the full height. The caps gain it twice; the walls lose + // their arcs to the contacts and gain the round. + let (m0, m1) = (body.tessellate_with(0.0005, true).unwrap().measure(), blend.tessellate_with(0.0005, true).unwrap().measure()); + let volume = h * g.area; + assert!((m1.volume - m0.volume - volume).abs() < volume * 3e-3, "added volume {} vs {volume}", m1.volume - m0.volume); + let [a1, a2, af] = g.angles; + let area = 2.0 * g.area + h * (r * af - r1 * a1 - r2 * a2); + assert!((m1.area - m0.area - area).abs() < area.abs() * 3e-3, "added area {} vs {area}", m1.area - m0.area); + // The round is the analytic arc at every height, from contact to contact. + let surfaces: Vec<_> = blend.solid.face_iter().map(|f| f.oriented_surface()).filter(|s| matches!(s, mt::Surface::NurbsSurface(_))).collect(); + let mut worst: f64 = 0.0; + for z in [0.0, 0.25, 0.5, 0.75, 1.0] { + for p in &g.arc { + worst = worst.max(nearest_blend(&surfaces, *p + Vec3::Z * (h * z)).0); + } + } + assert!(worst < 1e-3, "blend surface is {worst} mm off the analytic round"); +} + +#[test] +fn closed_curved_seam_is_not_supported() { + // A closed seam (one edge starting and ending at the same vertex, as a full cap circle can + // be in imported geometry) has no "between" two contacts, so trimming it is refused rather + // than guessed. + let body = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 5.0).unwrap(); + let arc = body.solid.edge_iter().find(|e| !matches!(e.curve(), mt::Curve::Line(_))).expect("a cap arc"); + let circle = mt::Edge::new_unchecked(arc.front(), arc.front(), arc.curve()); + assert_eq!(circle.front(), circle.back()); + // Contacts on the circle itself: a third and two thirds of the way round. + let (lo, hi) = circle.curve().range_tuple(); + let on = |f: f64| mt::Vertex::new(circle.curve().subs(lo + (hi - lo) * f)); + for (a, b) in [(Some(on(1.0 / 3.0)), None), (None, Some(on(2.0 / 3.0))), (Some(on(1.0 / 3.0)), Some(on(2.0 / 3.0))), (None, None)] { + let err = trim_curved_seam(&circle, a, b, 20.0).unwrap_err(); + assert!(err.to_string().contains("not supported yet: a closed curved edge"), "{err}"); + } +}