diff --git a/ROADMAP.md b/ROADMAP.md index c62e6ee4..fcbd9834 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,7 +9,7 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat |---|---| | Where are we? | **M0 done; M1/M2 in progress.** Sketch with solver, extrude (taper, two-sided, through all), revolve, fillet/chamfer, holes, patterns, mirror, shell, draft, loft, sweep, split, construction planes, parameters that rebuild the timeline, STEP/STL export, desktop and browser (wasm) app with Fusion-style selection (hover/selected highlights, origin planes, click-to-pick sketch planes, multi-select, window/crossing box selection, animated view cube), CLI, MCP server, oracle harness. | | Command parity (in scope) | **139 / 541 (26%)** in-scope commands; SOLID + SKETCH **102 / 291 (35%)** — [docs/parity.md](docs/parity.md) | -| Fusion oracle | **53 / 66 parts match** (batch 1: 29/29; batch 2 in progress) — [docs/oracle.md](docs/oracle.md) | +| Fusion oracle | **55 / 76 parts match** (batch 1: 29/29; batch 2 in progress) — [docs/oracle.md](docs/oracle.md) | | Tests | 85 (solver, profiles, kernel booleans/blends/measures, expressions, timeline, file formats, camera, engine end-to-end, hostile-input fuzz over every command) | | Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green | | Weighted parity estimate | **≈ 5%** of Fusion's Design workspace by importance (sketch + basic solids are the core, but surfaces, assemblies, sheet metal, CAM, drawings are untouched) | @@ -74,7 +74,8 @@ headline counts in-scope tabs only and lists the deferred ones separately. - No persistent naming (fillet edges are re-found by position; projected sketch geometry too). - Control-point splines of degree 4 and up, and free-form curves split by crossings, enter profiles as polylines; 3D sketch curves are reference geometry (not sweep paths yet). -- Shell works on planar bodies (convex or not), draft on convex planar bodies; loft is ruled. +- Shell and offset faces work on bodies of planes and cylinders (convex or not: filleted boxes, + bosses), draft on convex planar bodies; loft is ruled. ## Kernel diff --git a/apps/solvecraft-cli/src/recipe.rs b/apps/solvecraft-cli/src/recipe.rs index 706bc8e0..aca21f34 100644 --- a/apps/solvecraft-cli/src/recipe.rs +++ b/apps/solvecraft-cli/src/recipe.rs @@ -402,7 +402,23 @@ pub fn to_script(recipe: &Value) -> Result { } Some("hole") => { let n = f.get("face").and_then(|fc| fc.get("plane_normal_outward").or(fc.get("normal_at_point_on_face"))).cloned(); - let dir = n.and_then(|n| n.as_array().map(|a| a.iter().map(|x| -x.as_f64().unwrap_or(0.0)).collect::>())); + let mut dir = n.and_then(|n| n.as_array().map(|a| a.iter().map(|x| -x.as_f64().unwrap_or(0.0)).collect::>())); + // On a cylinder the normal given is at the face's sample point: drill toward the axis. + let v3 = |v: Option<&Value>| -> Option<[f64; 3]> { + let a = v?.as_array()?; + Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?]) + }; + if let Some(fc) = f.get("face").filter(|fc| fc.get("type").and_then(Value::as_str) == Some("cylinder")) + && let (Some(o), Some(ax), Some(p)) = (v3(fc.get("axis_origin")), v3(fc.get("axis")), v3(f.get("position"))) + { + let d = [p[0] - o[0], p[1] - o[1], p[2] - o[2]]; + let t = d[0] * ax[0] + d[1] * ax[1] + d[2] * ax[2]; + let r = [d[0] - t * ax[0], d[1] - t * ax[1], d[2] - t * ax[2]]; + let l = (r[0] * r[0] + r[1] * r[1] + r[2] * r[2]).sqrt(); + if l > 1e-9 { + dir = Some(vec![-r[0] / l, -r[1] / l, -r[2] / l]); + } + } let ty = f.get("hole_type").and_then(Value::as_str).unwrap_or("simple"); let mut p = json!({"position": f.get("position"), "diameter": f.get("diameter"), "type": ty, "name": name}); // "sketch HolePoints points p1..p6" diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index 8782a442..e6e771c9 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -81,7 +81,10 @@ fn refined(pm: &PolygonMesh, surf: &mt::Surface, tol: f64) -> Option 200_000 { + // Bounded work: a face may grow to a few times its triangles (wild surfaces sag + // everywhere and are left as meshed). + let cap = (tris.len() * 8).clamp(2_000, 60_000); + if !check || tris.len() > 50_000 { return None; } // Edges on the face's boundary are shared with the next face: never split them. @@ -145,7 +148,7 @@ fn refined(pm: &PolygonMesh, surf: &mt::Surface, tol: f64) -> Option 400_000 { + if !any || tris.len() > cap { break; } } diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index f3c21e50..c6d566fa 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -22,6 +22,7 @@ mod helix; mod loopblend; mod measure; mod meshbody; +mod offset; mod ops; mod polybool; mod polyhedron; diff --git a/crates/kernel/src/offset.rs b/crates/kernel/src/offset.rs new file mode 100644 index 00000000..c6794ffb --- /dev/null +++ b/crates/kernel/src/offset.rs @@ -0,0 +1,336 @@ +//! Topology-preserving face offsets for bodies made of planes and cylinders (shells, offset +//! faces). Each face moves along its outward normal by its own distance: a plane is translated, +//! a cylinder changes radius about its axis. Every vertex and edge goes to where its faces' +//! moved surfaces meet; edges are lines or circular arcs. This is exact when the moved +//! surfaces meet the way the old ones did (prismatic parts, bosses, fillets between walls); +//! anything else is refused, so callers can fall back. + +use std::collections::HashMap; + +use solvecraft_geom::Vec3; +use truck_modeling::{self as mt, builder}; + +use crate::body::{Body, Solid, from_p3, p3}; +use crate::{KernelError, Result, guard}; + +#[derive(Clone, Copy, Debug)] +enum Surf { + /// Outward normal and offset: n·x = d. + Plane { n: Vec3, d: f64 }, + /// Axis point and direction, radius; `convex` when the outward normal points away from + /// the axis. + Cylinder { o: Vec3, a: Vec3, r: f64, convex: bool }, +} + +impl Surf { + /// Outward unit normal of the surface at `p` (on or near it). + fn normal(&self, p: Vec3) -> Option { + match *self { + Surf::Plane { n, .. } => Some(n), + Surf::Cylinder { o, a, convex, .. } => { + let v = p - o; + let radial = (v - a * v.dot(a)).normalized()?; + Some(if convex { radial } else { -radial }) + } + } + } + fn moved(&self, s: f64) -> Option { + match *self { + Surf::Plane { n, d } => Some(Surf::Plane { n, d: d + s }), + Surf::Cylinder { o, a, r, convex } => { + let r2 = if convex { r + s } else { r - s }; + (r2 > 1e-9).then_some(Surf::Cylinder { o, a, r: r2, convex }) + } + } + } + fn dist(&self, p: Vec3) -> f64 { + match *self { + Surf::Plane { n, d } => n.dot(p) - d, + Surf::Cylinder { o, a, r, .. } => { + let v = p - o; + (v - a * v.dot(a)).len() - r + } + } + } + /// The affine map taking this surface onto `to` (same kind, same axis). + fn map_to(&self, to: &Surf) -> Option { + match (*self, *to) { + (Surf::Plane { n, d }, Surf::Plane { d: d2, .. }) => { + let t = n * (d2 - d); + Some(mt::Matrix4::from_translation(mt::Vector3::new(t.x, t.y, t.z))) + } + (Surf::Cylinder { o, a, r, .. }, Surf::Cylinder { r: r2, .. }) => { + // Scale by k across the axis: x ↦ o + k (x − o) + (1 − k) a a·(x − o). + let k = r2 / r; + let m = |i: usize, j: usize| { + let (ai, aj) = ([a.x, a.y, a.z][i], [a.x, a.y, a.z][j]); + (if i == j { k } else { 0.0 }) + (1.0 - k) * ai * aj + }; + let lin = [[m(0, 0), m(0, 1), m(0, 2)], [m(1, 0), m(1, 1), m(1, 2)], [m(2, 0), m(2, 1), m(2, 2)]]; + let ov = [o.x, o.y, o.z]; + let t: Vec = (0..3).map(|i| ov[i] - (0..3).map(|j| lin[i][j] * ov[j]).sum::()).collect(); + // Column-major. + Some(mt::Matrix4::new( + lin[0][0], + lin[1][0], + lin[2][0], + 0.0, + lin[0][1], + lin[1][1], + lin[2][1], + 0.0, + lin[0][2], + lin[1][2], + lin[2][2], + 0.0, + *t.first()?, + *t.get(1)?, + *t.get(2)?, + 1.0, + )) + } + _ => None, + } + } +} + +fn fail(m: &str) -> KernelError { + KernelError::Failed(format!("offset: {m}")) +} + +/// Points and outward normals sampled over a face (boundary and a grid inside its +/// parameter box). +fn samples(f: &mt::Face) -> Vec<(Vec3, Vec3)> { + use mt::{BoundedCurve, ParametricCurve, ParametricSurface, ParametricSurface3D, SearchNearestParameter}; + let surf = f.oriented_surface(); + let mut uv: Vec<(f64, f64)> = Vec::new(); + for w in f.boundaries() { + for e in w.edge_iter() { + let c = e.oriented_curve(); + let (t0, t1) = c.range_tuple(); + for k in 0..5 { + if let Some(x) = surf.search_nearest_parameter(c.subs(t0 + (t1 - t0) * k as f64 / 5.0), uv.last().copied(), 50) { + uv.push(x); + } + } + } + } + let Some(&(u0, v0)) = uv.first() else { return Vec::new() }; + let (mut lo, mut hi) = ((u0, v0), (u0, v0)); + for (u, v) in &uv { + lo = (lo.0.min(*u), lo.1.min(*v)); + hi = (hi.0.max(*u), hi.1.max(*v)); + } + for i in 0..=4 { + for j in 0..=4 { + uv.push((lo.0 + (hi.0 - lo.0) * i as f64 / 4.0, lo.1 + (hi.1 - lo.1) * j as f64 / 4.0)); + } + } + uv.iter() + .filter_map(|(u, v)| { + let n = surf.normal(*u, *v); + Some((from_p3(surf.subs(*u, *v)), Vec3::new(n.x, n.y, n.z).normalized()?)) + }) + .collect() +} + +/// What surface a face lies on (planes and cylinders only). +fn surf_of(f: &mt::Face, tol: f64) -> Option { + if let mt::Surface::Plane(pl) = f.oriented_surface() { + let n = pl.normal(); + let n = Vec3::new(n.x, n.y, n.z).normalized()?; + return Some(Surf::Plane { n, d: n.dot(from_p3(pl.origin())) }); + } + let s = samples(f); + let (p0, n0) = *s.first()?; + let far = s.iter().map(|x| x.1).min_by(|a, b| a.dot(n0).total_cmp(&b.dot(n0)))?; + let a = n0.cross(far).normalized()?; + if s.iter().any(|(_, n)| n.dot(a).abs() > 1e-6) { + return None; + } + // The axis: where the normal lines through two samples meet (seen along a). + let (p1, n1) = *s.iter().max_by(|x, y| (x.1 - n0).len().total_cmp(&(y.1 - n0).len()))?; + // p0 + t0 n0 = p1 + t1 n1 (projected): solve in the plane ⟂ a. + let w = p1 - p0; + let c = n0.dot(n1); + let den = 1.0 - c * c; + if den.abs() < 1e-9 { + return None; + } + let t0 = (w.dot(n0) - c * w.dot(n1)) / den; + let o = p0 + n0 * t0; + let o = o - a * (o - p0).dot(a); + let r = t0.abs(); + // Outward normal away from the axis: p = o + r n (t0 < 0 means o = p − r n). + let convex = t0 < 0.0; + let cyl = Surf::Cylinder { o, a, r, convex }; + let ok = s.iter().all(|(p, n)| cyl.dist(*p).abs() < tol && cyl.normal(*p).is_some_and(|m| m.dot(*n) > 1.0 - 1e-6)); + ok.then_some(cyl) +} + +/// Move `p` (on all of `old`) onto the moved surfaces `new`: the smallest displacement δ with +/// n_i · δ = s_i for each face's normal n_i at p, then checked against the moved surfaces. +fn move_point(p: Vec3, old: &[Surf], shifts: &[f64], new: &[Surf], tol: f64) -> Option { + let ns: Vec = old.iter().map(|sf| sf.normal(p)).collect::>()?; + // Orthonormal basis of the normals' span (tangent faces share a normal). + let mut basis: Vec = Vec::new(); + for n in &ns { + let mut v = *n; + for e in &basis { + v = v - *e * v.dot(*e); + } + if let Some(u) = v.normalized().filter(|_| v.len() > 1e-6) { + basis.push(u); + } + } + // Least squares in the span: rows n_i · e_k, right-hand sides s_i. + let r = basis.len(); + let mut m = [[0.0f64; 3]; 3]; + let mut rhs = [0.0f64; 3]; + for (n, s) in ns.iter().zip(shifts) { + let row: Vec = basis.iter().map(|e| n.dot(*e)).collect(); + for i in 0..r { + for j in 0..r { + m[i][j] += row[i] * row[j]; + } + rhs[i] += row[i] * s; + } + } + // Pad unused dimensions with the identity. + for (i, row) in m.iter_mut().enumerate().skip(r) { + row[i] = 1.0; + } + let c = crate::polyhedron::solve3_pub(m, rhs)?; + let mut q = p; + for (k, e) in basis.iter().enumerate() { + q += *e * c[k]; + } + new.iter().all(|sf| sf.dist(q).abs() < tol).then_some(q) +} + +/// A line or circular arc through an edge (by samples): `None` for other curves. +fn arc_mid(e: &mt::Edge, tol: f64) -> Option> { + use mt::{BoundedCurve, ParametricCurve}; + let c = e.curve(); + let (t0, t1) = c.range_tuple(); + let pts: Vec = (0..=16).map(|k| from_p3(c.subs(t0 + (t1 - t0) * k as f64 / 16.0))).collect(); + let (a, b, m) = (*pts.first()?, *pts.last()?, *pts.get(8)?); + if a.dist(b) < tol { + return None; + } + if pts.iter().all(|p| p.dist_to_segment(a, b) < tol) { + return Some(None); + } + let (u, v) = (b - a, m - a); + let w = u.cross(v); + let d = 2.0 * w.len2(); + if d < 1e-300 { + return None; + } + let centre = a + (v.cross(w) * u.len2() + w.cross(u) * v.len2()) * (1.0 / d); + let r = centre.dist(a); + pts.iter().all(|p| (p.dist(centre) - r).abs() < tol && (*p - centre).dot(w.normalized().unwrap_or(Vec3::Z)).abs() < tol).then_some(Some(m)) +} + +/// The body with each face moved along its outward normal by `shift(face index, normal at a +/// face point)`, keeping the topology. Faces must be planes or cylinders. +pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Result { + let faces: Vec = b.solid.face_iter().cloned().collect(); + let size = b.size(); + let tol = (size * 1e-7).max(1e-9); + let mut old = Vec::with_capacity(faces.len()); + let mut new = Vec::with_capacity(faces.len()); + let mut shifts = Vec::with_capacity(faces.len()); + for (i, f) in faces.iter().enumerate() { + let sf = surf_of(f, tol * 100.0).ok_or_else(|| fail("only bodies with planar and cylindrical faces"))?; + let n = match sf { + Surf::Plane { n, .. } => n, + Surf::Cylinder { a, .. } => a.any_perp(), + }; + let s = shift(i, n); + old.push(sf); + new.push(sf.moved(s).ok_or_else(|| fail("a cylinder shrinks to nothing"))?); + shifts.push(s); + } + let pick = |ids: &[usize]| -> (Vec, Vec, Vec) { + ( + ids.iter().filter_map(|i| old.get(*i).copied()).collect(), + ids.iter().filter_map(|i| shifts.get(*i).copied()).collect(), + ids.iter().filter_map(|i| new.get(*i).copied()).collect(), + ) + }; + // Vertex → its faces; edge → its faces. + let mut vfaces: HashMap> = HashMap::new(); + let mut efaces: HashMap> = HashMap::new(); + for (i, f) in faces.iter().enumerate() { + for v in f.vertex_iter() { + let e = vfaces.entry(v.id()).or_default(); + if !e.contains(&i) { + e.push(i); + } + } + for ed in f.edge_iter() { + let e = efaces.entry(ed.id()).or_default(); + if !e.contains(&i) { + e.push(i); + } + } + } + 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 q = move_point(from_p3(v.point()), &o, &s, &n, size * 1e-6) + .ok_or_else(|| fail("a corner whose faces don't meet the same way after the move"))?; + newpos.insert(v.id(), q); + } + guard("offset", || { + let verts: HashMap = newpos.iter().map(|(k, p)| (*k, builder::vertex(p3(*p)))).collect(); + let mut edges: HashMap = 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 { + return Err(fail("an edge vanishes (the offset is too large)")); + } + let kind = arc_mid(&e, size * 1e-6).ok_or_else(|| fail("edges must be lines or arcs"))?; + let ne = match kind { + None => builder::line(a, c), + Some(m) => { + let fs = efaces.get(&e.id()).ok_or_else(|| fail("edge faces"))?; + let (o, s, n) = pick(fs); + let m2 = + move_point(m, &o, &s, &n, size * 1e-6).ok_or_else(|| fail("an edge whose faces don't meet the same way after the move"))?; + builder::circle_arc(a, c, p3(m2)) + } + }; + edges.insert(e.id(), ne); + } + let mut out = Vec::new(); + for (i, f) in faces.iter().enumerate() { + 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() })) + .collect::>() + .into() + }) + .collect(); + let (Some(o), Some(n)) = (old.get(i), new.get(i)) else { return Err(fail("face")) }; + let mat = o.map_to(n).ok_or_else(|| fail("surface"))?; + let surface = mt::Transformed::transformed(&f.surface(), mat); + let mut nf = mt::Face::try_new(wires, surface).map_err(|e| fail(&e.to_string()))?; + if !f.orientation() { + nf.invert(); + } + out.push(nf); + } + let shell: mt::Shell = out.into(); + let solid = Solid::try_new(vec![shell]).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 27693fcc..861ef1cd 100644 --- a/crates/kernel/src/polyhedron.rs +++ b/crates/kernel/src/polyhedron.rs @@ -350,95 +350,11 @@ fn point_tri(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 { /// The body with every planar face moved along its outward normal by `shift(face index, /// normal)`, keeping the topology: each vertex goes to where its faces' moved planes meet. pub(crate) fn offset_planar(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Result { - let fail = |m: &str| KernelError::Failed(format!("offset: {m}")); - let faces: Vec = b.solid.face_iter().cloned().collect(); - let mut planes: Vec<(Vec3, f64)> = Vec::new(); - for (i, f) in faces.iter().enumerate() { - let mt::Surface::Plane(pl) = f.oriented_surface() else { return Err(fail("only bodies with planar faces")) }; - let n = pl.normal(); - let n = Vec3::new(n.x, n.y, n.z).normalized().ok_or_else(|| fail("normal"))?; - let d = n.dot(from_p3(pl.origin())) + shift(i, n); - planes.push((n, d)); - } - // Vertex → its faces. - let mut vfaces: HashMap> = HashMap::new(); - for (i, f) in faces.iter().enumerate() { - for v in f.vertex_iter() { - let e = vfaces.entry(v.id()).or_default(); - if !e.contains(&i) { - e.push(i); - } - } - } - let size = b.size(); - let mut newpos: HashMap = HashMap::new(); - for v in b.solid.vertex_iter() { - let fs = vfaces.get(&v.id()).ok_or_else(|| fail("vertex"))?; - // Least squares over the vertex's planes (exact for three independent ones). - let mut m = [[0.0f64; 3]; 3]; - let mut r = [0.0f64; 3]; - for fi in fs { - let (n, d) = *planes.get(*fi).ok_or_else(|| fail("plane"))?; - let nv = [n.x, n.y, n.z]; - for i in 0..3 { - for j in 0..3 { - m[i][j] += nv[i] * nv[j]; - } - r[i] += nv[i] * d; - } - } - let x = solve3(m, r).ok_or_else(|| fail("a corner whose faces don't meet in a point"))?; - let x = Vec3::new(x[0], x[1], x[2]); - for fi in fs { - let (n, d) = *planes.get(*fi).ok_or_else(|| fail("plane"))?; - if (n.dot(x) - d).abs() > size * 1e-6 { - return Err(fail("a corner with more faces than can move together")); - } - } - newpos.insert(v.id(), x); - } - guard("offset", || { - let verts: HashMap = newpos.iter().map(|(k, p)| (*k, builder::vertex(p3(*p)))).collect(); - let mut edges: HashMap = 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 { - return Err(fail("an edge vanishes (the offset is too large)")); - } - edges.insert(e.id(), builder::line(a, c)); - } - let mut out = Vec::new(); - for (i, f) in faces.iter().enumerate() { - 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() })) - .collect::>() - .into() - }) - .collect(); - let (n, d) = *planes.get(i).ok_or_else(|| fail("plane"))?; - let shift_v = n - * (d - n.dot(from_p3(match f.oriented_surface() { - mt::Surface::Plane(pl) => pl.origin(), - _ => return Err(fail("plane")), - }))); - let surface = mt::Transformed::transformed(&f.surface(), mt::Matrix4::from_translation(v3(shift_v))); - let mut nf = mt::Face::try_new(wires, surface).map_err(|e| fail(&e.to_string()))?; - if !f.orientation() { - nf.invert(); - } - out.push(nf); - } - let shell: mt::Shell = out.into(); - let solid = Solid::try_new(vec![shell]).map_err(|e| fail(&e.to_string()))?; - Body::new(solid) - }) + crate::offset::offset_body(b, shift) +} + +pub(crate) fn solve3_pub(m: [[f64; 3]; 3], r: [f64; 3]) -> Option<[f64; 3]> { + solve3(m, r) } fn solve3(m: [[f64; 3]; 3], r: [f64; 3]) -> Option<[f64; 3]> { diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index 040577b0..025f6999 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -189,7 +189,8 @@ fn polyhedra_shell_and_draft() { .unwrap(); assert!(rel(measure(&tet).unwrap().volume, 36.0) < 1e-9); let cyl = cylinder(Vec3::ZERO, Vec3::Z, 5.0, 5.0).unwrap(); - assert!(shell(&cyl, &[Vec3::new(0.0, 0.0, 5.0)], 1.0).is_err(), "curved faces"); + let cup = shell(&cyl, &[Vec3::new(0.0, 0.0, 5.0)], 1.0).unwrap(); + assert!(rel(measure(&cup).unwrap().volume, PI * (25.0 * 5.0 - 16.0 * 4.0)) < 1e-4, "cup"); } #[test] @@ -909,3 +910,25 @@ fn trimmed_revolved_faces_measure_exactly() { let got = measure(&shaft).unwrap().volume - measure(&c).unwrap().volume; assert!((got - want).abs() < 0.01 * want, "removed {got}, want {want}"); } + +#[test] +fn shell_with_cylindrical_faces() { + // A box with its four vertical edges rounded, opened at the top. + let b = box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap(); + let edges: Vec = [(0.0, 0.0), (100.0, 0.0), (100.0, 60.0), (0.0, 60.0)].iter().map(|(x, y)| Vec3::new(*x, *y, 15.0)).collect(); + let r = fillet(&b, &edges, 6.0).unwrap(); + let s = shell(&r, &[Vec3::new(50.0, 30.0, 30.0)], 2.0).unwrap(); + let outer = (100.0 * 60.0 - (4.0 - PI) * 36.0) * 30.0; + let cavity = (96.0 * 56.0 - (4.0 - PI) * 16.0) * 28.0; + let v = measure(&s).unwrap().volume; + assert!(rel(v, outer - cavity) < 1e-4, "{v} vs {}", outer - cavity); + // A plate with a round boss, opened underneath: the cavity follows the boss. + let plate = box_solid(Vec3::new(-30.0, -30.0, 0.0), Vec3::new(30.0, 30.0, 10.0)).unwrap(); + let boss = cylinder(Vec3::new(0.0, 0.0, 5.0), Vec3::Z, 12.0, 25.0).unwrap(); + let pb = boolean(&plate, &boss, BoolOp::Union).unwrap().unwrap(); + let s = shell(&pb, &[Vec3::new(0.0, 0.0, 0.0)], 2.0).unwrap(); + let outer = 3600.0 * 10.0 + PI * 144.0 * 20.0; + let cavity = 56.0 * 56.0 * 8.0 + PI * 100.0 * 20.0; + let v = measure(&s).unwrap().volume; + assert!(rel(v, outer - cavity) < 1e-4, "{v} vs {}", outer - cavity); +} diff --git a/crates/kernel/src/topo.rs b/crates/kernel/src/topo.rs index 6033fea3..48b30d2e 100644 --- a/crates/kernel/src/topo.rs +++ b/crates/kernel/src/topo.rs @@ -323,6 +323,32 @@ fn classify_faces(mesh: &Mesh, nf: usize, tol: f64) -> Vec { } } } + // A point where one face's corners disagree on the normal is singular (a cone's apex): + // leave it out of the fit. + for (pv, nv) in pts.iter_mut().zip(nrm.iter_mut()) { + let key = |p: &Vec3| ((p.x / tol).round() as i64, (p.y / tol).round() as i64, (p.z / tol).round() as i64); + let mut first: HashMap<(i64, i64, i64), Vec3> = HashMap::new(); + let mut bad: std::collections::HashSet<(i64, i64, i64)> = std::collections::HashSet::new(); + for (p, n) in pv.iter().zip(nv.iter()) { + let k = key(p); + match first.get(&k) { + Some(m) if m.dot(*n) < 0.999 => { + bad.insert(k); + } + Some(_) => {} + None => { + first.insert(k, *n); + } + } + } + if !bad.is_empty() && bad.len() * 4 < first.len() { + let keep: Vec = pv.iter().map(|p| !bad.contains(&key(p))).collect(); + let mut it = keep.iter(); + pv.retain(|_| it.next().copied().unwrap_or(true)); + let mut it = keep.iter(); + nv.retain(|_| it.next().copied().unwrap_or(true)); + } + } (0..nf).map(|i| classify(pts.get(i).map(Vec::as_slice).unwrap_or(&[]), nrm.get(i).map(Vec::as_slice).unwrap_or(&[]), tol * 10.0, i)).collect() }