From 32d3cf6877afdf193d9074eb641c13cbeb64d0bb Mon Sep 17 00:00:00 2001 From: Tobias Breckle <25928171+tbreckle@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:40:23 +0200 Subject: [PATCH] Fillets and chamfers meeting at corners on curved faces: what #935 leaves (#854) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fillets and chamfers meeting at corners on curved faces, on top of #935 What #935 and #926 leave of #851 and the matrix: - Corner build for blends meeting on faces of any surface (curvecorner.rs): the knife-edge tip's round with the arc's or the back edge's, edges picked in any order, a round ending in an earlier round of its radius (as a feature of its own), and chamfers meeting at the arc's corner. Its end curves are fitted by chord length: a uniform parameter through points clustered at the ends doubled back on itself there, and the display planner refused the face. - A straight edge ending at a curved face (the back edge into the knife-edge tip): rolled, or cut by the face in the planar corner build (blend_corner.rs). - A rolling ball turning about a rounded corner of its own radius (a pivot: a piece of sphere or cone). - Seams that are an earlier blend's straight contact (fitted curves) count as straight. - No spline across a joint where the strip's curvature jumps (a line into a small round): each strip ends on the joint's section. - Sections at a chain's vertices on the faces of the edge from it alone. - Every new fillet path passes #949's check for openings. Tests measure volumes through the display plan, as #935's do. Action matrix: 51 more fillets and chamfers pass (sweep, tangent arc chain, sheet-metal hem, bracket holes), none fewer. Robustness seed 33 recorded (its last fillet builds differently; display and measured volumes now agree). Co-Authored-By: bherbruck Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015bV2ntwLfUHV4JnCMPSyRi * Corner blend tests: scale, seed 33's volumes, tighter engine bounds From the review of #854: - seed_33_display_plan_and_measure_agree: seed 33's bodies measure the same through the display plan and the measure (2e-5), which the golden's re-record rests on. - the_arc_rounded_after_the_back_edge_matches_both_together: the order that still leaves 0.05 mm³ at the tip, as an ignored test. - corner_blends_hold_at_any_scale: the cases this branch adds on the #851 part, at 0.01x and 100x, take off what they take at full size times k³ (within 5e-6, checked to 1e-4). - The engine tests on the part as drawn measure through the display plan; bounds are about twice what each reads (2e-4 to 3e-3), and the front corner's sets are checked against the kernel's CSG grid. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015bV2ntwLfUHV4JnCMPSyRi --------- Co-authored-by: bherbruck Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/features_more_tests.rs | 194 +++ crates/engine/src/tests/robustness.rs | 15 + crates/kernel/src/blend.rs | 235 +++- crates/kernel/src/blend_corner.rs | 189 ++- crates/kernel/src/build.rs | 28 +- crates/kernel/src/curveblend.rs | 418 ++++++- crates/kernel/src/curveblend_tests.rs | 237 ++++ crates/kernel/src/curvecorner.rs | 1160 ++++++++++++++++++ crates/kernel/src/curvecorner_tests.rs | 312 +++++ crates/kernel/src/issue_851_tests.rs | 2 +- crates/kernel/src/loopblend.rs | 21 + tests/action_matrix/baseline.json | 54 +- tests/robustness/goldens.json | 3 +- 13 files changed, 2739 insertions(+), 129 deletions(-) create mode 100644 crates/kernel/src/curvecorner.rs create mode 100644 crates/kernel/src/curvecorner_tests.rs diff --git a/crates/engine/src/cmd/features_more_tests.rs b/crates/engine/src/cmd/features_more_tests.rs index 510e90de..3b0e688a 100644 --- a/crates/engine/src/cmd/features_more_tests.rs +++ b/crates/engine/src/cmd/features_more_tests.rs @@ -729,3 +729,197 @@ fn cutting_a_text_removes_each_letter_whether_its_letters_stand_apart_or_touch() assert_eq!(s.world_state().bodies.len(), 1); } } + +/// A rounded part as drawn in the app: a rectangle and a quarter circle tangent to its top +/// line, extruded together. The arc (about (62.17, -21), radius 21) ends square at the front +/// face and in a knife-edge tip where it runs into the back face. +fn knife_edge_part() -> Session { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0.0, 0.0], "p1": [41.5, -21.0]})); + run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "text_at": [36.5, -8.5], "value": "21 mm"})); + run(&mut s, "sketch.line", json!({"infer": true, "points": ["p2", [61.5, -21.0]]})); + run(&mut s, "sketch.dimension", json!({"entities": ["l5"], "text_at": [54.5, -26.5], "value": "21"})); + run(&mut s, "sketch.circle.center", json!({"center": "l5.end", "radius": 21.0})); + run(&mut s, "sketch.line", json!({"infer": true, "points": ["p3", [62.166666666666664, 0.0]]})); + run(&mut s, "sketch.constraint.tangent", json!({"a": "c1", "b": "l6"})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"direction": "positive", "distance": "10 mm", "operation": "new", "profiles": [0, 1], "sketch": 1})); + s +} + +/// The volume of the session's bodies through the display plan (the measure's mesh cuts across +/// the concave arc). It holds to about 1e-3 of what a blend removes here; the bounds below are +/// about twice what each test reads. +fn planned(s: &Session) -> f64 { + s.model.state().bodies.iter().map(|b| b.body.display_plan(2e-4).unwrap().evaluate().measure().volume).sum() +} + +/// The middle of the part's arc. +const KNIFE_ARC_MID: [f64; 2] = [62.166666666666664 - 21.0 * std::f64::consts::FRAC_1_SQRT_2, -21.0 + 21.0 * std::f64::consts::FRAC_1_SQRT_2]; + +/// Simpson's rule over the round's corner (2 × 2 less a quarter disc) at distance ρ = 21 + x +/// from the arc's axis, times `length(ρ)`. +fn round_along(length: impl Fn(f64) -> f64) -> f64 { + let (r, n) = (2.0f64, 2000); + let h = r / n as f64; + (0..=n) + .map(|i| { + let x = i as f64 * h; + let w = if i == 0 || i == n { 1.0 } else { [2.0, 4.0][i % 2] }; + w * (r - (r * r - (r - x) * (r - x)).max(0.0).sqrt()) * length(21.0 + x) + }) + .sum::() + * h + / 3.0 +} + +/// Its edges are stored the other way round from their curves, which once chained the arc +/// with the front edge across their corner. Rounding the arc at the bottom and the top removes +/// the round's section along it, cut off at the tip by the back face's plane: ρ asin(21 / ρ). +#[test] +fn fillet_an_arc_that_runs_into_a_knife_edge_tip() { + let mut s = knife_edge_part(); + let before = planned(&s); + let [x, y] = KNIFE_ARC_MID; + run(&mut s, "solid.fillet", json!({"edges": [[x, y, 0.0]], "radius": 2})); + run(&mut s, "solid.fillet", json!({"edges": [[x, y, 10.0]], "radius": 2})); + let removed = round_along(|rho| rho * (21.0 / rho).asin()); + let after = planned(&s); + assert!(rel(before - after, 2.0 * removed) < 2e-3, "removed {} vs {}", before - after, 2.0 * removed); +} + +/// The part's front corner (the front face into the arc) rounded with 2 first, then the bottom +/// run (front edge, the corner round's arc, the arc) with 2 as well: a round on a round of the +/// same radius. Along the front edge a straight round; at the corner the ball turns about one +/// point, a piece of sphere (θ r³ / 6 over the corner's θ); along the arc the torus section from +/// the corner round to the tip. +#[test] +fn fillet_a_run_over_a_corner_rounded_with_the_same_radius() { + let mut s = knife_edge_part(); + run(&mut s, "solid.fillet", json!({"edges": [[41.1667, -21.0, 5.0]], "radius": 2})); + let before = planned(&s); + let [x, y] = KNIFE_ARC_MID; + // The corner round: about (62.17 - √(23² - 2²), -19), its arc's middle 2 from there. + let cx = 62.166666666666664 - (23.0f64 * 23.0 - 4.0).sqrt(); + let joint = 2.0f64.atan2((23.0f64 * 23.0 - 4.0).sqrt()); + let theta = std::f64::consts::FRAC_PI_2 - joint; + let corner_mid = [cx + 2.0 * (theta / 2.0).sin(), -19.0 - 2.0 * (theta / 2.0).cos()]; + run(&mut s, "solid.fillet", json!({"edges": [[x, y, 0.0], [corner_mid[0], corner_mid[1], 0.0], [20.0, -21.0, 0.0]], "radius": 2})); + let removed = (1.0 - std::f64::consts::FRAC_PI_4) * 4.0 * cx + theta * 8.0 / 6.0 + round_along(|rho| rho * ((21.0 / rho).asin() - joint)); + let after = planned(&s); + assert!(rel(before - after, removed) < 1e-3, "removed {} vs {removed}", before - after); +} + +/// Straight edges of the part ending at its arc wall: the long front edge at the top (the wall +/// cuts the round off at x = 62.17 − √(21² − d²), d from the front face) and the back edge at +/// the top, which runs into the knife-edge tip (x = 62.17 − √(21² − (21 − d)²), d from the back +/// face). +#[test] +fn fillet_straight_edges_that_end_at_the_arc_wall() { + let cx = 62.166666666666664; + for (edge, cut) in [([20.0, -21.0, 10.0], 0.0), ([30.0, 0.0, 10.0], 21.0)] { + let mut s = knife_edge_part(); + let before = planned(&s); + run(&mut s, "solid.fillet", json!({"edges": [edge], "radius": 2})); + let removed = round_along(|rho| { + let d = rho - 21.0; + cx - (441.0 - (cut - d) * (cut - d)).sqrt() + }); + let after = planned(&s); + assert!(rel(before - after, removed) < 3e-3, "{edge:?}: removed {} vs {removed}", before - after); + } +} + +/// The vertical knife edge at the tip (the arc and the back face meeting at no angle) rounds to +/// the ball 2 from both, about 13 back from the tip: the cusp beyond the ball, on the tip's side +/// of its contacts, all the way up. +#[test] +fn fillet_the_knife_edge_at_the_tip() { + let cx = 62.166666666666664; + let (kx, ky) = (cx - (23.0f64 * 23.0 - 19.0 * 19.0).sqrt(), -2.0); + let (hx, hy) = (0.002, 0.0005); + let mut area = 0.0; + let mut x = kx + hx / 2.0; + while x < cx { + let lo = -21.0 + (441.0 - (x - cx) * (x - cx)).max(0.0).sqrt(); + for j in 0..((0.0 - lo) / hy).ceil() as usize { + let (y0, y1) = (lo + j as f64 * hy, (lo + (j + 1) as f64 * hy).min(0.0)); + let y = (y0 + y1) * 0.5; + let a = (y - ky).atan2(x - kx); + if (x - kx).hypot(y - ky) > 2.0 && a >= (-21.0 - ky).atan2(cx - kx) { + area += (y1 - y0) * hx; + } + } + x += hx; + } + let mut s = knife_edge_part(); + let before = planned(&s); + run(&mut s, "solid.fillet", json!({"edges": [[cx, 0.0, 5.0]], "radius": 2})); + let after = planned(&s); + assert!(rel(before - after, area * 10.0) < 5e-4, "removed {} vs {}", before - after, area * 10.0); + let mut s = knife_edge_part(); + run(&mut s, "solid.chamfer", json!({"edges": [[cx, 0.0, 5.0]], "distance": 2})); + assert!(before - planned(&s) > before - after, "the chamfer takes the ball's bulge off too"); +} + +/// Rounds of the edges at the arc's top front corner, two or all three together (r 2): they meet +/// in creases (with all three, a piece of sphere fills the corner). Each set takes off its rounds +/// alone, less what each alone takes in a box about the corner, plus what they take there +/// together: the CSG grid of the kernel's `rounds_meeting_at_a_corner_of_an_arc_wall` +/// (curvecorner_tests.rs, 0.02 mm cells), within its accuracy. +#[test] +fn fillet_edges_meeting_at_the_front_corner_of_the_arc() { + // In the box about the corner: what the long edge's, the arc's and the upright's rounds take + // alone, and what two or all three take together. + let corner = [2.719176, 3.015864, 1.863]; + let together = [(vec![0, 2], 3.886936), (vec![0, 1], 4.971256), (vec![1, 2], 4.19864), (vec![0, 1, 2], 6.3332)]; + let [x, y] = KNIFE_ARC_MID; + let (long, arc, upright) = ([20.0, -21.0, 10.0], [x, y, 10.0], [41.1667, -21.0, 5.0]); + let off = |edges: Vec<[f64; 3]>| { + let mut s = knife_edge_part(); + let before = planned(&s); + run(&mut s, "solid.fillet", json!({"edges": edges, "radius": 2})); + before - planned(&s) + }; + let edges = [long, arc, upright]; + let alone = edges.map(|e| off(vec![e])); + for (which, both) in together { + let want = which.iter().map(|i| alone[*i] - corner[*i]).sum::() + both; + let got = off(which.iter().map(|i| edges[*i]).collect()); + assert!((got - want).abs() < 0.03, "{which:?}: removed {got} vs {want}"); + } +} + +/// The long top front edge rounded, then the small front-left upright as a fillet of its own +/// (as in the app): the new round ends in the earlier one, taking off what both take together. +#[test] +fn fillet_the_long_edge_then_the_small_left_edge() { + let mut s = knife_edge_part(); + let before = planned(&s); + run(&mut s, "solid.fillet", json!({"edges": [[20.0, -21.0, 10.0]], "radius": 2})); + run(&mut s, "solid.fillet", json!({"edges": [[0.0, -21.0, 5.0]], "radius": 2})); + let one_by_one = before - planned(&s); + let mut s = knife_edge_part(); + run(&mut s, "solid.fillet", json!({"edges": [[20.0, -21.0, 10.0], [0.0, -21.0, 5.0]], "radius": 2})); + let together = before - planned(&s); + // Within the display meshes' accuracy (the two are built of different faces). + assert!(rel(one_by_one, together) < 2e-4, "one by one {one_by_one} vs together {together}"); +} + +/// The long top front edge and the front-left vertical edge rounded together (r 2): the long +/// round ends at the arc wall, the two meet at the top-left corner (the top-left edge sharp). +/// Each removes its own round (the long one cut off by the wall), less what both would take at +/// the corner: in its 2 mm cube, what lies outside both quarter circles, ∫ (2 − √(4 − t²))² dt +/// over 0..2 = 16 − 8/3 − 4π. +#[test] +fn fillet_two_edges_meeting_at_a_corner_one_running_into_the_arc_wall() { + let mut s = knife_edge_part(); + let before = planned(&s); + run(&mut s, "solid.fillet", json!({"edges": [[20.0, -21.0, 10.0], [0.0, -21.0, 5.0]], "radius": 2})); + let long = round_along(|rho| 62.166666666666664 - (441.0 - (rho - 21.0) * (rho - 21.0)).sqrt()); + let upright = (1.0 - std::f64::consts::FRAC_PI_4) * 4.0 * 10.0; + let removed = long + upright - (16.0 - 8.0 / 3.0 - 4.0 * std::f64::consts::PI); + let after = planned(&s); + assert!(rel(before - after, removed) < 1e-3, "removed {} vs {removed}", before - after); +} diff --git a/crates/engine/src/tests/robustness.rs b/crates/engine/src/tests/robustness.rs index 1faf6af8..48a06a6b 100644 --- a/crates/engine/src/tests/robustness.rs +++ b/crates/engine/src/tests/robustness.rs @@ -559,3 +559,18 @@ fn a_boundary_found_across_a_seam_is_not_used() { let r = fuzz::run(20, 8); assert_eq!(r.outcome.kind, "ok", "seed 20: {:?}\n{}", r.outcome, r.script()); } + +/// Seed 33 ends in a fillet on its second body (#854's corner blends build it differently from +/// before). Its display plan and its measured volume agree for every body (on main before #854, +/// that body read 4439.42 through the display plan and 4443.51 measured). +#[test] +fn seed_33_display_plan_and_measure_agree() { + let r = fuzz::run(33, 8); + assert_eq!(r.outcome.kind, "ok", "seed 33: {:?}\n{}", r.outcome, r.script()); + let s = script(r.script()); + for b in &s.model.state().bodies { + let planned = b.body.display_plan(2e-4).unwrap().evaluate().measure().volume; + let measured = solidcraft_kernel::measure(&b.body).unwrap().volume; + assert!((planned - measured).abs() < 2e-5 * measured, "{}: planned {planned}, measured {measured}", b.name); + } +} diff --git a/crates/kernel/src/blend.rs b/crates/kernel/src/blend.rs index c7dd9de6..51cfc950 100644 --- a/crates/kernel/src/blend.rs +++ b/crates/kernel/src/blend.rs @@ -495,6 +495,18 @@ fn blend_geometry( return Err(unsupported("G2 fillets require straight edges around each planar end face")); } let g = faces.get(ga).ok_or_else(|| KernelError::Failed("face".into()))?; + // An end face running on smoothly from a side, an earlier round of this radius (its + // end an arc of it): the blend ends in that round, which takes a corner of its own + // (`curveblend::corner_blend`), the same as rounding both together. + let normal = |f: &mt::Face| crate::curveblend::project(f, p, None).map(|x| x.1); + let of_radius = |e: &mt::Edge| crate::curveblend::circle_of(e, r * 1e-4).is_some_and(|c| (c.1 - r).abs() <= r * 1e-4); + if let Some(ng) = normal(g) + && [i1, i2].iter().filter_map(|i| faces.get(*i)).filter_map(|f| normal(f)).any(|ns| ns.dot(ng) > 1.0 - 1e-6) + && shape == Shape::Round + && (of_radius(&e1) || of_radius(&e2)) + { + return Err(unsupported("a blend ending in an earlier round")); + } let surf = g.surface(); let surf_b = faces.get(gb).ok_or_else(|| KernelError::Failed("face".into()))?.surface(); let on_tol = (size * 1e-5).max(1e-6); @@ -1134,8 +1146,14 @@ fn orders(items: &[T]) -> Vec> { /// boundary leaves its surface (a blend that ran on past the end of a round's surface, which a /// round of a curved edge does not have) another order that has none is preferred. fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Result { + blend_with(body, edges, r, shape, what, false) +} + +/// [`blend`]; `curved_ends`: also roll a ball along a straight edge between planes that ends at +/// a curved face (the last resort, after the corner build for an earlier round there). +fn blend_with(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str, curved_ends: bool) -> Result { let sound = |b: &Body| crate::tess::boundaries_on_surfaces(&b.solid, (b.size() * 1e-4).max(1e-6)); - let first = blend_in_order(body, edges, r, shape, what); + let first = blend_in_order(body, edges, r, shape, what, curved_ends); let reorder = (2..=MAX_REORDERED).contains(&edges.len()) && match &first { Ok(b) => !sound(b), @@ -1147,7 +1165,7 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul } let mut fallback: Option = None; for order in orders(edges).into_iter().skip(1) { - if let Ok(done) = blend_in_order(body, &order, r, shape, what) { + if let Ok(done) = blend_in_order(body, &order, r, shape, what, curved_ends) { if sound(&done) { return Ok(done); } @@ -1164,7 +1182,7 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul } } -fn blend_in_order(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Result { +fn blend_in_order(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str, curved_ends: bool) -> Result { if !(r.is_finite() && r > 1e-6 && r < 1e6) { return Err(KernelError::Invalid(format!("{what} size must be positive"))); } @@ -1178,6 +1196,16 @@ fn blend_in_order(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) let counts = record::counts(&[body]); // Coplanar neighbours as one face (as after a join), so corners look as they should. let mut cur = crate::boolean_record::healed(body)?; + // The last resort only for edges it treats otherwise: else it repeats the refusal. + if curved_ends { + let solid = cur.deep_copy(); + let ids = edge_ids(&cur, &solid, edges)?; + let other = + Body::unique_edges(&solid).iter().any(|e| ids.contains(&e.id()) && !curved_case(&solid, e, false) && curved_case(&solid, e, true)); + if !other { + return Err(unsupported("no edge ending at a curved face")); + } + } // Every edge of a convex planar body: rounded as a whole (sphere corners). if shape == Shape::Round && edges.len() >= 6 { let solid = cur.deep_copy(); @@ -1207,8 +1235,8 @@ fn blend_in_order(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) { let _ = groups; let loop_pts: Vec = edges.iter().filter(|p| !rest.iter().any(|q| q.dist(**p) < 1e-12)).copied().collect(); - cur = record::after(blend_in_order(&cur, &rest, r, shape, what)?, &cur, &counts); - return Ok(record::after(blend_in_order(&cur, &loop_pts, r, shape, what)?, &cur, &counts)); + cur = record::after(blend_in_order(&cur, &rest, r, shape, what, curved_ends)?, &cur, &counts); + return Ok(record::after(blend_in_order(&cur, &loop_pts, r, shape, what, curved_ends)?, &cur, &counts)); } // A loop the loop blend refuses for its shape (seams that aren't straight, walls it can't // trim) is blended edge by edge below instead: a ball rolls along its smooth chain. @@ -1236,11 +1264,11 @@ fn blend_in_order(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) Err(_) => {} } } - blend_each(cur, edges, r, shape, what, &counts) + blend_each(cur, edges, r, shape, what, &counts, curved_ends) } /// The edges blended one at a time (see [`blend`]), `cur` being the healed body. -fn blend_each(mut cur: Body, edges: &[Vec3], r: f64, shape: Shape, what: &str, counts: &[(usize, usize)]) -> Result { +fn blend_each(mut cur: Body, edges: &[Vec3], r: f64, shape: Shape, what: &str, counts: &[(usize, usize)], curved_ends: bool) -> Result { let counts = counts.to_vec(); // One at a time, in whatever order works: a run along a planar face's loop (bridging arcs // that earlier blends left between selected edges, so corners round off), a single edge, @@ -1251,6 +1279,30 @@ fn blend_each(mut cur: Body, edges: &[Vec3], r: f64, shape: Shape, what: &str, c // refused away from every later blend would be refused the same way again: it is not tried // again (a refusal costs a rolling-ball attempt, seconds each on a long curved edge). let mut refused: Vec<(Vec3, (Vec3, Vec3), KernelError)> = Vec::new(); + // A chain runs on along the edges smooth with a selected one in the body as given, not along + // a round this blend has made meanwhile (which would take in edges nobody selected, or not, + // by the order the edges come in). + let along: Vec = { + let solid = cur.deep_copy(); + let all = Body::unique_edges(&solid); + let tol = (cur.size() * 2e-3).max(1e-3); + let mut list: Vec = Vec::new(); + for p in edges { + if let Some((idx, _)) = cur.nearest_edge(*p, tol)? + && let Some(e) = all.get(idx) + { + let ids = smooth_chain(&solid, e).unwrap_or_else(|| vec![e.id()]); + for x in all.iter().filter(|x| ids.contains(&x.id())) { + if !list.iter().any(|y| y.id() == x.id()) { + list.push(x.clone()); + } + } + } + } + list + }; + let near_chain = (cur.size() * 1e-6).max(1e-7); + let on_chains = |e: &mt::Edge| lies_on(e, &along, near_chain); for _ in 0..(2 * edges.len() + 4) { if todo.is_empty() { return Ok(cur); @@ -1315,11 +1367,23 @@ fn blend_each(mut cur: Body, edges: &[Vec3], r: f64, shape: Shape, what: &str, c }; // A straight edge running on smoothly into curved ones (a flange into its bend, a // side into a rounded corner): the blend follows the whole smooth chain, as the - // rolling ball would. - let chain = smooth_chain(&solid, &edge); - let runs_on = !curved_case(&solid, &edge) + // rolling ball would. A fillet doesn't run on into an earlier round's arc of its + // radius: it ends in that round (as if both were rounded together). + let chain = smooth_chain_within(&solid, &edge, &on_chains); + let into_round = |ids: &[mt::EdgeID]| { + Body::unique_edges(&solid).iter().any(|x| { + ids.contains(&x.id()) + && x.id() != edge.id() + && [edge.front(), edge.back()].iter().any(|v| x.front() == *v || x.back() == *v) + && crate::curveblend::circle_of(x, size * 1e-6).is_some_and(|c| (c.1 - r).abs() <= r * 1e-4) + }) + }; + let own = edge.id(); + let runs_on = !curved_case(&solid, &edge, false) && chain.as_ref().is_some_and(|ids| { - ids.len() > 1 && Body::unique_edges(&solid).iter().any(|x| ids.contains(&x.id()) && !matches!(x.curve(), mt::Curve::Line(_))) + ids.len() > 1 + && Body::unique_edges(&solid).iter().any(|x| ids.contains(&x.id()) && !matches!(x.curve(), mt::Curve::Line(_))) + && !(shape == Shape::Round && into_round(ids)) }); let local = || guard(what, || blend_one(&solid, &edge, size, r, shape)); let attempt = match chain.as_deref().filter(|_| runs_on).map(roll) { @@ -1333,16 +1397,24 @@ fn blend_each(mut cur: Body, edges: &[Vec3], r: f64, shape: Shape, what: &str, c None => match local() { Ok(o) => Ok((o, vec![j])), // A curved edge, or one between curved faces: roll a ball along its smooth chain. - Err(e) if !curved_case(&solid, &edge) => Err(e), + Err(e) if !curved_case(&solid, &edge, curved_ends) => Err(e), Err(e) => match chain.as_deref() { // The chain that cannot be rolled (it runs on into the edge an earlier round - // left where it met another face): the edge alone, ending on that round. - Some(ids) => roll(ids) - .or_else(|first| if ids.len() > 1 { roll(&[edge.id()]).map_err(|_| first) } else { Err(first) }) - .map_err(|e2| match e2 { - KernelError::TooLarge(m) => KernelError::TooLarge(format!("{m} ({e})")), - e2 => KernelError::Failed(format!("{e2} ({e})")), - }), + // left where it met another face): the edge alone, ending on that round. A + // straight edge into an earlier round of this radius: on its own. + Some(ids) => { + let ids = if shape == Shape::Round && !curved_case(&solid, &edge, false) && into_round(ids) { + std::slice::from_ref(&own) + } else { + ids + }; + roll(ids).or_else(|first| if ids.len() > 1 { roll(&[edge.id()]).map_err(|_| first) } else { Err(first) }).map_err(|e2| { + match e2 { + KernelError::TooLarge(m) => KernelError::TooLarge(format!("{m} ({e})")), + e2 => KernelError::Failed(format!("{e2} ({e})")), + } + }) + } None => Err(e), }, }, @@ -1500,12 +1572,16 @@ fn run_on_loop(cur: &Body, solid: &Solid, pts: &[Vec3]) -> Result bool { +/// Is the edge curved, or does it lie between faces that aren't both planar (or, with +/// `curved_ends`, end at one)? +fn curved_case(solid: &Solid, edge: &mt::Edge, curved_ends: bool) -> bool { if !matches!(edge.curve(), mt::Curve::Line(_)) { return true; } - solid.face_iter().filter(|f| f.edge_iter().any(|e| e.id() == edge.id())).any(|f| !matches!(f.oriented_surface(), mt::Surface::Plane(_))) + // Between curved faces, or (when asked) ending at one: a straight edge running into a wall. + let curved = |f: &mt::Face| !matches!(f.oriented_surface(), mt::Surface::Plane(_)); + solid.face_iter().filter(|f| f.edge_iter().any(|e| e.id() == edge.id())).any(curved) + || (curved_ends && solid.face_iter().filter(|f| f.vertex_iter().any(|v| v == *edge.front() || v == *edge.back())).any(curved)) } /// Whether the two faces of `edge` meet tangentially along it (sampled at a quarter, the @@ -1531,6 +1607,32 @@ fn tangent_faces(solid: &Solid, edge: &mt::Edge) -> bool { /// The chain of edges continuing `edge` smoothly at its ends (its ids): closed, or open where /// the next edge turns sharply. fn smooth_chain(solid: &Solid, edge: &mt::Edge) -> Option> { + smooth_chain_within(solid, edge, &|_| true) +} + +/// Whether edge `e` runs along one of `along` (is a piece of it): points through it lie on one. +fn lies_on(e: &mt::Edge, along: &[mt::Edge], tol: f64) -> bool { + use mt::{BoundedCurve, ParametricCurve, SearchNearestParameter}; + if along.iter().any(|a| a.id() == e.id()) { + return true; + } + let c = e.curve(); + let (t0, t1) = c.range_tuple(); + [0.25, 0.5, 0.75].iter().all(|f| { + let p = c.subs(t0 + (t1 - t0) * f); + along.iter().any(|a| { + let ac = a.curve(); + let (a0, a1) = ac.range_tuple(); + let d = |t: f64| crate::body::from_p3(ac.subs(t)).dist(crate::body::from_p3(p)); + let Some(hint) = (0..=16).map(|i| a0 + (a1 - a0) * i as f64 / 16.0).min_by(|x, y| d(*x).total_cmp(&d(*y))) else { return false }; + let t = ac.search_nearest_parameter(p, hint, 50).unwrap_or(hint).clamp(a0, a1); + d(t).min(d(hint)) <= tol + }) + }) +} + +/// [`smooth_chain`], walking on only over edges `keep` allows. +fn smooth_chain_within(solid: &Solid, edge: &mt::Edge, keep: &dyn Fn(&mt::Edge) -> bool) -> Option> { use mt::{BoundedCurve, ParametricCurve}; let all = Body::unique_edges(solid); // Along the edge as it runs from its front to its back (an edge may run against its curve). @@ -1550,7 +1652,7 @@ fn smooth_chain(solid: &Solid, edge: &mt::Edge) -> Option> { let next = all.iter().filter(|e| !ids.contains(&e.id()) && (*e.front() == v || *e.back() == v)).find_map(|e| { let fwd = *e.front() == v; let t = if fwd { dir(e, false)? } else { -dir(e, true)? }; - (t.dot(d) > 1.0 - 1e-4 && !smooth_seam(solid, e)).then(|| (e.clone(), fwd)) + (t.dot(d) > 1.0 - 1e-4 && !smooth_seam(solid, e) && keep(e)).then(|| (e.clone(), fwd)) }); let Some((e, fwd)) = next else { return Some(false) }; ids.push(e.id()); @@ -1713,21 +1815,52 @@ fn closed(b: Body) -> Result { pub fn fillet(body: &Body, edges: &[Vec3], radius: f64) -> Result { body.require_brep("fillet")?; // Corners the local blends refuse (an earlier round at an edge's end): radius per edge. - blend(body, edges, radius, Shape::Round, "fillet").and_then(closed).or_else(|e| { - let sel: Vec<(Vec3, f64)> = edges.iter().map(|p| (*p, radius)).collect(); - // The local blend's refusal, unless only the corner build found the radius too large. - crate::blend_corner::fillet_radii(body, &sel) - .and_then(closed) - .map_err(|e2| match (&e, e2) { - (KernelError::TooLarge(_), _) => e, - (_, e2 @ KernelError::TooLarge(_)) => e2, - _ => e, - }) - // Three edges at a vertex of any faces end in a sphere. - .or_else(|e| { - if edges.len() >= 3 { crate::blend_vertex::fillet_ball_vertices(body, edges, radius).and_then(closed).map_err(|_| e) } else { Err(e) } - }) - }) + blend(body, edges, radius, Shape::Round, "fillet") + .and_then(closed) + .or_else(|e| { + let sel: Vec<(Vec3, f64)> = edges.iter().map(|p| (*p, radius)).collect(); + // The local blend's refusal, unless only the corner build found the radius too large. + crate::blend_corner::fillet_radii(body, &sel) + .and_then(closed) + .map_err(|e2| keep_size(e, e2)) + // Three edges at a vertex of any faces end in a sphere. + .or_else(|e| { + if edges.len() >= 3 { + crate::blend_vertex::fillet_ball_vertices(body, edges, radius).and_then(closed).map_err(|_| e) + } else { + Err(e) + } + }) + }) + // Straight edges ending at a curved face that isn't an earlier round's: a rolling ball + // trimmed by the face. Refused, the refusal above stands. + .or_else(|e| blend_with(body, edges, radius, Shape::Round, "fillet", true).and_then(closed).map_err(|_| e)) + // Blends meeting at corners on curved faces: all at once. + .or_else(|e| corners(body, edges, radius, Shape::Round, "fillet").and_then(closed).map_err(|e2| keep_size(e, e2))) +} + +/// The first refusal, unless only the second found the blend too large (a smaller one may fit). +fn keep_size(e: KernelError, e2: KernelError) -> KernelError { + match (&e, e2) { + (KernelError::TooLarge(_), _) => e, + (_, e2 @ KernelError::TooLarge(_)) => e2, + _ => e, + } +} + +/// Blend the edges together, rebuilding the corners where they meet on faces of any surface +/// (see [`crate::curveblend::corner_blend`]). +fn corners(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Result { + let counts = record::counts(&[body]); + let cur = crate::boolean_record::healed(body)?; + let size = cur.size(); + let solid = cur.deep_copy(); + let ids = edge_ids(&cur, &solid, edges)?; + if ids.len() != edges.len() { + return Err(KernelError::Invalid("no edge near a selected point".into())); + } + let (made, rec) = guard(what, || crate::curveblend::corner_blend(&solid, &ids, size, r, shape == Shape::Round))?; + Ok(record::after(record::rebuilt(made, &[&solid], &rec)?, &cur, &counts)) } /// The step of the radius search: three significant digits of the requested radius. @@ -1925,7 +2058,7 @@ pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result { return Ok(result); } // Coplanar neighbours as one face (as after a join), so corners look as they should. - let mut cur = crate::boolean_record::healed(body)?; + let cur = crate::boolean_record::healed(body)?; if let Some(groups) = loop_groups(&cur, edges)? { let whole = || -> Result { let mut lc = cur.clone(); @@ -1951,14 +2084,22 @@ pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result { Err(_) => {} } } - for p in edges { - let step = match chamfer_tool(&cur, *p, distance, 0).and_then(|t| cut_one(&cur, &t)) { - Ok(Some(b)) => b, - _ => blend(&cur, std::slice::from_ref(p), distance, Shape::Flat, "chamfer")?, - }; - cur = record::after(step, &cur, &counts); - } - Ok(cur) + let start = cur.clone(); + let one_by_one = || -> Result { + let mut cur = start.clone(); + for p in edges { + let step = match chamfer_tool(&cur, *p, distance, 0).and_then(|t| cut_one(&cur, &t)) { + Ok(Some(b)) => b, + // A chain running on into curved faces, or an edge ending at one: as for fillets. + _ => blend(&cur, std::slice::from_ref(p), distance, Shape::Flat, "chamfer") + .or_else(|e| blend_with(&cur, std::slice::from_ref(p), distance, Shape::Flat, "chamfer", true).map_err(|_| e))?, + }; + cur = record::after(step, &cur, &counts); + } + Ok(cur) + }; + // Chamfers meeting at corners on curved faces: all at once. + one_by_one().or_else(|e| corners(body, edges, distance, Shape::Flat, "chamfer").map_err(|e2| keep_size(e, e2))) } /// `cur` cut by one cutter, with its history over `[cur]` (`None`: nothing left). diff --git a/crates/kernel/src/blend_corner.rs b/crates/kernel/src/blend_corner.rs index 6a5945b5..ea3cd7de 100644 --- a/crates/kernel/src/blend_corner.rs +++ b/crates/kernel/src/blend_corner.rs @@ -128,6 +128,150 @@ fn hit(v: Vec3, u: Vec3, t: Vec3, s: f64) -> Result { Ok(v + u * (s / k)) } +/// Unit direction in which a (curved) edge leaves its end vertex `v`. +fn leaving(e: &mt::Edge, v: &mt::Vertex) -> Option { + use mt::{BoundedCurve, ParametricCurve}; + let c = e.curve(); + let (t0, t1) = c.range_tuple(); + let at_front = e.absolute_front() == v; + let d = c.der(if at_front { t0 } else { t1 }); + let d = Vec3::new(d.x, d.y, d.z); + (if at_front { d } else { d * -1.0 }).normalized() +} + +/// Point where the contact line at setback `s` (in-face direction `t`) crosses a curved edge +/// leaving `v`: the first along it from `v`. +fn crossing(e: &mt::Edge, v: Vec3, t: Vec3, s: f64) -> Result { + use mt::{BoundedCurve, ParametricCurve}; + let c = e.curve(); + let (t0, t1) = c.range_tuple(); + let from_front = crate::body::from_p3(e.absolute_front().point()).dist(v) <= crate::body::from_p3(e.absolute_back().point()).dist(v); + let at = |f: f64| crate::body::from_p3(c.subs(if from_front { t0 + (t1 - t0) * f } else { t1 - (t1 - t0) * f })); + let g = |f: f64| (at(f) - v).dot(t) - s; + let steps = 256; + let mut lo = 0.0; + for i in 1..=steps { + let hi = i as f64 / steps as f64; + if g(hi) >= 0.0 { + let (mut a, mut b) = (lo, hi); + for _ in 0..60 { + let m = (a + b) * 0.5; + if g(m) >= 0.0 { + b = m; + } else { + a = m; + } + } + return Ok(at((a + b) * 0.5)); + } + lo = hi; + } + Err(too_large("a fillet larger than the faces next to it")) +} + +/// The cylinder's (a chamfer's plane's) section by a curved end face `g`, from `a` (on the +/// contact line with in-face direction `ta`) to `b` (`tb`) round the edge's end `v` (its other +/// end `far`): points of the blend's cross-sections on the face, interpolated; within tolerance +/// of both. +#[allow(clippy::too_many_arguments)] +fn curved_section( + sel: &Sel, + g: &mt::Face, + (a, ta): (Vec3, Vec3), + (b, tb): (Vec3, Vec3), + v: Vec3, + far: Vec3, + round: bool, + size: f64, +) -> Result { + use mt::ParametricCurve; + let tolerance = (size * 1e-5).clamp(1e-5, 0.001); + let fail = || too_large("a fillet larger than the faces next to it"); + // Position along the edge, and the cross-section there (fraction 0 on `ta`'s side). + let along = |x: Vec3| (x - sel.p0).dot(sel.d); + let point = |f: f64, l: f64| -> Vec3 { + let apex = sel.p0 + sel.d * l; + let (pa, pb) = (apex + ta * sel.s, apex + tb * sel.s); + if round { crate::body::from_p3(conic(pa, apex, pb, sel.w).subs(f)) } else { pa + (pb - pa) * f } + }; + // The face's surface, each search started from the last one's answer (the points come in + // small steps). + let surface = g.oriented_surface(); + let last = std::cell::Cell::new(None::<(f64, f64)>); + let side = |x: Vec3| -> f64 { + use mt::{ParametricSurface, ParametricSurface3D, SearchNearestParameter}; + let found = last + .get() + .and_then(|hint| surface.search_nearest_parameter(p3(x), Some(hint), 20)) + .or_else(|| surface.search_nearest_parameter(p3(x), None, 100)); + found.map_or(f64::NAN, |(u, w)| { + last.set(Some((u, w))); + let n = surface.normal(u, w); + (x - crate::body::from_p3(surface.subs(u, w))).dot(Vec3::new(n.x, n.y, n.z)) + }) + }; + let (la, lb) = (along(a), along(b)); + // Out of the material: toward the edge's end. + let outward = if along(v) >= along(far) { 1.0 } else { -1.0 }; + let step = ((lb - la).abs().max(sel.s * 0.05)) / 16.0; + let root = |f: f64| -> Option { + let guess = la + (lb - la) * f; + let gv = side(point(f, guess)); + if !gv.is_finite() { + return None; + } + let dir = if gv < 0.0 { outward } else { -outward }; + let mut prev = (guess, gv); + for i in 1..=4096 { + let l = guess + dir * step * i as f64; + let cur = (l, side(point(f, l))); + if !cur.1.is_finite() { + return None; + } + if (prev.1 < 0.0) != (cur.1 < 0.0) { + let (mut x, mut y) = (prev, cur); + for _ in 0..60 { + let m = (x.0 + y.0) * 0.5; + let gm = side(point(f, m)); + if (gm < 0.0) == (x.1 < 0.0) { + x = (m, gm); + } else { + y = (m, gm); + } + } + return Some((x.0 + y.0) * 0.5); + } + prev = cur; + } + None + }; + // Closer together toward the ends, where the face may only touch the blend. + let across = |t: f64| (1.0 - (std::f64::consts::PI * t).cos()) * 0.5; + let mut m = 8; + loop { + let mut pts = vec![a]; + for i in 1..m { + let f = across(i as f64 / m as f64); + pts.push(point(f, root(f).ok_or_else(fail)?)); + } + pts.push(b); + let curve = crate::build::interpolate_cubic(&pts).ok_or_else(|| KernelError::Failed("fillet: end curve".into()))?; + let accurate = (0..m).all(|i| { + let t = (i as f64 + 0.5) / m as f64; + let f = across(t); + let x = crate::body::from_p3(curve.subs(t)); + root(f).is_some_and(|l| point(f, l).dist(x) <= tolerance) && side(x).abs() <= tolerance + }); + if accurate { + return Ok(mt::Curve::BSplineCurve(curve)); + } + if m >= 128 { + return Err(KernelError::Failed("fillet: could not fit the blend's end in a curved face".into())); + } + m *= 2; + } +} + /// Point in a face where the contact lines of two edges from `v` (directions `u1`, `u2`, in-face /// directions `t1`, `t2`, setbacks `s1`, `s2`) cross. fn meet(v: Vec3, (u1, t1, s1): (Vec3, Vec3, f64), (u2, t2, s2): (Vec3, Vec3, f64)) -> Result { @@ -292,18 +436,26 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result { if eids.len() != 3 || fids.len() != 3 { return Err(unsupported("fillets ending where more than three edges meet")); } - if fids.iter().any(|f| normal(*f).is_none()) { + // Where one fillet ends, the face it ends on may be curved (and the edges it shares + // with the others): the blend ends in that face's section of the cylinder. + let chosen = eids.iter().filter(|id| sels.contains_key(id)).count(); + let sides: Vec = eids.iter().filter_map(|id| sels.get(id)).flat_map(|s| [s.fa, s.fb]).collect(); + if fids.iter().any(|f| normal(*f).is_none() && (chosen != 1 || sides.contains(f))) { return Err(unsupported("fillets ending on curved faces")); } let pv = vtx(v); let mut list = Vec::new(); for id in eids { let e = edges.get(id).ok_or_else(|| KernelError::Failed("edge".into()))?; - if !matches!(e.curve(), mt::Curve::Line(_)) { + let u = if matches!(e.curve(), mt::Curve::Line(_)) { + let other = if e.absolute_front() == v { e.absolute_back() } else { e.absolute_front() }; + (vtx(other) - pv).normalized() + } else if chosen == 1 && !sels.contains_key(id) { + leaving(e, v) + } else { return Err(unsupported("fillets ending next to curved edges")); } - let other = if e.absolute_front() == v { e.absolute_back() } else { e.absolute_front() }; - let u = (vtx(other) - pv).normalized().ok_or_else(|| KernelError::Failed("zero-length edge".into()))?; + .ok_or_else(|| KernelError::Failed("zero-length edge".into()))?; list.push((*id, u)); } around.insert(*vid, Around { edges: list }); @@ -345,14 +497,29 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result { let s1 = sel(*e1)?; let (f12, f31) = (face_with(*e1, *e2)?, face_with(*e1, *e3)?); let f23 = face_with(*e2, *e3)?; - let p2 = hit(*u2, s1.t(f12), s1.s, f12)?; - let p3_ = hit(*u3, s1.t(f31), s1.s, f31)?; + // Along a curved neighbouring edge: where it crosses the contact line. + let reach = |e: mt::EdgeID, u: Vec3, f: usize| -> Result { + match edges.get(&e) { + Some(x) if !matches!(x.curve(), mt::Curve::Line(_)) => crossing(x, v, s1.t(f), s1.s), + _ => hit(u, s1.t(f), s1.s, f), + } + }; + let p2 = reach(*e2, *u2, f12)?; + let p3_ = reach(*e3, *u3, f31)?; let (a, b) = (builder::vertex(p3(p2)), builder::vertex(p3(p3_))); endpt.insert((*e2, *vid, None), a.clone()); endpt.insert((*e1, *vid, Some(f12)), a.clone()); endpt.insert((*e3, *vid, None), b.clone()); endpt.insert((*e1, *vid, Some(f31)), b.clone()); - let c = if round { edge(&a, &b, mt::Curve::NurbsCurve(conic(p2, v, p3_, s1.w)))? } else { builder::line(&a, &b) }; + let c = match faces.get(f23) { + Some(g) if normal(f23).is_none() => { + let e1v = edges.get(e1).ok_or_else(|| KernelError::Failed("edge".into()))?; + let far = if e1v.absolute_front().id() == *vid { vtx(e1v.absolute_back()) } else { vtx(e1v.absolute_front()) }; + edge(&a, &b, curved_section(s1, g, (p2, s1.t(f12)), (p3_, s1.t(f31)), v, far, round, size)?)? + } + _ if round => edge(&a, &b, mt::Curve::NurbsCurve(conic(p2, v, p3_, s1.w)))?, + _ => builder::line(&a, &b), + }; list.push((c, vec![Owner::Face(f23), Owner::Cyl(*e1)])); } ([(ea, ua), (eb, ub)], [(e3, u3)]) => { @@ -467,7 +634,13 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result { if (pb - pa).dot(orig) <= size * 1e-9 * orig.len() { return Err(too_large("a fillet larger than the faces next to it")); } - let x = builder::line(&a, &b); + let x = if matches!(e.curve(), mt::Curve::Line(_)) { + builder::line(&a, &b) + } else { + // A curved edge next to a fillet's end, shortened along its own curve. + let moved = |n: &mt::Vertex, o: &mt::Vertex| (n != o).then(|| n.clone()); + crate::curveblend::trim_curved_seam(&e.absolute_clone(), moved(&a, e.absolute_front()), moved(&b, e.absolute_back()), size)? + }; lines.insert((e.id(), f), x.clone()); Ok(x) }; diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index 77d6a859..acb2175e 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -1332,10 +1332,34 @@ fn cubic_basis(knots: &[f64], n_ctrl: usize, u: f64) -> Vec { /// Cubic B-spline through points at uniform parameters (clamped, knots by averaging). pub(crate) fn interpolate_cubic(pts: &[Vec3]) -> Option> { let m = pts.len(); - if m < 4 { + let u: Vec = (0..m).map(|i| i as f64 / (m.max(2) - 1) as f64).collect(); + interpolate_cubic_at(pts, &u) +} + +/// [`interpolate_cubic`] by chord length: the parameters (0 to 1) in proportion to the distance +/// along the points. Points closer together toward an end (where a curve leaves another +/// tangentially) then don't slow the curve to a stop there, where a uniform parameter lets it +/// double back on itself. +pub(crate) fn interpolate_cubic_chordal(pts: &[Vec3]) -> Option<(mt::BSplineCurve, Vec)> { + let mut u = vec![0.0]; + for w in pts.windows(2) { + let (Some(a), Some(b)) = (w.first(), w.get(1)) else { return None }; + u.push(u.last().copied().unwrap_or(0.0) + a.dist(*b)); + } + let total = u.last().copied().filter(|t| t.is_finite() && *t > 0.0)?; + let u: Vec = u.iter().map(|x| x / total).collect(); + if u.windows(2).any(|w| w.first() >= w.get(1)) { + return None; + } + interpolate_cubic_at(pts, &u).map(|c| (c, u)) +} + +/// The cubic B-spline through `pts` at parameters `u` (rising from 0 to 1), its knots averaged. +fn interpolate_cubic_at(pts: &[Vec3], u: &[f64]) -> Option> { + let m = pts.len(); + if m < 4 || u.len() != m { return None; } - let u: Vec = (0..m).map(|i| i as f64 / (m - 1) as f64).collect(); let mut knots = vec![0.0; 4]; for j in 1..(m - 3) { knots.push((j..j + 3).filter_map(|i| u.get(i)).sum::() / 3.0); diff --git a/crates/kernel/src/curveblend.rs b/crates/kernel/src/curveblend.rs index 7d7bd27e..0ede742d 100644 --- a/crates/kernel/src/curveblend.rs +++ b/crates/kernel/src/curveblend.rs @@ -459,6 +459,24 @@ fn solve( cusp: f64, extra: Option<(usize, Vec3, Vec3)>, tol: f64, +) -> Option
{ + solve_from(f1, f2, p, t, r, sigma, cusp, extra, tol, None) +} + +/// [`solve`], started from the centre `start` when given (the corner builder's balls at a knife +/// edge start beside the last one). +#[allow(clippy::too_many_arguments)] +fn solve_from( + f1: &[&mt::Face], + f2: &[&mt::Face], + p: Vec3, + t: Vec3, + r: f64, + sigma: f64, + cusp: f64, + extra: Option<(usize, Vec3, Vec3)>, + tol: f64, + start: Option, ) -> Option
{ // 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 @@ -484,7 +502,9 @@ fn solve( let (q2, n2) = near(f2, p)?; let _ = (q1, q2); let den = 1.0 + n1.dot(n2); - let mut c = if cusp != 0.0 && den.abs() < KNIFE { + let mut c = if let Some(c) = start { + c + } else if cusp != 0.0 && den.abs() < KNIFE { // The normals are (nearly) opposite: `(n1 + n2) / den` has no direction. The centre is r // inside face 1 and slides along it, away from the edge, until it is r from face 2 too. let w = n1.cross(t).normalized()? * cusp; @@ -552,6 +572,21 @@ fn solve( None } +/// Whether the strip's curvature jumps where two runs of sections meet (each three sections from +/// the joint outward, the joint's first): for the centres or either contact, the difference of +/// the circles through them would move the middle section by more than a tenth of `tolerance`. +fn bends(a: [&Section; 3], b: [&Section; 3], tolerance: f64) -> bool { + let curvature = |p: [Vec3; 3]| { + let (x, y, z) = (p[1] - p[0], p[2] - p[1], p[2] - p[0]); + let d = x.len() * y.len() * z.len(); + if d > 1e-300 { 2.0 * x.cross(y).len() / d } else { 0.0 } + }; + let h = a[0].c.dist(a[1].c); + [|s: &Section| s.c, |s: &Section| s.q1, |s: &Section| s.q2] + .iter() + .any(|f| (curvature(a.map(f)) - curvature(b.map(f))).abs() * h * h > tolerance * 0.1) +} + /// Homogeneous control points of the circular (or straight) cross-section from q1 to q2. fn section_controls(s: &Section, round: bool) -> Option<[mt::Vector4; 3]> { let h = |p: Vec3, w: f64| mt::Vector4::new(p.x * w, p.y * w, p.z * w, w); @@ -570,10 +605,168 @@ 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)]) } +/// Where to start the ball at a knife edge through `p` (direction `t`, the sides running on from +/// it along `into`): the first point along them, from the edge, where they are a diameter +/// apart, midway. +fn knife_start(o1: &[&mt::Face], o2: &[&mt::Face], p: Vec3, t: Vec3, into: Vec3, r: f64) -> Option { + let w = (into - t * into.dot(t)).normalized()?; + (1..=256).find_map(|k| { + let x = p + w * (r * k as f64 / 8.0); + let a = o1.iter().filter_map(|f| project(f, x, None)).min_by(|a, b| a.0.dist(x).total_cmp(&b.0.dist(x)))?; + let b = o2.iter().filter_map(|f| project(f, x, None)).min_by(|a, b| a.0.dist(x).total_cmp(&b.0.dist(x)))?; + let c = (a.0 + b.0) * 0.5; + (a.0.dist(b.0) >= 2.0 * r).then(|| c - t * (c - p).dot(t)) + }) +} + +/// Whether two faces meeting at no angle at `pm` (the first's outward normal `n1`, running on +/// from the edge along `into`) hold the material between them: a little way in, the second is +/// behind the first. +fn knife_convex(f1: &mt::Face, f2: &mt::Face, pm: Vec3, into: Vec3, r: f64) -> Option { + let x = pm + into * r; + let (a, b) = (project(f1, x, None)?, project(f2, x, None)?); + Some((b.0 - a.0).dot(a.1) < 0.0) +} + +/// The nearest point of a face's (oriented) surface to `x`, its outward normal and parameters; +/// `last`: where the last search on it ended (the points asked about come in small steps). +fn nearest_on(surface: &mt::Surface, last: &std::cell::Cell>, x: Vec3) -> Option<(Vec3, Vec3, (f64, f64))> { + use mt::{ParametricSurface, ParametricSurface3D, SearchNearestParameter}; + // The nearest of the search from the last answer, a fresh one (the first time, also from + // a grid over the surface's parameters: a fresh search on a conic can settle on its far + // side) and either kept within the parameters (past them a conic runs on round). + let (ru, rv) = surface.try_range_tuple(); + let ((u0, u1), (v0, v1)) = (ru.unwrap_or((f64::NEG_INFINITY, f64::INFINITY)), rv.unwrap_or((f64::NEG_INFINITY, f64::INFINITY))); + let inside = |uv: (f64, f64)| uv.0 >= u0 - 1e-9 && uv.0 <= u1 + 1e-9 && uv.1 >= v0 - 1e-9 && uv.1 <= v1 + 1e-9; + let warm = last.get().and_then(|hint| surface.search_nearest_parameter(p3(x), Some(hint), 20)); + let mut found: Vec<(f64, f64)> = warm.into_iter().collect(); + if !warm.is_some_and(inside) { + found.extend(surface.search_nearest_parameter(p3(x), None, 100)); + } + if last.get().is_none() && [u0, u1, v0, v1].iter().all(|x| x.is_finite()) { + for i in 0..5 { + for j in 0..5 { + let hint = (u0 + (u1 - u0) * (i as f64 + 0.5) / 5.0, v0 + (v1 - v0) * (j as f64 + 0.5) / 5.0); + found.extend(surface.search_nearest_parameter(p3(x), Some(hint), 50)); + } + } + } + let clamped: Vec<(f64, f64)> = found.iter().map(|(u, v)| (u.clamp(u0, u1), v.clamp(v0, v1))).collect(); + found.extend(clamped); + let d = |uv: &(f64, f64)| from_p3(surface.subs(uv.0, uv.1)).dist(x); + let uv = found.into_iter().filter(|uv| d(uv).is_finite()).min_by(|a, b| d(a).total_cmp(&d(b)))?; + last.set(Some(uv)); + let n = surface.normal(uv.0, uv.1); + Some((from_p3(surface.subs(uv.0, uv.1)), Vec3::new(n.x, n.y, n.z).normalized()?, uv)) +} + +/// The strip with parameters in proportion to its lengths across and along (the same surface). +/// The mesher triangulates a face in its parameters: on a unit square for a strip a few +/// millimetres wide and tens long, the points of a slanted end fan out into triangles so long +/// along the curve that they turn over. +fn in_proportion(surface: &mt::Surface) -> Result { + use mt::ParametricSurface; + let fail = || KernelError::Failed("curve blend: strip parameters".into()); + let mt::Surface::NurbsSurface(ns) = surface else { return Err(fail()) }; + let length = |at: &dyn Fn(f64) -> Vec3| (0..32).map(|i| at(i as f64 / 32.0).dist(at((i + 1) as f64 / 32.0))).sum::(); + let across = length(&|u| from_p3(surface.subs(u, 0.5))); + let along = length(&|v| from_p3(surface.subs(0.5, v))); + if !(across.is_finite() && along.is_finite() && across > 1e-9 && along > 1e-9) { + return Err(fail()); + } + let bs = ns.non_rationalized(); + let (mut uk, mut vk) = bs.knot_vecs().clone(); + uk.transform(across, 0.0); + vk.transform(along, 0.0); + let bs = mt::BSplineSurface::try_new((uk, vk), bs.control_points().clone()).map_err(|_| fail())?; + Ok(mt::Surface::NurbsSurface(mt::NurbsSurface::new(bs))) +} + +/// A chain edge the ball turns about instead of rolling along: an arc of the blend's radius on a +/// planar side (`side`), curving round the ball (a rounded corner rounded again). The ball's +/// centre stays at `c`, it touches the planar side only at the arc's centre `o`, and the blend +/// there is a piece of sphere (of cone, for a chamfer) about the axis through `c` along `axis`. +#[derive(Clone, Copy, Debug)] +struct Pivot { + c: Vec3, + o: Vec3, + axis: Vec3, + side: usize, +} + +/// The circle an edge lies on (centre, radius, axis), if it is an arc of one within `tol`. +pub(crate) fn circle_of(e: &mt::Edge, tol: f64) -> Option<(Vec3, f64, Vec3)> { + use mt::{BoundedCurve, ParametricCurve}; + let curve = e.oriented_curve(); + let (t0, t1) = curve.range_tuple(); + let at = |f: f64| from_p3(curve.subs(t0 + (t1 - t0) * f)); + let (a, u, v) = (at(0.0), at(0.5) - at(0.0), at(1.0) - at(0.0)); + let w = u.cross(v); + if w.len2() < 1e-24 { + return None; + } + let o = a + (v * u.len2() - u * v.len2()).cross(w) * (1.0 / (2.0 * w.len2())); + let (rho, axis) = (o.dist(a), w.normalized()?); + (0..=8).map(|i| at(i as f64 / 8.0)).all(|p| (p.dist(o) - rho).abs() <= tol && (p - o).dot(axis).abs() <= tol).then_some((o, rho, axis)) +} + +/// The surface the section (homogeneous controls from contact 1 to contact 2) sweeps turning by +/// `angle` about the axis through `c` along the unit `axis`: rational quadratic arcs in v, a +/// quarter turn at most each, so it is exact. +fn revolve(ctrl: [mt::Vector4; 3], c: Vec3, axis: Vec3, angle: f64) -> Option { + let pieces = ((angle.abs() / std::f64::consts::FRAC_PI_2).ceil() as usize).clamp(1, 8); + let step = angle / pieces as f64; + let half = (step / 2.0).cos(); + if !(half.is_finite() && half > 1e-6) { + return None; + } + let rot = |x: Vec3, phi: f64| { + let (s, co) = phi.sin_cos(); + x * co + axis.cross(x) * s + axis * (axis.dot(x) * (1.0 - co)) + }; + let h = |p: Vec3, w: f64| mt::Vector4::new(p.x * w, p.y * w, p.z * w, w); + let mut rows = Vec::with_capacity(3); + for q in ctrl { + if !(q.w.is_finite() && q.w > 1e-12) { + return None; + } + let x = Vec3::new(q.x / q.w, q.y / q.w, q.z / q.w) - c; + let along = axis * axis.dot(x); + let mut row = vec![h(c + x, q.w)]; + for i in 0..pieces { + let a0 = step * i as f64; + row.push(h(c + along + rot(x - along, a0 + step / 2.0) * (1.0 / half), q.w * half)); + row.push(h(c + rot(x, a0 + step), q.w)); + } + rows.push(row); + } + let mut knots = vec![0.0; 3]; + for i in 1..pieces { + let k = i as f64 / pieces as f64; + knots.extend([k, k]); + } + knots.extend([1.0; 3]); + let bs = mt::BSplineSurface::try_new((mt::KnotVec::bezier_knot(2), mt::KnotVec::from(knots)), rows).ok()?; + Some(mt::Surface::NurbsSurface(mt::NurbsSurface::new(bs))) +} + +/// Whether the edge is straight: a line, or a curve on the segment between its ends within +/// `tol` (an earlier blend's contact along a straight edge is a fitted curve). +fn straight(e: &mt::Edge, tol: f64) -> bool { + use mt::{BoundedCurve, ParametricCurve}; + let c = e.curve(); + if matches!(c, mt::Curve::Line(_)) { + return true; + } + let (a, b) = (from_p3(e.front().point()), from_p3(e.back().point())); + let (t0, t1) = c.range_tuple(); + a.dist(b) > tol && (0..=16).all(|i| from_p3(c.subs(t0 + (t1 - t0) * i as f64 / 16.0)).dist_to_segment(a, b) <= tol) +} + /// 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 { +pub(crate) 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() { @@ -771,6 +964,52 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, if n1.dot(n2) > 1.0 - 1e-6 { return Err(unsupported("blending a smooth edge")); } + // Arcs on a planar side that curve round the ball: its centre runs round them at their + // radius less the blend's. Less than nothing is too large; nothing is a pivot. + let mut pivots: Vec> = vec![None; n]; + for (k, e) in chain.iter().enumerate() { + let Some((o, rho, _)) = circle_of(e, tol * 1e3) else { continue }; + for side in [1, 2] { + let Some(f) = (if side == 1 { side1.get(k) } else { side2.get(k) }).and_then(|i| faces.get(*i)) else { continue }; + let mt::Surface::Plane(pl) = f.oriented_surface() else { continue }; + let Some(np) = Vec3::new(pl.normal().x, pl.normal().y, pl.normal().z).normalized() else { continue }; + // Into the face (left of the edge as the face runs it, about its outward normal). + let Some(used) = f.boundary_iters().into_iter().flatten().find(|x| x.id() == e.id()) else { continue }; + let Some((pm, tm)) = ({ + use mt::{BoundedCurve, ParametricCurve}; + let c = used.oriented_curve(); + let (a, b) = c.range_tuple(); + let d = c.der((a + b) * 0.5); + Vec3::new(d.x, d.y, d.z).normalized().map(|t| (from_p3(c.subs((a + b) * 0.5)), t)) + }) else { + continue; + }; + if (o - pm).dot(np.cross(tm)) <= 0.0 { + continue; + } + if rho < r - tol * 1e3 { + return Err(too_large("the blend is larger than a corner radius")); + } + if (rho - r).abs() > tol * 1e3 { + continue; + } + // The ball sits on the planar side at the arc's centre; the other side must be a round + // about the same axis, the ball touching it all along. + let c = o + np * (sigma * r); + let (a1, a2) = edge_sides(&[k]); + let other = if side == 1 { a2 } else { a1 }; + let reach = other.iter().filter_map(|f| project(f, c, None)).map(|x| x.0.dist(c)).fold(f64::INFINITY, f64::min); + if (reach - r).abs() <= size * 1e-4 { + if let Some(slot) = pivots.get_mut(k) { + *slot = Some(Pivot { c, o, axis: np, side }); + } + break; + } + } + } + if pivots.iter().all(Option::is_some) { + return Err(unsupported("rounding again only rounded corners of the blend's radius")); + } // Seams at the chain's vertices: edges there that aren't in the chain, and their side. let chain_ids: Vec = chain.iter().map(|e| e.id()).collect(); let mut seams: Vec> = Vec::with_capacity(nv); @@ -834,6 +1073,21 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, .ok_or_else(|| KernelError::Failed("tangent".into()))?; let here = seams.get(k).cloned().unwrap_or_default(); let at_end = end_faces.iter().any(|(kk, _)| *kk == k); + // Next to a pivot the ball is at its centre, touching the other side square above the + // vertex. + let pivoting: Vec = [(k < n).then_some(k), if k > 0 { Some(k - 1) } else { closed.then_some(n - 1) }] + .into_iter() + .flatten() + .filter_map(|i| pivots.get(i).copied().flatten()) + .collect(); + if let Some(pv) = pivoting.first().copied() { + if at_end || pivoting.iter().any(|x| x.c.dist(pv.c) > tol * 1e3) { + return Err(unsupported("a rounded corner of the blend's radius where the edge chain ends or turns")); + } + let q = pv.c + (p - pv.o); + vsec.push(if pv.side == 1 { Section { c: pv.c, q1: pv.o, q2: q } } else { Section { c: pv.c, q1: q, q2: pv.o } }); + continue; + } if at_end { // A plane square to the edge holds the blend's end section. Any other end face: the // blend runs on past the end and is cut back where it meets that face. @@ -856,7 +1110,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, // The plane through the seam square to that side's surface. let o = from_p3(seam.front().point()); let d = (from_p3(seam.back().point()) - o).normalized().ok_or_else(|| KernelError::Failed("seam".into()))?; - if !matches!(seam.curve(), mt::Curve::Line(_)) { + if !straight(&seam, tol * 10.0) { return Err(unsupported("a curved seam where the edge chain turns")); } let fs = if side == 1 { &s1 } else { &s2 }; @@ -871,13 +1125,17 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, Some((side, o, d.cross(nn).normalized().ok_or_else(|| KernelError::Failed("seam plane".into()))?)) } }; - let (a1, a2) = edge_sides(&vertex_edges(k)); + // On the faces of the edge from this vertex alone (the last edge's at an open chain's + // end): the other edge's faces are untrimmed here and can run on near the ball where it no + // longer is (a wall past the round at a corner, which a ball rolling round it pokes + // through). + let (a1, a2) = edge_sides(&[k.min(n - 1)]); let s = solve(&a1, &a2, p, t, r, sigma, cusp, extra, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge"))?; // A seam on the other side too must pass through that side's contact. if let Some((side, seam)) = here.get(1).filter(|_| !at_end) { let q = if *side == 1 { s.q1 } else { s.q2 }; let (a, b) = (from_p3(seam.front().point()), from_p3(seam.back().point())); - if !matches!(seam.curve(), mt::Curve::Line(_)) || q.dist_to_segment(a, b) > tol * 1e3 { + if !straight(seam, tol * 10.0) || q.dist_to_segment(a, b) > tol * 1e3 { return Err(unsupported("seams on both sides of a corner of the edge chain")); } } @@ -962,8 +1220,13 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, if n.checked_mul(samples).is_none_or(|count| count > MAX_CHAIN_SAMPLES) { return Err(unsupported("curve blend exceeds the section fitting limit")); } - let mut guides = Vec::with_capacity(n); + // No guide along a pivot: its sections are the two at its ends. + let mut guides: Vec> = Vec::with_capacity(n); for (k, e) in chain.iter().enumerate() { + if pivots.get(k).copied().flatten().is_some() { + guides.push(None); + continue; + } let mut list = vec![*vsec.get(k).ok_or_else(|| KernelError::Failed("section".into()))?]; for j in 1..samples - 1 { list.push(section_at(k, e, j as f64 / (samples - 1) as f64)?); @@ -976,14 +1239,14 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, list.extend(ext.iter().copied()); } } - guides.push(Guide::new(list)?); + guides.push(Some(Guide::new(list)?)); } // Intersection curves inherit their leader's piecewise parameter speed. Fitting // those parameters creates oscillations even on a geometrically smooth spine. // Resample by distance along the rolling centres, projecting each interpolated // centre back onto the exact two offset surfaces before it becomes a section. let uniform_section = |k: usize, along: f64| -> Result
{ - let guide = guides.get(k).ok_or_else(|| KernelError::Failed("spine guide".into()))?; + let guide = guides.get(k).and_then(Option::as_ref).ok_or_else(|| KernelError::Failed("spine guide".into()))?; if along == 0.0 { return guide.sections.first().copied().ok_or_else(|| KernelError::Failed("section".into())); } @@ -996,13 +1259,26 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, .ok_or_else(|| unsupported("the ball doesn't fit along the resampled spine")) }; let esec: Vec> = (0..n) - .map(|k| (0..samples).map(|j| uniform_section(k, j as f64 / (samples - 1) as f64)).collect::>>()) + .map(|k| match guides.get(k) { + Some(Some(_)) => (0..samples).map(|j| uniform_section(k, j as f64 / (samples - 1) as f64)).collect::>>(), + _ => Ok(vec![ + *vsec.get(k).ok_or_else(|| KernelError::Failed("section".into()))?, + *vsec.get((k + 1) % nv).ok_or_else(|| KernelError::Failed("section".into()))?, + ]), + }) .collect::>()?; // Each strip's neighbouring sections at its own spacing (edges of different lengths // have differently spaced samples, which makes a uniform interpolation overshoot). - let length = |k: usize| guides.get(k).and_then(|g| g.lengths.last().copied()).unwrap_or(0.0); - let mut fits = Vec::with_capacity(n); + let length = |k: usize| guides.get(k).and_then(Option::as_ref).and_then(|g| g.lengths.last().copied()).unwrap_or(0.0); + let rolls = |k: usize| guides.get(k).is_some_and(Option::is_some); + let mut fits: Vec> = Vec::with_capacity(n); for k in 0..n { + // A pivot's surface is made whole (`revolve`); a strip next to one ends on the joint's + // section on its own. + if !rolls(k) { + fits.push(None); + continue; + } let h = length(k) / (samples - 1) as f64; let spaced = |nb: usize, from_end: bool| -> Result>> { let l = length(nb); @@ -1018,18 +1294,38 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, } Ok(Some(out)) }; - let before = if closed || k > 0 { spaced((k + n - 1) % n, true)? } else { Some(Vec::new()) }; - let after = if closed || k + 1 < n { spaced((k + 1) % n, false)? } else { Some(Vec::new()) }; + let (prev, next) = ((k + n - 1) % n, (k + 1) % n); let list = esec.get(k).ok_or_else(|| KernelError::Failed("section".into()))?; + // Not across a joint where the strip's curvature jumps (a line or a wide arc into a + // small round): a spline smooth through it spreads the jump over the sections beside + // it, and the error falls only with the square of their spacing. The strip then ends + // on the joint's section on its own, which the next one shares. + let smooth = |near: [Option<&Section>; 3], beyond: [Option<&Section>; 3]| match (near, beyond) { + ([Some(a), Some(b), Some(c)], [Some(x), Some(y), Some(z)]) => !bends([a, b, c], [x, y, z], fit_tolerance), + _ => false, + }; + let before = match (closed || k > 0) && rolls(prev) { + true => spaced(prev, true)? + .map(|b| if smooth([list.first(), list.get(1), list.get(2)], [list.first(), b.get(1), b.first()]) { b } else { Vec::new() }), + false => Some(Vec::new()), + }; + let after = match (closed || k + 1 < n) && rolls(next) { + true => spaced(next, false)?.map(|a| { + let m = list.len(); + let tail = [list.last(), m.checked_sub(2).and_then(|i| list.get(i)), m.checked_sub(3).and_then(|i| list.get(i))]; + if smooth(tail, [list.last(), a.first(), a.get(1)]) { a } else { Vec::new() } + }), + false => Some(Vec::new()), + }; let fit = match (before, after) { (Some(b), Some(a)) => crate::guard("fit curve blend", || fit_strip(list, &b, &a, round))?, _ => crate::guard("fit curve blend", || fit_sections(&esec, k, closed, round))?, }; - fits.push(fit); + fits.push(Some(fit)); } let mut accurate = true; 'edges: for k in 0..n { - let fit = fits.get(k).ok_or_else(|| KernelError::Failed("curve blend fit".into()))?; + let Some(Some(fit)) = fits.get(k) else { continue }; for j in 0..samples - 1 { for fraction in [0.25, 0.5, 0.75] { let along = (j as f64 + fraction) / (samples - 1) as f64; @@ -1054,7 +1350,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, let mut caps: Vec<(usize, usize, f64, f64, mt::Curve)> = Vec::new(); for (k, fi, _, _, tangent) in &slanted { let ke = if *k == 0 { 0 } else { n - 1 }; - let fit = fits.get(ke).ok_or_else(|| KernelError::Failed("curve blend fit".into()))?; + let fit = fits.get(ke).and_then(Option::as_ref).ok_or_else(|| KernelError::Failed("curve blend fit".into()))?; let g = faces.get(*fi).ok_or_else(|| KernelError::Failed("end face".into()))?; let at = vsec.get(*k).copied().ok_or_else(|| KernelError::Failed("section".into()))?; let seam = |s: u8| seams.get(*k).and_then(|l| l.iter().find(|(x, _)| *x as u8 == s)).map(|(_, e)| e); @@ -1085,6 +1381,16 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, *b = builder::vertex(curve.subs(hi)); } } + // Along a pivot the planar side's contact is one point: one vertex at both its ends. + for (k, pv) in pivots.iter().enumerate() { + let Some(pv) = pv else { continue }; + let vs = if pv.side == 1 { &mut v1 } else { &mut v2 }; + if let Some(v) = vs.get(k).cloned() + && let Some(slot) = vs.get_mut((k + 1) % nv) + { + *slot = v; + } + } // A chain end (a cap, or the section at a square end) on the far end of its seam uses that // seam up: that vertex is the end. let mut consumed: HashMap = HashMap::new(); @@ -1117,15 +1423,48 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, }; arcs.push(e); } + // Outward: toward the ball centre on a concave edge, away on a convex one. + let oriented = |mut surface: mt::Surface, list: &[Section]| -> Result { + use mt::{ParametricSurface3D, SearchNearestParameter}; + let s = list.get(list.len() / 2).ok_or_else(|| fail("section"))?; + let mid = s.c + ((s.q1 - s.c) + (s.q2 - s.c)).normalized().ok_or_else(|| fail("mid"))? * r; + if let Some((u, v)) = surface.search_nearest_parameter(p3(mid), None, 100) { + let nn = surface.normal(u, v); + let want = (s.c - mid) * sigma; + if Vec3::new(nn.x, nn.y, nn.z).dot(want) < 0.0 { + surface = mt::Invertible::inverse(&surface); + } + } + Ok(surface) + }; // Contact curves and blend faces per edge. - let mut c1: Vec = Vec::with_capacity(n); - let mut c2: Vec = Vec::with_capacity(n); + // (None: the planar side's contact along a pivot, a point.) + let mut c1: Vec> = Vec::with_capacity(n); + let mut c2: Vec> = Vec::with_capacity(n); let mut blends: Vec = Vec::with_capacity(n); for (k, list) in esec.iter().enumerate() { let k1 = (k + 1) % nv; let (a1, b1, a2, b2) = (v1.get(k), v1.get(k1), v2.get(k), v2.get(k1)); let (Some(a1), Some(b1), Some(a2), Some(b2)) = (a1, b1, a2, b2) else { return Err(fail("vertex")) }; - let fit = fits.get(k).ok_or_else(|| fail("fit"))?; + if let Some(pv) = pivots.get(k).copied().flatten() { + // The section at the start turned about the axis to the one at the end; the other + // side's contact is the circle its end runs on. + let (s0, s1e) = (list.first().ok_or_else(|| fail("section"))?, list.last().ok_or_else(|| fail("section"))?); + let (qa, qb) = if pv.side == 1 { (s0.q2, s1e.q2) } else { (s0.q1, s1e.q1) }; + let (a, b) = (qa - pv.c, qb - pv.c); + let angle = pv.axis.dot(a.cross(b)).atan2(a.dot(b)); + let (sn, cs) = (angle / 2.0).sin_cos(); + let mid = pv.c + a * cs + pv.axis.cross(a) * sn + pv.axis * (pv.axis.dot(a) * (1.0 - cs)); + let surface = section_controls(s0, round).and_then(|ctrl| revolve(ctrl, pv.c, pv.axis, angle)).ok_or_else(|| fail("pivot"))?; + let (from, to) = if pv.side == 1 { (a2, b2) } else { (a1, b1) }; + let arc = builder::circle_arc(from, to, p3(mid)); + let (e1, e2) = if pv.side == 1 { (None, Some(arc)) } else { (Some(arc), None) }; + c1.push(e1); + c2.push(e2); + blends.push(oriented(surface, list)?); + continue; + } + let fit = fits.get(k).and_then(Option::as_ref).ok_or_else(|| fail("fit"))?; let [cv1, cv2] = &fit.contacts; // Contacts past a slanted end stop at the end curve. let (mut cv1, mut cv2) = (cv1.clone(), cv2.clone()); @@ -1143,28 +1482,12 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, cv2 = cv2.cut(*t2); } } - let e1 = mt::Edge::new(a1, b1, cv1); - let e2 = mt::Edge::new(a2, b2, cv2); - let mut surface = fit.surface.clone(); - // Outward: toward the ball centre on a concave edge, away on a convex one. - { - use mt::{ParametricSurface3D, SearchNearestParameter}; - let s = list.get(list.len() / 2).ok_or_else(|| fail("section"))?; - let mid = s.c + ((s.q1 - s.c) + (s.q2 - s.c)).normalized().ok_or_else(|| fail("mid"))? * r; - if let Some((u, v)) = surface.search_nearest_parameter(p3(mid), None, 100) { - let nn = surface.normal(u, v); - let want = (s.c - mid) * sigma; - if Vec3::new(nn.x, nn.y, nn.z).dot(want) < 0.0 { - surface = mt::Invertible::inverse(&surface); - } - } - } - c1.push(e1); - c2.push(e2); - blends.push(surface); + c1.push(Some(mt::Edge::new(a1, b1, cv1))); + c2.push(Some(mt::Edge::new(a2, b2, cv2))); + blends.push(oriented(fit.surface.clone(), list)?); } // Rebuild the side faces: chain edges become contact curves, seams start at contacts. - let mut subst: HashMap = HashMap::new(); + let mut subst: HashMap, Option)> = HashMap::new(); for (k, e) in chain.iter().enumerate() { let (Some(x), Some(y)) = (c1.get(k), c2.get(k)) else { return Err(fail("contact")) }; subst.insert(e.id(), (x.clone(), y.clone())); @@ -1293,7 +1616,8 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, let mut es = Vec::new(); for e in w.edge_iter() { let ne = if let Some((x, y)) = subst.get(&e.id()) { - let base = if on1 { x } else { y }; + // A point along a pivot: the contacts beside it meet there. + let Some(base) = (if on1 { x } else { y }) else { continue }; // Same direction as the chain edge, or reversed. let chain_e = chain.iter().find(|c| c.id() == e.id()).ok_or_else(|| fail("edge"))?; if e.front() == chain_e.front() { base.clone() } else { base.inverse() } @@ -1328,11 +1652,15 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, .map(|e| e.front() == chain_e.front()) .ok_or_else(|| fail("edge use"))?; // Side 1 runs the contact forward (as the chain) when it ran the edge forward. + // A pivot's planar contact is a point: three sides. let wire: mt::Wire = if used_fwd { - vec![e1.inverse(), ak.clone(), e2.clone(), ak1.inverse()].into() + [e1.as_ref().map(|e| e.inverse()), Some(ak.clone()), e2.clone(), Some(ak1.inverse())] } else { - vec![e1.clone(), ak1.clone(), e2.inverse(), ak.inverse()].into() - }; + [e1.clone(), Some(ak1.clone()), e2.as_ref().map(|e| e.inverse()), Some(ak.inverse())] + } + .into_iter() + .flatten() + .collect(); let face = mt::Face::try_new(vec![wire], bf.clone()).map_err(|e| fail(&e.to_string()))?; rec.blend(&face, [chain_e.id()]); out.push(face); @@ -1345,6 +1673,10 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, }) } +#[path = "curvecorner.rs"] +mod corner; +pub(crate) use corner::corner_blend; + #[cfg(test)] #[path = "curveblend_tests.rs"] mod tests; diff --git a/crates/kernel/src/curveblend_tests.rs b/crates/kernel/src/curveblend_tests.rs index 522f94df..bf160624 100644 --- a/crates/kernel/src/curveblend_tests.rs +++ b/crates/kernel/src/curveblend_tests.rs @@ -5,6 +5,7 @@ use mt::{BoundedCurve, ParametricCurve, ParametricSurface, ParametricSurface3D, use super::*; use crate::blend::plane_normal; +use crate::issue_851_tests::planned_volume; use crate::{Body, BoolOp, boolean, cylinder, fillet}; fn tee(scale: f64, origin: Vec3) -> Body { @@ -408,3 +409,239 @@ fn a_smooth_seam_is_refused_as_having_no_corner() { assert!(fillet(&one, &[Vec3::new(20.0, 0.0, 20.0)], 1.0).is_ok()); assert!(matches!(fillet(&one, &[Vec3::new(35.0, 30.0, 10.0)], 1.0), Err(KernelError::Invalid(_)))); } + +/// A 40 × 40 × 20 box whose front wall is an arc of radius 40 about a Z axis: `concave` cuts a +/// cylinder away from the front (the wall curves into the part), otherwise the part is the box +/// within the cylinder (the wall curves out). The arc meets the planar side walls x = 0 and +/// x = 40 at an angle, not square. With the bottom arc's midpoint, the axis's base point and the +/// side of the wall the material is on (+1 away from the axis, −1 toward it). +fn arc_walled_box(concave: bool) -> (Body, Vec3, Vec3, f64) { + let block = crate::box_solid(Vec3::ZERO, Vec3::new(40.0, 40.0, 20.0)).unwrap(); + let (axis, op, side) = + if concave { (Vec3::new(20.0, -30.0, 0.0), BoolOp::Cut, 1.0) } else { (Vec3::new(20.0, 70.0, 0.0), BoolOp::Intersect, -1.0) }; + let tool = cylinder(axis, Vec3::Z, 40.0, 20.0).unwrap(); + let body = boolean(&block, &tool, op).unwrap().unwrap(); + (body, axis + Vec3::new(0.0, side * 40.0, 0.0), axis, side) +} + +/// Volume the blend removes along the bottom arc: the corner's cross-section at each distance +/// ρ from the axis (`depth(x)`, x ∈ [0, size] from the wall into the material), times the arc +/// of radius ρ between the side walls. +fn removed_volume(side: f64, size: f64, depth: impl Fn(f64) -> f64) -> f64 { + let steps = 4000; + let h = size / steps as f64; + (0..=steps) + .map(|i| { + let x = i as f64 * h; + let rho = 40.0 + side * x; + let w = if i == 0 || i == steps { + 1.0 + } else if i % 2 == 1 { + 4.0 + } else { + 2.0 + }; + w * depth(x) * 2.0 * rho * (20.0 / rho).asin() + }) + .sum::() + * h + / 3.0 +} + +#[test] +fn rolling_ball_along_an_arc_ends_in_side_walls_at_an_angle() { + // The bottom edge of an arc wall ends in the side walls at an angle (#556). The round is the + // torus about the cylinder's axis (centre circle of radius 40 ± r at height r), trimmed by + // the side walls; the chamfer is the cone between the two contacts. + let r = 2.0; + for concave in [true, false] { + let (body, mid, axis, side) = arc_walled_box(concave); + let v0 = planned_volume(&body); + for round in [true, false] { + let what = format!("{} {}", if concave { "concave" } else { "convex" }, if round { "fillet" } else { "chamfer" }); + let blend = if round { fillet(&body, &[mid], r) } else { crate::chamfer(&body, &[mid], r) }.unwrap_or_else(|e| panic!("{what}: {e}")); + assert!(blend.validity().is_empty(), "{what}: {:?}", blend.validity()); + let volume = planned_volume(&blend); + let removed = + if round { removed_volume(side, r, |x| r - (r * r - (r - x) * (r - x)).max(0.0).sqrt()) } else { removed_volume(side, r, |x| r - x) }; + assert!((v0 - volume - removed).abs() < removed * 3e-3, "{what}: removed {} vs {removed}", v0 - volume); + // The blend face stays between the side walls and, for the round, on the torus. + let surfaces: Vec<_> = + blend.solid.face_iter().map(|f| f.oriented_surface()).filter(|s| matches!(s, mt::Surface::NurbsSurface(_))).collect(); + assert!(!surfaces.is_empty(), "{what}: no blend face"); + let mesh = blend.tessellate_with(0.0005, true).unwrap(); + let (lo, hi) = mesh.positions.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p.x), hi.max(p.x))); + assert!(lo > -1e-3 && hi < 40.0 + 1e-3, "{what}: the blend runs past the side walls: x {lo}..{hi}"); + if round { + // Sections of the round across the arc: the quarter circle about the centre + // ring (radius 40 ± r about the axis, at height r) from contact to contact. + let ring = 40.0 + side * r; + let mut worst: f64 = 0.0; + for i in 0..=40 { + let x = 40.0 * i as f64 / 40.0; + for a in 1..8 { + let t = std::f64::consts::FRAC_PI_2 * a as f64 / 8.0; + let (rho, z) = (ring - side * r * t.cos(), r - r * t.sin()); + let dx = x - axis.x; + let dy = (rho * rho - dx * dx).sqrt() * if concave { 1.0 } else { -1.0 }; + let p = Vec3::new(x, axis.y + dy, z); + worst = worst.max(nearest_blend(&surfaces, p).0); + } + } + assert!(worst < 1e-3, "{what}: blend surface is {worst} mm off the torus"); + } + } + } +} + +#[test] +fn rolling_ball_past_a_slanted_end_face_is_too_large_and_reduces() { + // On the outward-curving wall a round of 4 would run its bottom contact into the back face + // before it reaches the side wall (the contact circle of radius 36 meets x = 0 at y ≈ 40.07): + // too large, and the largest that fits rounds the edge. + let (body, mid, _, _) = arc_walled_box(false); + let e = fillet(&body, &[mid], 4.0).unwrap_err(); + assert!(matches!(e, KernelError::TooLarge(_)), "{e}"); + let (r, blend) = crate::fillet_max(&body, &[mid], 4.0).unwrap(); + assert!(r > 3.0 && r < 4.0, "{r}"); + assert!(blend.validity().is_empty(), "{:?}", blend.validity()); +} + +#[test] +fn rolling_ball_runs_into_a_knife_edge_tip() { + // The corner of a block outside a cylinder of radius 21 about (21, −21): its wall meets the + // back face y = 0 tangentially at (21, 0) and the left face x = 0 at (0, −21), knife-edge + // tips at both ends of the arc. The round of the bottom arc runs into both, cut off by the + // end faces' planes, which touch the wall contact at the tips without crossing it: at + // distance ρ from the axis it runs from x = 0 to y = 0, ρ (asin(21 / ρ) − acos(21 / ρ)). + let block = crate::box_solid(Vec3::new(0.0, -21.0, 0.0), Vec3::new(21.0, 0.0, 10.0)).unwrap(); + let body = boolean(&block, &cylinder(Vec3::new(21.0, -21.0, -1.0), Vec3::Z, 21.0, 12.0).unwrap(), BoolOp::Cut).unwrap().unwrap(); + let mid = Vec3::new(21.0 - 21.0 * std::f64::consts::FRAC_1_SQRT_2, -21.0 + 21.0 * std::f64::consts::FRAC_1_SQRT_2, 0.0); + let r = 2.0; + let blend = fillet(&body, &[mid], r).unwrap(); + assert!(blend.validity().is_empty(), "{:?}", blend.validity()); + let (n, h) = (4000, r / 4000.0); + let removed = (0..=n) + .map(|i| { + let x = i as f64 * h; + let rho = 21.0 + x; + let w = if i == 0 || i == n { + 1.0 + } else if i % 2 == 1 { + 4.0 + } else { + 2.0 + }; + w * (r - (r * r - (r - x) * (r - x)).max(0.0).sqrt()) * rho * ((21.0 / rho).asin() - (21.0 / rho).acos()) + }) + .sum::() + * h + / 3.0; + let got = planned_volume(&body) - planned_volume(&blend); + assert!((got - removed).abs() < removed * 5e-3, "removed {got} vs {removed}"); +} + +#[test] +fn rolling_ball_turns_about_a_rounded_corner_of_its_radius() { + // The box with the inward-curving wall, its 60° corner at x = 0 rounded (r 2) first. The + // bottom edges then run: the side wall's, the corner round's (an arc of radius 2) and the + // arc wall's into the slanted side wall at x = 40. Rounded again with r 2 the ball can't + // roll round the corner: it turns about one point there, and the blend is a piece of sphere. + // It is what the rolling round tends to as its radius tends to the corner's, too large past + // it, and its mesh is closed. + let (body, _, _, _) = arc_walled_box(true); + let body = fillet(&body, &[Vec3::new(0.0, 4.641016151377546, 10.0)], 2.0).unwrap(); + // The corner round's centre is 4 from the corner along its bisector, (2, 8.105): its arc's + // middle is 2 back toward the corner. + let ends = [Vec3::new(0.0, 25.0, 0.0), Vec3::new(1.0, 6.373, 0.0), Vec3::new(20.0, 10.0, 0.0)]; + let v0 = planned_volume(&body); + let removed = |r: f64| { + let b = fillet(&body, &ends, r).unwrap_or_else(|e| panic!("r {r}: {e}")); + assert!(b.validity().is_empty(), "r {r}: {:?}", b.validity()); + v0 - planned_volume(&b) + }; + let (a, b, at) = (removed(1.96), removed(1.98), removed(2.0)); + let expected = 2.0 * b - a; + assert!((at - expected).abs() < expected * 2e-3, "removed {at} at r 2, {a} and {b} before it"); + let e = fillet(&body, &ends, 2.5).unwrap_err(); + assert!(matches!(e, KernelError::TooLarge(_)), "{e}"); + let (r, _) = crate::fillet_max(&body, &ends, 2.5).unwrap(); + assert!(r > 1.5 && r <= 2.0 + 1e-9, "{r}"); +} + +#[test] +fn rolling_ball_along_a_straight_edge_ends_in_a_curved_face() { + // The box with the inward-curving wall: the bottom edge of the side wall x = 0 runs from the + // back to the curved wall (#556). The round of radius 2 along it is cut off by that wall: + // at d from the side wall it runs from y = −30 + √(40² − (d − 20)²) to the back at y = 40. + let (body, _, _, _) = arc_walled_box(true); + let r = 2.0; + for round in [true, false] { + let blend = + if round { fillet(&body, &[Vec3::new(0.0, 25.0, 0.0)], r) } else { crate::chamfer(&body, &[Vec3::new(0.0, 25.0, 0.0)], r) }.unwrap(); + assert!(blend.validity().is_empty(), "{:?}", blend.validity()); + let (n, h) = (4000, r / 4000.0); + let removed = (0..=n) + .map(|i| { + let d = i as f64 * h; + let w = if i == 0 || i == n { + 1.0 + } else if i % 2 == 1 { + 4.0 + } else { + 2.0 + }; + let depth = if round { r - (r * r - (r - d) * (r - d)).max(0.0).sqrt() } else { r - d }; + w * depth * (40.0 - (-30.0 + (1600.0 - (d - 20.0) * (d - 20.0)).sqrt())) + }) + .sum::() + * h + / 3.0; + let (m0, m1) = (body.tessellate_with(0.0005, true).unwrap(), blend.tessellate_with(0.0005, true).unwrap()); + let got = m0.measure().volume - m1.measure().volume; + assert!((got - removed).abs() < removed * 3e-3, "round {round}: removed {got} vs {removed}"); + let volume_about = |o: Vec3| { + let at = |i: u32| m1.positions[i as usize] - o; + m1.triangles.iter().map(|t| at(t[0]).dot(at(t[1]).cross(at(t[2]))) / 6.0).sum::() + }; + let shift = volume_about(Vec3::ZERO) - volume_about(Vec3::new(-100.0, 50.0, 30.0)); + assert!(shift.abs() < 1e-2, "round {round}: the mesh is not closed (volume moves {shift} with the origin)"); + } +} + +#[test] +fn chamfer_a_chain_of_a_line_and_an_arc_into_a_slanted_end() { + // A 40 × 40 block, 20 high, whose front runs straight for 20 and then along an arc of + // radius 29 about (20, 29), tangent to it, up to (40, 8): the right face meets the arc at an + // angle. The top front chain chamfered by 2 (the planes' and the cone's sections are 2 × 2 + // corners, 2 − w high at w in from the edge): 2 a millimetre along the line, and along the + // arc out to the right face, at each distance ρ from its axis, the height times the arc of + // radius ρ inside x ≤ 40. + use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2}; + let start = -std::f64::consts::FRAC_PI_2; + let end = (-21.0f64).atan2(20.0); + let segs = vec![ + Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(20.0, 0.0) }, + Seg2::Arc { center: Vec2::new(20.0, 29.0), radius: 29.0, start, sweep: end - start }, + Seg2::Line { a: Vec2::new(40.0, 8.0), b: Vec2::new(40.0, 40.0) }, + Seg2::Line { a: Vec2::new(40.0, 40.0), b: Vec2::new(0.0, 40.0) }, + Seg2::Line { a: Vec2::new(0.0, 40.0), b: Vec2::new(0.0, 0.0) }, + ]; + let body = crate::extrude(&Plane::XY, &[Region2 { outer: Loop2 { segs }, holes: vec![] }], 0.0, 20.0).unwrap().pop().unwrap(); + let volume = |b: &Body| b.tessellate_with(0.0005, true).unwrap().measure().volume; + let made = crate::chamfer(&body, &[Vec3::new(10.0, 0.0, 20.0)], 2.0).unwrap(); + let n = 2000; + let h = 2.0 / n as f64; + let arc = (0..=n) + .map(|i| { + let rho = 27.0 + i as f64 * h; + let w = if i == 0 || i == n { 1.0 } else { [2.0, 4.0][i % 2] }; + w * (rho - 27.0) * rho * (20.0 / rho).asin() + }) + .sum::() + * h + / 3.0; + let want = 40.0 + arc; + let got = volume(&body) - volume(&made); + assert!((got - want).abs() < want * 1e-3, "removed {got} vs {want}"); +} diff --git a/crates/kernel/src/curvecorner.rs b/crates/kernel/src/curvecorner.rs new file mode 100644 index 00000000..46b943d0 --- /dev/null +++ b/crates/kernel/src/curvecorner.rs @@ -0,0 +1,1160 @@ +//! Corners where rolling-ball blends meet on faces of any surface (the arc wall of an extruded +//! profile and the edges round it): what [`crate::blend_corner`] does for straight edges between +//! planes, with each blend fitted as in the parent module. +//! +//! Each blended edge's strip (its rolling-ball sections, fitted) runs a little past both ends. +//! Every end vertex has three faces and three edges. There: +//! - one blended edge: the strip ends where the third face cuts it, between where its contacts +//! cross the other two edges; +//! - two: the strips cut each other along a crease, from where their contacts cross on the face +//! they share to the third edge. When one strip's contact +//! reaches further along the third edge, the other's ends on the crease alone and the first +//! runs on beyond it to end on the other's third face; +//! - three (one radius): each strip ends at its section through the ball touching all three +//! faces, and a piece of that ball's sphere fills the corner. + +use std::cell::Cell; +use std::collections::{HashMap, HashSet}; + +use solidcraft_geom::Vec3; +use truck_modeling::{self as mt, builder}; + +use super::{ + Guide, MAX_SAMPLES, SAMPLES, Section, fit_sections, in_proportion, knife_convex, knife_start, nearest_on, project, section_fits, solve_from, + unsupported, +}; +use crate::blend::{too_large, vtx}; +use crate::body::{Solid, from_p3, p3, v3}; +use crate::record::Rebuild; +use crate::{KernelError, Result}; + +fn fail(m: &str) -> KernelError { + KernelError::Failed(format!("blend corner: {m}")) +} + +/// One blended edge's strip: the fitted rolling-ball surface (u across, from the contact with +/// side `fa` at 0 to side `fb`'s at 1; v along the edge from its front, running on past both +/// ends), its contacts (in v) and its sections. +struct Strip { + edge: mt::Edge, + fa: usize, + fb: usize, + surface: mt::Surface, + contacts: [mt::Curve; 2], + /// v of the sections at the edge's front and back vertex. + ends: [f64; 2], + /// +1 when the surface's normal points out of the material, −1 when into it. + flip: f64, + /// −1 for a convex edge (the ball inside the material), +1 for a concave one. + sigma: f64, + /// Sections with their v. + sections: Vec<(f64, Section)>, +} + +impl Strip { + fn row(&self, f: usize) -> Result { + if f == self.fa { + Ok(0) + } else if f == self.fb { + Ok(1) + } else { + Err(fail("a face beside a blend")) + } + } + + fn contact(&self, f: usize) -> Result<&mt::Curve> { + self.contacts.get(self.row(f)?).ok_or_else(|| fail("contact")) + } + + /// u of the contact with face `f`. + fn u(&self, f: usize) -> Result { + self.row(f).map(|i| i as f64) + } + + fn at(&self, u: f64, v: f64) -> Vec3 { + use mt::ParametricSurface; + from_p3(self.surface.subs(u, v)) + } + + /// The edge's own stretch of v. + fn own(&self) -> (f64, f64) { + let [a, b] = self.ends; + (a.min(b), a.max(b)) + } + + /// v of the edge's end at `v`, and +1 when the strip runs on past it toward larger v. + fn end_at(&self, v: &mt::Vertex) -> (f64, f64) { + let [a, b] = self.ends; + if self.edge.front() == v { (a, -1.0) } else { (b, 1.0) } + } + + /// The strip's nearest point to `x` (u and v within it): from the section whose ball is + /// nearest, by Gauss-Newton steps kept within the strip. + fn nearest(&self, x: Vec3) -> Option<(f64, f64)> { + use mt::ParametricSurface; + let (v0, s0) = self.sections.iter().min_by(|a, b| a.1.c.dist(x).total_cmp(&b.1.c.dist(x)))?; + let (a, b, w) = (s0.q1 - s0.c, s0.q2 - s0.c, x - s0.c); + let angle = |p: Vec3, q: Vec3| (p.dot(q) / (p.len() * q.len()).max(1e-300)).clamp(-1.0, 1.0).acos(); + let total = angle(a, b); + let mut uv = ((angle(a, w) / total.max(1e-12)).clamp(0.0, 1.0), *v0); + let dist = |uv: (f64, f64)| from_p3(self.surface.subs(uv.0, uv.1)).dist(x); + let mut d = dist(uv); + for _ in 0..50 { + let p = from_p3(self.surface.subs(uv.0, uv.1)); + let (su, sv) = (self.surface.uder(uv.0, uv.1), self.surface.vder(uv.0, uv.1)); + let (su, sv) = (Vec3::new(su.x, su.y, su.z), Vec3::new(sv.x, sv.y, sv.z)); + let r = x - p; + let (a11, a12, a22) = (su.dot(su), su.dot(sv), sv.dot(sv)); + let (b1, b2) = (su.dot(r), sv.dot(r)); + let det = a11 * a22 - a12 * a12; + if !(det.abs() > 1e-300) { + break; + } + let (mut du, mut dv) = ((b1 * a22 - b2 * a12) / det, (a11 * b2 - a12 * b1) / det); + let mut moved = false; + for _ in 0..30 { + let next = ((uv.0 + du).clamp(0.0, 1.0), (uv.1 + dv).clamp(0.0, 1.0)); + let dn = dist(next); + if dn <= d { + moved = (next.0 - uv.0).abs() + (next.1 - uv.1).abs() > 1e-15; + uv = next; + d = dn; + break; + } + du *= 0.5; + dv *= 0.5; + } + if !moved { + break; + } + } + Some(uv) + } +} + +/// Signed distance from a surface, positive out of the material; each search starts where the +/// last ended (the points asked about come in small steps). A strip's is measured to its +/// nearest point within it: past a contact, to the contact along the face there. +enum Away<'a> { + Face(Box<(mt::Surface, Cell>)>), + Strip(&'a Strip), +} + +impl<'a> Away<'a> { + fn face(f: &mt::Face) -> Self { + Away::Face(Box::new((f.oriented_surface(), Cell::new(None)))) + } + + fn strip(s: &'a Strip) -> Self { + Away::Strip(s) + } + + fn at(&self, x: Vec3) -> Option { + use mt::{ParametricSurface, ParametricSurface3D, SearchNearestParameter}; + match self { + Away::Face(face) if matches!(face.0, mt::Surface::Plane(_)) => nearest_on(&face.0, &face.1, x).map(|(q, n, _)| (x - q).dot(n)), + Away::Face(face) => { + let (surface, last) = (&face.0, &face.1); + // The nearest of the search from the last answer, a fresh one and either kept + // within the surface's parameters: past them a conic's patch runs on round to + // its far side, where a search can settle. + let (ru, rv) = surface.try_range_tuple(); + let ((u0, u1), (v0, v1)) = (ru.unwrap_or((f64::NEG_INFINITY, f64::INFINITY)), rv.unwrap_or((f64::NEG_INFINITY, f64::INFINITY))); + let inside = |uv: (f64, f64)| uv.0 >= u0 - 1e-9 && uv.0 <= u1 + 1e-9 && uv.1 >= v0 - 1e-9 && uv.1 <= v1 + 1e-9; + let warm = last.get().and_then(|h| surface.search_nearest_parameter(p3(x), Some(h), 20)); + let mut found: Vec<(f64, f64)> = warm.into_iter().collect(); + if !warm.is_some_and(inside) { + found.extend(surface.search_nearest_parameter(p3(x), None, 100)); + } + let clamped: Vec<(f64, f64)> = found.iter().map(|(u, v)| (u.clamp(u0, u1), v.clamp(v0, v1))).collect(); + found.extend(clamped); + let d = |uv: &(f64, f64)| from_p3(surface.subs(uv.0, uv.1)).dist(x); + let uv = found.into_iter().filter(|uv| d(uv).is_finite()).min_by(|a, b| d(a).total_cmp(&d(b)))?; + last.set(Some(uv)); + let q = from_p3(surface.subs(uv.0, uv.1)); + let n = surface.normal(uv.0, uv.1); + Some((x - q).dot(Vec3::new(n.x, n.y, n.z).normalized()?)) + } + Away::Strip(s) => { + let (u, v) = s.nearest(x)?; + let q = from_p3(s.surface.subs(u, v)); + let n = s.surface.normal(u, v); + Some((x - q).dot(Vec3::new(n.x, n.y, n.z).normalized()?) * s.flip) + } + } + } +} + +/// Unit direction along a curve at `t`. +fn tangent(c: &mt::Curve, t: f64) -> Option { + use mt::ParametricCurve; + let d = c.der(t); + Vec3::new(d.x, d.y, d.z).normalized() +} + +/// Nearest parameter on `c` to `x`, started from the nearest of a few samples. +/// The radius of edge `e` seen along the unit `axis`: its points, moved along the axis into one +/// plane, all on one circle (within `tol`). +fn radius_along(e: &mt::Edge, axis: Vec3, tol: f64) -> Option { + use mt::{BoundedCurve, ParametricCurve}; + let curve = e.curve(); + let (t0, t1) = curve.range_tuple(); + let at = |f: f64| { + let p = from_p3(curve.subs(t0 + (t1 - t0) * f)); + p - axis * axis.dot(p) + }; + let (a, u, v) = (at(0.0), at(0.5) - at(0.0), at(1.0) - at(0.0)); + let w = u.cross(v); + if w.len2() < 1e-24 || w.normalized()?.dot(axis).abs() < 1.0 - 1e-6 { + return None; + } + let o = a + (v * u.len2() - u * v.len2()).cross(w) * (1.0 / (2.0 * w.len2())); + let rho = o.dist(a); + (0..=16).map(|i| at(i as f64 / 16.0)).all(|p| (p.dist(o) - rho).abs() <= tol).then_some(rho) +} + +fn param_on(c: &mt::Curve, x: Vec3) -> Option { + use mt::{BoundedCurve, ParametricCurve, SearchNearestParameter}; + let (lo, hi) = c.range_tuple(); + let d = |t: f64| from_p3(c.subs(t)).dist(x); + let hint = (0..=64).map(|i| lo + (hi - lo) * i as f64 / 64.0).min_by(|a, b| d(*a).total_cmp(&d(*b)))?; + let t = c.search_nearest_parameter(p3(x), hint, 100).unwrap_or(hint).clamp(lo, hi); + Some(if d(t) <= d(hint) { t } else { hint }) +} + +/// Where curve `a` crosses curve `b` on face `f`, both lying in it: its parameter on `a` and +/// the point. Of several, the one within `own` (a range of `a`'s parameter) nearest `near`. +fn cross(a: &mt::Curve, b: &mt::Curve, f: &mt::Face, near: Vec3, own: (f64, f64), tol: f64) -> Option<(f64, Vec3)> { + use mt::{BoundedCurve, ParametricCurve, SearchNearestParameter}; + let (a0, a1) = a.range_tuple(); + let (b0, b1) = b.range_tuple(); + let surface = f.oriented_surface(); + let last = Cell::new(None); + let hint = Cell::new(None::); + // Signed distance across `b` in the face, and how far `b`'s nearest point is. + // `b` sampled once (an intersection curve is slow to evaluate). + let along_b: Vec<(f64, Vec3)> = (0..=64).map(|i| b0 + (b1 - b0) * i as f64 / 64.0).map(|t| (t, from_p3(b.subs(t)))).collect(); + let lateral = |s: f64| -> Option<(f64, f64)> { + let x = from_p3(a.subs(s)); + // From the last answer when that is still nearest, else from the nearest sample of `b` + // (a search from the far end of a short arc can stay there). + let d = |t: f64| from_p3(b.subs(t)).dist(x); + let warm = hint.get().map(|h| b.search_nearest_parameter(p3(x), h, 50).unwrap_or(h).clamp(b0, b1)); + let (start, ds) = along_b.iter().map(|(t, p)| (*t, p.dist(x))).min_by(|p, q| p.1.total_cmp(&q.1))?; + let t = match warm { + Some(w) if d(w) <= ds => w, + _ => { + let cold = b.search_nearest_parameter(p3(x), start, 50).unwrap_or(start).clamp(b0, b1); + match warm { + Some(w) if d(w) <= d(cold).min(ds) => w, + _ if d(cold) <= ds => cold, + _ => start, + } + } + }; + hint.set(Some(t)); + let y = from_p3(b.subs(t)); + let (_, n, _) = nearest_on(&surface, &last, y)?; + let m = n.cross(tangent(b, t)?).normalized()?; + Some(((x - y).dot(m), x.dist(y))) + }; + let steps = 256; + let samples: Vec<(f64, Option<(f64, f64)>)> = (0..=steps) + .map(|i| { + let s = a0 + (a1 - a0) * i as f64 / steps as f64; + (s, lateral(s)) + }) + .collect(); + // Only where `a` comes within a sample's step of `b` (elsewhere the side flips only round + // an end of `b`). + let step = samples.windows(2).map(|w| from_p3(a.subs(w[1].0)).dist(from_p3(a.subs(w[0].0)))).fold(0.0, f64::max); + let close = |d: f64| d <= 2.0 * step + tol; + let mut found: Vec<(f64, Vec3)> = Vec::new(); + for w in samples.windows(2) { + let [(s0, Some((h0, d0))), (s1, Some((h1, d1)))] = w else { continue }; + if h1.abs() <= tol && *d1 <= tol { + found.push((*s1, from_p3(a.subs(*s1)))); + continue; + } + if (*h0 < 0.0) == (*h1 < 0.0) || !(close(*d0) || close(*d1)) { + continue; + } + let (mut lo, mut hi, glo) = (*s0, *s1, *h0); + hint.set(None); + for _ in 0..60 { + let m = (lo + hi) * 0.5; + let Some((hm, _)) = lateral(m) else { break }; + if (hm < 0.0) == (glo < 0.0) { + lo = m; + } else { + hi = m; + } + } + let s = (lo + hi) * 0.5; + hint.set(None); + if lateral(s).is_some_and(|(_, d)| d <= tol) { + found.push((s, from_p3(a.subs(s)))); + } + } + if let Some((s, Some((h, d)))) = samples.first() + && h.abs() <= tol + && *d <= tol + { + found.push((*s, from_p3(a.subs(*s)))); + } + // Touching without crossing: through an end of `b` (an edge the blend uses up to its far + // vertex), where the side `b` is measured from turns over. Where the distance comes down + // to nothing. + for w in samples.windows(3) { + let [(s0, Some((_, d0))), (_, Some((_, d1))), (s2, Some((_, d2)))] = w else { continue }; + if !(d1 <= d0 && d1 <= d2 && close(*d1)) { + continue; + } + let gap = |s: f64| { + hint.set(None); + lateral(s).map_or(f64::INFINITY, |x| x.1) + }; + let (mut lo, mut hi) = (*s0, *s2); + for _ in 0..80 { + let (x, y) = (lo + (hi - lo) * 0.381_966, lo + (hi - lo) * 0.618_034); + if gap(x) < gap(y) { + hi = y; + } else { + lo = x; + } + } + let s = (lo + hi) * 0.5; + if gap(s) <= tol && !found.iter().any(|f| (f.0 - s).abs() <= (a1 - a0) * 1e-6) { + found.push((s, from_p3(a.subs(s)))); + } + } + let slack = (a1 - a0) * 1e-6; + let outside = |s: f64| s < own.0 - slack || s > own.1 + slack; + found.into_iter().min_by(|x, y| outside(x.0).cmp(&outside(y.0)).then(x.1.dist(near).total_cmp(&y.1.dist(near)))) +} + +/// Where curve `c` crosses `side`'s surface: its parameter and the point; of several, the one +/// nearest `near`. +fn reach(c: &mt::Curve, side: &Away, near: Vec3, tol: f64) -> Option<(f64, Vec3)> { + use mt::{BoundedCurve, ParametricCurve}; + let (lo, hi) = c.range_tuple(); + let g = |s: f64| side.at(from_p3(c.subs(s))).filter(|x| x.is_finite()); + let steps = 256; + let samples: Vec<(f64, Option)> = (0..=steps) + .map(|i| { + let s = lo + (hi - lo) * i as f64 / steps as f64; + (s, g(s)) + }) + .collect(); + let mut found: Vec<(f64, Vec3)> = Vec::new(); + for w in samples.windows(2) { + let [(s0, Some(g0)), (s1, Some(g1))] = w else { continue }; + if (*g0 < 0.0) == (*g1 < 0.0) { + continue; + } + let (mut a, mut b, ga) = (*s0, *s1, *g0); + for _ in 0..60 { + let m = (a + b) * 0.5; + let Some(gm) = g(m) else { break }; + if (gm < 0.0) == (ga < 0.0) { + a = m; + } else { + b = m; + } + } + let s = (a + b) * 0.5; + if g(s).is_some_and(|x| x.abs() <= tol) { + found.push((s, from_p3(c.subs(s)))); + } + } + found.into_iter().min_by(|x, y| x.1.dist(near).total_cmp(&y.1.dist(near))) +} + +/// The curve on strip `s` from `a` to `b` (each u, v and point) where `side` changes sign (it is +/// negative in the material kept): for each u between, the v where it does nearest a guess +/// between the ends, stepping toward `out` (+1: larger v) while inside. Interpolated by chord +/// length through u spaced closer toward the ends; between its points it stays on the strip and +/// on the surface. +fn trace(s: &Strip, a: (f64, f64, Vec3), b: (f64, f64, Vec3), side: &Away, out: f64, tolerance: f64) -> Result { + use mt::ParametricCurve; + let beyond = || too_large("the blend is larger than the neighbouring faces"); + let (ua, va, pa) = a; + let (ub, vb, pb) = b; + let f = |u: f64, v: f64| side.at(s.at(u, v)).unwrap_or(f64::NAN); + let root = |frac: f64| -> Option<(f64, f64)> { + let u = ua + (ub - ua) * frac; + let guess = (va + (vb - va) * frac).clamp(0.0, 1.0); + // Steps in proportion to the distance from the nearer end, where the curve is known: a + // face tangent to a contact there (a knife-edge tip) crosses the strip only within a + // window that narrows toward it. + let step = (vb - va).abs().max(1e-3) * frac.min(1.0 - frac).clamp(1e-6, 0.5) / 8.0; + let g = f(u, guess); + if !g.is_finite() { + return None; + } + let dir = if g < 0.0 { out } else { -out }; + let mut prev = (guess, g); + for i in 1..=4096 { + let v = (guess + dir * step * i as f64).clamp(0.0, 1.0); + let cur = (v, f(u, v)); + if !cur.1.is_finite() { + return None; + } + if (prev.1 < 0.0) != (cur.1 < 0.0) { + let (mut x, mut y) = (prev, cur); + for _ in 0..60 { + let m = (x.0 + y.0) * 0.5; + let fm = f(u, m); + if (fm < 0.0) == (x.1 < 0.0) { + x = (m, fm); + } else { + y = (m, fm); + } + } + return Some((u, (x.0 + y.0) * 0.5)); + } + if v <= 0.0 || v >= 1.0 { + return None; + } + prev = cur; + } + None + }; + let across = |t: f64| (1.0 - (std::f64::consts::PI * t).cos()) * 0.5; + let mut m = 8; + loop { + let mut pts = vec![pa]; + for i in 1..m { + let (u, v) = root(across(i as f64 / m as f64)).ok_or_else(beyond)?; + pts.push(s.at(u, v)); + } + pts.push(pb); + let (curve, params) = crate::build::interpolate_cubic_chordal(&pts).ok_or_else(|| fail("end curve"))?; + let mut accurate = true; + for (i, w) in params.windows(2).enumerate() { + let (Some(t0), Some(t1)) = (w.first(), w.get(1)) else { continue }; + root(across((i as f64 + 0.5) / m as f64)).ok_or_else(beyond)?; + let x = from_p3(curve.subs((t0 + t1) * 0.5)); + let on = s.nearest(x).map(|(a, b)| s.at(a, b).dist(x)); + let off = side.at(x).map(f64::abs); + if !(on.is_some_and(|d| d <= tolerance) && off.is_some_and(|d| d <= tolerance)) { + accurate = false; + break; + } + } + if accurate { + return Ok(mt::Curve::BSplineCurve(curve)); + } + if m >= 256 { + return Err(fail("could not fit where the blends meet")); + } + m *= 2; + } +} + +/// Convex (−1) or concave (+1): from the edge's direction as its first face runs it and the +/// faces' outward normals at its middle. At a knife edge (normals opposite), also the +/// direction both faces run on from it. +fn convexity(faces: &[mt::Face], e: &mt::Edge, fa: usize, fb: usize, r: f64) -> Result<(f64, Option)> { + use mt::{BoundedCurve, ParametricCurve}; + let (Some(f1), Some(f2)) = (faces.get(fa), faces.get(fb)) else { return Err(fail("face")) }; + let used = f1.boundary_iters().into_iter().flatten().find(|x| x.id() == e.id()).ok_or_else(|| fail("edge use"))?; + let c = used.oriented_curve(); + let (a, b) = c.range_tuple(); + let pm = from_p3(c.subs((a + b) * 0.5)); + let t = tangent(&c, (a + b) * 0.5).ok_or_else(|| fail("tangent"))?; + let (Some(n1), Some(n2)) = (project(f1, pm, None).map(|x| x.1), project(f2, pm, None).map(|x| x.1)) else { return Err(fail("normals")) }; + if n1.dot(n2) > 1.0 - 1e-6 { + return Err(unsupported("blending a smooth edge")); + } + if n1.dot(n2) < -1.0 + 1e-6 { + let into = n1.cross(t).normalized().ok_or_else(|| fail("normals"))?; + return match knife_convex(f1, f2, pm, into, r) { + Some(true) => Ok((-1.0, Some(into))), + _ => Err(unsupported("blending a knife edge inside the material (a crack)")), + }; + } + Ok((if t.dot(n1.cross(n2)) > 0.0 { -1.0 } else { 1.0 }, None)) +} + +/// The strip of edge `e` between faces `fa` and `fb`, running on past each end as far as the +/// ball still fits, up to `reach`. +#[allow(clippy::too_many_arguments)] +fn strip( + faces: &[mt::Face], + e: &mt::Edge, + (fa, fb): (usize, usize), + r: f64, + round: bool, + (sigma, knife): (f64, Option), + reach: f64, + tol: f64, + fit_tol: f64, +) -> Result { + use mt::{BoundedCurve, ParametricCurve, ParametricSurface3D, SearchNearestParameter}; + let (Some(f1), Some(f2)) = (faces.get(fa), faces.get(fb)) else { return Err(fail("face")) }; + let (o1, o2) = ([f1], [f2]); + let curve = e.curve(); + let (t0, t1) = curve.range_tuple(); + let at = |s: f64| -> Option<(Vec3, Vec3)> { + let t = t0 + (t1 - t0) * s; + Some((from_p3(curve.subs(t)), tangent(&curve, t)?)) + }; + // At a knife edge each ball starts where the last one was, beside it (the first where the + // faces are a diameter apart): the edge itself is no start. + let offset: Cell> = Cell::new(None); + let ball = |p: Vec3, t: Vec3| -> Option
{ + let start = match (knife, offset.get()) { + (None, _) => None, + (Some(_), Some(o)) => Some(p + o), + (Some(into), None) => Some(knife_start(&o1, &o2, p, t, into, r)?), + }; + let s = solve_from(&o1, &o2, p, t, r, sigma, 0.0, None, tol * 100.0, start)?; + if knife.is_some() { + offset.set(Some(s.c - p)); + } + Some(s) + }; + // Along the edge, then on along its curve past its ends (a line runs straight on, a conic's + // arc round its circle) while the ball fits. + let mut raw: Vec
= Vec::new(); + let (p0, _) = at(0.0).ok_or_else(|| fail("tangent"))?; + let n = 32; + let mut along = Vec::with_capacity(n + 1); + for i in 0..=n { + let (p, t) = at(i as f64 / n as f64).ok_or_else(|| fail("tangent"))?; + along.push(ball(p, t).ok_or_else(|| unsupported("the ball doesn't fit along the edge"))?); + } + let length = (0..n).filter_map(|i| Some(at(i as f64 / n as f64)?.0.dist(at((i + 1) as f64 / n as f64)?.0))).sum::(); + if !(length.is_finite() && length > tol) { + return Err(fail("edge length")); + } + let past = |sign: f64| -> Vec
{ + let mut list = Vec::new(); + for k in 1..=16 { + let s = (sign * reach * k as f64 / 16.0) / length; + match at(if sign < 0.0 { s } else { 1.0 + s }).filter(|(p, t)| p.is_finite() && t.is_finite()).and_then(|(p, t)| ball(p, t)) { + Some(x) => list.push(x), + None => break, + } + } + list + }; + let after = past(1.0); + offset.set(along.first().map(|s| s.c - p0)); + let before = past(-1.0); + raw.extend(before.iter().rev().copied()); + let first = raw.len(); + raw.extend(along); + let last = raw.len() - 1; + raw.extend(after); + let guide = Guide::new(raw)?; + let total = guide.lengths.last().copied().ok_or_else(|| fail("spine"))?; + let ends = [ + guide.lengths.get(first).copied().ok_or_else(|| fail("spine"))? / total, + guide.lengths.get(last).copied().ok_or_else(|| fail("spine"))? / total, + ]; + let uniform = |along: f64| -> Result
{ + if along <= 0.0 { + return guide.sections.first().copied().ok_or_else(|| fail("section")); + } + if along >= 1.0 { + return guide.sections.last().copied().ok_or_else(|| fail("section")); + } + let (anchor, t) = guide.anchor(along).ok_or_else(|| unsupported("degenerate rolling-ball spine"))?; + solve_from(&o1, &o2, anchor, t, r, sigma, 0.0, None, tol * 100.0, None) + .ok_or_else(|| unsupported("the ball doesn't fit along the resampled spine")) + }; + let mut samples = SAMPLES; + let (list, fit) = loop { + let list: Vec
= (0..samples).map(|j| uniform(j as f64 / (samples - 1) as f64)).collect::>()?; + let fit = crate::guard("fit corner blend", || fit_sections(std::slice::from_ref(&list), 0, false, round))?; + let mut accurate = true; + 'check: for j in 0..samples - 1 { + for fraction in [0.25, 0.5, 0.75] { + let along = (j as f64 + fraction) / (samples - 1) as f64; + if !section_fits(&fit, &uniform(along)?, along, round, sigma, fit_tol) { + accurate = false; + break 'check; + } + } + } + if accurate { + break (list, fit); + } + if samples >= MAX_SAMPLES { + return Err(fail("could not fit the rolling-ball surface")); + } + samples = (2 * samples - 1).min(MAX_SAMPLES); + }; + // Outward: toward the ball centre on a concave edge, away on a convex one. + let mid = list.get(list.len() / 2).ok_or_else(|| fail("section"))?; + let m = mid.c + ((mid.q1 - mid.c) + (mid.q2 - mid.c)).normalized().ok_or_else(|| fail("section"))? * r; + let (u, v) = fit.surface.search_nearest_parameter(p3(m), Some((0.5, 0.5)), 100).ok_or_else(|| fail("orientation"))?; + let nn = fit.surface.normal(u, v); + let flip = if Vec3::new(nn.x, nn.y, nn.z).dot((mid.c - m) * sigma) < 0.0 { -1.0 } else { 1.0 }; + let sections = list.iter().enumerate().map(|(j, s)| (j as f64 / (samples - 1) as f64, *s)).collect(); + let [c1, c2] = fit.contacts; + Ok(Strip { edge: e.clone(), fa, fb, surface: fit.surface, contacts: [c1, c2], ends, flip, sigma, sections }) +} + +/// The point a radius from all three faces (inside the material for a convex corner), from +/// `start`, and its contacts with them. +fn corner_ball(faces: [&mt::Face; 3], start: Vec3, r: f64, sigma: f64, tol: f64) -> Option<(Vec3, [Vec3; 3])> { + let mut c = start; + for _ in 0..100 { + let mut rows = [[0.0; 3]; 3]; + let mut rhs = [0.0; 3]; + let mut qs = [Vec3::ZERO; 3]; + let mut worst: f64 = 0.0; + for (((f, row), b), q) in faces.iter().zip(rows.iter_mut()).zip(rhs.iter_mut()).zip(qs.iter_mut()) { + let (x, n, _) = project(f, c, None)?; + let g = (c - x).dot(n) - sigma * r; + *row = [n.x, n.y, n.z]; + *b = -g; + *q = x; + worst = worst.max(g.abs()); + } + let d = crate::polyhedron::solve3_pub(rows, rhs)?; + let dc = Vec3::new(d[0], d[1], d[2]); + if worst < tol && dc.len() < tol { + return Some((c, qs)); + } + c += dc; + if !c.is_finite() { + return None; + } + } + None +} + +/// Flip a surface so that its normal near `at` points along `outward`. +fn orient(mut surf: mt::Surface, at: Vec3, outward: Vec3) -> mt::Surface { + use mt::{ParametricSurface3D, SearchNearestParameter}; + if let Some((u, v)) = surf.search_nearest_parameter(p3(at), None, 100) { + let n = surf.normal(u, v); + if Vec3::new(n.x, n.y, n.z).dot(outward) < 0.0 { + surf = mt::Invertible::inverse(&surf); + } + } + surf +} + +/// Where a blend's contact ends at a vertex where it ends alone: crossing the edge there (its +/// parameter and the point), or past that edge's far vertex `w`, crossing the next edge `g` of +/// its face there (`h`: the third edge at `w`, between the faces `f4` beyond and the vertex's +/// third face). +enum Reached { + Edge(f64, Vec3), + Past { t: f64, p: Vec3, w: mt::Vertex, g: mt::EdgeID, h: mt::EdgeID, f4: usize }, +} + +/// A face of the shell being rebuilt, a strip, or a corner's sphere. +#[derive(Clone, Copy, PartialEq)] +enum Owner { + Face(usize), + Strip(mt::EdgeID), + Patch, +} + +/// Edges from `from` to `to` through the pool edges of `owner`. +fn connect(from: &mt::Vertex, to: &mt::Vertex, pool: &[(mt::Edge, Vec)], owner: Owner) -> Result> { + let mut out = Vec::new(); + let mut used: HashSet = HashSet::new(); + let mut cur = from.clone(); + for _ in 0..=pool.len() { + if &cur == to { + return Ok(out); + } + let next = pool.iter().filter(|(e, o)| o.contains(&owner) && !used.contains(&e.id())).find_map(|(e, _)| { + if e.front() == &cur { + Some(e.clone()) + } else if e.back() == &cur { + Some(e.inverse()) + } else { + None + } + }); + let Some(e) = next else { break }; + used.insert(e.id()); + cur = e.back().clone(); + out.push(e); + } + Err(fail("a boundary does not close")) +} + +/// Blend the edges `ids` of `solid` with radius `r` (a chamfer's distance), rebuilding the +/// corners at their ends; with what it built from `solid`. +pub(crate) fn corner_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Result<(Solid, Rebuild)> { + if !(size.is_finite() && size > 0.0 && r.is_finite() && r > 0.0) { + return Err(KernelError::Invalid("blend size and radius must be finite and positive".into())); + } + if ids.is_empty() || ids.len() > 64 { + return Err(KernelError::Invalid("select 1…64 edges".into())); + } + let tol = (size * 1e-7).max(1e-9); + let fit_tol = (size * 1e-5).clamp(1e-5, 0.001); + let shells = solid.boundaries(); + let si = shells.iter().position(|sh| sh.edge_iter().any(|e| ids.contains(&e.id()))).ok_or_else(|| fail("edge not found"))?; + let shell = shells.get(si).ok_or_else(|| fail("shell"))?; + if ids.iter().any(|id| !shell.edge_iter().any(|e| e.id() == *id)) { + return Err(unsupported("blends on separate lumps in one go")); + } + let faces: Vec = shell.face_iter().cloned().collect(); + let edges: HashMap = shell.edge_iter().map(|e| (e.id(), e.absolute_clone())).collect(); + let mut on_edge: HashMap> = HashMap::new(); + let mut at_vertex: HashMap, Vec)> = HashMap::new(); + for (fi, f) in faces.iter().enumerate() { + for e in f.edge_iter() { + let list = on_edge.entry(e.id()).or_default(); + if !list.contains(&fi) { + list.push(fi); + } + for v in [e.front(), e.back()] { + let a = at_vertex.entry(v.id()).or_default(); + a.0.insert(e.id()); + if !a.1.contains(&fi) { + a.1.push(fi); + } + } + } + } + let face_with = |a: mt::EdgeID, b: mt::EdgeID| -> Result { + let fa = on_edge.get(&a).cloned().unwrap_or_default(); + let fb = on_edge.get(&b).cloned().unwrap_or_default(); + fa.into_iter().find(|f| fb.contains(f)).ok_or_else(|| fail("faces")) + }; + let face = |fi: usize| faces.get(fi).ok_or_else(|| fail("face")); + + // The vertices at the blended edges' ends: three faces, three edges. + let mut touched: HashMap = HashMap::new(); + let mut sides: HashMap)> = HashMap::new(); + for id in ids { + let e = edges.get(id).ok_or_else(|| fail("edge"))?; + if e.front() == e.back() { + return Err(unsupported("blending a closed edge at a corner")); + } + let fs = on_edge.get(id).cloned().unwrap_or_default(); + let [fa, fb] = fs[..] else { return Err(unsupported("the edge is not between two faces")) }; + let (sigma, knife) = convexity(&faces, e, fa, fb, r)?; + sides.insert(*id, (fa, fb, sigma, knife)); + touched.insert(e.front().id(), e.front().clone()); + touched.insert(e.back().id(), e.back().clone()); + } + for vid in touched.keys() { + let (eids, fids) = at_vertex.get(vid).ok_or_else(|| fail("vertex"))?; + if eids.len() != 3 || fids.len() != 3 { + return Err(unsupported("blends ending where more than three edges meet")); + } + let chosen: Vec<&mt::EdgeID> = eids.iter().filter(|id| sides.contains_key(id)).collect(); + if chosen.len() >= 2 && chosen.iter().any(|id| sides.get(*id).is_none_or(|s| s.2 > 0.0)) { + return Err(unsupported("concave blends meeting at a corner")); + } + if chosen.len() == 3 && !round { + return Err(unsupported("three chamfers at one corner")); + } + // A blended edge running on smoothly into one that isn't: the blend follows it there + // (a chain round the corner, blended elsewhere), unless it is an earlier round's arc of + // this radius, which the blend ends in. Round a smaller one the ball can't. + let v = touched.get(vid).ok_or_else(|| fail("vertex"))?; + let leaves = |e: &mt::Edge| { + use mt::BoundedCurve; + let c = e.curve(); + let (t0, t1) = c.range_tuple(); + tangent(&c, if e.front() == v { t0 } else { t1 }).map(|d| if e.front() == v { d } else { -d }) + }; + for id in &chosen { + let Some(d) = edges.get(*id).and_then(leaves) else { continue }; + for g in eids.iter().filter(|g| !sides.contains_key(*g)) { + let Some(e) = edges.get(g) else { continue }; + if !leaves(e).is_some_and(|x| x.dot(d) < -1.0 + 1e-6) { + continue; + } + // The earlier round's end may also be where it meets a curved face: a circle only + // seen along the round's axis, which its contact with the third face runs along. + let third = eids.iter().find(|x| *x != *id && *x != g).and_then(|x| edges.get(x)).and_then(leaves); + let seen = super::circle_of(e, tol * 1e3).map(|c| c.1).or_else(|| third.and_then(|a| radius_along(e, a, tol * 1e3))); + match seen { + Some(rho) if (rho - r).abs() <= tol * 1e3 => {} + Some(rho) if rho < r => return Err(too_large("the blend is larger than a corner radius")), + _ => return Err(unsupported("a blend running on smoothly into an edge not blended")), + } + } + } + } + + crate::guard("blend corners", || { + // The strips. + let mut strips: HashMap = HashMap::new(); + for id in ids { + let (Some(e), Some((fa, fb, sigma, knife))) = (edges.get(id), sides.get(id).copied()) else { return Err(fail("edge")) }; + strips.insert(*id, strip(&faces, e, (fa, fb), r, round, (sigma, knife), 8.0 * r, tol, fit_tol)?); + } + let strip_of = |id: &mt::EdgeID| strips.get(id).ok_or_else(|| fail("strip")); + // New end points: (edge, vertex, the face of a blended edge's contact). + let mut endpt: HashMap<(mt::EdgeID, mt::VertexID, Option), mt::Vertex> = HashMap::new(); + let mut pool: HashMap)>> = HashMap::new(); + let mut patches: Vec<(mt::Face, Vec)> = Vec::new(); + // Edges a blend uses up to their far vertex (an earlier round's edge, when the radii + // are equal): left out, the blend ends at that vertex. + let mut consumed: HashSet = HashSet::new(); + // Vertices a blend takes in (an earlier round's corner past the end of its edge). + let mut gone: HashMap = HashMap::new(); + // In a fixed order (by position): the work and the result don't depend on hashing. + let mut order_v: Vec<(&mt::VertexID, &mt::Vertex)> = touched.iter().collect(); + order_v.sort_by(|a, b| { + let (p, q) = (vtx(a.1), vtx(b.1)); + p.x.total_cmp(&q.x).then(p.y.total_cmp(&q.y)).then(p.z.total_cmp(&q.z)) + }); + for (vid, vert) in order_v { + let v = vtx(vert); + let (eids, _) = at_vertex.get(vid).ok_or_else(|| fail("vertex"))?; + let mut chosen: Vec = eids.iter().filter(|id| sides.contains_key(id)).copied().collect(); + let rest: Vec = eids.iter().filter(|id| !sides.contains_key(id)).copied().collect(); + chosen.sort_by_key(|id| ids.iter().position(|x| x == id)); + let curve_of = |id: &mt::EdgeID| edges.get(id).map(|e| e.curve()).ok_or_else(|| fail("edge")); + // The new end on edge `id` at `p`: its far vertex when the blend reaches it. + // A contact (`along`: the curves arriving there, and where) that runs into that + // vertex along another of its edges would leave a face no wider than nothing: refused. + let at_end = |id: &mt::EdgeID, p: Vec3, along: &[(&mt::Curve, f64, usize)]| -> Result<(mt::Vertex, bool)> { + let far = edges.get(id).map(|e| if e.front().id() == *vid { e.back().clone() } else { e.front().clone() }); + let Some(w) = far.filter(|w| vtx(w).dist(p) <= fit_tol * 10.0 && !touched.contains_key(&w.id())) else { + return Ok((builder::vertex(p3(p)), false)); + }; + let others = at_vertex.get(&w.id()).map(|x| x.0.iter().filter(|g| *g != id).copied().collect::>()).unwrap_or_default(); + for g in others { + use mt::BoundedCurve; + let Some(e) = edges.get(&g) else { continue }; + let c = e.curve(); + let (t0, t1) = c.range_tuple(); + let leaves = tangent(&c, if e.front() == &w { t0 } else { t1 }); + let of = on_edge.get(&g).cloned().unwrap_or_default(); + if along + .iter() + .filter(|(_, _, f)| of.contains(f)) + .filter_map(|(c, t, _)| tangent(c, *t)) + .any(|d| leaves.is_some_and(|l| l.dot(d).abs() > 1.0 - 1e-6)) + { + return Err(unsupported("a blend that runs along an edge where it ends")); + } + } + Ok((w, true)) + }; + let mut ending = |id: &mt::EdgeID, p: Vec3, along: &[(&mt::Curve, f64, usize)]| -> Result { + let (w, used) = at_end(id, p, along)?; + if used { + consumed.insert(*id); + } + Ok(w) + }; + let list = pool.entry(*vid).or_default(); + match (&chosen[..], &rest[..]) { + ([e1], [e2, e3]) => { + let s1 = strip_of(e1)?; + let (f12, f31, f23) = (face_with(*e1, *e2)?, face_with(*e1, *e3)?, face_with(*e2, *e3)?); + let (_, out) = s1.end_at(vert); + let (c12, c31) = (s1.contact(f12)?, s1.contact(f31)?); + let none = || too_large("the blend is larger than the neighbouring faces"); + // Each contact crosses its edge at the vertex, or runs past that edge's far + // end (the blend takes in that corner, an earlier round's) to the next edge of + // its face there. + let side = |ei: &mt::EdgeID, fi: usize| -> Result { + let c = s1.contact(fi)?; + if let Some((t, p)) = cross(c, &curve_of(ei)?, face(fi)?, v, s1.own(), fit_tol) { + return Ok(Reached::Edge(t, p)); + } + // Only an earlier round's corner: the third face runs on smoothly from a + // side at the vertex (else the blend would wrap round a corner of the part). + let normal = |f: usize| face(f).ok().and_then(|g| project(g, v, None)).map(|x| x.1); + let smooth = normal(f23).is_some_and(|n| [f12, f31].iter().any(|f| normal(*f).is_some_and(|m| m.dot(n) > 1.0 - 1e-6))); + if !smooth { + return Err(none()); + } + let e = edges.get(ei).ok_or_else(|| fail("edge"))?; + let w = if e.front().id() == *vid { e.back().clone() } else { e.front().clone() }; + let (ws, wf) = at_vertex.get(&w.id()).ok_or_else(|| fail("vertex"))?; + if touched.contains_key(&w.id()) || ws.len() != 3 || wf.len() != 3 || ws.iter().any(|x| sides.contains_key(x)) { + return Err(none()); + } + let on = |f: usize| ws.iter().filter(|x| *x != ei).find(|x| on_edge.get(*x).is_some_and(|fs| fs.contains(&f))).copied(); + let (Some(g), Some(h)) = (on(fi), on(f23)) else { return Err(none()) }; + let f4 = face_with(g, h)?; + let (t, p) = cross(c, &curve_of(&g)?, face(fi)?, v, s1.own(), fit_tol).ok_or_else(none)?; + // Through that far vertex itself: the contact reaches the edge's end there + // (touching it where it runs in tangent to the contact, as at a knife-edge + // tip). + if p.dist(vtx(&w)) <= fit_tol * 10.0 { + return Ok(Reached::Edge(t, vtx(&w))); + } + Ok(Reached::Past { t, p, w, g, h, f4 }) + }; + match (side(e2, f12)?, side(e3, f31)?) { + (Reached::Edge(v2, p2), Reached::Edge(v3, p3_)) => { + let (a, b) = (ending(e2, p2, &[(c12, v2, f12)])?, ending(e3, p3_, &[(c31, v3, f31)])?); + endpt.insert((*e2, *vid, None), a.clone()); + endpt.insert((*e1, *vid, Some(f12)), a.clone()); + endpt.insert((*e3, *vid, None), b.clone()); + endpt.insert((*e1, *vid, Some(f31)), b.clone()); + let end = Away::face(face(f23)?); + let c = trace(s1, (s1.u(f12)?, v2, p2), (s1.u(f31)?, v3, p3_), &end, out, fit_tol)?; + list.push((mt::Edge::new(&a, &b, c), vec![Owner::Face(f23), Owner::Strip(*e1)])); + } + (r2, r3) => { + // The end runs from the edge reached across the third face to where + // it crosses the edge `h` from the corner taken in, then across the + // face beyond (`f4`) to the next edge `g`. + let ((ed, fd, td, pd), (ex, fx, past)) = match (r2, r3) { + (Reached::Edge(t, p), past @ Reached::Past { .. }) => ((e2, f12, t, p), (e3, f31, past)), + (past @ Reached::Past { .. }, Reached::Edge(t, p)) => ((e3, f31, t, p), (e2, f12, past)), + _ => return Err(unsupported("a blend running past both edges where it ends")), + }; + let Reached::Past { t: tx, p: px, w, g, h, f4 } = past else { return Err(fail("corner")) }; + let a = ending(ed, pd, &[(s1.contact(fd)?, td, fd)])?; + let b = builder::vertex(p3(px)); + let (_, y) = reach(&curve_of(&h)?, &Away::strip(s1), vtx(&w), fit_tol).ok_or_else(none)?; + let (u_y, v_y) = s1.nearest(y).ok_or_else(none)?; + if s1.at(u_y, v_y).dist(y) > fit_tol * 10.0 { + return Err(none()); + } + let yv = builder::vertex(p3(y)); + consumed.insert(*ex); + gone.insert(w.id(), w.clone()); + endpt.insert((*ed, *vid, None), a.clone()); + endpt.insert((*e1, *vid, Some(fd)), a.clone()); + endpt.insert((*e1, *vid, Some(fx)), b.clone()); + endpt.insert((g, w.id(), None), b.clone()); + endpt.insert((h, w.id(), None), yv.clone()); + let first = trace(s1, (s1.u(fd)?, td, pd), (u_y, v_y, y), &Away::face(face(f23)?), out, fit_tol)?; + let second = trace(s1, (u_y, v_y, y), (s1.u(fx)?, tx, px), &Away::face(face(f4)?), out, fit_tol)?; + list.push((mt::Edge::new(&a, &yv, first), vec![Owner::Face(f23), Owner::Strip(*e1)])); + list.push((mt::Edge::new(&yv, &b, second), vec![Owner::Face(f4), Owner::Strip(*e1)])); + } + } + } + ([ea, eb], [e3]) => { + let (sa, sb) = (strip_of(ea)?, strip_of(eb)?); + let fab = face_with(*ea, *eb)?; + let (fa3, fb3) = (face_with(*ea, *e3)?, face_with(*eb, *e3)?); + let c3 = curve_of(e3)?; + let none = || too_large("the blend is larger than the neighbouring faces"); + // Where the contacts cross on the shared face, and each reaches the third edge. + let (va_q, q) = cross(sa.contact(fab)?, sb.contact(fab)?, face(fab)?, v, sa.own(), fit_tol).ok_or_else(none)?; + let vb_q = param_on(sb.contact(fab)?, q).ok_or_else(none)?; + let (va_x, xa) = cross(sa.contact(fa3)?, &c3, face(fa3)?, v, sa.own(), fit_tol).ok_or_else(none)?; + let (vb_x, xb) = cross(sb.contact(fb3)?, &c3, face(fb3)?, v, sb.own(), fit_tol).ok_or_else(none)?; + let qv = builder::vertex(p3(q)); + endpt.insert((*ea, *vid, Some(fab)), qv.clone()); + endpt.insert((*eb, *vid, Some(fab)), qv.clone()); + if xa.dist(xb) <= fit_tol * 10.0 { + // The crease runs to the third edge. + let x = (xa + xb) * 0.5; + let xv = ending(e3, x, &[(sa.contact(fa3)?, va_x, fa3), (sb.contact(fb3)?, vb_x, fb3)])?; + endpt.insert((*e3, *vid, None), xv.clone()); + endpt.insert((*ea, *vid, Some(fa3)), xv.clone()); + endpt.insert((*eb, *vid, Some(fb3)), xv.clone()); + let (_, out) = sa.end_at(vert); + let c = trace(sa, (sa.u(fab)?, va_q, q), (sa.u(fa3)?, va_x, x), &Away::strip(sb), out, fit_tol)?; + list.push((mt::Edge::new(&qv, &xv, c), vec![Owner::Strip(*ea), Owner::Strip(*eb)])); + } else { + // The strip reaching further along the third edge (`big`) runs on past + // the crease to end on the other's third face. + let (big, small, ebig, esmall, fbig3, fsmall3, x, vbig_x) = + if xa.dist(v) > xb.dist(v) { (sa, sb, *ea, *eb, fa3, fb3, xa, va_x) } else { (sb, sa, *eb, *ea, fb3, fa3, xb, vb_x) }; + let vs_q = if esmall == *ea { va_q } else { vb_q }; + let (vs_y, y) = reach(small.contact(fsmall3)?, &Away::strip(big), v, fit_tol).ok_or_else(none)?; + let (u_y, v_y) = big.nearest(y).ok_or_else(none)?; + if big.at(u_y, v_y).dist(y) > fit_tol * 10.0 { + return Err(unsupported("blends this different at one corner")); + } + let xv = ending(e3, x, &[(big.contact(fbig3)?, vbig_x, fbig3)])?; + let yv = builder::vertex(p3(y)); + endpt.insert((*e3, *vid, None), xv.clone()); + endpt.insert((ebig, *vid, Some(fbig3)), xv.clone()); + endpt.insert((esmall, *vid, Some(fsmall3)), yv.clone()); + let (_, out_s) = small.end_at(vert); + let (_, out_b) = big.end_at(vert); + let crease = trace(small, (small.u(fab)?, vs_q, q), (small.u(fsmall3)?, vs_y, y), &Away::strip(big), out_s, fit_tol)?; + let section = trace(big, (u_y, v_y, y), (big.u(fbig3)?, vbig_x, x), &Away::face(face(fsmall3)?), out_b, fit_tol)?; + list.push((mt::Edge::new(&qv, &yv, crease), vec![Owner::Strip(*ea), Owner::Strip(*eb)])); + list.push((mt::Edge::new(&yv, &xv, section), vec![Owner::Face(fsmall3), Owner::Strip(ebig)])); + } + } + ([e1, e2, e3], []) => { + let s1 = strip_of(e1)?; + let (f12, f23, f31) = (face_with(*e1, *e2)?, face_with(*e2, *e3)?, face_with(*e3, *e1)?); + // The corner ball, from the first strip's section nearest the vertex. + let (v1, _) = s1.end_at(vert); + let start = s1 + .sections + .iter() + .min_by(|a, b| (a.0 - v1).abs().total_cmp(&(b.0 - v1).abs())) + .map(|x| x.1.c) + .ok_or_else(|| fail("section"))?; + let (c, [q12, q23, q31]) = corner_ball([face(f12)?, face(f23)?, face(f31)?], start, r, s1.sigma, tol * 100.0) + .ok_or_else(|| unsupported("the ball doesn't fit in the corner"))?; + let (v12, v23, v31) = (builder::vertex(p3(q12)), builder::vertex(p3(q23)), builder::vertex(p3(q31))); + for (e, f, x) in [(e1, f12, &v12), (e2, f12, &v12), (e2, f23, &v23), (e3, f23, &v23), (e3, f31, &v31), (e1, f31, &v31)] { + endpt.insert((*e, *vid, Some(f)), x.clone()); + } + let arc = |a: &mt::Vertex, b: &mt::Vertex| -> Result { + let m = c + ((vtx(a) - c) + (vtx(b) - c)).normalized().ok_or_else(|| fail("corner arc"))? * r; + Ok(builder::circle_arc(a, b, p3(m))) + }; + let (a1, a2, a3) = (arc(&v12, &v31)?, arc(&v12, &v23)?, arc(&v23, &v31)?); + // A sphere with its poles and seam away from the patch. + let n = |f: usize, q: Vec3| face(f).ok().and_then(|g| project(g, q, None)).map(|x| x.1).unwrap_or(Vec3::Z); + let out = (n(f12, q12) + n(f23, q23) + n(f31, q31)).normalized().unwrap_or(Vec3::Z); + let side = out.any_perp(); + let (np, sp, back) = (c + side * r, c - side * r, c - out * r); + let meridian = builder::circle_arc(&builder::vertex(p3(np)), &builder::vertex(p3(sp)), p3(back)); + let surf = mt::Surface::RevolutedCurve(mt::Processor::new(mt::RevolutedCurve::by_revolution( + meridian.oriented_curve(), + p3(c), + v3(side), + ))); + let surf = orient(surf, c + out * r, out * -s1.sigma); + let wire: mt::Wire = vec![a2.clone(), a3.clone(), a1.inverse()].into(); + let patch = mt::Face::try_new(vec![wire], surf).map_err(|e| fail(&format!("corner patch: {e}")))?; + patches.push((patch, vec![*e1, *e2, *e3])); + list.push((a1, vec![Owner::Strip(*e1), Owner::Patch])); + list.push((a2, vec![Owner::Strip(*e2), Owner::Patch])); + list.push((a3, vec![Owner::Strip(*e3), Owner::Patch])); + } + _ => return Err(unsupported("this corner")), + } + } + + // Rebuilt edges: contacts per (edge, face), shortened edges per edge. + let end_of = + |id: mt::EdgeID, v: &mt::Vertex, f: Option| -> mt::Vertex { endpt.get(&(id, v.id(), f)).cloned().unwrap_or_else(|| v.clone()) }; + let mut lines: HashMap<(mt::EdgeID, Option), mt::Edge> = HashMap::new(); + let mut line_of = |e: &mt::Edge, f: Option| -> Result { + use mt::Cut; + if let Some(x) = lines.get(&(e.id(), f)) { + return Ok(x.clone()); + } + let abs = e.absolute_clone(); + let (a, b) = (end_of(e.id(), abs.front(), f), end_of(e.id(), abs.back(), f)); + let x = match (f, strips.get(&e.id())) { + (Some(fi), Some(s)) => { + // The contact between its new ends. + let c = s.contact(fi)?; + let none = || too_large("the blend is larger than the neighbouring faces"); + let (ta, tb) = (param_on(c, vtx(&a)).ok_or_else(none)?, param_on(c, vtx(&b)).ok_or_else(none)?); + if tb - ta < 1e-9 { + return Err(too_large("the blend is larger than the neighbouring faces")); + } + let mut piece = c.clone(); + let _ = piece.cut(tb); + let piece = piece.cut(ta); + mt::Edge::try_new(&a, &b, piece).map_err(|e| fail(&format!("contact: {e}")))? + } + _ => { + let orig = vtx(abs.back()) - vtx(abs.front()); + if (vtx(&b) - vtx(&a)).dot(orig) <= size * 1e-9 * orig.len() { + return Err(too_large("the blend is larger than the neighbouring faces")); + } + if matches!(abs.curve(), mt::Curve::Line(_)) { + builder::line(&a, &b) + } else { + let moved = |n: &mt::Vertex, o: &mt::Vertex| (n != o).then(|| n.clone()); + super::trim_curved_seam(&abs, moved(&a, abs.front()), moved(&b, abs.back()), size)? + } + } + }; + lines.insert((e.id(), f), x.clone()); + Ok(x) + }; + let changed = |v: &mt::Vertex| touched.contains_key(&v.id()) || gone.contains_key(&v.id()); + let moved = |e: &mt::Edge| changed(e.front()) || changed(e.back()); + let everywhere: Vec<(mt::Edge, Vec)> = pool.values().flatten().cloned().collect(); + let mut rec = Rebuild::default(); + let mut out: Vec = Vec::with_capacity(faces.len() + strips.len() + patches.len()); + for (fi, f) in faces.iter().enumerate() { + if !f.vertex_iter().any(|v| changed(&v)) { + out.push(f.clone()); + continue; + } + let mut wires = Vec::new(); + for w in f.boundaries() { + let olds: Vec = w.edge_iter().filter(|e| !consumed.contains(&e.id())).cloned().collect(); + let mut news = Vec::with_capacity(olds.len()); + for e in &olds { + let ne = if strips.contains_key(&e.id()) { + line_of(e, Some(fi))? + } else if moved(e) { + let piece = line_of(e, None)?; + rec.piece(&piece, e); + piece + } else { + news.push(e.clone()); + continue; + }; + news.push(if e.front() == e.absolute_front() { ne } else { ne.inverse() }); + } + let n = news.len(); + let mut ws = Vec::new(); + for i in 0..n { + let (Some(a), Some(b)) = (news.get(i), news.get((i + 1) % n)) else { continue }; + ws.push(a.clone()); + if a.back() != b.front() { + ws.extend(connect(a.back(), b.front(), &everywhere, Owner::Face(fi))?); + } + } + wires.push(mt::Wire::from(ws)); + } + let rebuilt = mt::Face::try_new(wires, f.oriented_surface()).map_err(|e| fail(&format!("face: {e}")))?; + rec.face(&rebuilt, f); + out.push(rebuilt); + } + // The strips: back along the first face's contact, round the front corner, along the + // second's, round the back corner. + for id in ids { + let (Some(s), Some(e)) = (strips.get(id), edges.get(id)) else { continue }; + let (va, vb) = (e.front(), e.back()); + let a_forward = faces.get(s.fa).is_some_and(|f| f.boundaries().iter().any(|w| w.edge_iter().any(|x| x.id() == *id && x.front() == va))); + let la = line_of(e, Some(s.fa))?; + let lb = line_of(e, Some(s.fb))?; + let (first, second) = if a_forward { (la.inverse(), lb) } else { (lb.inverse(), la) }; + let p1 = pool.get(&va.id()).map(|x| &x[..]).unwrap_or(&[]); + let p2 = pool.get(&vb.id()).map(|x| &x[..]).unwrap_or(&[]); + let mut w = vec![first.clone()]; + w.extend(connect(first.back(), second.front(), p1, Owner::Strip(*id))?); + w.push(second.clone()); + w.extend(connect(second.back(), first.front(), p2, Owner::Strip(*id))?); + let surface = if s.flip < 0.0 { mt::Invertible::inverse(&s.surface) } else { s.surface.clone() }; + let surface = in_proportion(&surface)?; + let blend = mt::Face::try_new(vec![w.into()], surface).map_err(|e| fail(&format!("blend face: {e}")))?; + rec.blend(&blend, [*id]); + out.push(blend); + } + // Corner patches run each section opposite to its strip. + for (mut p, corner) in patches { + let uses = |f: &mt::Face, id: mt::EdgeID| { + f.boundaries().iter().flat_map(|w| w.edge_iter().cloned().collect::>()).find(|x| x.id() == id).map(|x| x.front().id()) + }; + let flip = p + .boundaries() + .first() + .and_then(|w| w.edge_iter().next()) + .and_then(|pe| out.iter().find_map(|f| uses(f, pe.id())).map(|front| front == pe.front().id())); + if flip == Some(true) { + let surf = p.oriented_surface(); + let wires: Vec = p.boundaries().iter().map(|w| w.inverse()).collect(); + p = mt::Face::try_new(wires, surf).map_err(|e| fail(&format!("corner patch: {e}")))?; + } + rec.blend(&p, corner); + out.push(p); + } + let mut shells = solid.boundaries().clone(); + if let Some(sh) = shells.get_mut(si) { + *sh = out.into(); + } + let res = Solid::try_new(shells).map_err(|e| fail(&format!("invalid solid: {e}")))?; + Ok((res, rec)) + }) +} + +#[cfg(test)] +#[path = "curvecorner_tests.rs"] +mod tests; diff --git a/crates/kernel/src/curvecorner_tests.rs b/crates/kernel/src/curvecorner_tests.rs new file mode 100644 index 00000000..7773a2b9 --- /dev/null +++ b/crates/kernel/src/curvecorner_tests.rs @@ -0,0 +1,312 @@ +use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3}; + +use crate::Body; + +const X0: f64 = 41.0 + 1.0 / 6.0; +/// The arc's axis. +const CX: f64 = X0 + 21.0; + +/// The arc part: a 41⅙ × 21 rectangle and, beside it, the corner a circle of radius 21 about +/// (CX, −21) leaves against the top line it touches (its arc square to the front, a knife edge +/// at the tip), extruded 10. +fn arc_part() -> Body { + scaled_arc_part(1.0) +} + +/// [`arc_part`] scaled by `k` about the origin. +fn scaled_arc_part(k: f64) -> Body { + let p = |x: f64, y: f64| Vec2::new(x * k, y * k); + let segs = vec![ + Seg2::Line { a: p(0.0, 0.0), b: p(0.0, -21.0) }, + Seg2::Line { a: p(0.0, -21.0), b: p(X0, -21.0) }, + Seg2::Arc { center: p(CX, -21.0), radius: 21.0 * k, start: std::f64::consts::PI, sweep: -std::f64::consts::FRAC_PI_2 }, + Seg2::Line { a: p(CX, 0.0), b: p(0.0, 0.0) }, + ]; + crate::extrude(&Plane::XY, &[Region2 { outer: Loop2 { segs }, holes: vec![] }], 0.0, 10.0 * k).unwrap().pop().unwrap() +} + +/// Points on the edges at the top: the long front edge, the arc, the back edge; the uprights +/// between the front and the arc, and at the tip (a knife edge); the left top edge and the +/// left front upright. +const LONG: Vec3 = Vec3::new(20.0, -21.0, 10.0); +fn arc_top() -> Vec3 { + let a = std::f64::consts::FRAC_PI_4; + Vec3::new(CX - 21.0 * a.cos(), -21.0 + 21.0 * a.sin(), 10.0) +} +const BACK: Vec3 = Vec3::new(30.0, 0.0, 10.0); +const UPRIGHT: Vec3 = Vec3::new(X0, -21.0, 5.0); +const TIP: Vec3 = Vec3::new(CX, 0.0, 5.0); +const LEFT: Vec3 = Vec3::new(0.0, -10.0, 10.0); +const LEFT_FRONT: Vec3 = Vec3::new(0.0, -21.0, 5.0); + +/// The volume of the body's display mesh, asserting it is closed (the legacy mesher can misread +/// a zero-angle face corner such as the tip's). +fn volume(b: &Body) -> f64 { + crate::issue_851_tests::planned_volume(b) +} + +/// What a blend (radius or distance 2) of the edges at `pts` takes off the part. +fn removed(round: bool, pts: &[Vec3]) -> f64 { + let body = arc_part(); + let b = if round { crate::fillet(&body, pts, 2.0) } else { crate::chamfer(&body, pts, 2.0) }.unwrap(); + volume(&body) - volume(&b) +} + +/// The part's material at `p`. +fn in_part(p: Vec3) -> bool { + (0.0..=CX).contains(&p.x) && (-21.0..=0.0).contains(&p.y) && (0.0..=10.0).contains(&p.z) && (p.x <= X0 || (p.x - CX).hypot(p.y + 21.0) >= 21.0) +} + +/// Rounds of radius 2 about the top front corner of the arc: what each edge's round alone +/// takes off there (between the edge and the ball, on the edge's side of its contacts). +fn off_long(p: Vec3) -> bool { + in_part(p) && p.y < -19.0 && p.z > 8.0 && (p.y + 19.0).hypot(p.z - 8.0) > 2.0 +} +/// The upright's ball, 2 from the front and 23 from the arc's axis. +fn upright_centre() -> f64 { + CX - (23.0f64 * 23.0 - 4.0).sqrt() +} +fn off_upright(p: Vec3) -> bool { + let (dx, dy) = (p.x - upright_centre(), p.y + 19.0); + let a = dy.atan2(dx); + in_part(p) && a >= -std::f64::consts::FRAC_PI_2 && a <= (-2.0f64).atan2(CX - upright_centre()) && dx.hypot(dy) > 2.0 +} +fn off_arc(p: Vec3) -> bool { + let rho = (p.x - CX).hypot(p.y + 21.0); + in_part(p) && p.y >= -21.0 && rho < 23.0 && p.z > 8.0 && (rho - 23.0).hypot(p.z - 8.0) > 2.0 +} +/// Kept when all three are rounded and the corner with them: within 2 of a ball centre 2 from +/// all three faces (z ≤ 8, y ≥ −19, 23 from the arc's axis), the nearest on a face, an edge +/// or the corner of that region. +fn kept_by_balls(p: Vec3) -> bool { + let c = Vec3::new(upright_centre(), -19.0, 8.0); + let inside = |q: Vec3| q.z <= 8.0 + 1e-12 && q.y >= -19.0 - 1e-12 && (q.x - CX).hypot(q.y + 21.0) >= 23.0 - 1e-12; + let mut near = + vec![c, Vec3::new(p.x.min(c.x), -19.0, 8.0), Vec3::new(c.x, -19.0, p.z.min(8.0)), Vec3::new(p.x, p.y, 8.0), Vec3::new(p.x, -19.0, p.z)]; + let (dx, dy) = (p.x - CX, p.y + 21.0); + let d = dx.hypot(dy); + if d > 1e-9 { + let (ux, uy) = (dx / d, dy / d); + near.push(Vec3::new(CX + ux * 23.0, -21.0 + uy * 23.0, p.z)); + near.push(Vec3::new(CX + ux * 23.0, -21.0 + uy * 23.0, 8.0)); + } + in_part(p) && (inside(p) || near.iter().filter(|q| inside(**q)).any(|q| q.dist(p) <= 2.0)) +} + +/// Volume of the points of the box about the corner where `f` holds (midpoint rule). +fn corner_volume(f: impl Fn(Vec3) -> bool) -> f64 { + let h = 0.02; + let (lo, hi) = (Vec3::new(38.0, -21.0, 7.5), Vec3::new(42.5, -17.5, 10.0)); + let n = |a: f64, b: f64| ((b - a) / h).round() as usize; + let mut count = 0usize; + for i in 0..n(lo.x, hi.x) { + for j in 0..n(lo.y, hi.y) { + for k in 0..n(lo.z, hi.z) { + if f(lo + Vec3::new((i as f64 + 0.5) * h, (j as f64 + 0.5) * h, (k as f64 + 0.5) * h)) { + count += 1; + } + } + } + } + count as f64 * h * h * h +} + +/// Two or three of the edges at the arc's top front corner rounded together: the strips cut +/// each other in a crease (the long one running on past it to end in the arc wall, as far as +/// its contact reaches along the arc's edge), and with all three a piece of the ball touching +/// the three faces fills the corner. What they take off: each round alone, less what each alone +/// takes in a box about the corner, plus what they take there together (rolling-ball rounds +/// trim each other where each one's corner region ends; with three, what no ball inside the +/// part reaches). +#[test] +fn rounds_meeting_at_a_corner_of_an_arc_wall() { + let offs: [(Vec3, fn(Vec3) -> bool); 3] = [(LONG, off_long), (arc_top(), off_arc), (UPRIGHT, off_upright)]; + let alone: Vec = offs.iter().map(|(p, f)| removed(true, &[*p]) - corner_volume(f)).collect(); + for which in [vec![0, 2], vec![0, 1], vec![1, 2], vec![0, 1, 2]] { + let pts: Vec = which.iter().map(|i| offs[*i].0).collect(); + let fs: Vec bool> = which.iter().map(|i| offs[*i].1).collect(); + let together = if which.len() == 3 { corner_volume(|p| in_part(p) && !kept_by_balls(p)) } else { corner_volume(|p| fs.iter().any(|f| f(p))) }; + let want = which.iter().map(|i| alone[*i]).sum::() + together; + let got = removed(true, &pts); + assert!((got - want).abs() < 0.03, "{which:?}: removed {got} vs {want}"); + } +} + +/// The ball at the knife-edge tip: 2 below the top line and 23 from the arc's axis. +fn tip_ball() -> Vec2 { + Vec2::new(CX - (23.0f64 * 23.0 - 19.0 * 19.0).sqrt(), -2.0) +} + +/// Integral over the cusp between the arc and the top line (midpoint rule, the last cell of +/// each column cut at the top line), from `x0` to the tip. +fn over_cusp(x0: f64, f: impl Fn(f64, f64) -> f64) -> f64 { + let (hx, hy) = (0.001, 0.0002); + let mut sum = 0.0; + let mut x = x0 + hx / 2.0; + while x < CX { + let lo = -21.0 + (441.0 - (x - CX) * (x - CX)).max(0.0).sqrt(); + let n = ((0.0 - lo) / hy).ceil().max(0.0) as usize; + sum += (0..n) + .map(|j| { + let (y0, y1) = (lo + j as f64 * hy, (lo + (j + 1) as f64 * hy).min(0.0)); + f(x, (y0 + y1) * 0.5) * (y1 - y0) + }) + .sum::() + * hx; + x += hx; + } + sum +} + +/// Whether the tip's round takes off (x, y), all the way up: the cusp beyond the ball, on the +/// tip's side of its contacts. +fn off_tip(x: f64, y: f64) -> bool { + let k = tip_ball(); + let a = (y - k.y).atan2(x - k.x); + (x - k.x).hypot(y - k.y) > 2.0 && a >= (-21.0 - k.y).atan2(CX - k.x) && a <= std::f64::consts::FRAC_PI_2 +} + +/// The knife-edge tip (the arc and the top line meeting at no angle) rounds to the ball 2 from +/// both, 13 back from the tip; its chamfer cuts the cusp at the chord between the same contacts. +#[test] +fn a_knife_edge_tip_rounds_where_the_ball_fits() { + let k = tip_ball(); + let round = over_cusp(k.x - 1.0, |x, y| if off_tip(x, y) { 1.0 } else { 0.0 }) * 10.0; + let got = removed(true, &[TIP]); + assert!((got - round).abs() < round * 1e-3, "round: removed {got} vs {round}"); + // The chord from the top line's contact to the arc's. + let (a, b) = (Vec2::new(k.x, 0.0), k + (Vec2::new(CX, -21.0) - k) * (2.0 / 23.0)); + let beyond = |x: f64, y: f64| (b.x - a.x) * (y - a.y) - (b.y - a.y) * (x - a.x) > 0.0; + let flat = over_cusp(k.x - 1.0, |x, y| if beyond(x, y) { 1.0 } else { 0.0 }) * 10.0; + let got = removed(false, &[TIP]); + assert!((got - flat).abs() < flat * 1e-3, "chamfer: removed {got} vs {flat}"); +} + +/// The tip's round with the arc's or the back edge's at the top, where all three meet: as at +/// the front corner, each alone less what both would take twice — over the tip's corner region, +/// the other's (above 8 + √(4 − d²), d across from its ball's path). +#[test] +fn rounds_meeting_at_a_knife_edge_tip() { + let k = tip_ball(); + let tip = removed(true, &[TIP]); + let arc_d = |x: f64, y: f64| 23.0 - (x - CX).hypot(y + 21.0); + let back_d = |_: f64, y: f64| y + 2.0; + for (edge, d) in [(arc_top(), &arc_d as &dyn Fn(f64, f64) -> f64), (BACK, &back_d)] { + let both = over_cusp(k.x - 1.0, |x, y| { + let d = d(x, y); + if off_tip(x, y) && d > 0.0 && d < 2.0 { 2.0 - (4.0 - d * d).sqrt() } else { 0.0 } + }); + let want = tip + removed(true, &[edge]) - both; + let got = removed(true, &[edge, TIP]); + assert!((got - want).abs() < want * 1e-3, "{edge:?}: removed {got} vs {want}"); + } +} + +/// Chamfers of the long top edge and the arc's top edge meeting at the front corner: their +/// planes (the arc's a cone) cut each other. Each alone, less what both take: over the top's +/// corner, 10 − z above both 8 + d (d across from the front face, or from the arc toward its axis). +#[test] +fn chamfers_meeting_at_a_corner_of_an_arc_wall() { + let h = 0.002; + let mut both = 0.0; + for i in 0..((44.0 - 36.0) / h) as usize { + for j in 0..(3.0 / h) as usize { + let (x, y) = (36.0 + (i as f64 + 0.5) * h, -21.0 + (j as f64 + 0.5) * h); + let rho = (x - CX).hypot(y + 21.0); + if in_part(Vec3::new(x, y, 9.99)) && y < -19.0 && rho < 23.0 { + both += (2.0 - (y + 21.0).max(rho - 21.0)).max(0.0) * h * h; + } + } + } + let want = removed(false, &[LONG]) + removed(false, &[arc_top()]) - both; + let got = removed(false, &[LONG, arc_top()]); + assert!((got - want).abs() < want * 1e-3, "removed {got} vs {want}"); +} + +/// A fillet over an earlier one at the end of its edge, as a feature of its own: the new round +/// ends in the earlier one (running on past it across the next face when the earlier one's end +/// is shorter than the new round), taking off what the two take together. +#[test] +fn a_round_ending_in_an_earlier_round_matches_both_together() { + for (first, then) in [ + (LONG, LEFT), + (LONG, LEFT_FRONT), + (LONG, arc_top()), + (arc_top(), LONG), + (LONG, UPRIGHT), + (arc_top(), UPRIGHT), + // At the knife-edge tip, where the first round's end takes in the tip's corner. + (arc_top(), TIP), + (BACK, TIP), + (arc_top(), BACK), + ] { + one_then_the_other(first, then); + } +} + +/// The back edge rounded first, then the arc's top edge: the arc's round leaves 0.05 mm³ more at +/// the knife-edge tip than both together, or the other order. +#[test] +#[ignore = "not yet: the arc's round after the back edge's leaves 0.05 mm³ more at the tip"] +fn the_arc_rounded_after_the_back_edge_matches_both_together() { + one_then_the_other(BACK, arc_top()); +} + +/// The edge at `first` rounded (2), then the one at `then` as a fillet of its own, against both +/// rounded together. +fn one_then_the_other(first: Vec3, then: Vec3) { + let body = arc_part(); + let step = crate::fillet(&body, &[first], 2.0).unwrap(); + let after = crate::fillet(&step, &[then], 2.0).unwrap(); + let v = volume(&after); + let together = volume(&crate::fillet(&body, &[first, then], 2.0).unwrap()); + // Within the display meshes' accuracy (two different B-reps, meshed apart). + assert!((v - together).abs() < 5e-3, "{first:?} then {then:?}: {v} vs together {together}"); +} + +/// Edges rounded together come out the same in any order: a round made first doesn't carry the +/// next one on along its arc into edges nobody selected. +#[test] +fn rounds_together_in_any_order() { + let body = arc_part(); + for (a, b) in [(UPRIGHT, LONG), (TIP, arc_top()), (TIP, BACK)] { + let v = |x: &[Vec3]| volume(&crate::fillet(&body, x, 2.0).unwrap()); + let (ab, ba) = (v(&[a, b]), v(&[b, a])); + assert!((ab - ba).abs() < 1e-3, "{a:?} + {b:?}: {ab} vs {ba}"); + } +} + +/// The blends this part needs the corner build for (and the back edge, rolled into the tip) at +/// 0.01 and 100 times the part's size, the radius scaled with it: they take off what they take at +/// full size, scaled by the cube, so the build's tolerances and limits follow the model's size. +#[test] +fn corner_blends_hold_at_any_scale() { + let cases: [(&str, Vec>); 6] = [ + ("tip and arc", vec![vec![TIP, arc_top()]]), + ("arc and tip", vec![vec![arc_top(), TIP]]), + ("back into the tip", vec![vec![BACK]]), + ("arc, then tip", vec![vec![arc_top()], vec![TIP]]), + ("back, then tip", vec![vec![BACK], vec![TIP]]), + ("arc, then back", vec![vec![arc_top()], vec![BACK]]), + ]; + // What a blend takes off the part scaled by `k`, over k³, through a display plan of matching + // accuracy. + let removed = |k: f64, steps: &[Vec]| { + let volume = |b: &Body| b.display_plan(2e-4 * k).unwrap().evaluate().measure().volume; + let body = scaled_arc_part(k); + let mut b = body.clone(); + for pts in steps { + let pts: Vec = pts.iter().map(|p| *p * k).collect(); + b = crate::fillet(&b, &pts, 2.0 * k).unwrap_or_else(|e| panic!("at {k}x: {steps:?}: {e}")); + assert!(b.validity().is_empty(), "at {k}x: {steps:?}: {:?}", b.validity()); + } + (volume(&body) - volume(&b)) / (k * k * k) + }; + for (name, steps) in &cases { + let full = removed(1.0, steps); + for k in [0.01, 100.0] { + let got = removed(k, steps); + assert!((got - full).abs() < full * 1e-4, "{name} at {k}x: removed {got} (scaled back) vs {full}"); + } + } +} diff --git a/crates/kernel/src/issue_851_tests.rs b/crates/kernel/src/issue_851_tests.rs index ba0673e4..1bea650c 100644 --- a/crates/kernel/src/issue_851_tests.rs +++ b/crates/kernel/src/issue_851_tests.rs @@ -163,7 +163,7 @@ fn arc_top_removed(size: f64, round: bool) -> f64 { /// Volume through the display planner (the mesh the app draws): closed and exact where the /// legacy mesher behind `measure` can leak at a zero-angle corner like the one at the tip. -fn planned_volume(b: &Body) -> f64 { +pub(crate) fn planned_volume(b: &Body) -> f64 { let mesh = b.display_plan(2e-4).unwrap().evaluate(); let key = |p: Vec3| [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()]; let mut uses: std::collections::HashMap<([u64; 3], [u64; 3]), i32> = std::collections::HashMap::new(); diff --git a/crates/kernel/src/loopblend.rs b/crates/kernel/src/loopblend.rs index 7538747e..ab50132a 100644 --- a/crates/kernel/src/loopblend.rs +++ b/crates/kernel/src/loopblend.rs @@ -271,6 +271,27 @@ fn build( side = sd; walls.push(wf); } + // A run ends in the cross-section through its end vertex: the face there must hold it (a + // plane square to the run). One at an angle cuts the blend's two contacts at different + // places, which the rolling-ball blend handles. + if open { + let ends = [ + (edges.first().map(|e| e.front().clone()), segs.first().map(|s| (s.start(), *s))), + (edges.last().map(|e| e.back().clone()), segs.last().map(|s| (s.end(), *s))), + ]; + for (v, seg) in ends { + let (Some(v), Some((p, s))) = (v, seg) else { return Err(KernelError::Failed("run end".into())) }; + let t = s.tangent(p).ok_or_else(|| unsupported("degenerate loop edge"))?; + let square = faces + .iter() + .enumerate() + .filter(|(gi, g)| *gi != fi && !walls.contains(gi) && g.vertex_iter().any(|x| x == v)) + .all(|(_, g)| plane_normal(g).is_some_and(|gn| gn.dot(t).abs() > 1.0 - 1e-6)); + if !square { + return Err(unsupported("a run of edges ending at a face at an angle")); + } + } + } // Cross-section at a loop point: into the face (m) and up the wall (t, away from the edge, // up a concave wall and down a convex one). The ball of radius r touches both at the // setback s = r / tan(φ/2), φ the angle between m and t; its centre is on the bisector. diff --git a/tests/action_matrix/baseline.json b/tests/action_matrix/baseline.json index 6483585b..abbb9eea 100644 --- a/tests/action_matrix/baseline.json +++ b/tests/action_matrix/baseline.json @@ -8454,20 +8454,20 @@ "Wave/F2:side:a1": "curved.axis curved.tangent_plane", "Wave/F2:side:a1|F2:side:a2": "edge.axis edge.normal_plane edge.project", "Wave/F2:side:a1|F2:side:l3": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.normal_plane+edit edge.project", - "Wave/F2:side:a1|F2:start": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", - "Wave/F2:side:a1|F2:top": "!edge.chamfer !edge.fillet edge.normal_plane edge.project edge.project+edit", + "Wave/F2:side:a1|F2:start": "edge.chamfer edge.fillet edge.normal_plane edge.project", + "Wave/F2:side:a1|F2:top": "edge.chamfer edge.fillet edge.normal_plane edge.project edge.project+edit", "Wave/F2:side:a2": "curved.axis curved.tangent_plane", "Wave/F2:side:a2|F2:side:a3": "edge.axis edge.normal_plane edge.normal_plane+edit edge.project", - "Wave/F2:side:a2|F2:start": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", - "Wave/F2:side:a2|F2:top": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Wave/F2:side:a2|F2:start": "edge.chamfer edge.fillet edge.normal_plane edge.project", + "Wave/F2:side:a2|F2:top": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Wave/F2:side:a3": "curved.axis curved.tangent_plane", "Wave/F2:side:a3|F2:side:a4": "edge.axis edge.normal_plane edge.normal_plane+edit edge.project", - "Wave/F2:side:a3|F2:start": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", - "Wave/F2:side:a3|F2:top": "!edge.chamfer !edge.fillet edge.normal_plane edge.project edge.project+edit", + "Wave/F2:side:a3|F2:start": "edge.chamfer edge.fillet edge.normal_plane edge.project", + "Wave/F2:side:a3|F2:top": "edge.chamfer edge.fillet edge.normal_plane edge.project edge.project+edit", "Wave/F2:side:a4": "curved.axis curved.tangent_plane", "Wave/F2:side:a4|F2:side:l1": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project edge.project+edit", - "Wave/F2:side:a4|F2:start": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", - "Wave/F2:side:a4|F2:top": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Wave/F2:side:a4|F2:start": "edge.chamfer edge.fillet edge.normal_plane edge.project", + "Wave/F2:side:a4|F2:top": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Wave/F2:side:l1": "face.angle_plane !face.draft_neutral face.extrude face.extrude+edit face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch", "Wave/F2:side:l1|F2:side:l2": "edge.axis edge.chamfer edge.fillet edge.fillet+edit edge.normal_plane edge.project", "Wave/F2:side:l1|F2:start": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", @@ -9166,7 +9166,7 @@ }, "oracle/73-sm-hem": { "Body1/F2:face:0": "face.angle_plane !face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch", - "Body1/F2:face:0|F2:face:3#[F2:face:0,F2:face:1,F2:face:4]": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F2:face:0|F2:face:3#[F2:face:0,F2:face:1,F2:face:4]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F2:face:0|F2:face:4": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F2:face:0|F2:face:5#[F2:face:0,F2:face:1,F2:face:4]": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F2:face:0|F3:face:10": "edge.axis edge.normal_plane edge.project", @@ -9179,7 +9179,7 @@ "Body1/F2:face:3#[F2:face:0,F2:face:1,F2:face:4]|F2:face:3#[F3:face:10,F3:face:11,F3:face:9]": "edge.axis edge.normal_plane edge.project", "Body1/F2:face:3#[F2:face:0,F2:face:1,F2:face:4]|F2:face:4": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F2:face:3#[F3:face:10,F3:face:11,F3:face:9]": "face.angle_plane face.extrude !face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch", - "Body1/F2:face:3#[F3:face:10,F3:face:11,F3:face:9]|F3:face:10": "edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F2:face:3#[F3:face:10,F3:face:11,F3:face:9]|F3:face:10": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F2:face:3#[F3:face:10,F3:face:11,F3:face:9]|F3:face:11": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F2:face:3#[F3:face:10,F3:face:11,F3:face:9]|F3:face:9": "edge.axis edge.normal_plane edge.project", "Body1/F2:face:4": "face.angle_plane !face.draft_neutral face.extrude !face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch", @@ -9466,7 +9466,7 @@ "Body1/F4:face:10|F4:face:14": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F4:face:10|F4:face:19": "edge.axis edge.normal_plane edge.project", "Body1/F4:face:11": "face.angle_plane !face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch", - "Body1/F4:face:11|F4:face:12": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F4:face:11|F4:face:12": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F4:face:11|F4:face:13": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F4:face:11|F4:face:14": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F4:face:11|F4:face:20": "edge.axis edge.normal_plane edge.project", @@ -11277,38 +11277,38 @@ "Body1/F3:end|F3:side:l4#[F3:end]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F3:side:l1#1|F3:side:l1#2": "edge.axis edge.normal_plane edge.project", "Body1/F3:side:l1#1|F3:side:l1#[F3:start]": "edge.axis edge.normal_plane edge.project", - "Body1/F3:side:l1#1|F3:side:l2#1": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", - "Body1/F3:side:l1#1|F3:side:l4#1": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F3:side:l1#1|F3:side:l2#1": "edge.chamfer edge.fillet edge.normal_plane edge.project", + "Body1/F3:side:l1#1|F3:side:l4#1": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F3:side:l1#2|F3:side:l1#[F3:end]": "edge.axis edge.normal_plane edge.project", - "Body1/F3:side:l1#2|F3:side:l2#2": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", - "Body1/F3:side:l1#2|F3:side:l4#2": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F3:side:l1#2|F3:side:l2#2": "edge.chamfer edge.fillet edge.normal_plane edge.project", + "Body1/F3:side:l1#2|F3:side:l4#2": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F3:side:l1#[F3:end]": "face.angle_plane !face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch", - "Body1/F3:side:l1#[F3:end]|F3:side:l2#[F3:end]": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", - "Body1/F3:side:l1#[F3:end]|F3:side:l4#[F3:end]": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F3:side:l1#[F3:end]|F3:side:l2#[F3:end]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", + "Body1/F3:side:l1#[F3:end]|F3:side:l4#[F3:end]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F3:side:l1#[F3:start]": "face.angle_plane !face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch", - "Body1/F3:side:l1#[F3:start]|F3:side:l2#[F3:start]": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", - "Body1/F3:side:l1#[F3:start]|F3:side:l4#[F3:start]": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F3:side:l1#[F3:start]|F3:side:l2#[F3:start]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", + "Body1/F3:side:l1#[F3:start]|F3:side:l4#[F3:start]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F3:side:l1#[F3:start]|F3:start": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F3:side:l2#1": "curved.axis curved.tangent_plane", "Body1/F3:side:l2#1|F3:side:l2#[F3:start]": "edge.axis edge.normal_plane edge.project", - "Body1/F3:side:l2#1|F3:side:l3#1": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F3:side:l2#1|F3:side:l3#1": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F3:side:l2#2": "curved.axis curved.tangent_plane", "Body1/F3:side:l2#2|F3:side:l2#[F3:end]": "edge.axis edge.normal_plane edge.project", - "Body1/F3:side:l2#2|F3:side:l3#2": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F3:side:l2#2|F3:side:l3#2": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F3:side:l2#[F3:end]": "face.angle_plane !face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch", - "Body1/F3:side:l2#[F3:end]|F3:side:l3#[F3:end]": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F3:side:l2#[F3:end]|F3:side:l3#[F3:end]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F3:side:l2#[F3:start]": "face.angle_plane !face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch", - "Body1/F3:side:l2#[F3:start]|F3:side:l3#[F3:start]": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F3:side:l2#[F3:start]|F3:side:l3#[F3:start]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F3:side:l2#[F3:start]|F3:start": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F3:side:l3#1|F3:side:l3#2": "edge.axis edge.normal_plane edge.project", "Body1/F3:side:l3#1|F3:side:l3#[F3:start]": "edge.axis edge.normal_plane edge.project", - "Body1/F3:side:l3#1|F3:side:l4#1": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F3:side:l3#1|F3:side:l4#1": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F3:side:l3#2|F3:side:l3#[F3:end]": "edge.axis edge.normal_plane edge.project", - "Body1/F3:side:l3#2|F3:side:l4#2": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F3:side:l3#2|F3:side:l4#2": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F3:side:l3#[F3:end]": "face.angle_plane !face.draft_neutral face.extrude face.hole face.offset_plane face.press_pull !face.shell face.sketch", - "Body1/F3:side:l3#[F3:end]|F3:side:l4#[F3:end]": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F3:side:l3#[F3:end]|F3:side:l4#[F3:end]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F3:side:l3#[F3:start]": "face.angle_plane !face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch", - "Body1/F3:side:l3#[F3:start]|F3:side:l4#[F3:start]": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F3:side:l3#[F3:start]|F3:side:l4#[F3:start]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F3:side:l3#[F3:start]|F3:start": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F3:side:l4#1": "curved.axis curved.tangent_plane", "Body1/F3:side:l4#1|F3:side:l4#[F3:start]": "edge.axis edge.normal_plane edge.project", diff --git a/tests/robustness/goldens.json b/tests/robustness/goldens.json index 5c963874..2e1dce84 100644 --- a/tests/robustness/goldens.json +++ b/tests/robustness/goldens.json @@ -34,7 +34,7 @@ "30": {"area":853.525389,"bodies":1,"edges":23,"error":null,"faces":10,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","chamfer"],"unsupported":0,"volume":1337.14821}, "31": {"area":10195.46137,"bodies":3,"edges":69,"error":null,"faces":30,"outcome":"ok","rejected":1,"shells":3,"steps":["box","cut box","fillet","join box","circular pattern","cut box","circular pattern","hole"],"unsupported":1,"volume":56416.127527}, "32": {"area":3220.802769,"bodies":1,"edges":51,"error":null,"faces":19,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut sphere","cut box","cut box","join box"],"unsupported":0,"volume":7565.955897}, - "33": {"area":5285.817318,"bodies":2,"edges":59,"error":null,"faces":27,"outcome":"ok","rejected":0,"shells":3,"steps":["revolved section","fillet","join box","cut cylinder","chamfer","join box","chamfer","fillet"],"unsupported":2,"volume":13859.24945}, + "33": {"area":5286.736353,"bodies":2,"edges":59,"error":null,"faces":27,"outcome":"ok","rejected":0,"shells":3,"steps":["revolved section","fillet","join box","cut cylinder","chamfer","join box","chamfer","fillet"],"unsupported":2,"volume":13858.467983}, "34": {"area":1086.303472,"bodies":1,"edges":30,"error":null,"faces":14,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut sphere","hole"],"unsupported":0,"volume":2134.365275}, "35": {"area":4605.630216,"bodies":1,"edges":26,"error":null,"faces":11,"outcome":"ok","rejected":0,"shells":1,"steps":["box","fillet","join box"],"unsupported":0,"volume":13965.526982}, "36": {"area":10408.825748,"bodies":1,"edges":60,"error":null,"faces":26,"outcome":"ok","rejected":0,"shells":2,"steps":["revolved section","cut cylinder","join box","fillet","join cylinder"],"unsupported":0,"volume":55729.313103}, @@ -112,6 +112,7 @@ {"reason":"the last-resort tool nudge is tried the other way when it fails or leaves sliver faces the operands didn't have: seed 43's join builds and the design runs to the end, 31 and 78 come out with fewer faces (#827, #897)","seeds":[31,43,78]}, {"reason":"main's fillet changes since the last record (#926, #949) move seeds 33 and 91: a fillet step builds differently or is picked differently","seeds":[33,91]}, {"reason":"hole tangent inside a stepped boss: touching sews split zero-thickness curved walls, so a pattern of holes spaced by their diameter (touching) in a stepped body cuts exactly instead of by a tool nudge: 42 builds more faces (the walls split where the holes touch), 51 picks other edges afterwards","seeds":[42,51]}, + {"reason":"corner blends on curved faces (#854): seed 33's last fillet (its second body) is built differently; its display-plan and measured volumes now agree (4442.73 mm³; on main they read 4439.42 and 4443.51)","seeds":[33]}, {"reason":"#952: a hole placed on a face drills into the material; seed 81's hole moves its volume by 0.1 mm\u00b3 (area by 0.04 mm\u00b2)","seeds":[81]} ] }