From 20a7dd8764abc8ce7b92f5aaf03bb53c301de33a Mon Sep 17 00:00:00 2001 From: Karl Von Richter Date: Fri, 16 Oct 2026 01:34:48 +1000 Subject: [PATCH] Chamfer chains ending at existing bevels (#1303) (#1372) A curved chamfer chain with straight planar ends can end across two earlier planar bevel faces, shortening their shared seam to where the new chamfer plane crosses it; picks on one tangent chain are applied once. Fixes #1303. --- ROADMAP.md | 2 + crates/kernel/src/blend.rs | 15 ++++ crates/kernel/src/curveblend.rs | 124 ++++++++++++++++++++++++-- crates/kernel/src/curveblend_tests.rs | 48 ++++++++++ 4 files changed, 184 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b204f4c1..f7562fb8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -286,6 +286,8 @@ retain their saved two-finger scroll preference; camera motion is available thro basin rims within the rim width); convex fillets also meet sloped planar ends and reflex corners around pocket rims, with unequal wall slopes and extensions confined to the face; connected equal-distance chamfers also trim each other across unequal sloped rim walls; + curved chamfer chains with straight planar ends can terminate across two existing planar + bevel faces, splitting their shared seam; picks on the same tangent chain are applied once (#1303); whole smooth loops of a planar face, picked whole or by one edge (tangent chain), including circles and walls at any angle (boss bases and tops, bores, pocket floors, plate outlines), loops with sharp corners between straight edges (the blends meet in mitres), runs of diff --git a/crates/kernel/src/blend.rs b/crates/kernel/src/blend.rs index 59b27aa3..f0f003a8 100644 --- a/crates/kernel/src/blend.rs +++ b/crates/kernel/src/blend.rs @@ -2122,6 +2122,21 @@ fn chamfer_built(body: &Body, edges: &[Vec3], distance: f64) -> Result { Err(_) => {} } } + // A curved blend follows the whole tangent chain. Give it all picks together so + // picks on that chain are consumed once, rather than chamfering its new rails again. + if tools.is_err() && edges.len() > 1 { + let solid = cur.deep_copy(); + let ids = edge_ids(&cur, &solid, edges)?; + let all = Body::unique_edges(&solid); + let same_chain = ids + .first() + .and_then(|id| all.iter().find(|e| e.id() == *id)) + .and_then(|e| smooth_chain(&solid, e)) + .is_some_and(|chain| ids.len() > 1 && ids.iter().all(|id| chain.contains(id))); + if same_chain && let Ok(result) = blend(&cur, edges, distance, Shape::Flat, "chamfer") { + return Ok(result); + } + } let start = cur.clone(); let one_by_one = || -> Result { let mut cur = start.clone(); diff --git a/crates/kernel/src/curveblend.rs b/crates/kernel/src/curveblend.rs index 0f09d99d..6de2a465 100644 --- a/crates/kernel/src/curveblend.rs +++ b/crates/kernel/src/curveblend.rs @@ -1059,6 +1059,9 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, let mut seams: Vec> = Vec::with_capacity(nv); // At an open chain's ends: the end face (planar, square to the edge there). let mut end_faces: Vec<(usize, usize)> = Vec::new(); // (vertex index, face index) + // A flat strip on planar sides can end across two planar faces (an earlier chamfer). + // Their shared seam is shortened to the crossing of the new chamfer plane. + let mut split_ends: Vec<(usize, usize, usize, mt::Edge)> = Vec::new(); // Chain ends whose end face is not a plane square to the edge: (vertex, face, point, tangent, // the side (1 or 2) the end face is tangent to when it lies along the edge there). let mut slanted: Vec<(usize, usize, Vec3, Vec3, Option)> = Vec::new(); @@ -1075,7 +1078,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, } let on_side = side1.contains(&fi) || side2.contains(&fi); if !on_side { - if !at_end || end_faces.iter().any(|(kk, _)| *kk == k) { + if !at_end || end_faces.iter().filter(|(kk, _)| *kk == k).count() >= 2 { return Err(unsupported("a vertex of the edge chain where other faces meet")); } end_faces.push((k, fi)); @@ -1092,6 +1095,40 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, if at_end && !end_faces.iter().any(|(kk, _)| *kk == k) { return Err(unsupported("an open chain of curved edges must end at a face")); } + let ends: Vec = end_faces.iter().filter(|(kk, _)| *kk == k).map(|(_, fi)| *fi).collect(); + if let [ga, gb] = ends[..] { + let terminal = chain.get(if k == 0 { 0 } else { n - 1 }).ok_or_else(|| unsupported("chain end"))?; + let planar = [ga, gb, k1, k2].iter().all(|i| faces.get(*i).and_then(crate::blend::plane_normal).is_some()); + if round || !planar || !straight(terminal, tol * 10.0) { + return Err(unsupported("a vertex of the edge chain where other faces meet")); + } + let shares = |g: usize, side: usize| { + faces + .get(g) + .zip(faces.get(side)) + .is_some_and(|(g, s)| g.edge_iter().any(|e| (e.front() == v || e.back() == v) && s.edge_iter().any(|x| x.id() == e.id()))) + }; + let (ga, gb) = if shares(ga, k1) && shares(gb, k2) { + (ga, gb) + } else if shares(gb, k1) && shares(ga, k2) { + (gb, ga) + } else { + return Err(unsupported("the end faces do not meet both sides of the chain")); + }; + let seam = faces + .get(ga) + .and_then(|a| { + faces.get(gb).and_then(|b| a.edge_iter().find(|e| (e.front() == v || e.back() == v) && b.edge_iter().any(|x| x.id() == e.id()))) + }) + .ok_or_else(|| unsupported("the end faces have no shared seam"))?; + if !straight(&seam, tol * 10.0) { + return Err(unsupported("a curved seam between the end faces")); + } + // The first face is the one reached by side 1's contact. + end_faces.retain(|(kk, _)| *kk != k); + end_faces.extend([(k, ga), (k, gb)]); + split_ends.push((k, ga, gb, seam.absolute_clone())); + } // At most one seam per side. if found.iter().filter(|x| x.0 == 1).count() > 1 || found.iter().filter(|x| x.0 == 2).count() > 1 { return Err(unsupported("a vertex of the edge chain where several seams meet")); @@ -1144,7 +1181,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, } _ => false, }; - if !square { + if !square || split_ends.iter().any(|e| e.0 == k) { slanted.push((k, fi, p, t, None)); } } @@ -1193,7 +1230,12 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, 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 (_, ng, _) = project(g, *p, None).ok_or_else(|| unsupported("the blend's end on its end face"))?; - let cos = ng.dot(*t).abs(); + let cos = split_ends + .iter() + .find(|e| e.0 == *k) + .and_then(|(_, _, gb, _)| faces.get(*gb)) + .and_then(crate::blend::plane_normal) + .map_or(ng.dot(*t).abs(), |nb| ng.dot(*t).abs().min(nb.dot(*t).abs())); // An end face along the edge and tangent to one side there (a wall that runs out into // the face): the blend meets it along a curve that touches that side's contact at the // vertex. Any other end face that close to the edge is refused. @@ -1417,6 +1459,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, // Where each extended blend meets its slanted end face: (vertex, edge, cut parameters of // the two contacts, the end curve from contact 1 to contact 2). let mut caps: Vec<(usize, usize, f64, f64, mt::Curve)> = Vec::new(); + let mut split_caps: Vec<(usize, mt::Edge, Vec3)> = Vec::new(); for (k, fi, _, _, tangent) in &slanted { let ke = if *k == 0 { 0 } else { n - 1 }; let fit = fits.get(ke).and_then(Option::as_ref).ok_or_else(|| KernelError::Failed("curve blend fit".into()))?; @@ -1424,6 +1467,43 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, 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); let both = [seam(1), seam(2)]; + if let Some((_, ga, gb, middle)) = split_ends.iter().find(|e| e.0 == *k) { + use mt::{BSplineCurve, KnotVec, ParametricCurve, SearchNearestParameter}; + let fail = || unsupported("the chamfer does not cross both end faces"); + let p = from_p3(verts.get(*k).ok_or_else(fail)?.point()); + let (_, t) = tangent_at(chain.get(ke).ok_or_else(fail)?, if *k == 0 { 0.0 } else { 1.0 }).ok_or_else(fail)?; + let hit = |q: Vec3, fi: usize| -> Option { + let normal = faces.get(fi).and_then(crate::blend::plane_normal)?; + let den = normal.dot(t); + (den.abs() > 1e-6).then(|| q - t * (normal.dot(q - p) / den)) + }; + let a = hit(at.q1, *ga).ok_or_else(fail)?; + let b = hit(at.q2, *gb).ok_or_else(fail)?; + let m = (at.q2 - at.q1).cross(t).normalized().ok_or_else(fail)?; + let far = if middle.front() == verts.get(*k).ok_or_else(fail)? { middle.back() } else { middle.front() }; + let u = from_p3(far.point()) - p; + let den = m.dot(u); + if den.abs() < tol { + return Err(fail()); + } + let fraction = m.dot(at.q1 - p) / den; + let c = p + u * fraction; + let across = (c - at.q1).dot(at.q2 - at.q1) / (at.q2 - at.q1).len2(); + if !(fraction > 1e-6 && fraction < 1.0 - 1e-6 && across > 1e-6 && across < 1.0 - 1e-6) { + return Err(fail()); + } + let parameter = |row: usize, q: Vec3| -> Option { + let curve = fit.contacts.get(row)?; + let v = curve.search_nearest_parameter(p3(q), None, 100)?; + (v > fit.range.0 + 1e-6 && v < fit.range.1 - 1e-6 && from_p3(curve.subs(v)).dist(q) <= fit_tolerance).then_some(v) + }; + let (ta, tb) = (parameter(0, a).ok_or_else(fail)?, parameter(1, b).ok_or_else(fail)?); + let curve = BSplineCurve::try_new(KnotVec::from(vec![0.0, 0.0, 0.5, 1.0, 1.0]), vec![p3(a), p3(c), p3(b)]) + .map_err(|e| KernelError::Failed(format!("chamfer end: {e}")))?; + caps.push((*k, ke, ta, tb, mt::Curve::BSplineCurve(curve))); + split_caps.push((*k, middle.clone(), c)); + continue; + } let cap = crate::guard("curve blend end", || match tangent { Some(side) => tangent_cap(fit, g, *side, both, fit_tolerance), None => end_cap(fit, g, &at, both, round, fit_tolerance), @@ -1478,6 +1558,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, } } let mut arcs = Vec::with_capacity(nv); + let mut split_arcs: HashMap> = HashMap::new(); for (k, s) in vsec.iter().enumerate() { let (a, b) = (v1.get(k).ok_or_else(|| fail("vertex"))?, v2.get(k).ok_or_else(|| fail("vertex"))?); let e = if round { @@ -1490,6 +1571,10 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, Some((_, _, _, _, curve)) => mt::Edge::try_new(a, b, curve.clone()).map_err(|e| fail(&e.to_string()))?, None => e, }; + if let Some((_, _, c)) = split_caps.iter().find(|cap| cap.0 == k) { + let v = builder::vertex(p3(*c)); + split_arcs.insert(k, vec![builder::line(a, &v), builder::line(&v, b)]); + } arcs.push(e); } // Outward: toward the ball centre on a concave edge, away on a convex one. @@ -1575,6 +1660,11 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, } } let mut seam_subst: HashMap = HashMap::new(); + for (k, seam, _) in &split_caps { + let old = verts.get(*k).ok_or_else(|| fail("end vertex"))?; + let v = split_arcs.get(k).and_then(|es| es.first()).map(|e| e.back()).ok_or_else(|| fail("end seam"))?; + seam_subst.insert(seam.id(), crate::blend::retarget(seam, old, v, fit_tolerance)?); + } for (id, (seam, a, b)) in ends { if consumed.contains_key(&id) { continue; @@ -1630,6 +1720,16 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, for (fi, f) in faces.iter().enumerate() { let corners: Vec = end_faces.iter().filter(|(_, g)| *g == fi).map(|(k, _)| *k).collect(); if !corners.is_empty() { + if corners.iter().any(|k| split_arcs.contains_key(k)) { + let connectors: Vec = corners + .iter() + .flat_map(|k| split_arcs.get(k).cloned().unwrap_or_else(|| arcs.get(*k).cloned().into_iter().collect())) + .collect(); + let rebuilt = crate::blend::rebuild_face(f, &seam_subst, &connectors)?; + rec.face(&rebuilt, f); + out.push(rebuilt); + continue; + } // The end face (one or both ends of the chain): its two edges at a chain end now // stop at the contacts, with the blend's end arc between them. let mut wires = Vec::new(); @@ -1725,14 +1825,28 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, .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 { + let edges = if used_fwd { [e1.as_ref().map(|e| e.inverse()), Some(ak.clone()), e2.clone(), Some(ak1.inverse())] } else { [e1.clone(), Some(ak1.clone()), e2.as_ref().map(|e| e.inverse()), Some(ak.inverse())] } .into_iter() .flatten() - .collect(); + .collect::>(); + let wire: mt::Wire = edges + .into_iter() + .flat_map(|e| { + let split = [(k, ak), ((k + 1) % nv, ak1)] + .into_iter() + .find(|(_, arc)| arc.id() == e.id()) + .and_then(|(i, arc)| split_arcs.get(&i).map(|parts| (arc, parts))); + match split { + Some((arc, parts)) if e.orientation() == arc.orientation() => parts.clone(), + Some((_, parts)) => parts.iter().rev().map(|p| p.inverse()).collect(), + None => vec![e], + } + }) + .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); diff --git a/crates/kernel/src/curveblend_tests.rs b/crates/kernel/src/curveblend_tests.rs index bf160624..cf88c611 100644 --- a/crates/kernel/src/curveblend_tests.rs +++ b/crates/kernel/src/curveblend_tests.rs @@ -8,6 +8,54 @@ use crate::blend::plane_normal; use crate::issue_851_tests::planned_volume; use crate::{Body, BoolOp, boolean, cylinder, fillet}; +#[test] +fn issue_1303_chamfer_open_rim_after_end_chamfer() { + use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2}; + let rim = Region2 { + outer: Loop2 { + segs: vec![ + Seg2::Line { a: Vec2::new(31.0, -15.0), b: Vec2::new(31.0, -11.75) }, + Seg2::Line { a: Vec2::new(31.0, -11.75), b: Vec2::new(0.0, -11.75) }, + Seg2::Arc { center: Vec2::ZERO, radius: 11.75, start: -std::f64::consts::FRAC_PI_2, sweep: -std::f64::consts::PI }, + Seg2::Line { a: Vec2::new(0.0, 11.75), b: Vec2::new(31.0, 11.75) }, + Seg2::Line { a: Vec2::new(31.0, 11.75), b: Vec2::new(31.0, 15.0) }, + Seg2::Line { a: Vec2::new(31.0, 15.0), b: Vec2::new(0.0, 15.0) }, + Seg2::Arc { center: Vec2::ZERO, radius: 15.0, start: std::f64::consts::FRAC_PI_2, sweep: std::f64::consts::PI }, + Seg2::Line { a: Vec2::new(0.0, -15.0), b: Vec2::new(31.0, -15.0) }, + ], + }, + holes: vec![], + }; + let mut body = crate::extrude(&Plane::XY, &[rim], 0.0, 13.0).unwrap().pop().unwrap(); + // Exact local end bevels, meeting at the four-face vertices (30, +/-11.75, 13). + body = crate::blend_corner::chamfer( + &body, + &[Vec3::new(31.0, 13.375, 13.0), Vec3::new(31.0, -13.375, 13.0), Vec3::new(31.0, 11.75, 8.0), Vec3::new(31.0, -11.75, 8.0)], + 1.0, + ) + .unwrap(); + let picks = [Vec3::new(-11.75, 0.0, 13.0), Vec3::new(15.0, 11.75, 13.0), Vec3::new(15.0, -11.75, 13.0)]; + let result = crate::chamfer(&body, &picks, 1.0).unwrap(); + assert!(result.validity().is_empty(), "{:?}", result.validity()); + assert_eq!(result.crossed_face_loops().unwrap(), 0); + // At height 12 + u, the removed half-ring area is pi/2 * (2*r*u + u^2). + // The straight strips end at min(31-u, 30+v), 0 <= v <= u. Their integral + // over 0 <= u <= 1 is 30 + 1/6; this includes both split end sections. + let expected = 30.0 + 1.0 / 6.0 + std::f64::consts::PI * (11.75 / 2.0 + 1.0 / 6.0); + let volume = |b: &Body| b.tessellate_with(0.0001, true).unwrap().measure().volume; + let removed = volume(&body) - volume(&result); + assert!((removed - expected).abs() < 0.02, "removed {removed}, expected {expected}"); + for p in [Vec3::new(30.5, 12.25, 12.5), Vec3::new(30.5, -12.25, 12.5)] { + assert!(result.solid.vertex_iter().any(|v| from_p3(v.point()).dist(p) < 1e-6)); + } + for selection in [vec![picks[0]], picks.into_iter().rev().collect()] { + let other = crate::chamfer(&body, &selection, 1.0).unwrap(); + assert!(other.validity().is_empty(), "{:?}", other.validity()); + assert!((volume(&body) - volume(&other) - expected).abs() < 0.02); + } + assert!(crate::chamfer(&body, &picks, 4.0).is_err()); +} + fn tee(scale: f64, origin: Vec3) -> Body { let main = cylinder(origin + Vec3::new(-40.0, 0.0, 0.0) * scale, Vec3::X, 15.0 * scale, 80.0 * scale).unwrap(); let branch = cylinder(origin, Vec3::Z, 10.0 * scale, 35.0 * scale).unwrap();