diff --git a/ROADMAP.md b/ROADMAP.md index abc28802..20834a7d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -214,7 +214,14 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. edges between any two smooth sides (a branch pipe on a main pipe), by a rolling ball (the blend a rational B-spline through exact arcs), closed or ending at planar faces square to the edges, where straight or curved cap edges are trimmed to the contacts (the straight edge - between two overlapping extruded arcs). + between two overlapping extruded arcs); knife edges, where the two faces have opposite + normals (a wall that runs out tangent into a face: the ball that fits there); an edge that + runs out along an end face tangent to one side (the arc top edge of that wall), cut where the + blend meets it; rounds that end in an earlier round on curved faces, in any order (the rounds + of two edges at a vertex meet in a crease, as on a box), picked together or one after the + other, except a round after a round of the edge between the wall and a flat face that it + would run over; the three edges at a vertex of any smooth faces, of one radius, end in a + piece of sphere. - Booleans: prisms along one direction (plates with holes, slots, patterns of them, equal sections stacked end to end) are combined exactly in 2D. Qualified axial cuts preserve annular holes with overrun cutters, including inward Press Pull after a joined extrusion; diff --git a/crates/engine/src/tests/robustness.rs b/crates/engine/src/tests/robustness.rs index 55637a41..c4d6e801 100644 --- a/crates/engine/src/tests/robustness.rs +++ b/crates/engine/src/tests/robustness.rs @@ -352,3 +352,113 @@ fn lip_on_a_rounded_shelled_rim() { assert!((v - want).abs() < 1e-3 * want, "{extra}: {v} vs {want}"); } } + +/// The #851 part: a wall of radius 21 meets the front face square and runs out tangent into the +/// back face, in a knife edge at (62⅙, 0). Its two blends below are checked against the volumes +/// of the swept corner (the wall's angle integrated over the section), through the display plan. +fn knife_edge_part() -> Session { + script(json!([ + {"command": "sketch.create", "params": {"plane": "XY"}}, + {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [41.5, -21]}}, + {"command": "sketch.dimension", "params": {"entities": ["l2"], "text_at": [36.5, -8.5], "value": "21 mm"}}, + {"command": "sketch.line", "params": {"infer": true, "points": ["p2", [61.5, -21]]}}, + {"command": "sketch.dimension", "params": {"entities": ["l5"], "text_at": [54.5, -26.5], "value": "21"}}, + {"command": "sketch.circle.center", "params": {"center": "l5.end", "radius": 21}}, + {"command": "sketch.line", "params": {"infer": true, "points": ["p3", [62.166666666666664, 0]]}}, + {"command": "sketch.constraint.tangent", "params": {"a": "c1", "b": "l6"}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.extrude", "params": {"direction": "positive", "distance": "10 mm", "operation": "new", "profiles": [0, 1], "sketch": 1}} + ])) +} + +/// The volume of the session's bodies through the display plan. +fn planned_volume(s: &Session) -> f64 { + s.model.state().bodies.iter().map(|b| b.body.display_plan(2e-4).unwrap().evaluate().measure().volume).sum() +} + +/// #851: the knife edge where the wall runs out has opposite normals on its two faces. It rounds +/// to the ball that fits there and chamfers with the plane through that ball's contacts (the cusp +/// area less the wall's sector and the ball's, or the chord triangle less the wall's segment, +/// times the 10 mm height). +#[test] +fn blend_a_knife_edge_where_a_curved_wall_runs_out() { + let knife = json!([[62.1667, 0, 5]]); + for (command, params, removed) in + [("solid.fillet", json!({"edges": knife, "radius": 2}), 119.648_812), ("solid.chamfer", json!({"edges": knife, "distance": 2}), 159.236_451)] + { + let mut s = knife_edge_part(); + let before = planned_volume(&s); + s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command}: {e}")); + let after = planned_volume(&s); + assert!((before - after - removed).abs() < 0.5, "{command}: removed {}, expected {removed}", before - after); + for b in &s.model.state().bodies { + assert!(b.body.validity().is_empty(), "{command}: {:?}", b.body.validity()); + } + } +} + +/// #851: the arc's top edge runs out tangent to the back face at the knife edge's top. The blend +/// is cut by that face along a curve that touches the wall's contact at the tip. +#[test] +fn blend_an_arc_edge_that_runs_out_tangent_to_a_face() { + let arc = json!([[47.3174, -6.1508, 10]]); + for (command, params, removed) in + [("solid.fillet", json!({"edges": arc, "radius": 2}), 25.506), ("solid.chamfer", json!({"edges": arc, "distance": 2}), 58.077)] + { + let mut s = knife_edge_part(); + let before = planned_volume(&s); + s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command}: {e}")); + let after = planned_volume(&s); + assert!((before - after - removed).abs() < 0.05, "{command}: removed {}, expected {removed}", before - after); + for b in &s.model.state().bodies { + assert!(b.body.validity().is_empty(), "{command}: {:?}", b.body.validity()); + } + } +} + +/// #851: every selection of the issue's table works, picked together or one after the other as +/// the issue lists them, on the part with the wall that meets the front face square and runs out +/// tangent into the back face (the top front edge, the arc's top edge, the front upright at the +/// arc, the front-left upright and the knife edge at the tip). Each makes a sound solid the +/// display plan can mesh, and takes less than its edges would one by one where they meet. +#[test] +fn blend_corners_where_edges_meet_on_a_curved_wall() { + let (tf, at, fu, fl, kn) = + (json!([20, -21, 10]), json!([47.3174, -6.1508, 10]), json!([41.1667, -21, 5]), json!([0, -21, 5]), json!([62.1667, 0, 5])); + let fillet = |edges: Value| json!({"command": "solid.fillet", "params": {"edges": edges, "radius": 2}}); + let chamfer = |edges: Value| json!({"command": "solid.chamfer", "params": {"edges": edges, "distance": 2}}); + let base = planned_volume(&knife_edge_part()); + let rows: Vec<(&str, Vec)> = vec![ + ("three at the corner", vec![fillet(json!([tf, at, fu]))]), + ("top front and arc", vec![fillet(json!([tf, at]))]), + ("arc and upright", vec![fillet(json!([at, fu]))]), + ("top front and upright", vec![fillet(json!([tf, fu]))]), + ("arc alone", vec![fillet(json!([at]))]), + ("top front, then the front-left upright", vec![fillet(json!([tf])), fillet(json!([fl]))]), + ("top front and front-left together", vec![fillet(json!([tf, fl]))]), + ("the knife edge", vec![fillet(json!([kn]))]), + ("chamfer: the knife edge", vec![chamfer(json!([kn]))]), + ("chamfer: the arc", vec![chamfer(json!([at]))]), + ("chamfer: the arc and the top front", vec![chamfer(json!([at, tf]))]), + ("arc, then top front", vec![fillet(json!([at])), fillet(json!([tf]))]), + ("top front, then upright", vec![fillet(json!([tf])), fillet(json!([fu]))]), + ]; + for (name, steps) in rows { + let mut s = knife_edge_part(); + for step in &steps { + let (c, p) = (step["command"].as_str().unwrap_or(""), &step["params"]); + s.execute(c, p).unwrap_or_else(|e| panic!("{name}: {c} {p}: {e}")); + } + for b in &s.model.state().bodies { + assert!(b.body.validity().is_empty(), "{name}: {:?}", b.body.validity()); + assert!(b.body.display_plan(2e-4).is_ok(), "{name}: the display plan refuses the body"); + } + let removed = base - planned_volume(&s); + assert!(removed > 0.0 && removed < 250.0, "{name}: removed {removed}"); + } + // The three edges end in a sphere: the volume of the rounds and the ball's corner. + let mut s = knife_edge_part(); + s.execute("solid.fillet", &fillet(json!([tf, at, fu]))["params"]).unwrap_or_else(|e| panic!("three at the corner: {e}")); + let removed = base - planned_volume(&s); + assert!((removed - 67.05).abs() < 0.15, "three at the corner: removed {removed}, expected 67.05"); +} diff --git a/crates/kernel/src/blend.rs b/crates/kernel/src/blend.rs index 188e91f6..84d2b684 100644 --- a/crates/kernel/src/blend.rs +++ b/crates/kernel/src/blend.rs @@ -460,7 +460,14 @@ fn blend_geometry( } } let cap = cap.ok_or_else(|| unsupported("the blend's end on a curved face"))?; - ends.push(End { v: v.clone(), g: vec![ga], e1, e2, e3: None, a, b, planes: Vec::new(), ca: None, cb: None, cap: Some(cap), reach: far }); + // A rail that ends at the far end of its side edge uses that edge up (an earlier round's + // end arc, when the new blend meets it exactly where that round meets the next face). + let far_end = |e: &mt::Edge, q: Vec3| { + let f = if e.front() == v { e.back() } else { e.front() }; + (vtx(f).dist(q) <= on_tol).then(|| f.clone()) + }; + let (ca, cb) = (far_end(&e1, a), far_end(&e2, b)); + ends.push(End { v: v.clone(), g: vec![ga], e1, e2, e3: None, a, b, planes: Vec::new(), ca, cb, cap: Some(cap), reach: far }); continue; } let normal = |g: usize| -> Result { @@ -579,12 +586,9 @@ fn blend_geometry( // A side face whose corner edges are consumed at both ends is used up: its remaining edge, // parallel to the old edge at the setback, becomes the rail and the face is removed. let used_up = |fi: usize, c0: &Option, c1: &Option| -> Result> { - let (Some(x0), Some(x1)) = (c0, c1) else { - return match (c0, c1) { - (None, None) => Ok(None), - _ => Err(unsupported("the blend uses up a neighbouring face at one end only")), - }; - }; + // Used up only where it is consumed at both ends; consumed at one, the face stays and + // loses that edge. + let (Some(x0), Some(x1)) = (c0, c1) else { return Ok(None) }; let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?; if f.boundaries().len() != 1 || f.edge_iter().count() != 4 { return Err(unsupported("the blend uses up a neighbouring face with more edges")); @@ -683,11 +687,11 @@ fn blend_geometry( let nf = if i == i1 { let mut m = subst.clone(); m.insert(edge.id(), la.clone()); - rebuild_face(f, &m, &[])? + rebuild_face_dropping(f, &m, &dropped, &[])? } else if i == i2 { let mut m = subst.clone(); m.insert(edge.id(), lb.clone()); - rebuild_face(f, &m, &[])? + rebuild_face_dropping(f, &m, &dropped, &[])? } else if end0.g.contains(&i) { rebuild_face_dropping(f, &subst, &dropped, c0)? } else if end1.g.contains(&i) { @@ -758,7 +762,64 @@ fn blend_geometry( Ok((solid, rec)) } +/// Most edges whose other orders are tried when the order given is refused. +const MAX_REORDERED: usize = 4; + +/// All orders of `items` (the given one first). +fn orders(items: &[T]) -> Vec> { + if items.len() <= 1 { + return vec![items.to_vec()]; + } + let mut out = Vec::new(); + for i in 0..items.len() { + let mut rest = items.to_vec(); + let head = rest.remove(i); + for mut tail in orders(&rest) { + tail.insert(0, head.clone()); + out.push(tail); + } + } + out +} + +/// Blend the edges nearest the points, one at a time in an order that works. A blend ends in an +/// earlier round where they meet, which not every order allows (a blend over a round that an +/// edge it runs into left, or past a crease). So when the order given is refused for a reason +/// that is not the size, the other orders are tried too, and when its result has a face whose +/// 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 { + 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 reorder = (2..=MAX_REORDERED).contains(&edges.len()) + && match &first { + Ok(b) => !sound(b), + Err(KernelError::Failed(_)) => true, + Err(_) => false, + }; + if !reorder { + return first; + } + 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 sound(&done) { + return Ok(done); + } + fallback.get_or_insert(done); + } + } + match (first, fallback) { + (Ok(b), _) => Ok(b), + (Err(e), Some(done)) => { + let _ = e; + Ok(done) + } + (Err(e), None) => Err(e), + } +} + +fn blend_in_order(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Result { if !(r.is_finite() && r > 1e-6 && r < 1e6) { return Err(KernelError::Invalid(format!("{what} size must be positive"))); } @@ -801,8 +862,8 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul { 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(&cur, &rest, r, shape, what)?, &cur, &counts); - return Ok(record::after(blend(&cur, &loop_pts, r, shape, what)?, &cur, &counts)); + 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)); } if let Some(groups) = loop_groups(&cur, edges)? { for g in groups { @@ -898,18 +959,28 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul && 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(_))) }); + let local = || guard(what, || blend_one(&solid, &edge, size, r, shape)); let attempt = match chain.as_deref().filter(|_| runs_on).map(roll) { Some(Ok(o)) => Ok(o), - Some(Err(e2)) => guard(what, || blend_one(&solid, &edge, size, r, shape)).map(|o| (o, vec![j])).map_err(|_| e2), - None => match guard(what, || blend_one(&solid, &edge, size, r, shape)) { + // The chain cannot be rolled: the edge alone, locally or as a ball rolled along it + // (ending in the round the chain ran into). + Some(Err(e2)) => local() + .map(|o| (o, vec![j])) + .or_else(|_| if chain.as_ref().is_some_and(|c| c.len() > 1) { roll(&[edge.id()]) } else { Err(e2.clone()) }) + .map_err(|_| e2), + 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) => match chain.as_deref() { - Some(ids) => roll(ids).map_err(|e2| match e2 { - KernelError::TooLarge(m) => KernelError::TooLarge(format!("{m} ({e})")), - e2 => KernelError::Failed(format!("{e2} ({e})")), - }), + // 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})")), + }), None => Err(e), }, }, @@ -1258,11 +1329,14 @@ pub fn fillet(body: &Body, edges: &[Vec3], radius: f64) -> Result { blend(body, edges, radius, Shape::Round, "fillet").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).map_err(|e2| match (&e, e2) { - (KernelError::TooLarge(_), _) => e, - (_, e2 @ KernelError::TooLarge(_)) => e2, - _ => e, - }) + crate::blend_corner::fillet_radii(body, &sel) + .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).map_err(|_| e) } else { Err(e) }) }) } diff --git a/crates/kernel/src/blend_vertex.rs b/crates/kernel/src/blend_vertex.rs new file mode 100644 index 00000000..45c8f989 --- /dev/null +++ b/crates/kernel/src/blend_vertex.rs @@ -0,0 +1,400 @@ +//! Fillets of the three edges at a convex vertex of any smooth faces, of one radius: the rounds +//! end in a piece of sphere, as they do at the corner of a box. +//! +//! The ball of the fillets' radius that touches the three faces there (its centre `c`, its +//! contacts `q`) ends each round: round `k` stops at the circle where the ball meets the plane +//! through `c` and the contacts of its two faces, and the sphere's patch joins the three circles. +//! The construction uses only what a round with a plane end does already: +//! 1. cut off the corner beyond the three planes (a skewed box whose three faces are those +//! planes), so each of the three edges ends square on a plane face; +//! 2. round the three edges one after another, each ending in its plane face, which a round's +//! end arc and the sides it uses up reduce to a quarter disc of the ball's section; +//! 3. replace the three quarter discs, which meet along radii at `c`, by the sphere's patch, +//! which has the three end arcs for its boundary. + +use std::collections::{HashMap, HashSet}; + +use solidcraft_geom::Vec3; +use truck_modeling::{self as mt}; + +use crate::blend::{edge_mid, too_large, unsupported, vtx}; +use crate::body::{Body, Solid, from_p3, p3, v3}; +use crate::curveblend::project; +use crate::record; +use crate::{BoolOp, KernelError, Result}; + +/// Round the edges nearest the points, `radius` each. The three edges meeting at a vertex that +/// all lie among them end in a sphere; the others are rounded as they would be alone. +pub(crate) fn fillet_ball_vertices(body: &Body, edges: &[Vec3], radius: f64) -> Result { + body.require_brep("fillet")?; + if !(radius.is_finite() && radius > 1e-6 && radius < 1e6) { + return Err(KernelError::Invalid("fillet size must be positive".into())); + } + if edges.len() < 3 { + return Err(unsupported("a vertex with three rounded edges")); + } + let counts = record::counts(&[body]); + let mut cur = crate::boolean_record::healed(body)?; + let mut todo: Vec = edges.to_vec(); + let mut done = false; + while let Some((at, picks)) = find_vertex(&cur, &todo)? { + let next = round_vertex(&cur, at, radius)?; + cur = record::after(next, &cur, &counts); + todo.retain(|p| !picks.iter().any(|q| q.dist(*p) < 1e-12)); + done = true; + } + if !done { + return Err(unsupported("a vertex with three rounded edges")); + } + if todo.is_empty() { Ok(cur) } else { crate::blend::fillet(&cur, &todo, radius) } +} + +/// A vertex of three edges all among the picked ones (its position) and their picks. +fn find_vertex(cur: &Body, picks: &[Vec3]) -> Result)>> { + let size = cur.size(); + let tol = (size * 2e-3).max(1e-3); + let solid = cur.deep_copy(); + let all = Body::unique_edges(&solid); + // The picked edges, by identity, with the pick each came from. + let mut picked: Vec<(mt::EdgeID, Vec3)> = Vec::new(); + for p in picks { + let Some((idx, dist)) = cur.nearest_edge(*p, tol)? else { return Ok(None) }; + if dist > size * 0.05 + 1e-3 { + return Err(KernelError::Invalid(format!("no edge near {:?}", [p.x, p.y, p.z]))); + } + if let Some(e) = all.get(idx) + && !picked.iter().any(|(id, _)| *id == e.id()) + { + picked.push((e.id(), *p)); + } + } + let mut at_vertex: HashMap> = HashMap::new(); + for e in &all { + let Some((_, p)) = picked.iter().find(|(id, _)| *id == e.id()) else { continue }; + for v in [e.absolute_front(), e.absolute_back()] { + at_vertex.entry(v.id()).or_default().push((e.id(), *p)); + } + } + for (vid, list) in at_vertex { + let degree = all.iter().filter(|e| e.absolute_front().id() == vid || e.absolute_back().id() == vid).count(); + if list.len() == 3 && degree == 3 { + let v = all.iter().find_map(|e| [e.absolute_front(), e.absolute_back()].into_iter().find(|v| v.id() == vid)); + if let Some(v) = v { + return Ok(Some((vtx(v), list.iter().map(|(_, p)| *p).collect()))); + } + } + } + Ok(None) +} + +/// The ball touching three faces from inside, near the corner `v`: its centre and contacts. +struct Ball { + c: Vec3, + /// Contact with each face, and the face's outward normal there. + q: [Vec3; 3], +} + +fn ball_at(faces: &[&mt::Face; 3], v: Vec3, r: f64, tol: f64) -> Result { + let rows = |n: [Vec3; 3]| [[n[0].x, n[0].y, n[0].z], [n[1].x, n[1].y, n[1].z], [n[2].x, n[2].y, n[2].z]]; + let near = |p: Vec3, hint: &mut [Option<(f64, f64)>; 3]| -> Option<([Vec3; 3], [Vec3; 3])> { + let mut q = [Vec3::ZERO; 3]; + let mut n = [Vec3::ZERO; 3]; + for (i, f) in faces.iter().enumerate() { + let (qi, ni, uv) = project(f, p, hint[i])?; + (q[i], n[i], hint[i]) = (qi, ni, Some(uv)); + } + Some((q, n)) + }; + let mut hint = [None; 3]; + let (_, n0) = near(v, &mut hint).ok_or_else(|| unsupported("the faces at the vertex"))?; + // The planes' ball first: each face r inside its tangent plane at the vertex. + let x = crate::polyhedron::solve3_pub(rows(n0), [-r, -r, -r]).ok_or_else(|| unsupported("a vertex whose faces do not meet in a corner"))?; + let mut c = v + Vec3::new(x[0], x[1], x[2]); + for _ in 0..60 { + let (q, n) = near(c, &mut hint).ok_or_else(|| unsupported("the ball at the vertex"))?; + let g = [(c - q[0]).dot(n[0]) + r, (c - q[1]).dot(n[1]) + r, (c - q[2]).dot(n[2]) + r]; + let d = crate::polyhedron::solve3_pub(rows(n), [-g[0], -g[1], -g[2]]) + .ok_or_else(|| unsupported("a vertex whose faces do not meet in a corner"))?; + let dc = Vec3::new(d[0], d[1], d[2]); + c += dc; + if !c.is_finite() { + break; + } + if dc.len() < tol * 1e-3 && g.iter().all(|x| x.abs() < tol) { + let (q, n) = near(c, &mut hint).ok_or_else(|| unsupported("the ball at the vertex"))?; + // A convex corner: the vertex lies out from every face the ball touches. + if n.iter().any(|ni| (v - c).dot(*ni) < 0.25 * r) { + return Err(unsupported("rounds at a corner that is not convex")); + } + return Ok(Ball { c, q }); + } + } + Err(unsupported("the ball does not fit at the vertex")) +} + +/// Round the three edges at the vertex at `at`, ending in a sphere. +fn round_vertex(cur: &Body, at: Vec3, r: f64) -> Result { + let size = cur.size(); + let tol = (size * 1e-7).max(1e-9); + let solid = cur.deep_copy(); + let all = Body::unique_edges(&solid); + let face_list: Vec<&mt::Face> = solid.face_iter().collect(); + // The vertex, its three edges and the faces between them. + let v = all + .iter() + .flat_map(|e| [e.absolute_front().clone(), e.absolute_back().clone()]) + .find(|v| vtx(v).dist(at) <= size * 1e-9 + 1e-12) + .ok_or_else(|| KernelError::Failed("fillet vertex".into()))?; + let edges: Vec = all.iter().filter(|e| *e.absolute_front() == v || *e.absolute_back() == v).cloned().collect(); + let faces_of = |e: &mt::Edge| -> Vec { + (0..face_list.len()).filter(|i| face_list.get(*i).is_some_and(|f| f.edge_iter().any(|x| x.id() == e.id()))).collect() + }; + let mut around: Vec = Vec::new(); + for e in &edges { + for f in faces_of(e) { + if !around.contains(&f) { + around.push(f); + } + } + } + let [fa, fb, fc] = around[..] else { return Err(unsupported("a vertex of three faces")) }; + let (Some(a), Some(b), Some(c)) = (face_list.get(fa), face_list.get(fb), face_list.get(fc)) else { + return Err(KernelError::Failed("faces".into())); + }; + let faces = [*a, *b, *c]; + let ball = ball_at(&faces, vtx(&v), r, tol * 100.0)?; + // The plane of each edge's end: through the centre and the contacts of the edge's two faces, + // its normal out toward the vertex. + let mut normals: Vec = Vec::with_capacity(3); + for e in &edges { + let sides: Vec = faces_of(e).iter().filter_map(|f| around.iter().position(|x| x == f)).collect(); + let [i, j] = sides[..] else { return Err(unsupported("an edge not between two faces")) }; + let (Some(qi), Some(qj)) = (ball.q.get(i), ball.q.get(j)) else { return Err(KernelError::Failed("contacts".into())) }; + let m = (*qi - ball.c).cross(*qj - ball.c).normalized().ok_or_else(|| unsupported("a ball that touches an edge's faces at one point"))?; + normals.push(if m.dot(vtx(&v) - ball.c) < 0.0 { -m } else { m }); + } + let [m0, m1, m2] = normals[..] else { return Err(KernelError::Failed("planes".into())) }; + // The corner beyond the three planes, a skewed box from the centre along the three lines + // where two of the planes meet, long enough to hold the corner of the body. + let gen_ = |x: Vec3, y: Vec3, toward: Vec3| -> Result { + let g = x.cross(y).normalized().ok_or_else(|| unsupported("planes that do not meet in a corner"))?; + Ok(if g.dot(toward) < 0.0 { -g } else { g }) + }; + let reach = 3.0 * (vtx(&v).dist(ball.c) + r); + let (g0, g1, g2) = (gen_(m1, m2, m0)? * reach, gen_(m2, m0, m1)? * reach, gen_(m0, m1, m2)? * reach); + let unit = crate::build::box_solid(Vec3::ZERO, Vec3::new(1.0, 1.0, 1.0))?; + let m = [[g0.x, g0.y, g0.z, 0.0], [g1.x, g1.y, g1.z, 0.0], [g2.x, g2.y, g2.z, 0.0], [ball.c.x, ball.c.y, ball.c.z, 1.0]]; + let cutter = crate::ops::transform_matrix(&unit, m)?; + let notched = crate::ops::boolean(cur, &cutter, BoolOp::Cut)?.ok_or_else(|| unsupported("a corner this small"))?; + // The cut splits a curved face along the trace of its plane (the whole way across, not just + // where the plane cuts): the pieces are one face again. + let notched = merge_split_faces(¬ched, size)?; + // A point on what is left of each edge, half way from where its plane crosses it to its far end. + let mut picks: Vec = Vec::with_capacity(3); + for (e, m) in edges.iter().zip(&normals) { + picks.push(remaining_pick(e, &v, ball.c, *m)?); + } + // Round them one after another, in an order that works. + let mut last: Option = None; + for order in [[0usize, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]] { + let mut step = notched.clone(); + let mut ok = true; + for k in order { + let Some(p) = picks.get(k) else { return Err(KernelError::Failed("pick".into())) }; + match crate::blend::fillet(&step, &[*p], r) { + Ok(b) => step = b, + Err(e) => { + last = Some(e); + ok = false; + break; + } + } + } + if ok { + return close_with_sphere(&step, &ball, [m0, m1, m2], r, vtx(&v)); + } + } + Err(last.unwrap_or_else(|| unsupported("the three rounds at a vertex"))) +} + +/// A point on edge `e` between where the plane (through `c`, normal `m`) crosses it and its far +/// end (the end that is not `v`). +fn remaining_pick(e: &mt::Edge, v: &mt::Vertex, c: Vec3, m: Vec3) -> Result { + use mt::{BoundedCurve, ParametricCurve}; + let curve = e.curve(); + let (lo, hi) = curve.range_tuple(); + // From the end at the vertex (outside the plane's far side) to the far end. + let (near, far) = if e.absolute_front() == v { (lo, hi) } else { (hi, lo) }; + let side = |t: f64| (from_p3(curve.subs(t)) - c).dot(m); + let (mut a, mut b) = (near, far); + if !(side(a) > 0.0 && side(b) < 0.0) { + return Err(too_large("the blend is larger than the neighbouring faces")); + } + for _ in 0..80 { + let mid = 0.5 * (a + b); + if side(mid) > 0.0 { a = mid } else { b = mid } + } + Ok(from_p3(curve.subs(0.5 * (0.5 * (a + b) + far)))) +} + +/// `step` has the three rounds ending in three quarter discs; the sphere's patch takes their place. +fn close_with_sphere(step: &Body, ball: &Ball, planes: [Vec3; 3], r: f64, v: Vec3) -> Result { + let size = step.size(); + let on_tol = (size * 1e-6).max(1e-9); + let solid = step.deep_copy(); + let faces: Vec = solid.face_iter().cloned().collect(); + // The face in each plane: a plane face through the centre. + let mut discs: Vec = Vec::new(); + for m in planes { + let found = faces.iter().position(|f| { + crate::blend::plane_normal(f).is_some_and(|n| n.dot(m).abs() > 1.0 - 1e-9) + && f.vertex_iter().next().is_some_and(|p| (vtx(&p) - ball.c).dot(m).abs() <= on_tol * 10.0) + && f.vertex_iter().any(|p| vtx(&p).dist(ball.c) <= on_tol * 10.0) + }); + let Some(i) = found else { return Err(unsupported("the ends of the three rounds")) }; + discs.push(i); + } + // The arc of each: its one edge that is not a straight line. + let mut arcs: Vec = Vec::with_capacity(3); + for i in &discs { + let f = faces.get(*i).ok_or_else(|| KernelError::Failed("face".into()))?; + let curved: Vec = f.edge_iter().filter(|e| !matches!(e.curve(), mt::Curve::Line(_))).collect(); + let [arc] = &curved[..] else { return Err(unsupported("the ends of the three rounds")) }; + arcs.push(arc.absolute_clone()); + } + // The three arcs in a loop. + let first_arc = arcs.first().cloned().ok_or_else(|| KernelError::Failed("arc".into()))?; + let mut used: HashSet = HashSet::from([first_arc.id()]); + let mut wire: Vec = vec![first_arc]; + for _ in 0..2 { + let end = wire.last().map(|e| e.back().clone()).ok_or_else(|| KernelError::Failed("arc".into()))?; + let next = arcs + .iter() + .find(|a| !used.contains(&a.id()) && (*a.front() == end || *a.back() == end)) + .ok_or_else(|| unsupported("the ends of the three rounds"))?; + used.insert(next.id()); + wire.push(if *next.front() == end { next.clone() } else { next.inverse() }); + } + if wire.last().map(|e| e.back().clone()) != wire.first().map(|e| e.front().clone()) { + return Err(unsupported("the ends of the three rounds")); + } + // A sphere about the centre, its poles and seam away from the patch. + let out = (v - ball.c).normalized().ok_or_else(|| KernelError::Failed("sphere".into()))?; + let side = out.any_perp(); + let (np, sp, back) = (ball.c + side * r, ball.c - side * r, ball.c - out * r); + let meridian = mt::builder::circle_arc(&mt::builder::vertex(p3(np)), &mt::builder::vertex(p3(sp)), p3(back)); + let mut surf = + mt::Surface::RevolutedCurve(mt::Processor::new(mt::RevolutedCurve::by_revolution(meridian.oriented_curve(), p3(ball.c), v3(side)))); + { + use mt::{ParametricSurface3D, SearchNearestParameter}; + if let Some((u, w)) = surf.search_nearest_parameter(p3(ball.c + out * r), None, 100) { + let n = surf.normal(u, w); + if Vec3::new(n.x, n.y, n.z).dot(out) < 0.0 { + surf = mt::Invertible::inverse(&surf); + } + } + } + // The patch runs each arc opposite to the round that ends on it. + let rest: Vec<&mt::Face> = faces.iter().enumerate().filter(|(i, _)| !discs.contains(i)).map(|(_, f)| f).collect(); + let uses = |id: mt::EdgeID| { + rest.iter().find_map(|f| { + f.boundaries().iter().flat_map(|w| w.edge_iter().cloned().collect::>()).find(|x| x.id() == id).map(|x| x.front().id()) + }) + }; + if let (Some(first), Some(front)) = (wire.first(), wire.first().and_then(|e| uses(e.id()))) + && front == first.front().id() + { + wire = wire.iter().rev().map(|e| e.inverse()).collect(); + } + let patch = mt::Face::try_new(vec![mt::Wire::from(wire)], surf).map_err(|e| KernelError::Failed(format!("sphere patch: {e}")))?; + let mut new_faces: Vec = rest.into_iter().cloned().collect(); + new_faces.push(patch); + let mut shells = solid.boundaries().clone(); + let si = shells.iter().position(|sh| sh.face_iter().any(|f| discs.iter().any(|i| faces.get(*i).is_some_and(|d| d.id() == f.id())))).unwrap_or(0); + if let Some(sh) = shells.get_mut(si) { + *sh = new_faces.into(); + } + let out: Solid = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("rounds ending in a sphere: {e}")))?; + record::rebuilt(out, &[&step.solid], &record::Rebuild::default()) +} + +/// `body` with the curved faces that are pieces of one input face (as the cut that made `body` +/// recorded them) joined again: the edges two pieces share go, the rest make the one boundary. +fn merge_split_faces(body: &Body, size: f64) -> Result { + let tol = (size * 1e-6).max(1e-9); + let Some(history) = body.history() else { return Ok(body.clone()) }; + let solid = body.deep_copy(); + let faces: Vec = solid.face_iter().cloned().collect(); + if history.faces.len() != faces.len() { + return Ok(body.clone()); + } + let on = |a: &mt::Face, p: Vec3| project(a, p, None).is_some_and(|(q, _, _)| q.dist(p) <= tol * 10.0); + // Pieces by the input face they come from. + let mut groups: Vec> = Vec::new(); + for (i, f) in history.faces.iter().enumerate() { + let curved = faces.get(i).is_some_and(|x| crate::blend::plane_normal(x).is_none()); + if !(curved && f.change == crate::history::Change::Split && f.origins.len() == 1) { + continue; + } + match groups.iter_mut().find(|g| g.first().and_then(|k| history.faces.get(*k)).is_some_and(|o| o.origins == f.origins)) { + Some(g) => g.push(i), + None => groups.push(vec![i]), + } + } + let mut out: Vec> = faces.iter().cloned().map(Some).collect(); + let mut merged_any = false; + for group in groups.iter().filter(|g| g.len() >= 2) { + let pieces: Vec<&mt::Face> = group.iter().filter_map(|i| faces.get(*i)).collect(); + if pieces.iter().any(|f| f.boundaries().len() != 1) { + continue; + } + // Edges used once, as the pieces walk them. + let all: Vec = pieces + .iter() + .flat_map(|f| f.absolute_boundaries().iter().flat_map(|w| w.edge_iter().cloned().collect::>()).collect::>()) + .collect(); + let mut uses: HashMap = HashMap::new(); + for e in &all { + *uses.entry(e.id()).or_default() += 1; + } + let mut left: Vec = all.into_iter().filter(|e| uses.get(&e.id()) == Some(&1)).collect(); + let Some(start) = left.pop() else { continue }; + let mut wire = vec![start]; + while let Some(end) = wire.last().map(|e| e.back().clone()) { + if wire.first().is_some_and(|e| *e.front() == end) { + break; + } + let Some(at) = left.iter().position(|e| *e.front() == end) else { break }; + wire.push(left.swap_remove(at)); + } + // One closed boundary, all of it used. + if !left.is_empty() || wire.first().map(|e| e.front().clone()) != wire.last().map(|e| e.back().clone()) { + continue; + } + // On the surface of the piece whose patch reaches every corner of all. + let all_on = |f: &mt::Face| { + pieces.iter().all(|p| p.vertex_iter().all(|v| on(f, vtx(&v))) && p.edge_iter().all(|e| edge_mid(&e).is_none_or(|m| on(f, m)))) + }; + let Some(like) = pieces.iter().copied().find(|f| all_on(f)) else { continue }; + let Some(joined) = crate::heal::absolute_face(like, vec![mt::Wire::from(wire)]) else { continue }; + let Some(first) = group.first() else { continue }; + for i in group { + if let Some(slot) = out.get_mut(*i) { + *slot = None; + } + } + if let Some(slot) = out.get_mut(*first) { + *slot = Some(joined); + } + merged_any = true; + } + if !merged_any { + return Ok(body.clone()); + } + let mut shells = solid.boundaries().clone(); + if let Some(sh) = shells.first_mut() { + *sh = out.into_iter().flatten().collect::>().into(); + } + let rebuilt = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("a corner cut: {e}")))?; + record::rebuilt(rebuilt, &[&body.solid], &record::Rebuild::default()) +} diff --git a/crates/kernel/src/curveblend.rs b/crates/kernel/src/curveblend.rs index 631c116e..2a05786a 100644 --- a/crates/kernel/src/curveblend.rs +++ b/crates/kernel/src/curveblend.rs @@ -26,6 +26,15 @@ const MAX_SAMPLES: usize = 369; const MAX_CHAIN_SAMPLES: usize = 100_000; /// Sections past a slanted end of an open chain. const EXTENSION: usize = 8; +/// `1 + n1·n2` below this: a knife edge, where the faces' normals are opposite and the ball's +/// centre is found by sliding along the faces rather than from their normals. +const KNIFE: f64 = 1e-4; +/// An end face this close to square-on-to-its-sides' normal counts as tangent to a side. +const TANGENT_END: f64 = 1e-6; +/// Times the run past a tangent end is halved when the ball does not fit along it. +const TANGENT_RETRIES: usize = 8; +/// Slides of the ball's centre along a knife edge's faces, each 1.6 times the last. +const KNIFE_STEPS: usize = 40; struct Fit { surface: mt::Surface, @@ -177,7 +186,7 @@ fn section_fits(fit: &Fit, section: &Section, along: f64, round: bool, sigma: f6 /// Where a blend strip run on past a chain end meets the end face: the contacts' parameters /// there and the end curve from contact 1 to contact 2 (fitted through points where the /// strip's cross-section curves meet the face's surface, and checked against both). -fn end_cap(fit: &Fit, g: &mt::Face, at: &Section, round: bool, tolerance: f64) -> Result<(f64, f64, mt::Curve)> { +fn end_cap(fit: &Fit, g: &mt::Face, at: &Section, seams: [Option<&mt::Edge>; 2], round: bool, tolerance: f64) -> Result<(f64, f64, mt::Curve)> { use mt::{ParametricCurve, ParametricSurface, SearchNearestParameter}; let fail = || unsupported("the blend's end on its end face"); let (surf, gs) = (&fit.surface, g.surface()); @@ -235,6 +244,7 @@ fn end_cap(fit: &Fit, g: &mt::Face, at: &Section, round: bool, tolerance: f64) - vs.push(v); pts.push(from_p3(surf.subs(i as f64 / m as f64, v))); } + snap_to_seams(&mut pts, seams, tolerance); let bs = crate::build::interpolate_cubic(&pts).ok_or_else(fail)?; let fits = (0..m).all(|i| { let q = p3(from_p3(bs.subs((i as f64 + 0.5) / m as f64))); @@ -256,6 +266,148 @@ fn end_cap(fit: &Fit, g: &mt::Face, at: &Section, round: bool, tolerance: f64) - Err(fail()) } +/// The ends of an end curve (contact 1 to contact 2) that land within `tolerance` of an end of +/// their side's seam are that point exactly: the blend and the faces there meet at one vertex. +fn snap_to_seams(pts: &mut [Vec3], seams: [Option<&mt::Edge>; 2], tolerance: f64) { + let snap = |p: &mut Vec3, seam: Option<&mt::Edge>| { + for q in seam.into_iter().flat_map(|e| [from_p3(e.front().point()), from_p3(e.back().point())]) { + if p.dist(q) <= tolerance { + *p = q; + } + } + }; + if let Some(first) = pts.first_mut() { + snap(first, seams[0]); + } + if let Some(last) = pts.last_mut() { + snap(last, seams[1]); + } +} + +/// Where a blend strip run on past a chain end meets an end face that lies along the edge and is +/// tangent to one side there (a wall running out into the face, an earlier round that touches a +/// side along its contact): the end curve from contact 1 to contact 2, the same result as +/// [`end_cap`]. The strip touches the face along the tangent side's contact, so a Newton solve +/// for the crossing is singular there. +/// +/// Both ends of the curve are where a contact meets a seam, which is a plain crossing: the +/// tangent side's contact leaves the end face where it crosses the seam of that side with the +/// face, the other contact where it crosses the other side's seam. Between those stations each +/// cross-section crosses the face once, a simple root. Either station may lie before or after +/// the vertex's section: the curve runs inwards along the edge or out along the extension. +fn tangent_cap(fit: &Fit, g: &mt::Face, tangent_side: u8, seams: [Option<&mt::Edge>; 2], tolerance: f64) -> Result<(f64, f64, mt::Curve)> { + use mt::{BoundedCurve, ParametricCurve, ParametricSurface, SearchNearestParameter}; + let fail = || unsupported("the blend's end on its end face"); + let surf = &fit.surface; + let (lo, hi) = (fit.range.0.min(fit.range.1), fit.range.0.max(fit.range.1)); + let (u_t, u_o, seam_t, seam_o) = if tangent_side == 1 { (0.0, 1.0, seams[0], seams[1]) } else { (1.0, 0.0, seams[1], seams[0]) }; + let (seam_t, seam_o) = (seam_t.ok_or_else(fail)?, seam_o.ok_or_else(fail)?); + // The station where the strip's contact `u` crosses `seam`. + let crossing = |u: f64, seam: &mt::Edge| -> Option { + let curve = seam.curve(); + let (a, b) = curve.range_tuple(); + let polyline: Vec = (0..=1024).map(|i| from_p3(curve.subs(a + (b - a) * f64::from(i) / 1024.0))).collect(); + let dist = |v: f64| -> f64 { + let x = from_p3(surf.subs(u, v)); + polyline.windows(2).map(|w| x.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min) + }; + let n = 400; + let step = (hi - lo) / f64::from(n); + let (mut best, mut best_d) = (lo, f64::INFINITY); + for i in 0..=n { + let v = lo + step * f64::from(i); + let d = dist(v); + if d < best_d { + (best, best_d) = (v, d); + } + } + let (mut x0, mut x1) = ((best - step).max(lo), (best + step).min(hi)); + let phi = 0.618_033_988_749_894_9; + for _ in 0..80 { + let (c, d) = (x1 - phi * (x1 - x0), x0 + phi * (x1 - x0)); + if dist(c) < dist(d) { + x1 = d; + } else { + x0 = c; + } + } + let v = 0.5 * (x0 + x1); + (dist(v) <= tolerance).then_some(v) + }; + let (rt, ra) = (crossing(u_t, seam_t), crossing(u_o, seam_o)); + let v_t = rt.ok_or_else(fail)?; + let v_a = ra.ok_or_else(|| too_large("the blend is larger than the neighbouring faces"))?; + let inside = |t: f64| t > lo + 1e-6 && t < hi - 1e-6; + if !(inside(v_a) && inside(v_t)) || (v_a - v_t).abs() < 1e-9 { + return Err(too_large("the blend is larger than the neighbouring faces")); + } + // How far the strip's point (u, v) is outside the end face (negative: inside the body). + let outside = |u: f64, v: f64| -> Option { + let x = from_p3(surf.subs(u, v)); + let (q, n, _) = project(g, x, None)?; + Some((x - q).dot(n)) + }; + // Root of `f` between `lo` and `hi`, `f(lo)` and `f(hi)` of different sign. + let bisect = |f: &dyn Fn(f64) -> Option, mut x0: f64, mut x1: f64| -> Option { + let f0 = f(x0)?; + for _ in 0..60 { + let mid = 0.5 * (x0 + x1); + let fm = f(mid)?; + if fm.abs() <= tolerance * 1e-6 { + return Some(mid); + } + if fm.is_sign_negative() == f0.is_sign_negative() { + x0 = mid; + } else { + x1 = mid; + } + } + Some(0.5 * (x0 + x1)) + }; + for m in [16usize, 32, 64, 128] { + // The curve at stations v_i from the crossing contact to the tangent one, as (u, v, point). + let mut stations: Vec<(f64, f64, Vec3)> = Vec::with_capacity(m + 1); + for i in 0..=m { + // Clustered at both ends, where the curve can meet a contact tangentially (a square + // root in the station) and a uniform spacing would fit it poorly. + let v = v_a + (v_t - v_a) * 0.5 * (1.0 - (std::f64::consts::PI * i as f64 / m as f64).cos()); + let u = if i == 0 { + u_o + } else if i == m { + u_t + } else { + let at_t = outside(u_t, v).ok_or_else(fail)?; + let at_o = outside(u_o, v).ok_or_else(fail)?; + // No crossing where the contacts are on one side: the curve is at the tangent one. + if at_t * at_o >= 0.0 { u_t } else { bisect(&|x| outside(x, v), u_o, u_t).ok_or_else(fail)? } + }; + stations.push((u, v, from_p3(surf.subs(u, v)))); + } + let mut pts: Vec = stations.iter().map(|x| x.2).collect(); + // The curve runs from contact 1 to contact 2. + if u_o == 1.0 { + pts.reverse(); + } + snap_to_seams(&mut pts, seams, tolerance); + let bs = crate::build::interpolate_cubic(&pts).ok_or_else(fail)?; + let gs = g.surface(); + let fits = stations.windows(2).enumerate().all(|(i, w)| { + let (Some(a), Some(b)) = (w.first(), w.get(1)) else { return false }; + let t = if u_o == 1.0 { 1.0 - (i as f64 + 0.5) / m as f64 } else { (i as f64 + 0.5) / m as f64 }; + let q = p3(from_p3(bs.subs(t))); + let hint = (0.5 * (a.0 + b.0), 0.5 * (a.1 + b.1)); + let on_g = gs.search_nearest_parameter(q, None, 100).is_some_and(|(x, y)| from_p3(gs.subs(x, y)).dist(from_p3(q)) <= tolerance); + let on_s = surf.search_nearest_parameter(q, Some(hint), 100).is_some_and(|(x, y)| from_p3(surf.subs(x, y)).dist(from_p3(q)) <= tolerance); + on_g && on_s + }); + if fits { + let (t1, t2) = if u_o == 0.0 { (v_a, v_t) } else { (v_t, v_a) }; + return Ok((t1, t2, mt::Curve::BSplineCurve(bs))); + } + } + Err(fail()) +} + /// One cross-section of the blend: ball centre and its contacts with side 1 and side 2. #[derive(Clone, Copy, Debug)] struct Section { @@ -265,7 +417,7 @@ struct Section { } /// Nearest point of a face's surface and the outward normal there. -fn project(f: &mt::Face, p: Vec3, hint: Option<(f64, f64)>) -> Option<(Vec3, Vec3, (f64, f64))> { +pub(crate) fn project(f: &mt::Face, p: Vec3, hint: Option<(f64, f64)>) -> Option<(Vec3, Vec3, (f64, f64))> { use mt::{ParametricSurface, ParametricSurface3D, SearchNearestParameter}; let s = f.oriented_surface(); let uv = s.search_nearest_parameter(p3(p), hint, 100).or_else(|| s.search_nearest_parameter(p3(p), None, 100))?; @@ -294,8 +446,20 @@ fn in_range(f: &mt::Face, uv: (f64, f64)) -> bool { /// Solve for the ball touching both sides near `p`. `extra` adds a third condition (a plane the /// side-k contact must lie in); without it the centre stays in the plane through `p` square to /// `t`. `sigma` is +1 when the ball sits outside the material (concave edge), −1 inside. +/// `cusp` is ±1 at a knife edge (the faces' normals opposite: both leave the edge the same way, +/// along `cusp · n1 × t`, and the ball lies where the cusp has opened to its width), else 0. #[allow(clippy::too_many_arguments)] -fn solve(f1: &[&mt::Face], f2: &[&mt::Face], p: Vec3, t: Vec3, r: f64, sigma: f64, extra: Option<(usize, Vec3, Vec3)>, tol: f64) -> Option
{ +fn solve( + f1: &[&mt::Face], + f2: &[&mt::Face], + p: Vec3, + t: Vec3, + r: f64, + sigma: f64, + cusp: f64, + extra: Option<(usize, Vec3, Vec3)>, + tol: f64, +) -> 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 // answer outside the surface's parameters, or farther than that last point (the search @@ -320,10 +484,47 @@ fn solve(f1: &[&mt::Face], f2: &[&mt::Face], p: Vec3, t: Vec3, r: f64, sigma: f6 let (q2, n2) = near(f2, p)?; let _ = (q1, q2); let den = 1.0 + n1.dot(n2); - if den.abs() < 1e-9 { - return None; - } - let mut c = p + (n1 + n2) * (sigma * r / den); + let mut c = 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; + let centre = |s: f64| -> Option { + let (qa, na) = near(f1, p + w * s)?; + Some(qa + na * (sigma * r)) + }; + let gap = |s: f64| -> Option { + let c = centre(s)?; + let (q2, n2) = near(f2, c)?; + Some((c - q2).dot(n2) - sigma * r) + }; + let (mut lo, mut g_lo) = (0.0, gap(0.0)?); + let mut hi = None; + let mut s = r * 0.05; + for _ in 0..KNIFE_STEPS { + let Some(g) = gap(s) else { break }; + if g == 0.0 || g.is_sign_negative() != g_lo.is_sign_negative() { + hi = Some(s); + break; + } + (lo, g_lo) = (s, g); + s *= 1.6; + } + let mut hi = hi?; + for _ in 0..60 { + let mid = 0.5 * (lo + hi); + match gap(mid) { + Some(g) if g.is_sign_negative() == g_lo.is_sign_negative() => lo = mid, + Some(_) => hi = mid, + None => break, + } + } + centre(0.5 * (lo + hi))? + } else { + if den.abs() < 1e-9 { + return None; + } + p + (n1 + n2) * (sigma * r / den) + }; for _ in 0..60 { let (q1, n1) = near(f1, c)?; let (q2, n2) = near(f2, c)?; @@ -549,7 +750,23 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, (Some(x), Some(y)) => (x.1, y.1), _ => return Err(KernelError::Failed("blend: normals".into())), }; - let convex = t1.dot(n1.cross(n2)) > 0.0; + // A knife edge (opposite normals): both faces leave the edge along `w`; the second one lies + // below the first (against its outward normal) when the cusp is material, above when it is + // a gap. The cross product of the normals, which tells the other edges apart, is zero here. + let knife = 1.0 + n1.dot(n2) < KNIFE; + let (convex, cusp) = if knife { + let w = n1.cross(t1).normalized().ok_or_else(|| KernelError::Failed("blend: cusp direction".into()))?; + // Second order: from the point of face 1 a little way along `w`, face 2's nearest point. + let probe = project(f1, pm + w * r, None) + .and_then(|(qa, na, _)| s2.first().and_then(|f| project(f, qa, None)).map(|(qb, _, _)| (qb - qa).dot(na))) + .filter(|depth| depth.abs() > size * 1e-9) + .ok_or_else(|| unsupported("a knife edge whose faces lie flat on each other"))?; + // Whether the chain runs along the edge as face 1 uses it decides which way `w` points + // from the chain's tangent. + (probe < 0.0, if in_f1.front() == e0.front() { 1.0 } else { -1.0 }) + } else { + (t1.dot(n1.cross(n2)) > 0.0, 0.0) + }; let sigma = if convex { -1.0 } else { 1.0 }; if n1.dot(n2) > 1.0 - 1e-6 { return Err(unsupported("blending a smooth edge")); @@ -559,8 +776,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) - // Chain ends whose end face is not a plane square to the edge: (vertex, face, point, tangent). - let mut slanted: Vec<(usize, usize, Vec3, Vec3)> = 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(); for (k, v) in verts.iter().enumerate() { let at_end = !closed && (k == 0 || k == nv - 1); let (k1, k2) = { @@ -629,7 +847,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, _ => false, }; if !square { - slanted.push((k, fi, p, t)); + slanted.push((k, fi, p, t, None)); } } let extra = match here.first().cloned().filter(|_| !at_end) { @@ -654,7 +872,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, } }; let (a1, a2) = edge_sides(&vertex_edges(k)); - let s = solve(&a1, &a2, p, t, r, sigma, extra, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge"))?; + 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 }; @@ -668,25 +886,46 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, // Past a slanted end: sections of the same rolling ball beyond the end vertex (far to near // at the chain's start, near to far at its end), far enough to cross the end face. let mut beyond: Vec<(usize, Vec
)> = Vec::new(); - for (k, fi, p, t) in &slanted { + for (k, fi, p, t, tangent) in slanted.iter_mut() { 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(); - if cos < 0.15 { + // 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. + let (e1, e2) = edge_sides(&vertex_edges(*k)); + *tangent = if cos < TANGENT_END { + [(1u8, &e1), (2u8, &e2)].into_iter().find_map(|(side, faces)| { + faces.iter().filter_map(|f| project(f, *p, None)).any(|(_, n, _)| n.dot(ng).abs() > 1.0 - TANGENT_END).then_some(side) + }) + } else { + None + }; + if cos < 0.15 && tangent.is_none() { return Err(unsupported("an end face almost along the edge")); } let width = at.q1.dist(*p).max(at.q2.dist(*p)).max(at.c.dist(*p)) + r; - let reach = 1.5 * width / cos; + let reach = 1.5 * width / if tangent.is_some() { 1.0 } else { cos }; let out = if *k == 0 { -*t } else { *t }; - let (e1, e2) = edge_sides(&vertex_edges(*k)); + // Past a tangent end the straight run along the tangent leaves the curved spine: where + // the ball no longer fits, the run is shortened (it only gives the fit its context). let mut list = Vec::with_capacity(EXTENSION); - for j in 1..=EXTENSION { - let anchor = *p + out * (reach * j as f64 / EXTENSION as f64); - list.push( - solve(&e1, &e2, anchor, *t, r, sigma, None, tol * 100.0) - .ok_or_else(|| unsupported("the ball doesn't fit past the end of the edge"))?, - ); + let mut reach = reach; + for attempt in 0..(if tangent.is_some() { TANGENT_RETRIES } else { 1 }) { + list.clear(); + let sections = (1..=EXTENSION).map(|j| { + let anchor = *p + out * (reach * j as f64 / EXTENSION as f64); + solve(&e1, &e2, anchor, *t, r, sigma, cusp, None, tol * 100.0) + }); + if let Some(all) = sections.collect::>>() { + list = all; + break; + } + if attempt + 1 == TANGENT_RETRIES || tangent.is_none() { + return Err(unsupported("the ball doesn't fit past the end of the edge")); + } + reach *= 0.5; } if *k == 0 { list.reverse(); @@ -716,7 +955,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, let (start, end) = offsets.get(k).copied().ok_or_else(|| KernelError::Failed("section offset".into()))?; let anchor = p + t * (start * (1.0 - along) + end * along); let (a1, a2) = edge_sides(&[k]); - solve(&a1, &a2, anchor, t, r, sigma, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge")) + solve(&a1, &a2, anchor, t, r, sigma, cusp, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge")) }; let mut samples = SAMPLES.min(max_samples); let (esec, fits) = loop { @@ -753,7 +992,8 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, } let (anchor, tangent) = guide.anchor(along).ok_or_else(|| unsupported("degenerate rolling-ball spine"))?; let (a1, a2) = edge_sides(&[k]); - solve(&a1, &a2, anchor, tangent, r, sigma, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the resampled spine")) + solve(&a1, &a2, anchor, tangent, r, sigma, cusp, None, tol * 100.0) + .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::>>()) @@ -812,12 +1052,17 @@ 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(); - for (k, fi, _, _) in &slanted { + 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 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 cap = crate::guard("curve blend end", || end_cap(fit, g, &at, round, fit_tolerance))?; + 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)]; + 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), + })?; caps.push((*k, ke, cap.0, cap.1, cap.2)); } // Both ends of one edge cut back: the contacts must keep a piece between them. @@ -840,6 +1085,23 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, *b = builder::vertex(curve.subs(hi)); } } + // 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(); + for (k, _) in &end_faces { + for (side, seam) in seams.get(*k).into_iter().flatten() { + let Some(old) = verts.get(*k) else { continue }; + let far = if seam.front() == old { seam.back() } else { seam.front() }; + let slot = if *side == 1 { v1.get_mut(*k) } else { v2.get_mut(*k) }; + if let Some(nv) = slot + && far != old + && from_p3(far.point()).dist(from_p3(nv.point())) <= fit_tolerance + { + *nv = far.clone(); + consumed.insert(seam.id(), far.clone()); + } + } + } let mut arcs = Vec::with_capacity(nv); 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"))?); @@ -922,6 +1184,9 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, } let mut seam_subst: HashMap = HashMap::new(); for (id, (seam, a, b)) in ends { + if consumed.contains_key(&id) { + continue; + } // At a slanted end the blend may stop past the seam's old end: the seam then runs // on along its line or circle, within its planar face. let one_end = match (&a, &b) { @@ -988,13 +1253,17 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, } None => e.clone(), }; - // Arriving at an old corner: continue along that end's arc. + // Arriving at an old corner: continue along that end's arc. A seam the + // blend used up is left out, and the arc starts at its far end. let arrives = corners.iter().copied().find(|k| verts.get(*k).is_some_and(|v| *e.back() == *v)); - es.push(ne.clone()); + let used_up = consumed.contains_key(&e.id()); + if !used_up { + es.push(ne.clone()); + } if let Some(k) = arrives { let arc = arcs.get(k).ok_or_else(|| fail("arc"))?; let (a1, a2) = (v1.get(k).ok_or_else(|| fail("vertex"))?, v2.get(k).ok_or_else(|| fail("vertex"))?); - let to = ne.back(); + let to = if used_up { e.front() } else { ne.back() }; let corner = if to == a1 { arc.clone() } else if to == a2 { @@ -1029,6 +1298,8 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, if e.front() == chain_e.front() { base.clone() } else { base.inverse() } } else if let Some(s) = seam_subst.get(&e.id()) { if e.front() == e.absolute_front() { s.clone() } else { s.inverse() } + } else if consumed.contains_key(&e.id()) { + continue; } else { e.clone() }; diff --git a/crates/kernel/src/curveblend_tests.rs b/crates/kernel/src/curveblend_tests.rs index ddcd240c..522f94df 100644 --- a/crates/kernel/src/curveblend_tests.rs +++ b/crates/kernel/src/curveblend_tests.rs @@ -4,6 +4,7 @@ use mt::{BoundedCurve, ParametricCurve, ParametricSurface, ParametricSurface3D, SearchNearestParameter}; use super::*; +use crate::blend::plane_normal; use crate::{Body, BoolOp, boolean, cylinder, fillet}; fn tee(scale: f64, origin: Vec3) -> Body { @@ -352,6 +353,28 @@ fn a_refused_edge_is_not_rolled_again_after_blends_far_from_it() { assert!(fillet(&body, &ends, 3.0).is_ok()); } +/// At a knife edge (the plane and the wall's normals opposite) the ball's centre is the same +/// whichever face is side 1: `r` inside the plane and `r` outside the wall's cylinder. +#[test] +fn knife_edge_ball_centre_does_not_depend_on_the_side_order() { + const R: f64 = 21.0; + let tip = Vec3::new(62.0 + 1.0 / 6.0, 0.0, 5.0); + let slab = crate::box_solid(Vec3::new(0.0, -R, 0.0), Vec3::new(tip.x, 0.0, 10.0)).unwrap(); + let disc = cylinder(Vec3::new(tip.x, -R, 0.0), Vec3::Z, R, 10.0).unwrap(); + let body = boolean(&slab, &disc, BoolOp::Cut).unwrap().unwrap(); + let faces: Vec<&mt::Face> = body.solid.face_iter().collect(); + let plane = faces.iter().copied().find(|f| plane_normal(f).is_some_and(|n| n.y > 0.99)).unwrap(); + let wall = faces.iter().copied().find(|f| plane_normal(f).is_none()).unwrap(); + for r in [0.5, 2.0, 5.0] { + let centre = Vec3::new(tip.x - (4.0 * R * r).sqrt(), -r, tip.z); + // `cusp` is +1 when n1 × t points into the cusp: the wall's normal is −y, the plane's +y. + for (f1, f2, cusp) in [(wall, plane, 1.0), (plane, wall, -1.0)] { + let s = solve(&[f1], &[f2], tip, Vec3::Z, r, -1.0, cusp, None, 1e-6).unwrap(); + assert!(s.c.dist(centre) < 1e-6, "r={r} cusp={cusp}: centre {:?}, expected {centre:?}", s.c); + } + } +} + /// #896: the seams where rounds run into the faces beside them are picked to be rounded again. /// The faces meet tangentially there, so there is nothing to round: the refusal says so, rather /// than that blending such an edge isn't supported yet. diff --git a/crates/kernel/src/issue_851_tests.rs b/crates/kernel/src/issue_851_tests.rs new file mode 100644 index 00000000..ba0673e4 --- /dev/null +++ b/crates/kernel/src/issue_851_tests.rs @@ -0,0 +1,394 @@ +//! Fillets and chamfers where a curved wall runs out into a flat face in a knife edge (#851). +use crate::*; +use solidcraft_geom::Vec3; +use std::f64::consts::PI; + +const R_WALL: f64 = 21.0; +const HEIGHT: f64 = 10.0; +/// x of the knife edge: the arc wall's far end. +const TIP_X: f64 = 62.0 + 1.0 / 6.0; + +/// A 41⅙ × 21 plate plus the corner a circle of radius 21 leaves, extruded 10 mm: the arc wall +/// meets the front face square and runs out into the back face (y = 0) in a knife edge at (TIP_X, 0). +fn part() -> Body { + let x0 = TIP_X - R_WALL; + let plate = box_solid(Vec3::new(0.0, -R_WALL, 0.0), Vec3::new(x0, 0.0, HEIGHT)).unwrap(); + let corner = box_solid(Vec3::new(x0, -R_WALL, 0.0), Vec3::new(TIP_X, 0.0, HEIGHT)).unwrap(); + let disc = cylinder(Vec3::new(TIP_X, -R_WALL, 0.0), Vec3::Z, R_WALL, HEIGHT).unwrap(); + let corner = boolean(&corner, &disc, BoolOp::Cut).unwrap().unwrap(); + boolean(&plate, &corner, BoolOp::Union).unwrap().unwrap() +} + +/// Area a ball of radius `r` takes off the cusp: the quadrilateral tip–wall-contact–wall-centre– +/// ball-centre-contact, less the wall's sector and the ball's. +fn removed_area(r: f64) -> f64 { + let beta = ((R_WALL - r) / (R_WALL + r)).acos(); + let o = (TIP_X - (4.0 * R_WALL * r).sqrt(), -r); + let c = (TIP_X, -R_WALL); + let tip = (TIP_X, 0.0); + let t1 = (o.0, 0.0); + // Shoelace over t1 → tip → c → o. + let pts = [t1, tip, c, o]; + let quad: f64 = (0..4).map(|i| pts[i].0 * pts[(i + 1) % 4].1 - pts[(i + 1) % 4].0 * pts[i].1).sum::().abs() / 2.0; + quad - 0.5 * R_WALL * R_WALL * beta - 0.5 * r * r * (PI - beta) +} + +fn knife() -> Vec3 { + Vec3::new(TIP_X, 0.0, HEIGHT / 2.0) +} + +/// The same part with the corner joined first: its faces come in another order, so the plane +/// and the wall swap sides at the knife edge. +fn part_other_order() -> Body { + let x0 = TIP_X - R_WALL; + let plate = box_solid(Vec3::new(0.0, -R_WALL, 0.0), Vec3::new(x0, 0.0, HEIGHT)).unwrap(); + let corner = box_solid(Vec3::new(x0, -R_WALL, 0.0), Vec3::new(TIP_X, 0.0, HEIGHT)).unwrap(); + let disc = cylinder(Vec3::new(TIP_X, -R_WALL, 0.0), Vec3::Z, R_WALL, HEIGHT).unwrap(); + let both = boolean(&corner, &plate, BoolOp::Union).unwrap().unwrap(); + boolean(&both, &disc, BoolOp::Cut).unwrap().unwrap() +} + +/// The same solid as one box less the wall's cylinder: the plane comes before the wall in the +/// shell, so the plane is side 1 at the knife edge. +fn part_cut_from_a_box() -> Body { + let slab = box_solid(Vec3::new(0.0, -R_WALL, 0.0), Vec3::new(TIP_X, 0.0, HEIGHT)).unwrap(); + let disc = cylinder(Vec3::new(TIP_X, -R_WALL, 0.0), Vec3::Z, R_WALL, HEIGHT).unwrap(); + boolean(&slab, &disc, BoolOp::Cut).unwrap().unwrap() +} + +/// Area a chamfer of distance `d` takes off the cusp: the triangle between the plane's contact, +/// the tip and the wall's contact, less the wall's circular segment. +fn chamfered_area(d: f64) -> f64 { + let beta = ((R_WALL - d) / (R_WALL + d)).acos(); + let o = (TIP_X - (4.0 * R_WALL * d).sqrt(), -d); + let c = (TIP_X, -R_WALL); + let k = R_WALL / (R_WALL + d); + let q2 = (c.0 + (o.0 - c.0) * k, c.1 + (o.1 - c.1) * k); + let (t1, tip) = ((o.0, 0.0), (TIP_X, 0.0)); + let triangle = 0.5 * ((tip.0 - t1.0) * (q2.1 - t1.1) - (q2.0 - t1.0) * (tip.1 - t1.1)).abs(); + triangle - 0.5 * R_WALL * R_WALL * (beta - beta.sin()) +} + +#[test] +fn fillet_the_knife_edge() { + for (name, body) in [("plate first", part()), ("corner first", part_other_order()), ("cut from a box", part_cut_from_a_box())] { + let before = measure(&body).unwrap().volume; + for r in [0.5, 2.0, 5.0] { + let rounded = fillet(&body, &[knife()], r).unwrap_or_else(|e| panic!("{name} r={r}: {e}")); + assert!(rounded.validity().is_empty(), "{name} r={r}: {:?}", rounded.validity()); + assert_eq!(rounded.face_count(), body.face_count() + 1, "{name} r={r}: one blend face"); + let v = measure(&rounded).unwrap().volume; + let expected = before - HEIGHT * removed_area(r); + assert!((v - expected).abs() < 1e-3 * expected.abs(), "{name} r={r}: volume {v}, expected {expected}"); + } + } +} + +#[test] +fn chamfer_the_knife_edge() { + for (name, body) in [("plate first", part()), ("corner first", part_other_order()), ("cut from a box", part_cut_from_a_box())] { + let before = measure(&body).unwrap().volume; + for d in [0.5, 2.0, 5.0] { + let cut = chamfer(&body, &[knife()], d).unwrap_or_else(|e| panic!("{name} d={d}: {e}")); + assert!(cut.validity().is_empty(), "{name} d={d}: {:?}", cut.validity()); + assert_eq!(cut.face_count(), body.face_count() + 1, "{name} d={d}: one chamfer face"); + let v = measure(&cut).unwrap().volume; + let expected = before - HEIGHT * chamfered_area(d); + assert!((v - expected).abs() < 1e-3 * expected.abs(), "{name} d={d}: volume {v}, expected {expected}"); + } + } +} + +/// A ball that does not fit between the faces, and sizes at the ends of the range, are refused +/// or blended; they never panic or give an invalid solid. +#[test] +fn knife_edge_sizes_never_panic() { + let body = part(); + for r in [1e-5, 1e-3, 10.0, 21.0, 40.0, 1e3, 1e6 - 1.0] { + for round in [true, false] { + let made = if round { fillet(&body, &[knife()], r) } else { chamfer(&body, &[knife()], r) }; + if let Ok(b) = made { + assert!(b.validity().is_empty(), "r={r}: {:?}", b.validity()); + } + } + } +} + +/// Mirrored parts reverse the edge and its faces' use of it: the blend is the same. +#[test] +fn knife_edge_blends_the_same_in_a_mirror() { + let body = part(); + let reference = measure(&fillet(&body, &[knife()], 2.0).unwrap()).unwrap().volume; + let mirror = |axis: usize| { + let mut m = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]; + m[axis][axis] = -1.0; + m + }; + for (axis, at) in [(0, Vec3::new(-TIP_X, 0.0, 5.0)), (1, Vec3::new(TIP_X, 0.0, 5.0)), (2, Vec3::new(TIP_X, 0.0, -5.0))] { + let mirrored = transform_matrix(&body, mirror(axis)).unwrap(); + let v = measure(&fillet(&mirrored, &[at], 2.0).unwrap()).unwrap().volume; + assert!((v - reference).abs() < 1e-6, "mirror {axis}: volume {v}, expected {reference}"); + } +} + +/// A point on the arc's top edge (half way round the wall). +fn arc_top() -> Vec3 { + let (s, c) = (std::f64::consts::FRAC_PI_4.sin(), std::f64::consts::FRAC_PI_4.cos()); + Vec3::new(TIP_X - R_WALL * c, -R_WALL + R_WALL * s, HEIGHT) +} + +/// Volume the arc's top edge takes off, by integrating the corner of the blend over the wall's +/// angle: the part of the sweep above the back face (y = 0) is not material. Rounds: the corner +/// between the wall and the top, less the ball's quarter disc; chamfers: its triangle. +fn arc_top_removed(size: f64, round: bool) -> f64 { + let n = 2000; + let step = size / f64::from(n); + let mut total = 0.0; + for i in 0..n { + let rho = R_WALL + (f64::from(i) + 0.5) * step; + for j in 0..n { + let z = HEIGHT - size + (f64::from(j) + 0.5) * step; + let inside = if round { + (rho - (R_WALL + size)).powi(2) + (z - (HEIGHT - size)).powi(2) >= size * size + } else { + (rho - R_WALL) + (HEIGHT - z) <= size + }; + if inside { + total += rho * (R_WALL / rho).asin() * step * step; + } + } + } + total +} + +/// 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 { + 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(); + for t in &mesh.triangles { + let [a, b, c] = mesh.tri(t).unwrap(); + for (p, q) in [(a, b), (b, c), (c, a)] { + *uses.entry((key(p), key(q))).or_default() += 1; + *uses.entry((key(q), key(p))).or_default() -= 1; + } + } + assert!(uses.values().all(|n| *n == 0), "the mesh has cracks"); + mesh.measure().volume +} + +/// The arc's top edge runs out tangent to the back face at the knife edge's top: the blend is cut +/// by that face along a curve that touches the wall's contact at the tip. +#[test] +fn fillet_and_chamfer_the_arc_top_edge() { + for (name, body) in [("plate first", part()), ("corner first", part_other_order()), ("cut from a box", part_cut_from_a_box())] { + let before = planned_volume(&body); + for r in [0.5, 2.0, 4.0, 8.0] { + for round in [true, false] { + let what = if round { "fillet" } else { "chamfer" }; + let made = if round { fillet(&body, &[arc_top()], r) } else { chamfer(&body, &[arc_top()], r) }; + let made = made.unwrap_or_else(|e| panic!("{name} {what} r={r}: {e}")); + assert!(made.validity().is_empty(), "{name} {what} r={r}: {:?}", made.validity()); + assert_eq!(made.face_count(), body.face_count() + 1, "{name} {what} r={r}: one blend face"); + let removed = before - planned_volume(&made); + let expected = arc_top_removed(r, round); + assert!((removed - expected).abs() < 0.01 * expected + 0.01, "{name} {what} r={r}: removed {removed}, expected {expected}"); + } + } + } +} + +/// Mirrored parts take the edge the other way round and the tip at the other end of the chain. +#[test] +fn the_arc_top_edge_blends_the_same_in_a_mirror() { + let body = part(); + let reference = planned_volume(&fillet(&body, &[arc_top()], 2.0).unwrap()); + let mirror = |axis: usize| { + let mut m = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]; + m[axis][axis] = -1.0; + m + }; + let at = arc_top(); + for (axis, p) in [(0, Vec3::new(-at.x, at.y, at.z)), (1, Vec3::new(at.x, -at.y, at.z)), (2, Vec3::new(at.x, at.y, -at.z))] { + let mirrored = transform_matrix(&body, mirror(axis)).unwrap(); + let v = planned_volume(&fillet(&mirrored, &[p], 2.0).unwrap_or_else(|e| panic!("mirror {axis}: {e}"))); + assert!((v - reference).abs() < 1e-4 * reference, "mirror {axis}: volume {v}, expected {reference}"); + } +} + +/// Sizes from tiny to larger than the wall: blended, or refused, never a panic or a broken solid. +#[test] +fn arc_top_edge_sizes_never_panic() { + let body = part(); + for r in [1e-5, 1e-3, 6.0, 10.0, 21.0, 40.0, 1e3] { + for round in [true, false] { + let made = if round { fillet(&body, &[arc_top()], r) } else { chamfer(&body, &[arc_top()], r) }; + if let Ok(b) = made { + assert!(b.validity().is_empty(), "r={r}: {:?}", b.validity()); + } + } + } +} + +fn tf() -> Vec3 { + Vec3::new(20.0, -R_WALL, HEIGHT) +} +fn fl() -> Vec3 { + Vec3::new(0.0, -R_WALL, HEIGHT / 2.0) +} +fn fu() -> Vec3 { + Vec3::new(TIP_X - R_WALL, -R_WALL, HEIGHT / 2.0) +} + +/// Volume two blends take off together: each alone, less the corner both would take. +fn removed_together(body: &Body, picks: &[Vec3], r: f64, overlap: f64) -> f64 { + let before = planned_volume(body); + picks.iter().map(|p| before - planned_volume(&fillet(body, &[*p], r).unwrap())).sum::() - overlap +} + +/// The front's top edge and its left upright meet at a box corner; the edge's far end runs into +/// the curved wall. In either order, and picked together, the rounds end in each other where +/// they meet: the body is the one both would make (the two corner regions overlap by the +/// volume integrated over the box corner). +#[test] +fn fillet_the_top_front_edge_and_the_front_left_upright() { + let body = part(); + let (before, r) = (planned_volume(&body), 2.0); + let want = removed_together(&body, &[tf(), fl()], r, 0.766_957); + let check = |name: &str, made: Result| { + let made = made.unwrap_or_else(|e| panic!("{name}: {e}")); + assert!(made.validity().is_empty(), "{name}: {:?}", made.validity()); + let removed = before - planned_volume(&made); + assert!((removed - want).abs() < 0.02, "{name}: removed {removed}, expected {want}"); + made.face_count() + }; + let first = fillet(&body, &[tf()], r).unwrap(); + let second = fillet(&body, &[fl()], r).unwrap(); + let faces = [ + check("top front, then the upright", fillet(&first, &[fl()], r)), + check("upright, then the top front", fillet(&second, &[tf()], r)), + check("together", fillet(&body, &[tf(), fl()], r)), + ]; + assert!(faces.iter().all(|f| *f == faces[0]), "face counts {faces:?}"); +} + +/// The front's top edge and the front upright at the arc meet at the wall: the round of the one +/// ends in the other. Either way round and picked together, the body is the one both make: each +/// alone, less the corner both would take. +#[test] +fn fillet_the_top_front_edge_and_the_front_upright_at_the_arc() { + let body = part(); + let (before, r) = (planned_volume(&body), 2.0); + let want = removed_together(&body, &[tf(), fu()], r, 0.699_134); + let check = |name: &str, made: Result| { + let made = made.unwrap_or_else(|e| panic!("{name}: {e}")); + assert!(made.validity().is_empty(), "{name}: {:?}", made.validity()); + let removed = before - planned_volume(&made); + assert!((removed - want).abs() < 0.02, "{name}: removed {removed}, expected {want}"); + }; + let first = fillet(&body, &[tf()], r).unwrap(); + check("top front, then the upright", fillet(&first, &[fu()], r)); + check("together", fillet(&body, &[tf(), fu()], r)); + check("together, the other way", fillet(&body, &[fu(), tf()], r)); +} + +/// A blend of the edge nearest `p` (round or flat), size `r`. +fn blend_one_edge(body: &Body, p: Vec3, r: f64, round: bool) -> Result { + if round { fillet(body, &[p], r) } else { chamfer(body, &[p], r) } +} + +/// The arc's top edge and the front's top edge meet at the wall, where the wall's edge with the +/// front stays sharp: the blend of each ends in the other. Either way round, picked together or +/// one after the other, the body is each alone less the corner the two take together (volumes +/// integrated over the swept corners). +#[test] +fn blend_the_arc_top_edge_and_the_top_front_edge() { + let body = part(); + let before = planned_volume(&body); + for (name, round, overlap) in [("fillet", true, 0.759), ("chamfer", false, 2.69)] { + let alone = |p: Vec3| before - planned_volume(&blend_one_edge(&body, p, 2.0, round).unwrap()); + let want = alone(tf()) + alone(arc_top()) - overlap; + let check = |how: &str, made: Result| { + let made = made.unwrap_or_else(|e| panic!("{name} {how}: {e}")); + assert!(made.validity().is_empty(), "{name} {how}: {:?}", made.validity()); + let removed = before - planned_volume(&made); + assert!((removed - want).abs() < 0.06, "{name} {how}: removed {removed}, expected {want}"); + }; + let (a, t) = (blend_one_edge(&body, arc_top(), 2.0, round).unwrap(), blend_one_edge(&body, tf(), 2.0, round).unwrap()); + check("arc, then the front", blend_one_edge(&a, tf(), 2.0, round)); + check("front, then the arc", blend_one_edge(&t, arc_top(), 2.0, round)); + let both = |picks: &[Vec3]| if round { fillet(&body, picks, 2.0) } else { chamfer(&body, picks, 2.0) }; + check("together", both(&[tf(), arc_top()])); + check("together, the other way", both(&[arc_top(), tf()])); + } +} + +/// The arc's top edge and the front upright at the arc meet at the same corner, with the front's +/// top edge sharp: again the body is each alone less the corner both take. +#[test] +fn fillet_the_arc_top_edge_and_the_front_upright_at_the_arc() { + let body = part(); + let (before, r) = (planned_volume(&body), 2.0); + let alone = |p: Vec3| before - planned_volume(&fillet(&body, &[p], r).unwrap()); + let want = alone(arc_top()) + alone(fu()) - 0.682_379; + let check = |how: &str, made: Result| { + let made = made.unwrap_or_else(|e| panic!("{how}: {e}")); + assert!(made.validity().is_empty(), "{how}: {:?}", made.validity()); + let removed = before - planned_volume(&made); + assert!((removed - want).abs() < 0.06, "{how}: removed {removed}, expected {want}"); + }; + // (The upright first, then the arc as a feature of its own, is not supported yet: the arc's + // blend would run on over the upright's round, past the crease where the two meet.) + let a = fillet(&body, &[arc_top()], r).unwrap(); + check("arc, then the upright", fillet(&a, &[fu()], r)); + check("together", fillet(&body, &[arc_top(), fu()], r)); + check("together, the other way", fillet(&body, &[fu(), arc_top()], r)); +} + +/// The three edges at the arc's front corner (the front's top edge, the arc's top edge and the +/// front upright at the arc) end in a sphere of the rounds' radius. The volume is the three +/// rounds, each cut where the ball that touches the three faces ends it, and the corner beyond +/// those cuts less the ball's part of it (integrated over the swept corners and the sphere's +/// wedge). +#[test] +fn fillet_the_three_edges_at_the_arcs_front_corner() { + let body = part(); + let before = planned_volume(&body); + let want = 67.05; + for (name, picks) in [("as picked", [tf(), arc_top(), fu()]), ("another order", [fu(), tf(), arc_top()]), ("a third", [arc_top(), fu(), tf()])] { + let made = fillet(&body, &picks, 2.0).unwrap_or_else(|e| panic!("{name}: {e}")); + assert!(made.validity().is_empty(), "{name}: {:?}", made.validity()); + // The body's six faces, the three rounds and the sphere's patch. + assert_eq!(made.face_count(), 10, "{name}"); + let removed = before - planned_volume(&made); + assert!((removed - want).abs() < 0.12, "{name}: removed {removed}, expected {want}"); + } +} + +/// The construction does not depend on the faces being curved: at a box's corner it gives the +/// body the planar corner builder does (a sphere's octant where the three rounds meet). +#[test] +fn the_sphere_at_a_box_corner_is_the_planar_corner_builders() { + let b = box_solid(Vec3::ZERO, Vec3::new(30.0, 20.0, 10.0)).unwrap(); + let picks = [Vec3::new(15.0, 0.0, 10.0), Vec3::new(0.0, 10.0, 10.0), Vec3::new(0.0, 0.0, 5.0)]; + for r in [1.0, 2.5] { + let local = crate::blend_corner::fillet_radii(&b, &picks.iter().map(|p| (*p, r)).collect::>()).unwrap(); + let ball = crate::blend_vertex::fillet_ball_vertices(&b, &picks, r).unwrap_or_else(|e| panic!("r={r}: {e}")); + assert!(ball.validity().is_empty(), "r={r}: {:?}", ball.validity()); + assert_eq!(ball.face_count(), local.face_count(), "r={r}"); + let (a, c) = (planned_volume(&local), planned_volume(&ball)); + assert!((a - c).abs() < 1e-3 * a, "r={r}: planar corner {a}, ball corner {c}"); + } +} + +/// Sizes at the ends of the range at a ball vertex: rounded or refused, never a panic. +#[test] +fn rounds_ending_in_a_sphere_of_any_size_never_panic() { + let body = part(); + for r in [1e-5, 1e-3, 5.0, 9.0, 10.0, 21.0, 1e3] { + if let Ok(b) = fillet(&body, &[tf(), arc_top(), fu()], r) { + assert!(b.validity().is_empty(), "r={r}: {:?}", b.validity()); + } + } +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 4119156b..89d8d993 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -15,6 +15,7 @@ mod blend; mod blend_corner; mod blend_variable; +mod blend_vertex; mod body; mod boolean_diagnostics; mod boolean_guard; @@ -44,6 +45,8 @@ mod issue_616_tests; mod issue_674_tests; #[cfg(test)] mod issue_679_tests; +#[cfg(test)] +mod issue_851_tests; mod lip; mod loopblend; mod measure; diff --git a/crates/kernel/src/tess/mod.rs b/crates/kernel/src/tess/mod.rs index 10e18f5a..57ddce78 100644 --- a/crates/kernel/src/tess/mod.rs +++ b/crates/kernel/src/tess/mod.rs @@ -152,6 +152,33 @@ pub(crate) fn plan(solid: &Solid, tol: f64, turn: Option) -> Result< Ok((plan, stats)) } +/// Whether every curved face's boundary lies on the face's surface, within `accept`: a point +/// off the surface's own parameter range is off it. The planner needs this of every face; a +/// blend that runs on past the end of a neighbouring round's surface does not have it. +pub(crate) fn boundaries_on_surfaces(solid: &Solid, accept: f64) -> bool { + use mt::{BoundedCurve, ParametricCurve}; + for face in solid.face_iter() { + let Some(eval) = surface::eval_surface(&face.surface()) else { continue }; + if matches!(eval, EvalSurface::Plane { .. }) { + continue; + } + let dom = uv::domain(&eval); + let mut prev: Option<[f64; 2]> = None; + for e in face.edge_iter() { + let c = e.oriented_curve(); + let (lo, hi) = c.range_tuple(); + for k in 0..=32 { + let p = crate::body::from_p3(c.subs(lo + (hi - lo) * f64::from(k) / 32.0)); + match uv::project(&eval, &dom, p, prev, accept) { + Some(uv) => prev = Some(uv), + None => return false, + } + } + } + } + true +} + /// Bounded work for the facet angle, as the reference mesher bounds it: a face whose boundary /// would get more than `MAX_ANGLE_BOUNDARY` segments has its curved edges sampled again at twice /// the angle, as often as it takes, down to the tolerance alone (a plate with a thousand small diff --git a/tests/robustness/goldens.json b/tests/robustness/goldens.json index 41c57226..d7a93afc 100644 --- a/tests/robustness/goldens.json +++ b/tests/robustness/goldens.json @@ -65,7 +65,7 @@ "61": {"area":5020.413157,"bodies":1,"edges":36,"error":"boolean Cut: the operation failed: no result; the operation ","faces":15,"outcome":"failed","rejected":0,"shells":1,"steps":["extruded profile","cut cylinder","shell","rectangular pattern"],"unsupported":0,"volume":4906.628359}, "62": {"area":8771.003707,"bodies":1,"edges":60,"error":null,"faces":23,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","cut cylinder","circular pattern","cut box","cut box","join cylinder"],"unsupported":2,"volume":40107.001722}, "63": {"area":3060.376634,"bodies":1,"edges":57,"error":"boolean Union: the operation failed: no result; the operatio","faces":21,"outcome":"failed","rejected":1,"shells":1,"steps":["revolved section","join box","cut cylinder","hole","cut cylinder","join box"],"unsupported":0,"volume":6534.395244}, - "64": {"area":4137.422404,"bodies":1,"edges":36,"error":null,"faces":14,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","join cylinder","hole"],"unsupported":1,"volume":8399.391489}, + "64": {"area":4088.335645,"bodies":1,"edges":36,"error":null,"faces":14,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","fillet","join cylinder"],"unsupported":0,"volume":8495.220295}, "65": {"area":8469.64766,"bodies":1,"edges":30,"error":null,"faces":12,"outcome":"ok","rejected":0,"shells":1,"steps":["box","cut cylinder","fillet","rectangular pattern","circular pattern","circular pattern","fillet","cut box"],"unsupported":0,"volume":45143.068793}, "66": {"area":9222.76638,"bodies":1,"edges":99,"error":null,"faces":37,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","join box","join box","join box","cut sphere","cut box"],"unsupported":1,"volume":35217.203139}, "67": {"area":7942.099033,"bodies":2,"edges":85,"error":null,"faces":35,"outcome":"ok","rejected":0,"shells":2,"steps":["extruded profile","cut cylinder","hole","join cylinder","join box","hole","chamfer","rectangular pattern"],"unsupported":2,"volume":27607.71011}, @@ -103,6 +103,7 @@ {"reason":"seed 0 meshes differently since display tessellation plans (#805), which landed alongside these goldens (#826)","seeds":[0]}, {"reason":"the kernel fixes merged together on 13 October (#838, #839, #841, #855, #856) change what these seeds build: several joins that used to fail now succeed, small volume and area shifts, and shell now refuses seeds 6, 14, 43 and 73 with an offset corner error (seed 14 built before: #897)","seeds":[2,6,14,22,36,43,50,63,71,73,94]}, {"reason":"#936: a fillet or chamfer of a smooth seam is refused as an invalid pick (no corner to round) instead of as unsupported","seeds":[72]}, - {"reason":"the model's body mesh is the reference mesher's again (the display plan's since #805, which moved these seeds rather than the kernel fixes above): they build as they did before #805, and seed 14 builds again (#897)","seeds":[0,2,14,36,43,71,94]} + {"reason":"the model's body mesh is the reference mesher's again (the display plan's since #805, which moved these seeds rather than the kernel fixes above): they build as they did before #805, and seed 14 builds again (#897)","seeds":[0,2,14,36,43,71,94]}, + {"reason":"a fillet that used to be refused as unsupported on seed 64's extruded profile now builds, so the design goes on differently: rounds that meet at corners on curved faces (#851)","seeds":[64]} ] }