diff --git a/crates/engine/src/tests/robustness.rs b/crates/engine/src/tests/robustness.rs index 542c9bc6..0afd0fbb 100644 --- a/crates/engine/src/tests/robustness.rs +++ b/crates/engine/src/tests/robustness.rs @@ -110,6 +110,76 @@ fn samples_build_without_panics() { assert!(failed.is_empty(), "{failed:?}"); } +fn total_volume(s: &Session) -> f64 { + s.model.state().bodies.iter().map(|b| solvecraft_kernel::measure(&b.body).map(|m| m.volume).unwrap_or(0.0)).sum() +} + +/// Volume of a box L×W×H with its vertical edges rounded (R) and its bottom loop filleted +/// (ρ ≤ R): the straight runs lose (1 − π/4)ρ² each, the corners a quarter turn of that +/// (Pappus, about the corner's axis). +fn rounded_box(l: f64, w: f64, h: f64, r: f64, rho: f64) -> f64 { + use std::f64::consts::PI; + let a = (1.0 - PI / 4.0) * rho * rho; + let xbar = rho * (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI)); + l * w * h - (4.0 - PI) * r * r * h - a * (2.0 * (l - 2.0 * r) + 2.0 * (w - 2.0 * r)) - 2.0 * PI * (r - xbar) * a +} + +/// Box, fillets, shell: a 60×40×30 box with its vertical edges rounded r5 and its bottom loop +/// r3, shelled 2 mm with the top open. The bottom round runs round the corners as spindle tori +/// (the tube wider than its circle), which the offset now takes. +#[test] +fn shell_of_a_filleted_box() { + let s = script(json!([ + {"command": "solid.box", "params": {"length": 60, "width": 40, "height": 30}}, + {"command": "solid.fillet", "params": {"edges": [[0, 0, 15], [60, 0, 15], [60, 40, 15], [0, 40, 15]], "radius": 5}}, + {"command": "solid.fillet", "params": {"edges": [[30, 0, 0], [60, 20, 0], [30, 40, 0], [0, 20, 0]], "radius": 3}}, + {"command": "solid.shell", "params": {"faces": [[30, 20, 30]], "thickness": 2}} + ])); + let want = rounded_box(60.0, 40.0, 30.0, 5.0, 3.0) - rounded_box(56.0, 36.0, 28.0, 3.0, 1.0); + let v = total_volume(&s); + assert!((v - want).abs() < 1e-4 * want, "{v} vs {want}"); +} + +/// The first tutorial part: a 60×40×15 plate with its top front edge and front right edge +/// rounded r2, shelled with the top open. At 2 mm the rounds' inner sides collapse to sharp +/// edges (as in Fusion); at 1.5 mm the top's round, tangent to the open top, keeps its +/// tangency (every face moves in, the opening is swept out through the top). +#[test] +fn shell_of_the_tutorial_plate() { + let base = json!([ + {"command": "solid.box", "params": {"length": 60, "width": 40, "height": 15}}, + {"command": "solid.fillet", "params": {"edges": [[30, 0, 15], [60, 0, 7]], "radius": 2}} + ]); + let s0 = script(base.clone()); + let body = total_volume(&s0); + // 2 mm: the cavity is a sharp 56 × 36 box from z = 2 up through the top. + let mut steps = base.as_array().cloned().unwrap_or_default(); + steps.push(json!({"command": "solid.shell", "params": {"faces": [[30, 20, 15]], "thickness": 2}})); + let v = total_volume(&script(Value::Array(steps))); + let want = body - 56.0 * 36.0 * 13.0; + assert!((v - want).abs() < 1e-4 * want, "{v} vs {want}"); + // 1.5 mm: a 57 × 37 × 12 cavity with r0.5 rounds, opened by its top face (57 × 36.5) + // swept up through the top (estimated: the rounds' mitre is left out, hence 1e-3). + let mut steps = base.as_array().cloned().unwrap_or_default(); + steps.push(json!({"command": "solid.shell", "params": {"faces": [[30, 20, 15]], "thickness": 1.5}})); + let v = total_volume(&script(Value::Array(steps))); + let a = (1.0 - std::f64::consts::PI / 4.0) * 0.25; + let want = body - (57.0 * 37.0 * 12.0 - a * (57.0 + 12.0)) - 57.0 * 36.5 * 1.5; + assert!((v - want).abs() < 1e-3 * want, "{v} vs {want}"); +} + +/// Shell after a drilled hole (its tip a cone): the cone moves like the other faces. +#[test] +fn shell_after_a_drilled_hole() { + for open in [[30.0, 0.0, 7.5], [10.0, 10.0, 15.0]] { + script(json!([ + {"command": "solid.box", "params": {"length": 60, "width": 40, "height": 15}}, + {"command": "solid.hole", "params": {"position": [30, 20, 15], "diameter": 5, "depth": 8, "type": "drilled"}}, + {"command": "solid.shell", "params": {"faces": [open], "thickness": 2}} + ])); + } +} + /// Seeds that run clean: no failure, no invalid body, no volume going the wrong way. #[test] fn fixed_seeds_run_clean() { diff --git a/crates/kernel/src/offset.rs b/crates/kernel/src/offset.rs index e0b95b6c..c72ebfea 100644 --- a/crates/kernel/src/offset.rs +++ b/crates/kernel/src/offset.rs @@ -290,7 +290,9 @@ fn torus_of(f: &mt::Face, s: &[(Vec3, Vec3)], tol: f64) -> Option { } let x = crate::polyhedron::solve3_pub(m, rhs)?; let (big, h, r) = (x[0], x[1], x[2]); - if !(big > 1e-9 && r.abs() > 1e-9 && r.abs() < big) { + // (A tube wider than its circle, a spindle torus, is fine: blends use its outer part, where + // each point's tube centre is the one on its own side of the axis.) + if !(big > 1e-9 && r.abs() > 1e-9) { return None; } let t = Surf::Torus { o, a, big, h, r: r.abs(), convex: r > 0.0 }; @@ -453,12 +455,11 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul let mut old = Vec::with_capacity(faces.len()); let mut new = Vec::with_capacity(faces.len()); let mut shifts = Vec::with_capacity(faces.len()); + // Faces moved to nothing (a round thinner than the move): they go, their neighbours meet. + let mut collapsed: Vec = Vec::with_capacity(faces.len()); for (i, f) in faces.iter().enumerate() { let sf = surf_of(f, tol * 100.0).ok_or_else(|| { let at = samples(f).first().map(|(p, _)| format!(" (a face at [{:.2}, {:.2}, {:.2}])", p.x, p.y, p.z)).unwrap_or_default(); - if std::env::var("DBG_OFFSET").is_ok() { - eprintln!("unrecognised face: {:?}", f.surface()); - } fail(&format!("only bodies with planes, cylinders, cones, spheres and tori{at}")) })?; let n = match sf { @@ -468,7 +469,16 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul }; let s = shift(i, n); old.push(sf); - new.push(sf.moved(s).ok_or_else(|| fail("a cylinder shrinks to nothing"))?); + match sf.moved(s) { + Some(x) => { + new.push(x); + collapsed.push(false); + } + None => { + new.push(sf); + collapsed.push(true); + } + } shifts.push(s); } let pick = |ids: &[usize]| -> (Vec, Vec, Vec) { @@ -495,11 +505,102 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul } } } + let is_collapsed = |i: usize| collapsed.get(i).copied().unwrap_or(false); + let any_collapsed = collapsed.iter().any(|c| *c); + // A collapsed face's place is taken by the faces around it (through other collapsed ones). + let mut nbrs: HashMap> = HashMap::new(); + for fs in efaces.values() { + for a in fs { + for b2 in fs { + if a != b2 { + let e = nbrs.entry(*a).or_default(); + if !e.contains(b2) { + e.push(*b2); + } + } + } + } + } + let expand = |fs: &[usize]| -> Vec { + let mut out: Vec = fs.iter().copied().filter(|i| !is_collapsed(*i)).collect(); + let mut queue: Vec = fs.iter().copied().filter(|i| is_collapsed(*i)).collect(); + let mut seen = queue.clone(); + while let Some(c) = queue.pop() { + for nb in nbrs.get(&c).into_iter().flatten() { + if is_collapsed(*nb) { + if !seen.contains(nb) { + seen.push(*nb); + queue.push(*nb); + } + } else if !out.contains(nb) { + out.push(*nb); + } + } + } + out + }; + // Each edge's ends and middle, and the faces either side. + let edge_pts: Vec<(Vec, [Vec3; 3])> = b + .solid + .edge_iter() + .filter_map(|e| { + use mt::{BoundedCurve, ParametricCurve}; + let c = e.curve(); + let (t0, t1) = c.range_tuple(); + Some(( + efaces.get(&e.id())?.clone(), + [from_p3(e.absolute_front().point()), from_p3(e.absolute_back().point()), from_p3(c.subs((t0 + t1) / 2.0))], + )) + }) + .collect(); let mut newpos: HashMap = HashMap::new(); for v in b.solid.vertex_iter() { - let fs = vfaces.get(&v.id()).ok_or_else(|| fail("vertex"))?; - let (o, s, n) = pick(fs); + let at = vfaces.get(&v.id()).ok_or_else(|| fail("vertex"))?; let p = from_p3(v.point()); + let fs: Vec = if at.iter().any(|i| is_collapsed(*i)) { + // On a collapsed round: its own faces, then the faces along the rounds it is on, + // nearest first, until they fix a point. + let group = expand(at); + let mut own: Vec = at.iter().copied().filter(|i| !is_collapsed(*i)).collect(); + let mut cand: Vec<(f64, usize)> = group + .iter() + .copied() + .filter(|nb| !own.contains(nb)) + .map(|nb| { + let d = edge_pts + .iter() + .filter(|(fs, _)| fs.contains(&nb) && fs.iter().any(|i| is_collapsed(*i))) + .flat_map(|(_, q)| q.iter().map(|x| x.dist(p))) + .fold(f64::INFINITY, f64::min); + (d, nb) + }) + .collect(); + cand.sort_by(|x, y| x.0.total_cmp(&y.0)); + let rank = |ids: &[usize]| -> usize { + let mut basis: Vec = Vec::new(); + for i in ids { + let Some(nv) = old.get(*i).and_then(|sf| sf.normal(p)) else { continue }; + let mut w = nv; + for e in &basis { + w = w - *e * w.dot(*e); + } + if let Some(u) = w.normalized().filter(|_| w.len() > 1e-6) { + basis.push(u); + } + } + basis.len() + }; + for (_, c) in cand { + if rank(&own) >= 3 { + break; + } + own.push(c); + } + own + } else { + at.clone() + }; + let (o, s, n) = pick(&fs); // A cone's apex goes with the cone (its normal is not defined there). let apex = o.iter().zip(&n).find_map(|(a, b)| match (a, b) { (Surf::Cone { v: va, .. }, Surf::Cone { v: vb, .. }) if va.dist(p) < size * 1e-6 => Some(*vb), @@ -513,7 +614,11 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul } // An edge turned round: faces crossed over (a wall thinner than the move), which would // leave the body inside out. + let edge_collapsed = |e: &mt::Edge| efaces.get(&e.id()).is_some_and(|fs| fs.iter().any(|i| is_collapsed(*i))); for e in b.solid.edge_iter() { + if edge_collapsed(&e) { + continue; + } let (f0, f1) = (e.absolute_front(), e.absolute_back()); let (Some(q0), Some(q1)) = (newpos.get(&f0.id()), newpos.get(&f1.id())) else { continue }; let (old_v, new_v) = (from_p3(f1.point()) - from_p3(f0.point()), *q1 - *q0); @@ -522,16 +627,64 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul } } guard("offset", || { - let verts: HashMap = newpos.iter().map(|(k, p)| (*k, builder::vertex(p3(*p)))).collect(); - let mut edges: HashMap = HashMap::new(); + // Vertices of collapsed faces that land together become one. + let on_collapsed: std::collections::HashSet = faces + .iter() + .enumerate() + .filter(|(i, _)| is_collapsed(*i)) + .flat_map(|(_, f)| f.vertex_iter().map(|v| v.id()).collect::>()) + .collect(); + let mut verts: HashMap = HashMap::new(); + let mut welded: Vec<(Vec3, mt::Vertex)> = Vec::new(); + for v in b.solid.vertex_iter() { + let Some(p) = newpos.get(&v.id()).copied() else { continue }; + let nv = if any_collapsed && on_collapsed.contains(&v.id()) { + match welded.iter().find(|(q, _)| q.dist(p) < size * 1e-6) { + Some((_, w)) => w.clone(), + None => { + let w = builder::vertex(p3(p)); + welded.push((p, w.clone())); + w + } + } + } else { + builder::vertex(p3(p)) + }; + verts.insert(v.id(), nv); + } + // Old edge → new edge, and whether it runs the other way (merged with its twin). + let mut edges: HashMap = HashMap::new(); + let mut merged: HashMap<(mt::VertexID, mt::VertexID), mt::Edge> = HashMap::new(); for e in b.solid.edge_iter() { if edges.contains_key(&e.id()) { continue; } let (Some(a), Some(c)) = (verts.get(&e.absolute_front().id()), verts.get(&e.absolute_back().id())) else { return Err(fail("edge")) }; - if from_p3(a.point()).dist(from_p3(c.point())) < size * 1e-9 { + let touches = edge_collapsed(&e); + if a == c || from_p3(a.point()).dist(from_p3(c.point())) < size * 1e-9 { + if touches { + continue; + } return Err(fail("an edge vanishes (the offset is too large)")); } + if efaces.get(&e.id()).is_some_and(|fs| fs.iter().all(|i| is_collapsed(*i))) { + continue; + } + if touches { + // Along a collapsed round: a straight edge, shared with its twin from the + // round's other side. + let ne = match merged.get(&(a.id(), c.id())).or_else(|| merged.get(&(c.id(), a.id()))) { + Some(x) => x.clone(), + None => { + let x = builder::line(a, c); + merged.insert((a.id(), c.id()), x.clone()); + x + } + }; + let flipped = ne.front() != a; + edges.insert(e.id(), (ne, flipped)); + continue; + } let Some(kind) = arc_mid(&e, size * 1e-6) else { // Another curve (where two cylinders meet): its points moved onto the moved // faces, through a smooth curve. @@ -550,7 +703,7 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul } pts.push(from_p3(c.point())); let curve = crate::build::interpolate_cubic(&pts).ok_or_else(|| fail("edge curve"))?; - edges.insert(e.id(), mt::Edge::new(a, c, mt::Curve::BSplineCurve(curve))); + edges.insert(e.id(), (mt::Edge::new(a, c, mt::Curve::BSplineCurve(curve)), false)); continue; }; let ne = match kind { @@ -563,16 +716,21 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul builder::circle_arc(a, c, p3(m2)) } }; - edges.insert(e.id(), ne); + edges.insert(e.id(), (ne, false)); } let mut out = Vec::new(); for (i, f) in faces.iter().enumerate() { + if is_collapsed(i) { + continue; + } let wires: Vec = f .absolute_boundaries() .iter() .map(|w| { w.edge_iter() - .filter_map(|e| edges.get(&e.id()).map(|ne| if e.front() == e.absolute_front() { ne.clone() } else { ne.inverse() })) + .filter_map(|e| { + edges.get(&e.id()).map(|(ne, flip)| if (e.front() == e.absolute_front()) != *flip { ne.clone() } else { ne.inverse() }) + }) .collect::>() .into() }) @@ -606,11 +764,14 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul } out.push(nf); } - // One shell per shell of the body (pieces, voids). + // One shell per shell of the body (pieces, voids), less the collapsed faces. let mut shells: Vec = Vec::new(); let mut it = out.into_iter(); + let mut k = 0; for sh in b.solid.boundaries() { - shells.push(it.by_ref().take(sh.len()).collect::>().into()); + let keep = (k..k + sh.len()).filter(|i| !is_collapsed(*i)).count(); + k += sh.len(); + shells.push(it.by_ref().take(keep).collect::>().into()); } let solid = Solid::try_new(shells).map_err(|e| fail(&e.to_string()))?; Body::new(solid) diff --git a/crates/kernel/src/polyhedron.rs b/crates/kernel/src/polyhedron.rs index d34d154e..e35f97da 100644 --- a/crates/kernel/src/polyhedron.rs +++ b/crates/kernel/src/polyhedron.rs @@ -361,8 +361,20 @@ fn shell_planar(b: &Body, open: &[Vec3], thickness: f64) -> Result { return Err(KernelError::Invalid("select at least one face to remove".into())); } let margin = thickness + size * 0.05; - let cavity = offset_planar(&healed, |fi, _| if opened.contains(&fi) { margin } else { -thickness })?; - match crate::ops::boolean(&healed, &cavity, crate::BoolOp::Cut) { + // Open faces pushed out past the body: the simple way, unless a face tangent to an open + // one (a fillet along its edge) can't follow; then every face moves in (tangencies keep) + // and the opening is the open face's copy swept out through the body. + let cavity = match offset_planar(&healed, |fi, _| if opened.contains(&fi) { margin } else { -thickness }) { + Ok(c) => c, + Err(e) => return shell_by_opening(&healed, &opened, thickness, margin).map_err(|_| e), + }; + let r = crate::ops::boolean(&healed, &cavity, crate::BoolOp::Cut); + if r.is_err() + && let Ok(x) = shell_by_opening(&healed, &opened, thickness, margin) + { + return Ok(x); + } + match r { Ok(Some(r)) => Ok(r), Ok(None) => Err(KernelError::Failed("the shell removed everything".into())), // Healing can leave faces the boolean dislikes (revolved patches): the body as it was, @@ -393,6 +405,33 @@ fn shell_planar(b: &Body, open: &[Vec3], thickness: f64) -> Result { } } +/// A shell made from the cavity with every face moved in by the thickness (open faces too), +/// joined with each open face's moved copy swept out through the body. +fn shell_by_opening(b: &Body, opened: &[usize], thickness: f64, reach: f64) -> Result { + let closed = offset_planar(b, |_, _| -thickness)?; + let faces: Vec = closed.solid.face_iter().cloned().collect(); + let mut cavity = closed.clone(); + for fi in opened { + let f = faces.get(*fi).ok_or_else(|| KernelError::Failed("shell: open face".into()))?; + let mt::Surface::Plane(pl) = f.oriented_surface() else { + return Err(unsupported_shell("the open face must be planar here")); + }; + let n = pl.normal(); + let n = Vec3::new(n.x, n.y, n.z).normalized().ok_or_else(|| KernelError::Failed("shell: open face normal".into()))?; + let prism = guard("shell opening", || { + let solid: Solid = builder::tsweep(f, v3(n * (thickness + reach))); + Ok(solid) + })?; + let prism = Body::new(prism)?; + cavity = crate::ops::boolean(&cavity, &prism, crate::BoolOp::Union)?.ok_or_else(|| KernelError::Failed("shell: opening".into()))?; + } + crate::ops::boolean(b, &cavity, crate::BoolOp::Cut)?.ok_or_else(|| KernelError::Failed("the shell removed everything".into())) +} + +fn unsupported_shell(m: &str) -> KernelError { + KernelError::Failed(format!("not supported yet: {m}")) +} + fn point_tri(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 { let n = (b - a).cross(c - a); let Some(nn) = n.normalized() else { return f64::INFINITY };