Shell and offset faces on bodies of planes and cylinders (filleted boxes, bosses: each face moves along its normal, cylinders change radius, corners and arcs follow); surface classification leaves out singular points (cone apexes); recipes: holes on cylinders drill toward the axis

Oracle: 55/76 (67-76 sheet metal not replayed yet).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 03:25:21 +00:00
1 parent d285cedd73
commit 59685a9180
8 files changed
+417 -95

No files matched your search

+3 -2
View File
@@ -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
+17 -1
View File
@@ -402,7 +402,23 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
}
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::<Vec<f64>>()));
let mut dir = n.and_then(|n| n.as_array().map(|a| a.iter().map(|x| -x.as_f64().unwrap_or(0.0)).collect::<Vec<f64>>()));
// 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"
+5 -2
View File
@@ -81,7 +81,10 @@ fn refined(pm: &PolygonMesh, surf: &mt::Surface, tol: f64) -> Option<Vec<[(Vec3,
}
// The parameters must be the surface's own.
let check = tris.iter().take(8).flat_map(|t| t.iter()).all(|(p, (u, v), _)| from_p3(surf.subs(*u, *v)).dist(*p) < tol * 4.0 + 1e-9);
if !check || tris.len() > 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<Vec<[(Vec3,
}
tris = next;
changed |= any;
if !any || tris.len() > 400_000 {
if !any || tris.len() > cap {
break;
}
}
+1
View File
@@ -22,6 +22,7 @@ mod helix;
mod loopblend;
mod measure;
mod meshbody;
mod offset;
mod ops;
mod polybool;
mod polyhedron;
+336
View File
@@ -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<Vec3> {
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<Surf> {
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<mt::Matrix4> {
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<f64> = (0..3).map(|i| ov[i] - (0..3).map(|j| lin[i][j] * ov[j]).sum::<f64>()).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<Surf> {
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<Vec3> {
let ns: Vec<Vec3> = old.iter().map(|sf| sf.normal(p)).collect::<Option<_>>()?;
// Orthonormal basis of the normals' span (tangent faces share a normal).
let mut basis: Vec<Vec3> = 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<f64> = 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<Option<Vec3>> {
use mt::{BoundedCurve, ParametricCurve};
let c = e.curve();
let (t0, t1) = c.range_tuple();
let pts: Vec<Vec3> = (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<Body> {
let faces: Vec<mt::Face> = 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<Surf>, Vec<f64>, Vec<Surf>) {
(
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<mt::VertexID, Vec<usize>> = HashMap::new();
let mut efaces: HashMap<mt::EdgeID, Vec<usize>> = 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<mt::VertexID, Vec3> = 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<mt::VertexID, mt::Vertex> = newpos.iter().map(|(k, p)| (*k, builder::vertex(p3(*p)))).collect();
let mut edges: HashMap<mt::EdgeID, 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 {
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<mt::Wire> = 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::<Vec<_>>()
.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)
})
}
+5 -89
View File
@@ -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<Body> {
let fail = |m: &str| KernelError::Failed(format!("offset: {m}"));
let faces: Vec<mt::Face> = 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<mt::VertexID, Vec<usize>> = 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<mt::VertexID, Vec3> = 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<mt::VertexID, mt::Vertex> = newpos.iter().map(|(k, p)| (*k, builder::vertex(p3(*p)))).collect();
let mut edges: HashMap<mt::EdgeID, 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 {
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<mt::Wire> = 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::<Vec<_>>()
.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]> {
+24 -1
View File
@@ -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<Vec3> = [(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);
}
+26
View File
@@ -323,6 +323,32 @@ fn classify_faces(mesh: &Mesh, nf: usize, tol: f64) -> Vec<Surf> {
}
}
}
// 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<bool> = 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()
}