Shell of rounded parts (the first tutorial): a round thinner than the shell collapses to a sharp inner edge (as in Fusion) instead of failing; when an open face's tangent round can't follow it out, every face moves in and the opening is the open face's copy swept out through the body; spindle tori (a bottom round running round a rounded corner) are taken by the offset; shells after a drilled hole; engine tests against the analytic volumes (box with rounded vertical edges and bottom loop, the tutorial plate at 2 mm and 1.5 mm)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
1bf377c14b
commit
d7f9f0a516
3 files changed
+287
-17
No files matched your search
@@ -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() {
|
||||
|
||||
+176
-15
@@ -290,7 +290,9 @@ fn torus_of(f: &mt::Face, s: &[(Vec3, Vec3)], tol: f64) -> Option<Surf> {
|
||||
}
|
||||
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<bool> = 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<Surf>, Vec<f64>, Vec<Surf>) {
|
||||
@@ -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<usize, Vec<usize>> = 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<usize> {
|
||||
let mut out: Vec<usize> = fs.iter().copied().filter(|i| !is_collapsed(*i)).collect();
|
||||
let mut queue: Vec<usize> = 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<usize>, [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<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 at = vfaces.get(&v.id()).ok_or_else(|| fail("vertex"))?;
|
||||
let p = from_p3(v.point());
|
||||
let fs: Vec<usize> = 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<usize> = 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<Vec3> = 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<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();
|
||||
// Vertices of collapsed faces that land together become one.
|
||||
let on_collapsed: std::collections::HashSet<mt::VertexID> = faces
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| is_collapsed(*i))
|
||||
.flat_map(|(_, f)| f.vertex_iter().map(|v| v.id()).collect::<Vec<_>>())
|
||||
.collect();
|
||||
let mut verts: HashMap<mt::VertexID, mt::Vertex> = 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<mt::EdgeID, (mt::Edge, bool)> = 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<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() }))
|
||||
.filter_map(|e| {
|
||||
edges.get(&e.id()).map(|(ne, flip)| if (e.front() == e.absolute_front()) != *flip { ne.clone() } else { ne.inverse() })
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.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<mt::Shell> = 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::<Vec<_>>().into());
|
||||
let keep = (k..k + sh.len()).filter(|i| !is_collapsed(*i)).count();
|
||||
k += sh.len();
|
||||
shells.push(it.by_ref().take(keep).collect::<Vec<_>>().into());
|
||||
}
|
||||
let solid = Solid::try_new(shells).map_err(|e| fail(&e.to_string()))?;
|
||||
Body::new(solid)
|
||||
|
||||
@@ -361,8 +361,20 @@ fn shell_planar(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
|
||||
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<Body> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Body> {
|
||||
let closed = offset_planar(b, |_, _| -thickness)?;
|
||||
let faces: Vec<mt::Face> = 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 };
|
||||
|
||||
Reference in new issue
Block a user