diff --git a/ROADMAP.md b/ROADMAP.md index 629c5616..8a58e2c2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -62,8 +62,8 @@ headline counts in-scope tabs only and lists the deferred ones separately. rounded corners rounded again (sphere octants, tori at concave corners); every edge of a convex planar body or of an extruded part; fillets inside patterns; closed chains of curved edges between any two smooth sides (a branch pipe on a main pipe), by a rolling ball (the - blend a rational B-spline through exact arcs). Open chains of curved edges are not supported - yet. + blend a rational B-spline through exact arcs), closed or ending at planar faces square to the + edges. - Booleans: prisms along one direction (plates with holes, slots, patterns of them, equal sections stacked end to end) are combined exactly in 2D; bodies touching along one whole face (a part and its mirror image) are stitched; coincident planar faces are pushed apart when diff --git a/crates/kernel/src/blend.rs b/crates/kernel/src/blend.rs index c70867a9..d9714194 100644 --- a/crates/kernel/src/blend.rs +++ b/crates/kernel/src/blend.rs @@ -341,7 +341,8 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul let edge = Body::unique_edges(&solid).into_iter().nth(idx).ok_or_else(|| KernelError::Invalid("edge index".into()))?; let out = match guard(what, || blend_one(&solid, &edge, size, r, shape)) { Ok(o) => o, - // A curved edge: roll a ball along its smooth closed chain. + // A curved edge, or one between curved faces: roll a ball along its smooth chain. + Err(e) if !curved_case(&solid, &edge) => return Err(e), Err(e) => match smooth_chain(&solid, &edge) { Some(ids) => guard(what, || crate::curveblend::curve_blend(&solid, &ids, size, r, shape == Shape::Round)) .map_err(|e2| KernelError::Failed(format!("{e2} ({e})")))?, @@ -353,7 +354,16 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul Ok(cur) } -/// The closed chain of edges continuing `edge` smoothly at both ends (its ids), if any. +/// Is the edge curved, or does it lie between faces that aren't both planar? +fn curved_case(solid: &Solid, edge: &mt::Edge) -> bool { + if !matches!(edge.curve(), mt::Curve::Line(_)) { + return true; + } + solid.face_iter().filter(|f| f.edge_iter().any(|e| e.id() == edge.id())).any(|f| !matches!(f.oriented_surface(), mt::Surface::Plane(_))) +} + +/// The chain of edges continuing `edge` smoothly at its ends (its ids): closed, or open where +/// the next edge turns sharply. fn smooth_chain(solid: &Solid, edge: &mt::Edge) -> Option> { use mt::{BoundedCurve, ParametricCurve}; let all = Body::unique_edges(solid); @@ -364,28 +374,34 @@ fn smooth_chain(solid: &Solid, edge: &mt::Edge) -> Option> { Vec3::new(d.x, d.y, d.z).normalized() }; let mut ids = vec![edge.id()]; - let start = edge.front().clone(); - let (mut v, mut d) = (edge.back().clone(), dir(edge, true)?); - for _ in 0..200 { - if v == start { - return Some(ids); - } - let next = all.iter().filter(|e| !ids.contains(&e.id()) && (*e.front() == v || *e.back() == v)).find_map(|e| { - let fwd = *e.front() == v; - let t = if fwd { dir(e, false)? } else { -dir(e, true)? }; - (t.dot(d) > 1.0 - 1e-4).then(|| (e.clone(), fwd)) - }); - let (e, fwd) = next?; - ids.push(e.id()); - if fwd { - v = e.back().clone(); - d = dir(&e, true)?; - } else { - v = e.front().clone(); - d = -dir(&e, false)?; + // Walk on from one end of the edge (leaving vertex `v` in direction `d`). + let walk = |mut v: mt::Vertex, mut d: Vec3, ids: &mut Vec, stop: &mt::Vertex| -> Option { + for _ in 0..200 { + if v == *stop { + return Some(true); + } + let next = all.iter().filter(|e| !ids.contains(&e.id()) && (*e.front() == v || *e.back() == v)).find_map(|e| { + let fwd = *e.front() == v; + let t = if fwd { dir(e, false)? } else { -dir(e, true)? }; + (t.dot(d) > 1.0 - 1e-4).then(|| (e.clone(), fwd)) + }); + let Some((e, fwd)) = next else { return Some(false) }; + ids.push(e.id()); + if fwd { + v = e.back().clone(); + d = dir(&e, true)?; + } else { + v = e.front().clone(); + d = -dir(&e, false)?; + } } + None + }; + let closed = walk(edge.back().clone(), dir(edge, true)?, &mut ids, edge.front())?; + if !closed { + walk(edge.front().clone(), -dir(edge, false)?, &mut ids, edge.back())?; } - None + Some(ids) } fn edge_mid(e: &mt::Edge) -> Option { diff --git a/crates/kernel/src/curveblend.rs b/crates/kernel/src/curveblend.rs index b592f5c1..e98a3210 100644 --- a/crates/kernel/src/curveblend.rs +++ b/crates/kernel/src/curveblend.rs @@ -116,20 +116,37 @@ pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, let mut seen = std::collections::HashSet::new(); shell.edge_iter().filter(|e| ids.contains(&e.id()) && seen.insert(e.id())).map(|e| e.absolute_clone()).collect() }; - let first = all.first().cloned().ok_or_else(|| KernelError::Failed("no edges".into()))?; + // An open chain starts at a vertex only one of its edges touches. + let uses = |v: &mt::Vertex| all.iter().filter(|e| e.front() == v || e.back() == v).count(); + let first = match all.iter().find_map(|e| { + if uses(e.front()) == 1 { + Some(e.clone()) + } else if uses(e.back()) == 1 { + Some(e.inverse()) + } else { + None + } + }) { + Some(e) => e, + None => all.first().cloned().ok_or_else(|| KernelError::Failed("no edges".into()))?, + }; chain.push(first.clone()); while chain.len() < all.len() { let end = chain.last().map(|e| e.back().clone()).ok_or_else(|| KernelError::Failed("chain".into()))?; let next = all .iter() .find(|e| !chain.iter().any(|c| c.id() == e.id()) && (*e.front() == end || *e.back() == end)) - .ok_or_else(|| unsupported("the edges don't form one closed chain"))?; + .ok_or_else(|| unsupported("the edges don't form one chain"))?; chain.push(if *next.front() == end { next.clone() } else { next.inverse() }); } - if chain.first().map(|e| e.front().clone()) != chain.last().map(|e| e.back().clone()) { - return Err(unsupported("blending an open chain of curved edges")); - } + let closed = chain.first().map(|e| e.front().clone()) == chain.last().map(|e| e.back().clone()); let n = chain.len(); + // The chain's vertices (n for a closed chain, n + 1 for an open one). + let mut verts: Vec = chain.iter().map(|e| e.front().clone()).collect(); + if !closed && let Some(e) = chain.last() { + verts.push(e.back().clone()); + } + let nv = verts.len(); // Faces of each edge, split into the two sides. let faces_of = |e: &mt::Edge| -> Vec { (0..faces.len()).filter(|i| faces.get(*i).is_some_and(|f| f.edge_iter().any(|x| x.id() == e.id()))).collect() @@ -186,25 +203,39 @@ pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, } // Seams at the chain's vertices: edges there that aren't in the chain, and their side. let chain_ids: Vec = chain.iter().map(|e| e.id()).collect(); - let mut seams: Vec> = Vec::with_capacity(n); - for e in &chain { - let v = e.front().clone(); + 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) + for (k, v) in verts.iter().enumerate() { + let at_end = !closed && (k == 0 || k == nv - 1); + let (k1, k2) = { + let ei = if k == nv - 1 && !closed { n - 1 } else { k.min(n - 1) }; + (side1.get(ei).copied().unwrap_or(usize::MAX), side2.get(ei).copied().unwrap_or(usize::MAX)) + }; let mut found: Vec<(usize, mt::Edge)> = Vec::new(); for (fi, f) in faces.iter().enumerate() { + if !f.vertex_iter().any(|x| x == *v) { + continue; + } + let on_side = side1.contains(&fi) || side2.contains(&fi); + if !on_side { + if !at_end || end_faces.iter().any(|(kk, _)| *kk == k) { + return Err(unsupported("a vertex of the edge chain where other faces meet")); + } + end_faces.push((k, fi)); + continue; + } for x in f.edge_iter() { - if chain_ids.contains(&x.id()) || !(*x.front() == v || *x.back() == v) || found.iter().any(|(_, y)| y.id() == x.id()) { + if chain_ids.contains(&x.id()) || !(*x.front() == *v || *x.back() == *v) || found.iter().any(|(_, y)| y.id() == x.id()) { continue; } - let side = if side1.contains(&fi) { - 1 - } else if side2.contains(&fi) { - 2 - } else { - return Err(unsupported("a vertex of the edge chain where other faces meet")); - }; + let side = if fi == k1 || (side1.contains(&fi) && fi != k2) { 1 } else { 2 }; found.push((side, x.absolute_clone())); } } + if at_end && !end_faces.iter().any(|(kk, _)| *kk == k) { + return Err(unsupported("an open chain of curved edges must end at a face")); + } // 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")); @@ -220,11 +251,33 @@ pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, let d = c.der(t); Some((from_p3(c.subs(t)), Vec3::new(d.x, d.y, d.z).normalized()?)) }; - let mut vsec: Vec
= Vec::with_capacity(n); - for (k, e) in chain.iter().enumerate() { - let (p, t) = tangent_at(e, 0.0).ok_or_else(|| KernelError::Failed("tangent".into()))?; + let mut vsec: Vec
= Vec::with_capacity(nv); + for k in 0..nv { + let (p, t) = if k < n { + tangent_at(chain.get(k).ok_or_else(|| KernelError::Failed("edge".into()))?, 0.0) + } else { + tangent_at(chain.get(n - 1).ok_or_else(|| KernelError::Failed("edge".into()))?, 1.0) + } + .ok_or_else(|| KernelError::Failed("tangent".into()))?; let here = seams.get(k).cloned().unwrap_or_default(); - let extra = match here.first().cloned() { + let at_end = end_faces.iter().any(|(kk, _)| *kk == k); + if at_end { + // The end face must be square to the edge: the blend's end then lies in it. + let (_, fi) = end_faces.iter().find(|(kk, _)| *kk == k).copied().unwrap_or_default(); + let g = faces.get(fi).ok_or_else(|| KernelError::Failed("end face".into()))?; + let ng = match g.oriented_surface() { + mt::Surface::Plane(pl) => { + let m = pl.normal(); + Vec3::new(m.x, m.y, m.z).normalized() + } + _ => None, + } + .ok_or_else(|| unsupported("the faces at the ends of the edges must be planar"))?; + if ng.dot(t).abs() < 1.0 - 1e-6 { + return Err(unsupported("the faces at the ends of the edges must be square to them")); + } + } + let extra = match here.first().cloned().filter(|_| !at_end) { None => None, Some((side, seam)) => { // The plane through the seam square to that side's surface. @@ -241,7 +294,7 @@ pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, }; let s = solve(&s1, &s2, p, t, r, sigma, 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) { + if let Some((side, seam)) = here.get(1).filter(|_| !at_end) { let q = if *side == 1 { s.q1 } else { s.q2 }; let (a, b) = (from_p3(seam.front().point()), from_p3(seam.back().point())); if !matches!(seam.curve(), mt::Curve::Line(_)) || q.dist_to_segment(a, b) > tol * 1e3 { @@ -257,7 +310,7 @@ pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, let (p, t) = tangent_at(e, j as f64 / (SAMPLES - 1) as f64).ok_or_else(|| KernelError::Failed("tangent".into()))?; list.push(solve(&s1, &s2, p, t, r, sigma, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge"))?); } - list.push(*vsec.get((k + 1) % n).ok_or_else(|| KernelError::Failed("section".into()))?); + list.push(*vsec.get((k + 1) % nv).ok_or_else(|| KernelError::Failed("section".into()))?); esec.push(list); } crate::guard("curve blend", || { @@ -265,7 +318,7 @@ pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, // Vertices and arcs at the chain's vertices. let v1: Vec = vsec.iter().map(|s| builder::vertex(p3(s.q1))).collect(); let v2: Vec = vsec.iter().map(|s| builder::vertex(p3(s.q2))).collect(); - let mut arcs = Vec::with_capacity(n); + 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"))?); let e = if round { @@ -283,7 +336,7 @@ pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, for (k, list) in esec.iter().enumerate() { let q1: Vec = list.iter().map(|s| s.q1).collect(); let q2: Vec = list.iter().map(|s| s.q2).collect(); - let k1 = (k + 1) % n; + let k1 = (k + 1) % nv; let (a1, b1, a2, b2) = (v1.get(k), v1.get(k1), v2.get(k), v2.get(k1)); let (Some(a1), Some(b1), Some(a2), Some(b2)) = (a1, b1, a2, b2) else { return Err(fail("vertex")) }; let cv1 = crate::build::interpolate_cubic(&q1).ok_or_else(|| fail("contact curve"))?; @@ -293,14 +346,14 @@ pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, // The blend surface: each control row interpolated along the edge (homogeneous), // through two sections of each neighbouring edge too, so that neighbouring blend // faces meet smoothly. - let prev = esec.get((k + n - 1) % n).ok_or_else(|| fail("section"))?; - let next = esec.get(k1).ok_or_else(|| fail("section"))?; let mut ext: Vec
= Vec::with_capacity(list.len() + 4); - if n > 1 || prev.len() > 3 { + if closed || k > 0 { + let prev = esec.get((k + n - 1) % n).ok_or_else(|| fail("section"))?; ext.extend(prev.iter().rev().skip(1).take(2).rev().copied()); } ext.extend(list.iter().copied()); - if n > 1 || next.len() > 3 { + if closed || k + 1 < n { + let next = esec.get((k + 1) % n).ok_or_else(|| fail("section"))?; ext.extend(next.iter().skip(1).take(2).copied()); } let ctrl: Vec<[mt::Vector4; 3]> = ext.iter().map(|s| section_controls(s, round)).collect::>().ok_or_else(|| fail("section"))?; @@ -341,16 +394,82 @@ pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, let (Some(x), Some(y)) = (c1.get(k), c2.get(k)) else { return Err(fail("contact")) }; subst.insert(e.id(), (x.clone(), y.clone())); } - let mut seam_subst: HashMap = HashMap::new(); + // Seams get new ends at the contacts (an edge between two chain vertices at both). + let mut ends: HashMap, Option)> = HashMap::new(); for (k, sm) in seams.iter().enumerate().flat_map(|(k, l)| l.iter().map(move |x| (k, x))) { let (side, seam) = sm; - let v = chain.get(k).map(|e| e.front().clone()).ok_or_else(|| fail("vertex"))?; - let nv = if *side == 1 { v1.get(k) } else { v2.get(k) }.ok_or_else(|| fail("vertex"))?; - let ne = if *seam.front() == v { builder::line(nv, seam.back()) } else { builder::line(seam.front(), nv) }; - seam_subst.insert(seam.id(), ne); + let v = verts.get(k).cloned().ok_or_else(|| fail("vertex"))?; + let nv = if *side == 1 { v1.get(k) } else { v2.get(k) }.ok_or_else(|| fail("vertex"))?.clone(); + let slot = ends.entry(seam.id()).or_insert_with(|| (seam.clone(), None, None)); + if *seam.front() == v { + slot.1 = Some(nv); + } else { + slot.2 = Some(nv); + } + } + let mut seam_subst: HashMap = HashMap::new(); + for (id, (seam, a, b)) in ends { + if !matches!(seam.curve(), mt::Curve::Line(_)) { + return Err(unsupported("a curved edge where the blend ends")); + } + // The new ends must stay on the seam, in order, short of each other. + let (p0, p1) = (from_p3(seam.front().point()), from_p3(seam.back().point())); + let at = |v: &mt::Vertex| { + let q = from_p3(v.point()); + let d = p1 - p0; + ((q - p0).dot(d) / d.len2().max(1e-300), (q - p0 - d * ((q - p0).dot(d) / d.len2().max(1e-300))).len()) + }; + let (ta, oa) = a.as_ref().map(at).unwrap_or((0.0, 0.0)); + let (tb, ob) = b.as_ref().map(at).unwrap_or((1.0, 0.0)); + if oa > size * 1e-5 || ob > size * 1e-5 || ta < -1e-9 || tb > 1.0 + 1e-9 || tb - ta < 1e-6 { + return Err(unsupported("the blend is larger than the neighbouring faces")); + } + let a = a.unwrap_or_else(|| seam.front().clone()); + let b = b.unwrap_or_else(|| seam.back().clone()); + seam_subst.insert(id, builder::line(&a, &b)); } let mut out: Vec = Vec::new(); 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() { + // 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(); + for w in f.absolute_boundaries() { + let mut es: Vec = Vec::new(); + for e in w.edge_iter() { + let ne = match seam_subst.get(&e.id()) { + Some(s) => { + if e.front() == e.absolute_front() { + s.clone() + } else { + s.inverse() + } + } + None => e.clone(), + }; + // Arriving at an old corner: continue along that end's arc. + let arrives = corners.iter().copied().find(|k| verts.get(*k).is_some_and(|v| *e.back() == *v)); + 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 corner = if to == a1 { + arc.clone() + } else if to == a2 { + arc.inverse() + } else { + return Err(fail("end face corner")); + }; + es.push(corner); + } + } + wires.push(mt::Wire::from(es)); + } + out.push(crate::heal::absolute_face(f, wires).ok_or_else(|| fail("an end face could not be rebuilt"))?); + continue; + } let on1 = side1.contains(&fi); let on2 = side2.contains(&fi); if !on1 && !on2 && !f.edge_iter().any(|e| seam_subst.contains_key(&e.id())) { @@ -379,7 +498,7 @@ pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, } // Blend faces: each runs its side-1 contact the other way from side 1's face. for (k, bf) in blends.iter().enumerate() { - let (Some(e1), Some(e2), Some(ak), Some(ak1)) = (c1.get(k), c2.get(k), arcs.get(k), arcs.get((k + 1) % n)) else { + let (Some(e1), Some(e2), Some(ak), Some(ak1)) = (c1.get(k), c2.get(k), arcs.get(k), arcs.get((k + 1) % nv)) else { return Err(fail("blend")); }; let f1i = side1.get(k).ok_or_else(|| fail("side"))?; diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index 0f2df00d..9b98ce61 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -1079,3 +1079,26 @@ fn fillet_where_a_branch_pipe_meets_a_main_pipe() { // Main cylinder, branch cylinder, the blend, the branch's top and the main's two ends. assert_eq!(m.merged.faces, 6, "{:?}", m.merged); } + +#[test] +fn fillet_an_open_curved_edge() { + use solvecraft_geom::Seg2; + // A D-shaped block: the round top edge only, ending at the flat wall's sharp corners. + let d = Loop2 { + segs: vec![ + Seg2::Arc { center: Vec2::ZERO, radius: 10.0, start: 0.0, sweep: PI }, + Seg2::Line { a: Vec2::new(-10.0, 0.0), b: Vec2::new(10.0, 0.0) }, + ], + }; + let b = extrude(&Plane::XY, &[Region2 { outer: d, holes: vec![] }], 0.0, 15.0).unwrap().pop().unwrap(); + let v0 = measure(&b).unwrap().volume; + let f = fillet(&b, &[Vec3::new(0.0, 10.0, 15.0)], 2.0).unwrap(); + // (A fine tessellation: the difference of two measured volumes magnifies their error.) + let _ = v0; + let fine = |x: &Body| x.tessellate_with(0.002, true).unwrap().measure().volume; + let removed = fine(&b) - fine(&f); + // Pappus: the corner's cross-section r²(1 − π/4), its centroid 0.2234 r in from the wall. + let r = 2.0; + let want = r * r * (1.0 - PI / 4.0) * (10.0 - r * (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI))) * PI; + assert!(rel(removed, want) < 1e-3, "{removed} vs {want}"); +}