Kernel: concave edge fillets; whole smooth-loop blends of planar faces (cylinders and tori: pocket floors, plate outlines); rounding every edge of a convex planar body (sphere corners); healing after booleans (exact lines/arcs for intersection curves, coplanar faces merged, collinear edges joined); faces rebuilt on their stored surfaces

Oracle: 28/29.

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-07 22:08:27 +00:00
1 parent b2b9331415
commit a4a17331dc
9 files changed
+997 -43

No files matched your search

+4 -3
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 (SOLID + SKETCH toolbar) | **57 / 291 (20%)** — [docs/parity.md](docs/parity.md) |
| Fusion oracle | **25 / 29 parts match** Fusion's measurements — [docs/oracle.md](docs/oracle.md) |
| Fusion oracle | **28 / 29 parts match** Fusion's measurements — [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) |
@@ -35,8 +35,9 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
## Known limitations
- Fillets and chamfers: straight convex edges between planar faces with perpendicular planar end
faces; two blended edges may not share a corner yet.
- Fillets and chamfers: straight edges (convex or concave) between planar faces with perpendicular
planar end faces; whole smooth loops of a planar face (pocket floors, plate outlines); every edge
of a convex planar body (sphere corners). Other corner configurations are not supported yet.
- Booleans of curved bodies with coincident faces can fail (planar ones have an exact fallback);
fully internal voids are not supported. Spheres are built from six pole-free patches so they
combine reliably.
+97 -31
View File
@@ -44,7 +44,7 @@ fn plane_normal(f: &mt::Face) -> Option<Vec3> {
/// edges where consecutive edges no longer connect.
fn rebuild_face(f: &mt::Face, subst: &HashMap<mt::EdgeID, mt::Edge>, connectors: &[mt::Edge]) -> Result<mt::Face> {
let mut wires = Vec::new();
for w in f.boundaries() {
for w in f.absolute_boundaries() {
let mut edges: Vec<mt::Edge> = Vec::new();
for e in w.edge_iter() {
let ne = match subst.get(&e.id()) {
@@ -81,8 +81,7 @@ fn rebuild_face(f: &mt::Face, subst: &HashMap<mt::EdgeID, mt::Edge>, connectors:
}
wires.push(mt::Wire::from(out));
}
let surface = f.oriented_surface();
mt::Face::try_new(wires, surface).map_err(|e| KernelError::Failed(format!("blend: {e}")))
crate::heal::absolute_face(f, wires).ok_or_else(|| KernelError::Failed("blend: a face could not be rebuilt".into()))
}
fn blend_one(solid: &Solid, edge: &mt::Edge, size: f64, r: f64, shape: Shape) -> Result<Solid> {
@@ -103,22 +102,25 @@ fn blend_one(solid: &Solid, edge: &mt::Edge, size: f64, r: f64, shape: Shape) ->
let (v0, v1) = (edge.absolute_front().clone(), edge.absolute_back().clone());
let (p0, p1) = (vtx(&v0), vtx(&v1));
let d = (p1 - p0).normalized().ok_or_else(|| KernelError::Failed("zero-length edge".into()))?;
// In-face directions perpendicular to the edge, pointing away from it.
let mut t1 = n1.cross(d);
if t1.dot(n2) > 0.0 {
t1 = -t1;
}
let mut t2 = n2.cross(d);
if t2.dot(n1) > 0.0 {
t2 = -t2;
}
// Convex edge: the faces' outward normals point away from each other's interior.
// In-face directions perpendicular to the edge, into each face: a face's boundary runs
// counter-clockwise about its outward normal, so its interior is to the left.
let into = |f: &mt::Face, n: Vec3| -> Option<Vec3> {
let e = f.boundary_iters().into_iter().flatten().find(|e| e.id() == edge.id())?;
let dir = (vtx(e.back()) - vtx(e.front())).normalized()?;
n.cross(dir).normalized()
};
let (Some(t1), Some(t2)) = (into(f1, n1), into(f2, n2)) else { return Err(KernelError::Failed("blend: edge direction".into())) };
// Convex edge: each face's outward normal points away from the other face; concave: toward.
let cos_phi = t1.dot(t2).clamp(-1.0, 1.0);
let phi = cos_phi.acos();
if !(phi > 1e-3 && phi < std::f64::consts::PI - 1e-3) || n1.dot(t2) > 1e-9 || n2.dot(t1) > 1e-9 {
return Err(unsupported("only convex edges can be blended"));
let convex = n1.dot(t2) < -1e-9 && n2.dot(t1) < -1e-9;
let concave = n1.dot(t2) > 1e-9 && n2.dot(t1) > 1e-9;
if !(phi > 1e-3 && phi < std::f64::consts::PI - 1e-3) || !(convex || concave) {
return Err(unsupported("the faces at the edge are tangent or the edge is ambiguous"));
}
blend_geometry(solid, si, &faces, (i1, i2), edge, (v0, v1), (p0, p1, d), (n1, t1, t2, phi), size, r, shape)
// The blend's centre line is inside the material for a convex edge, outside for a concave one.
let side = if convex { -1.0 } else { 1.0 };
blend_geometry(solid, si, &faces, (i1, i2), edge, (v0, v1), (p0, p1, d), (n1, t1, t2, phi, side), size, r, shape)
}
/// Distance along each face from the edge to the blend boundary.
@@ -138,7 +140,7 @@ fn blend_geometry(
edge: &mt::Edge,
(v0, v1): (mt::Vertex, mt::Vertex),
(p0, p1, d): (Vec3, Vec3, Vec3),
(n1, t1, t2, phi): (Vec3, Vec3, Vec3, f64),
(n1, t1, t2, phi, side): (Vec3, Vec3, Vec3, f64, f64),
size: f64,
r: f64,
shape: Shape,
@@ -190,16 +192,17 @@ fn blend_geometry(
let b0 = builder::vertex(p3(p0 + t2 * s));
let a1 = builder::vertex(p3(p1 + t1 * s));
let b1 = builder::vertex(p3(p1 + t2 * s));
// Corner curves on the end faces (A → B) and the blend surface.
let bisector = (t1 + t2).normalized().ok_or_else(|| KernelError::Failed("bisector".into()))?;
// Corner curves on the end faces (A → B) and the blend surface. The arc centre is r from
// both faces (inside the material for a convex edge, outside for a concave one); the arc's
// middle is the point nearest the old edge.
let centre = |p: Vec3| p + t1 * s + n1 * (r * side);
let arc_mid = |p: Vec3| {
let c = centre(p);
c + (p - c).normalized().unwrap_or(-n1) * r
};
let corner = |a: &mt::Vertex, b: &mt::Vertex, p: Vec3| -> mt::Edge {
match shape {
Shape::Round => {
// Arc centre is r inside both faces; its mid point is on the bisector, toward the edge.
let c = p + t1 * s - n1 * r;
let mid = c - bisector * r;
builder::circle_arc(a, b, p3(mid))
}
Shape::Round => builder::circle_arc(a, b, p3(arc_mid(p))),
Shape::Flat => builder::line(a, b),
}
};
@@ -248,13 +251,14 @@ fn blend_geometry(
Shape::Round => {
let swept: mt::Face = builder::tsweep(&c0, v3(p1 - p0));
let mut surf = swept.oriented_surface();
// Outward normal at the arc middle points away from the arc centre.
let c = p0 + t1 * s - n1 * r;
let mid = c - bisector * r;
// Outward normal at the arc middle: away from the centre on a convex edge (the
// centre is in the material), toward it on a concave one.
let (c, mid) = (centre(p0), arc_mid(p0));
let outward = (mid - c) * (-side);
use mt::{ParametricSurface3D, SearchParameter};
if let Some((u, v)) = surf.search_parameter(p3(mid), None, 100) {
let nn = surf.normal(u, v);
if Vec3::new(nn.x, nn.y, nn.z).dot(bisector) > 0.0 {
if Vec3::new(nn.x, nn.y, nn.z).dot(outward) < 0.0 {
surf = mt::Invertible::inverse(&surf);
}
}
@@ -280,7 +284,48 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
if edges.len() > 1000 {
return Err(KernelError::Invalid("too many edges".into()));
}
let mut cur = body.clone();
// Coplanar neighbours as one face (as after a join), so corners look as they should.
let mut cur = Body::new(crate::heal::heal(body.deep_copy(), body.size()))?;
// Every edge of a convex planar body: rounded as a whole (sphere corners).
if shape == Shape::Round && edges.len() >= 6 {
let solid = cur.deep_copy();
let all = Body::unique_edges(&solid);
if all.len() == edges.len() {
let size = cur.size();
let tol = (size * 2e-3).max(1e-3);
let mut hit = std::collections::HashSet::new();
for p in edges {
if let Some((idx, _)) = cur.nearest_edge(*p, tol)? {
hit.insert(idx);
}
}
if hit.len() == all.len()
&& let Ok(b) = crate::polyhedron::round_all_edges(&cur, r)
{
return Ok(b);
}
}
}
// A whole smooth loop of a planar face is blended in one go.
if edges.len() >= 2 {
let size = cur.size();
let tol = (size * 2e-3).max(1e-3);
let solid = cur.deep_copy();
let all = Body::unique_edges(&solid);
let mut ids = Vec::new();
for p in edges {
if let Some((idx, dist)) = cur.nearest_edge(*p, tol)?
&& dist <= size * 0.05 + 1e-3
&& let Some(e) = all.get(idx)
&& !ids.contains(&e.id())
{
ids.push(e.id());
}
}
if let Some(r2) = crate::loopblend::loop_blend(&solid, &ids, size, r, shape == Shape::Round) {
return Body::new(guard(what, || r2)?);
}
}
for p in edges {
let size = cur.size();
let tol = (size * 2e-3).max(1e-3);
@@ -336,7 +381,28 @@ pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result<Body> {
return Ok(r);
}
}
let mut cur = body.clone();
// Coplanar neighbours as one face (as after a join), so corners look as they should.
let mut cur = Body::new(crate::heal::heal(body.deep_copy(), body.size()))?;
// A whole smooth loop of a planar face is blended in one go.
if edges.len() >= 2 {
let size = cur.size();
let tol = (size * 2e-3).max(1e-3);
let solid = cur.deep_copy();
let all = Body::unique_edges(&solid);
let mut ids = Vec::new();
for p in edges {
if let Some((idx, dist)) = cur.nearest_edge(*p, tol)?
&& dist <= size * 0.05 + 1e-3
&& let Some(e) = all.get(idx)
&& !ids.contains(&e.id())
{
ids.push(e.id());
}
}
if let Some(r2) = crate::loopblend::loop_blend(&solid, &ids, size, distance, false) {
return Body::new(guard("chamfer", || r2)?);
}
}
for p in edges {
cur = match chamfer_tool(&cur, *p, distance, 0).and_then(|t| crate::ops::boolean(&cur, &t, crate::BoolOp::Cut)) {
Ok(Some(b)) => b,
+1 -1
View File
@@ -609,7 +609,7 @@ fn cubic_basis(knots: &[f64], n_ctrl: usize, u: f64) -> Vec<f64> {
}
/// Cubic B-spline through points at uniform parameters (clamped, knots by averaging).
fn interpolate_cubic(pts: &[Vec3]) -> Option<mt::BSplineCurve<mt::Point3>> {
pub(crate) fn interpolate_cubic(pts: &[Vec3]) -> Option<mt::BSplineCurve<mt::Point3>> {
let m = pts.len();
if m < 4 {
return None;
+361
View File
@@ -0,0 +1,361 @@
//! Clean-up after operations: coplanar neighbouring faces become one face, and two straight
//! edges meeting in line at a vertex nothing else uses become one edge (as Fusion keeps them).
use std::collections::HashMap;
use solvecraft_geom::Vec3;
use truck_modeling::{self as mt, builder};
use crate::body::{Solid, from_p3};
fn plane_of(f: &mt::Face) -> Option<(Vec3, f64)> {
match f.oriented_surface() {
mt::Surface::Plane(p) => {
let n = p.normal();
let n = Vec3::new(n.x, n.y, n.z).normalized()?;
let o = p.origin();
Some((n, n.dot(Vec3::new(o.x, o.y, o.z))))
}
_ => None,
}
}
/// A face like `f` with new boundary wires given in `f`'s absolute (stored) orientation:
/// built on the stored surface and turned like `f` (rebuilding on the turned surface can make
/// the mesher pick the wrong side of a trim on periodic surfaces).
pub(crate) fn absolute_face(f: &mt::Face, wires: Vec<mt::Wire>) -> Option<mt::Face> {
let mut nf = mt::Face::try_new(wires, f.surface()).ok()?;
if !f.orientation() {
nf.invert();
}
Some(nf)
}
fn find(p: &mut [usize], mut i: usize) -> usize {
while let Some(&q) = p.get(i) {
if q == i {
break;
}
i = q;
}
i
}
/// Points along an oriented edge (for loop areas and containment).
fn samples(e: &mt::Edge) -> Vec<Vec3> {
use mt::{BoundedCurve, ParametricCurve};
let c = e.oriented_curve();
let (t0, t1) = c.range_tuple();
(0..8).map(|k| t0 + (t1 - t0) * k as f64 / 8.0).map(|t| from_p3(c.subs(t))).collect()
}
fn loop_area(edges: &[mt::Edge], n: Vec3) -> f64 {
let pts: Vec<Vec3> = edges.iter().flat_map(samples).collect();
let k = pts.len();
let mut s = Vec3::ZERO;
for i in 0..k {
if let (Some(a), Some(b)) = (pts.get(i), pts.get((i + 1) % k)) {
s += a.cross(*b);
}
}
s.dot(n) * 0.5
}
fn inside(p: Vec3, edges: &[mt::Edge], n: Vec3) -> bool {
let u = n.any_perp();
let w = n.cross(u);
let pts: Vec<(f64, f64)> = edges.iter().flat_map(samples).map(|q| (q.dot(u), q.dot(w))).collect();
let (px, py) = (p.dot(u), p.dot(w));
let k = pts.len();
let mut c = false;
for i in 0..k {
let (Some(&(ax, ay)), Some(&(bx, by))) = (pts.get(i), pts.get((i + 1) % k)) else { continue };
if (ay > py) != (by > py) && px < ax + (py - ay) / (by - ay) * (bx - ax) {
c = !c;
}
}
c
}
/// Merge each group of edge-connected coplanar faces into one face. Returns None when nothing
/// changed or the result would not be a valid solid.
fn merge_coplanar(shell: &mt::Shell, tol: f64) -> Option<mt::Shell> {
let faces: Vec<mt::Face> = shell.face_iter().cloned().collect();
let planes: Vec<Option<(Vec3, f64)>> = faces.iter().map(plane_of).collect();
let mut parent: Vec<usize> = (0..faces.len()).collect();
let mut by_edge: HashMap<mt::EdgeID, Vec<usize>> = HashMap::new();
for (i, f) in faces.iter().enumerate() {
for e in f.edge_iter() {
by_edge.entry(e.id()).or_default().push(i);
}
}
let mut merged_any = false;
for fs in by_edge.values() {
if let [a, b] = fs[..]
&& a != b
&& let (Some(Some((na, da))), Some(Some((nb, db)))) = (planes.get(a), planes.get(b))
&& na.dot(*nb) > 1.0 - 1e-9
&& (da - db).abs() < tol
{
let (ra, rb) = (find(&mut parent, a), find(&mut parent, b));
if ra != rb {
if let Some(x) = parent.get_mut(ra) {
*x = rb;
}
merged_any = true;
}
}
}
if !merged_any {
return None;
}
let mut groups: HashMap<usize, Vec<usize>> = HashMap::new();
for i in 0..faces.len() {
let r = find(&mut parent, i);
groups.entry(r).or_default().push(i);
}
let mut out: Vec<mt::Face> = Vec::new();
let mut order: Vec<usize> = groups.keys().copied().collect();
order.sort_unstable();
for g in order {
let members = groups.get(&g)?;
if members.len() == 1 {
out.push(faces.get(*members.first()?)?.clone());
continue;
}
let (n, _) = (*planes.get(*members.first()?)?)?;
// Oriented boundary edges of the group; edges used twice inside it are dropped.
let mut count: HashMap<mt::EdgeID, usize> = HashMap::new();
for m in members {
for e in faces.get(*m)?.boundary_iters().into_iter().flatten() {
*count.entry(e.id()).or_insert(0) += 1;
}
}
let mut rest: Vec<mt::Edge> = Vec::new();
for m in members {
for e in faces.get(*m)?.boundary_iters().into_iter().flatten() {
if count.get(&e.id()).copied().unwrap_or(0) == 1 {
rest.push(e);
}
}
}
// Chain into loops.
let mut loops: Vec<Vec<mt::Edge>> = Vec::new();
while let Some(first) = rest.pop() {
let start = first.front().clone();
let mut cur = first.back().clone();
let mut lp = vec![first];
let mut guard = 0;
while cur != start && guard < 100_000 {
guard += 1;
let k = rest.iter().position(|e| e.front() == &cur)?;
let e = rest.remove(k);
cur = e.back().clone();
lp.push(e);
}
loops.push(lp);
}
let (outer, holes): (Vec<Vec<mt::Edge>>, Vec<Vec<mt::Edge>>) = loops.into_iter().partition(|l| loop_area(l, n) > 0.0);
let surface = faces.get(*members.first()?)?.oriented_surface();
for o in &outer {
let mut wires: Vec<mt::Wire> = vec![o.clone().into()];
for h in &holes {
let probe = h.first().map(|e| from_p3(e.front().point()))?;
// The innermost outer loop around the hole owns it.
let owner = outer.iter().filter(|x| inside(probe, x, n)).min_by(|a, b| loop_area(a, n).total_cmp(&loop_area(b, n)));
if owner.is_some_and(|x| std::ptr::eq(x, o)) {
wires.push(h.clone().into());
}
}
out.push(mt::Face::try_new(wires, surface.clone()).ok()?);
}
}
Some(out.into())
}
/// Join pairs of collinear straight edges at vertices only they use.
fn merge_collinear(shell: &mt::Shell) -> Option<mt::Shell> {
let mut faces: Vec<mt::Face> = shell.face_iter().cloned().collect();
let mut changed = false;
for _round in 0..10_000 {
let mut at: HashMap<mt::VertexID, Vec<mt::Edge>> = HashMap::new();
let mut seen = std::collections::HashSet::new();
for f in &faces {
for e in f.edge_iter() {
if seen.insert(e.id()) {
at.entry(e.front().id()).or_default().push(e.absolute_clone());
at.entry(e.back().id()).or_default().push(e.absolute_clone());
}
}
}
let pair = at.iter().find_map(|(v, es)| {
let [a, b] = &es[..] else { return None };
if !matches!(a.curve(), mt::Curve::Line(_)) || !matches!(b.curve(), mt::Curve::Line(_)) {
return None;
}
let other = |e: &mt::Edge| if e.front().id() == *v { e.back().clone() } else { e.front().clone() };
let mid = if a.front().id() == *v { a.front().clone() } else { a.back().clone() };
let (pa, pm, pb) = (from_p3(other(a).point()), from_p3(mid.point()), from_p3(other(b).point()));
let (u, w) = ((pm - pa).normalized()?, (pb - pm).normalized()?);
(u.dot(w) > 1.0 - 1e-10).then(|| (a.clone(), b.clone(), other(a), other(b)))
});
let Some((a, b, va, vb)) = pair else { break };
let joined = builder::line(&va, &vb);
for f in &mut faces {
if !f.edge_iter().any(|e| e.id() == a.id()) {
continue;
}
let mut wires = Vec::new();
for w in f.absolute_boundaries() {
let mut es: Vec<mt::Edge> = Vec::new();
for e in w.edge_iter() {
if e.id() == b.id() {
continue;
}
if e.id() == a.id() {
// a+b (in either order) become the joined edge, in this wire's direction.
es.push(if e.front() == &va { joined.clone() } else { joined.inverse() });
continue;
}
es.push(e.clone());
}
wires.push(mt::Wire::from(es));
}
*f = absolute_face(f, wires)?;
}
changed = true;
}
changed.then(|| faces.into())
}
/// The exact curve an approximate edge (a boolean's intersection curve) really is: a line or a
/// circular arc, when every sample lies on it.
fn exact_curve(e: &mt::Edge, tol: f64) -> Option<mt::Edge> {
// Only the approximate curves booleans make; exact ones stay as they are.
if !matches!(e.curve(), mt::Curve::IntersectionCurve(_)) {
return None;
}
fit_exact(e, tol)
}
/// A line or circular arc through the edge's samples, if they all lie on one.
fn fit_exact(e: &mt::Edge, tol: f64) -> Option<mt::Edge> {
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) = (from_p3(e.absolute_front().point()), from_p3(e.absolute_back().point()));
if e.absolute_front() == e.absolute_back() || a.dist(b) < tol {
return None;
}
if pts.iter().all(|p| p.dist_to_segment(a, b) < tol) {
return Some(builder::line(e.absolute_front(), e.absolute_back()));
}
// A circle through the ends and the middle sample.
let m = *pts.get(8)?;
let n = (m - a).cross(b - a).normalized()?;
let (ab, am) = (b - a, m - a);
// Centre in the plane: solve |c-a| = |c-b| = |c-m|.
let d = 2.0 * ab.cross(am).len2();
if d < 1e-300 {
return None;
}
// Circumcentre: a + (|u|² v×w + |v|² w×u) / (2|w|²), u = b−a, v = m−a, w = u×v.
let w = ab.cross(am);
let centre = a + (am.cross(w) * ab.len2() + w.cross(ab) * am.len2()) * (1.0 / d);
let r = centre.dist(a);
if pts.iter().all(|p| (p.dist(centre) - r).abs() < tol && (*p - a).dot(n).abs() < tol) {
return Some(builder::circle_arc(e.absolute_front(), e.absolute_back(), mt::Point3::new(m.x, m.y, m.z)));
}
None
}
/// Replace approximate edges that are really lines or arcs with exact ones.
fn exact_edges(shell: &mt::Shell, tol: f64) -> Option<mt::Shell> {
let mut subst: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
for e in shell.edge_iter() {
if !subst.contains_key(&e.id())
&& let Some(x) = exact_curve(&e, tol)
{
subst.insert(e.id(), x);
}
}
if subst.is_empty() {
return None;
}
let mut faces = Vec::new();
for f in shell.face_iter() {
let mut wires = Vec::new();
for w in f.absolute_boundaries() {
let es: Vec<mt::Edge> = w
.edge_iter()
.map(|e| match subst.get(&e.id()) {
// The new edge runs absolute front → back; match this use's direction.
Some(n) => {
if e.front() == e.absolute_front() {
n.clone()
} else {
n.inverse()
}
}
None => e.clone(),
})
.collect();
wires.push(mt::Wire::from(es));
}
faces.push(absolute_face(f, wires)?);
}
Some(faces.into())
}
/// Heal a solid; returns the input unchanged when nothing applies or healing fails.
pub fn heal(solid: Solid, size: f64) -> Solid {
let tol = (size * 1e-7).max(1e-9);
let mut shells = Vec::new();
let mut changed = false;
for sh in solid.boundaries() {
let mut cur = sh.clone();
if let Some(m) = exact_edges(&cur, (size * 1e-5).max(1e-7)) {
cur = m;
changed = true;
}
if let Some(m) = merge_coplanar(&cur, tol) {
cur = m;
changed = true;
}
if let Some(m) = merge_collinear(&cur) {
cur = m;
changed = true;
}
shells.push(cur);
}
if !changed {
return solid;
}
Solid::try_new(shells).unwrap_or(solid)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn approximate_arcs_become_exact() {
// A cubic (non-rational) spline through a quarter circle.
let (a, b) = (builder::vertex(mt::Point3::new(10.0, 0.0, 0.0)), builder::vertex(mt::Point3::new(0.0, 10.0, 0.0)));
let pts: Vec<Vec3> = (0..=40)
.map(|k| {
let t = std::f64::consts::FRAC_PI_2 * k as f64 / 40.0;
Vec3::new(10.0 * t.cos(), 10.0 * t.sin(), 0.0)
})
.collect();
let curve = mt::Curve::BSplineCurve(crate::build::interpolate_cubic(&pts).expect("spline"));
let e = mt::Edge::new(&a, &b, curve);
let x = fit_exact(&e, 1e-2).expect("an arc");
use mt::{BoundedCurve, ParametricCurve};
let c = x.curve();
let (t0, t1) = c.range_tuple();
let mid = c.subs((t0 + t1) / 2.0);
assert!(((mid.x * mid.x + mid.y * mid.y).sqrt() - 10.0).abs() < 1e-9, "{mid:?}");
}
}
+2
View File
@@ -15,6 +15,8 @@
mod blend;
mod body;
mod build;
mod heal;
mod loopblend;
mod measure;
mod meshbody;
mod ops;
+306
View File
@@ -0,0 +1,306 @@
//! Blending a whole boundary loop of a planar face whose neighbours are walls standing
//! perpendicular to it (planes, and cylinders with axes along the face normal): a pocket floor,
//! the top outline of an extruded plate. The loop must be smooth (lines and arcs meeting
//! tangentially). The face shrinks by the radius, the walls are trimmed by it, and each loop edge
//! gets a blend face: a cylinder (or plane, for a chamfer) along a line, a torus (or cone) along
//! an arc, all meeting along the profile arcs at the joints. The result is exact.
use std::collections::HashMap;
use solvecraft_geom::Vec3;
use truck_modeling::{self as mt, builder};
use crate::body::{Solid, from_p3, p3, v3};
use crate::{KernelError, Result};
fn unsupported(msg: &str) -> KernelError {
KernelError::Failed(format!("not supported yet: {msg}"))
}
#[derive(Clone, Copy, Debug)]
enum Seg {
Line {
a: Vec3,
b: Vec3,
},
/// Arc from `a` to `b` about `c` turning by `angle` about `axis` (right hand, angle > 0).
Arc {
a: Vec3,
c: Vec3,
axis: Vec3,
angle: f64,
mid: Vec3,
},
}
impl Seg {
fn start(&self) -> Vec3 {
match *self {
Seg::Line { a, .. } | Seg::Arc { a, .. } => a,
}
}
/// Unit tangent (direction of travel) at a point of the segment.
fn tangent(&self, p: Vec3) -> Option<Vec3> {
match *self {
Seg::Line { a, b } => (b - a).normalized(),
Seg::Arc { c, axis, .. } => axis.cross(p - c).normalized(),
}
}
}
/// The exact shape of an oriented loop edge (a line, or an arc found from samples).
fn seg_of(e: &mt::Edge, tol: f64) -> Option<Seg> {
use mt::{BoundedCurve, ParametricCurve};
let (a, b) = (from_p3(e.front().point()), from_p3(e.back().point()));
let c = e.oriented_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();
if pts.iter().all(|p| p.dist_to_segment(a, b) < tol) {
return Some(Seg::Line { a, b });
}
let m = *pts.get(8)?;
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);
if !pts.iter().all(|p| (p.dist(centre) - r).abs() < tol) {
return None;
}
// Turning direction: the travel at a is axis × (a − c).
let t_a = (*pts.get(1)? - a).normalized()?;
let mut axis = w.normalized()?;
if axis.cross(a - centre).dot(t_a) < 0.0 {
axis = -axis;
}
let (ra, rb) = ((a - centre).normalized()?, (b - centre).normalized()?);
let mut angle = ra.dot(rb).clamp(-1.0, 1.0).acos();
if ra.cross(rb).dot(axis) < 0.0 {
angle = std::f64::consts::TAU - angle;
}
Some(Seg::Arc { a, c: centre, axis, angle, mid: m })
}
fn plane_normal(f: &mt::Face) -> Option<Vec3> {
match f.oriented_surface() {
mt::Surface::Plane(p) => {
let n = p.normal();
Vec3::new(n.x, n.y, n.z).normalized()
}
_ => None,
}
}
/// Outward normal of a face at a point on it.
fn face_normal(f: &mt::Face, p: Vec3) -> Option<Vec3> {
use mt::{ParametricSurface3D, SearchNearestParameter, SearchParameter};
let s = f.oriented_surface();
let (u, v) = s.search_parameter(p3(p), None, 100).or_else(|| s.search_nearest_parameter(p3(p), None, 100))?;
let n = s.normal(u, v);
Vec3::new(n.x, n.y, n.z).normalized()
}
/// Blend the loop of `edges` (by id) if it is a whole smooth boundary loop of a planar face with
/// perpendicular walls. `None` when the edges are not such a loop.
pub(crate) fn loop_blend(solid: &Solid, edge_ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Option<Result<Solid>> {
let tol = (size * 1e-6).max(1e-7);
for (si, shell) in solid.boundaries().iter().enumerate() {
let faces: Vec<&mt::Face> = shell.face_iter().collect();
for (fi, f) in faces.iter().enumerate() {
let Some(n) = plane_normal(f) else { continue };
for (wi, w) in f.boundaries().iter().enumerate() {
let ids: Vec<mt::EdgeID> = w.edge_iter().map(|e| e.id()).collect();
if ids.len() != edge_ids.len() || !edge_ids.iter().all(|e| ids.contains(e)) {
continue;
}
return Some(build(solid, si, &faces, fi, wi, n, size, tol, r, round));
}
}
}
None
}
#[allow(clippy::too_many_arguments)]
fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n: Vec3, size: f64, tol: f64, r: f64, round: bool) -> Result<Solid> {
let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?;
let wire = f.boundaries().get(wi).cloned().ok_or_else(|| KernelError::Failed("loop".into()))?;
let edges: Vec<mt::Edge> = wire.edge_iter().cloned().collect();
let k = edges.len();
let segs: Vec<Seg> = edges
.iter()
.map(|e| seg_of(e, (size * 1e-5).max(1e-6)))
.collect::<Option<_>>()
.ok_or_else(|| unsupported("loop edges must be lines or arcs"))?;
// Smooth joints.
for i in 0..k {
let (Some(prev), Some(cur)) = (segs.get((i + k - 1) % k), segs.get(i)) else { continue };
let p = cur.start();
let (Some(ta), Some(tb)) = (prev.tangent(p), cur.tangent(p)) else { return Err(unsupported("degenerate loop edge")) };
if ta.dot(tb) < 1.0 - 1e-6 {
return Err(unsupported("blending a loop with sharp corners"));
}
}
// Walls: the other face of each edge, perpendicular to the face; all on one side.
let mut walls: Vec<usize> = Vec::with_capacity(k);
let mut side = 0.0;
for (e, s) in edges.iter().zip(&segs) {
let wf = faces
.iter()
.position(|g| !std::ptr::eq(*g, *f) && g.edge_iter().any(|x| x.id() == e.id()))
.ok_or_else(|| unsupported("an edge with one face"))?;
let mid = match *s {
Seg::Line { a, b } => (a + b) * 0.5,
Seg::Arc { mid, .. } => mid,
};
let g = faces.get(wf).ok_or_else(|| KernelError::Failed("wall".into()))?;
let gn = face_normal(g, mid).ok_or_else(|| unsupported("wall normal"))?;
if gn.dot(n).abs() > 1e-6 {
return Err(unsupported("the walls must stand perpendicular to the face"));
}
// Into the face from the loop: left of travel.
let m = n.cross(s.tangent(mid).ok_or_else(|| unsupported("tangent"))?);
let sd = if gn.dot(m) > 0.0 { 1.0 } else { -1.0 };
if side != 0.0 && sd != side {
return Err(unsupported("a loop that is partly convex and partly concave"));
}
side = sd;
walls.push(wf);
}
// Offsets: into the face by r; along the wall by r (up a concave wall, down a convex one).
let into = |s: &Seg, p: Vec3| -> Vec3 { s.tangent(p).map(|t| n.cross(t)).unwrap_or(Vec3::ZERO) };
let lift = n * (r * side);
let pa: Vec<Vec3> = (0..k).map(|i| segs.get(i).map(|s| s.start() + into(s, s.start()) * r).unwrap_or_default()).collect();
let pb: Vec<Vec3> = (0..k).map(|i| segs.get(i).map(|s| s.start() + lift).unwrap_or_default()).collect();
// The offset arcs must not collapse.
for s in &segs {
if let Seg::Arc { a, c, .. } = *s {
let toward = into(s, a).dot(c - a) > 0.0;
if toward && a.dist(c) <= r + tol {
return Err(unsupported("the blend is larger than a corner radius"));
}
}
}
let va: Vec<mt::Vertex> = pa.iter().map(|p| builder::vertex(p3(*p))).collect();
let vb: Vec<mt::Vertex> = pb.iter().map(|p| builder::vertex(p3(*p))).collect();
let centre = |i: usize| pa.get(i).copied().unwrap_or_default() + lift;
let profile_mid = |i: usize| {
let (Some(s), c) = (segs.get(i), centre(i)) else { return Vec3::ZERO };
c + (s.start() - c).normalized().unwrap_or(n) * r
};
// Profiles at the joints (face side → wall side).
let profiles: Vec<mt::Edge> = (0..k)
.map(|i| {
let (Some(a), Some(b)) = (va.get(i), vb.get(i)) else {
return builder::line(&builder::vertex(p3(Vec3::ZERO)), &builder::vertex(p3(Vec3::X)));
};
if round { builder::circle_arc(a, b, p3(profile_mid(i))) } else { builder::line(a, b) }
})
.collect();
let rail = |vs: &[mt::Vertex], i: usize, offset: &dyn Fn(Vec3, &Seg) -> Vec3| -> Option<mt::Edge> {
let s = segs.get(i)?;
let (a, b) = (vs.get(i)?, vs.get((i + 1) % k)?);
Some(match *s {
Seg::Line { .. } => builder::line(a, b),
Seg::Arc { mid, .. } => builder::circle_arc(a, b, p3(offset(mid, s))),
})
};
let face_off = |p: Vec3, s: &Seg| p + into(s, p) * r;
let wall_off = |p: Vec3, _: &Seg| p + lift;
let face_rails: Vec<mt::Edge> =
(0..k).map(|i| rail(&va, i, &face_off)).collect::<Option<_>>().ok_or_else(|| KernelError::Failed("rail".into()))?;
let wall_rails: Vec<mt::Edge> =
(0..k).map(|i| rail(&vb, i, &wall_off)).collect::<Option<_>>().ok_or_else(|| KernelError::Failed("rail".into()))?;
// Seams between neighbouring walls at the joints get shorter.
let loop_vertex: Vec<mt::Vertex> = edges.iter().map(|e| e.front().clone()).collect();
let mut seam_subst: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
let loop_ids: Vec<mt::EdgeID> = edges.iter().map(|e| e.id()).collect();
for (i, v) in loop_vertex.iter().enumerate() {
for g in faces {
for e in g.edge_iter() {
if loop_ids.contains(&e.id()) || seam_subst.contains_key(&e.id()) || !(e.front() == v || e.back() == v) {
continue;
}
let (fa, fb) = (e.absolute_front().clone(), e.absolute_back().clone());
let other = if &fa == v { &fb } else { &fa };
if !matches!(e.curve(), mt::Curve::Line(_)) {
return Err(unsupported("the wall seams must be straight"));
}
let along = (from_p3(other.point()) - from_p3(v.point())).dot(n) * side;
if along <= r + tol {
return Err(unsupported("the blend is taller than the walls"));
}
let nb = vb.get(i).ok_or_else(|| KernelError::Failed("joint".into()))?;
let ne = if &fa == v { builder::line(nb, &fb) } else { builder::line(&fa, nb) };
seam_subst.insert(e.id(), ne);
}
}
}
// Rebuild faces.
let idx_of = |id: mt::EdgeID| loop_ids.iter().position(|x| *x == id);
let rebuild = |g: &mt::Face, rails: &[mt::Edge]| -> Result<mt::Face> {
let mut wires = Vec::new();
for w in g.absolute_boundaries() {
let mut es = Vec::new();
for e in w.edge_iter() {
let ne = if let Some(i) = idx_of(e.id()) {
let rl = rails.get(i).ok_or_else(|| KernelError::Failed("rail".into()))?;
// Rails run in loop order (joint i → i+1).
if e.front() == loop_vertex.get(i).ok_or_else(|| KernelError::Failed("joint".into()))? { rl.clone() } else { rl.inverse() }
} else if let Some(s) = seam_subst.get(&e.id()) {
if e.front() == e.absolute_front() { s.clone() } else { s.inverse() }
} else {
e.clone()
};
es.push(ne);
}
wires.push(mt::Wire::from(es));
}
crate::heal::absolute_face(g, wires).ok_or_else(|| KernelError::Failed("loop blend: a face could not be rebuilt".into()))
};
let mut out: Vec<mt::Face> = Vec::new();
for (gi, g) in faces.iter().enumerate() {
if gi == fi {
out.push(rebuild(g, &face_rails)?);
} else if walls.contains(&gi) {
out.push(rebuild(g, &wall_rails)?);
} else if g.edge_iter().any(|e| seam_subst.contains_key(&e.id())) {
out.push(rebuild(g, &[])?);
} else {
out.push((*g).clone());
}
}
// Blend faces.
use mt::{ParametricSurface3D, SearchNearestParameter};
for i in 0..k {
let (Some(s), Some(pr), Some(pr_next), Some(fr), Some(wr)) =
(segs.get(i), profiles.get(i), profiles.get((i + 1) % k), face_rails.get(i), wall_rails.get(i))
else {
continue;
};
let swept: mt::Shell = match *s {
Seg::Line { a, b } => vec![builder::tsweep(pr, v3(b - a))].into(),
Seg::Arc { c, axis, angle, .. } => builder::rsweep(pr, p3(c), v3(axis), mt::Rad(angle)),
};
let mut surf = swept.face_iter().next().map(|x| x.oriented_surface()).ok_or_else(|| KernelError::Failed("blend surface".into()))?;
// Outward: toward the profile centre on a concave loop, away from it on a convex one.
let (c, mid) = (centre(i), profile_mid(i));
let outward = (c - mid) * side;
if let Some((u, v)) = surf.search_nearest_parameter(p3(mid), None, 100) {
let nn = surf.normal(u, v);
if Vec3::new(nn.x, nn.y, nn.z).dot(outward) < 0.0 {
surf = mt::Invertible::inverse(&surf);
}
}
let w: mt::Wire = vec![fr.inverse(), pr.clone(), wr.clone(), pr_next.inverse()].into();
let face = mt::Face::try_new(vec![w.clone()], surf.clone()).map_err(|e| KernelError::Failed(format!("blend face: {e}")))?;
out.push(face);
}
let mut shells = solid.boundaries().clone();
if let Some(sh) = shells.get_mut(si) {
*sh = out.into();
}
Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("loop blend produced an invalid solid: {e}")))
}
+1 -1
View File
@@ -121,7 +121,7 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
empty_votes += 1;
last = "empty result".into();
}
Ok(s) => match Body::new(s) {
Ok(s) => match Body::new(crate::heal::heal(s, size)) {
Ok(body) => {
let v = volume(&body);
if v > 0.0 && plausible(v, &body) {
+133 -7
View File
@@ -8,7 +8,7 @@ use std::collections::HashMap;
use solvecraft_geom::{Plane, Vec3};
use truck_modeling::{self as mt, builder};
use crate::body::{Body, Solid, from_p3, p3};
use crate::body::{Body, Solid, from_p3, p3, v3};
use crate::{KernelError, Result, guard};
/// A half-space `n · x <= d` (n is the outward unit normal).
@@ -22,8 +22,9 @@ fn det3(a: Vec3, b: Vec3, c: Vec3) -> f64 {
a.dot(b.cross(c))
}
/// The convex polyhedron bounded by the half-spaces.
pub fn convex_polyhedron(hs: &[HalfSpace]) -> Result<Body> {
/// Vertices, and per half-space with a face its vertex loop (counter-clockwise about the
/// outward normal), of the convex polyhedron bounded by the half-spaces.
fn combinatorics(hs: &[HalfSpace]) -> Result<(Vec<Vec3>, Vec<(usize, Vec<usize>)>)> {
if hs.len() < 4 || hs.len() > 200 {
return Err(KernelError::Invalid("a polyhedron needs 4…200 planes".into()));
}
@@ -53,8 +54,8 @@ pub fn convex_polyhedron(hs: &[HalfSpace]) -> Result<Body> {
return Err(KernelError::Invalid("the planes don't enclose a solid".into()));
}
// Faces: the vertices on each plane, ordered counter-clockwise about its outward normal.
let mut faces_idx: Vec<Vec<usize>> = Vec::new();
for h in hs {
let mut faces_idx: Vec<(usize, Vec<usize>)> = Vec::new();
for (hi, h) in hs.iter().enumerate() {
let on: Vec<usize> = (0..verts.len()).filter(|i| verts.get(*i).is_some_and(|v| (h.n.dot(*v) - h.d).abs() < scale * 1e-7)).collect();
if on.len() < 3 {
continue;
@@ -67,13 +68,19 @@ pub fn convex_polyhedron(hs: &[HalfSpace]) -> Result<Body> {
let ang = |i: usize| verts.get(i).map(|v| (*v - c).dot(w).atan2((*v - c).dot(u))).unwrap_or(0.0);
ang(*a).total_cmp(&ang(*b))
});
faces_idx.push(ordered);
faces_idx.push((hi, ordered));
}
Ok((verts, faces_idx))
}
/// The convex polyhedron bounded by the half-spaces.
pub fn convex_polyhedron(hs: &[HalfSpace]) -> Result<Body> {
let (verts, faces_idx) = combinatorics(hs)?;
guard("polyhedron", || {
let tv: Vec<mt::Vertex> = verts.iter().map(|v| builder::vertex(p3(*v))).collect();
let mut edges: HashMap<(usize, usize), mt::Edge> = HashMap::new();
let mut faces: Vec<mt::Face> = Vec::new();
for f in &faces_idx {
for (_, f) in &faces_idx {
let n = f.len();
let mut wire: Vec<mt::Edge> = Vec::with_capacity(n);
for k in 0..n {
@@ -176,3 +183,122 @@ pub fn draft(b: &Body, faces: &[Vec3], neutral: &Plane, pull: Vec3, angle: f64)
}
convex_polyhedron(&out)
}
/// Round every edge of a convex body with planar faces by `r`: faces shrink, each edge becomes a
/// cylinder and each corner a sphere patch (the body is the inner polyhedron grown by a ball).
pub fn round_all_edges(b: &Body, r: f64) -> Result<Body> {
let hs: Vec<HalfSpace> = planar_convex(b)?.into_iter().map(|(h, _)| h).collect();
let inner: Vec<HalfSpace> = hs.iter().map(|h| HalfSpace { n: h.n, d: h.d - r }).collect();
let (q, faces) = combinatorics(&inner)?;
if faces.len() != hs.len() {
return Err(KernelError::Failed("not supported yet: the rounding is larger than a face".into()));
}
let normal = |hi: usize| hs.get(hi).map(|h| h.n).unwrap_or(Vec3::Z);
// Edges of the inner polyhedron: (vertex a, vertex b) → (face left, face right).
let mut edge_faces: HashMap<(usize, usize), Vec<usize>> = HashMap::new();
for (hi, lp) in &faces {
let n = lp.len();
for k in 0..n {
let (Some(&a), Some(&bb)) = (lp.get(k), lp.get((k + 1) % n)) else { continue };
edge_faces.entry((a.min(bb), a.max(bb))).or_default().push(*hi);
}
}
if edge_faces.values().any(|f| f.len() != 2) {
return Err(KernelError::Failed("not supported yet: rounding this shape".into()));
}
guard("round edges", || {
// Vertex of face `hi` at inner vertex `qi`.
let mut verts: HashMap<(usize, usize), mt::Vertex> = HashMap::new();
let mut vert = |hi: usize, qi: usize| -> mt::Vertex {
verts.entry((hi, qi)).or_insert_with(|| builder::vertex(p3(q.get(qi).copied().unwrap_or_default() + normal(hi) * r))).clone()
};
let mut lines: HashMap<(usize, usize, usize), mt::Edge> = HashMap::new();
let mut arcs: HashMap<(usize, usize, usize), mt::Edge> = HashMap::new();
// Straight edge of face hi between inner vertices a and b (stored low → high).
let mut line = |hi: usize, a: usize, bb: usize, vert: &mut dyn FnMut(usize, usize) -> mt::Vertex| -> mt::Edge {
let (lo, hi_v) = (a.min(bb), a.max(bb));
let e = lines.entry((hi, lo, hi_v)).or_insert_with(|| builder::line(&vert(hi, lo), &vert(hi, hi_v))).clone();
if a < bb { e } else { e.inverse() }
};
// Arc at inner vertex qi from face f to face g (stored with f < g).
let mut arc = |qi: usize, f: usize, g: usize, vert: &mut dyn FnMut(usize, usize) -> mt::Vertex| -> mt::Edge {
let (lo, hi_f) = (f.min(g), f.max(g));
let c = q.get(qi).copied().unwrap_or_default();
let e = arcs
.entry((qi, lo, hi_f))
.or_insert_with(|| {
let mid = c + (normal(lo) + normal(hi_f)).normalized().unwrap_or(Vec3::Z) * r;
builder::circle_arc(&vert(lo, qi), &vert(hi_f, qi), p3(mid))
})
.clone();
if f < g { e } else { e.inverse() }
};
let mut out: Vec<mt::Face> = Vec::new();
// Planar faces.
for (hi, lp) in &faces {
let n = lp.len();
let mut w: Vec<mt::Edge> = Vec::new();
for k in 0..n {
let (Some(&a), Some(&bb)) = (lp.get(k), lp.get((k + 1) % n)) else { continue };
w.push(line(*hi, a, bb, &mut vert));
}
out.push(builder::try_attach_plane(&[w.into()]).map_err(|e| KernelError::Failed(format!("rounded face: {e}")))?);
}
use mt::{ParametricSurface3D, SearchNearestParameter};
let orient = |mut surf: mt::Surface, at: Vec3, outward: Vec3| -> mt::Surface {
if let Some((u, v)) = surf.search_nearest_parameter(p3(at), None, 100) {
let nn = surf.normal(u, v);
if Vec3::new(nn.x, nn.y, nn.z).dot(outward) < 0.0 {
surf = mt::Invertible::inverse(&surf);
}
}
surf
};
// Edge cylinders: face f's loop runs a → b, so the cylinder (to f's right) runs b → a
// along f, then across to g.
for ((a, bb), fs) in &edge_faces {
let (Some(&f0), Some(&f1)) = (fs.first(), fs.get(1)) else { continue };
// Which face runs a → b?
let runs = |hi: usize| {
faces.iter().find(|(h, _)| *h == hi).is_some_and(|(_, lp)| {
let n = lp.len();
(0..n).any(|k| lp.get(k) == Some(a) && lp.get((k + 1) % n) == Some(bb))
})
};
let (f, g) = if runs(f0) { (f0, f1) } else { (f1, f0) };
let w: Vec<mt::Edge> =
vec![line(f, *bb, *a, &mut vert), arc(*a, f, g, &mut vert), line(g, *a, *bb, &mut vert), arc(*bb, g, f, &mut vert)];
let (qa, qb) = (q.get(*a).copied().unwrap_or_default(), q.get(*bb).copied().unwrap_or_default());
let profile = arc(*a, f, g, &mut vert);
let swept = builder::tsweep(&profile, v3(qb - qa));
let out_dir = (normal(f) + normal(g)).normalized().unwrap_or(Vec3::Z);
let mid = (qa + qb) * 0.5 + out_dir * r;
let surf = orient(swept.oriented_surface(), mid, out_dir);
out.push(mt::Face::try_new(vec![w.into()], surf).map_err(|e| KernelError::Failed(format!("rounded edge: {e}")))?);
}
// Corner sphere patches: the faces around each inner vertex, counter-clockwise.
for (qi, c) in q.iter().enumerate() {
let mut around: Vec<usize> = faces.iter().filter(|(_, lp)| lp.contains(&qi)).map(|(h, _)| *h).collect();
let axis = around.iter().fold(Vec3::ZERO, |acc, h| acc + normal(*h)).normalized().unwrap_or(Vec3::Z);
let u = axis.any_perp();
let w2 = axis.cross(u);
around.sort_by(|x, y| {
let ang = |h: usize| normal(h).dot(w2).atan2(normal(h).dot(u));
ang(*x).total_cmp(&ang(*y))
});
let k = around.len();
let w: Vec<mt::Edge> = (0..k).filter_map(|i| Some(arc(qi, *around.get(i)?, *around.get((i + 1) % k)?, &mut vert))).collect();
// A sphere surface whose poles and seam are away from this patch.
let side = axis.any_perp();
let (np, sp, back) = (*c + side * r, *c - side * r, *c - axis * r);
let meridian = builder::circle_arc(&builder::vertex(p3(np)), &builder::vertex(p3(sp)), p3(back));
let surf =
mt::Surface::RevolutedCurve(mt::Processor::new(mt::RevolutedCurve::by_revolution(meridian.oriented_curve(), p3(*c), v3(side))));
let surf = orient(surf, *c + axis * r, axis);
out.push(mt::Face::try_new(vec![w.into()], surf).map_err(|e| KernelError::Failed(format!("rounded corner: {e}")))?);
}
let shell: mt::Shell = out.into();
let solid = Solid::try_new(vec![shell]).map_err(|e| KernelError::Failed(format!("rounded body: {e}")))?;
Body::new(solid)
})
}
+92
View File
@@ -630,4 +630,96 @@ fn mesh_bodies_move_measure_and_refuse_solid_ops() {
assert!(crate::mesh_body(&pos, &[[0, 1, 99]]).is_err());
assert!(crate::mesh_body(&[Vec3::new(f64::NAN, 0.0, 0.0)], &[[0, 0, 0]]).is_err());
assert!(crate::mesh_body(&pos, &[[0, 0, 1]]).is_err());
fn concave_fillet() {
// An L made of a base and a wall (one solid), fillet the inside corner.
let base = box_solid(Vec3::ZERO, Vec3::new(80.0, 50.0, 8.0)).unwrap();
let wall = box_solid(Vec3::new(0.0, 42.0, 0.0), Vec3::new(80.0, 50.0, 50.0)).unwrap();
let l = boolean(&base, &wall, BoolOp::Union).unwrap().unwrap();
let v0 = measure(&l).unwrap().volume;
let f = fillet(&l, &[Vec3::new(40.0, 42.0, 8.0)], 5.0).unwrap();
let m = measure(&f).unwrap();
assert!(rel(m.volume, v0 + (25.0 - 25.0 * PI / 4.0) * 80.0) < 1e-4, "{} vs {}", m.volume, v0 + (25.0 - 25.0 * PI / 4.0) * 80.0);
assert_eq!(m.merged.face_types.get("cylinder"), Some(&1));
}
#[test]
#[ignore]
fn debug_pocket_corners() {
let b = box_solid(Vec3::ZERO, Vec3::new(80.0, 60.0, 20.0)).unwrap();
let p = box_solid(Vec3::new(15.0, 15.0, 12.0), Vec3::new(65.0, 45.0, 25.0)).unwrap();
let mut cur = boolean(&b, &p, BoolOp::Cut).unwrap().unwrap();
for e in [Vec3::new(65.0, 15.0, 16.0), Vec3::new(15.0, 15.0, 16.0), Vec3::new(65.0, 45.0, 16.0), Vec3::new(15.0, 45.0, 16.0)] {
match fillet(&cur, &[e], 5.0) {
Ok(n) => {
println!("ok {e:?} vol {}", measure(&n).unwrap().volume);
cur = n;
}
Err(err) => {
println!("fail {e:?}: {err}");
break;
}
}
}
}
#[test]
fn pocket_floor_loop_fillet() {
let b = box_solid(Vec3::ZERO, Vec3::new(80.0, 60.0, 20.0)).unwrap();
let p = box_solid(Vec3::new(15.0, 15.0, 12.0), Vec3::new(65.0, 45.0, 25.0)).unwrap();
let mut cur = boolean(&b, &p, BoolOp::Cut).unwrap().unwrap();
cur = fillet(&cur, &[Vec3::new(65.0, 15.0, 16.0), Vec3::new(15.0, 15.0, 16.0), Vec3::new(65.0, 45.0, 16.0), Vec3::new(15.0, 45.0, 16.0)], 5.0)
.unwrap();
let v1 = measure(&cur).unwrap().volume;
let floor: Vec<Vec3> = [(40.0, 15.0), (65.0, 30.0), (40.0, 45.0), (15.0, 30.0)].iter().map(|(x, y)| Vec3::new(*x, *y, 12.0)).collect();
let mut pts = floor.clone();
let d = 5.0 - 5.0 / 2f64.sqrt();
pts.extend([(15.0 + d, 15.0 + d), (65.0 - d, 15.0 + d), (65.0 - d, 45.0 - d), (15.0 + d, 45.0 - d)].iter().map(|(x, y)| Vec3::new(*x, *y, 12.0)));
let f = fillet(&cur, &pts, 2.0).unwrap();
let m = measure(&f).unwrap();
// Added: the fillet profile (r²(1 − π/4)) swept along the floor outline (Pappus for the
// corners: the profile's centroid sits 2 − 4·2/(3(4 − π))·… inward; check loosely).
let straight = 2.0 * (40.0 + 20.0);
let added_lines = (4.0 - PI) * straight;
assert!(m.volume > v1 + added_lines && m.volume < v1 + added_lines + 4.0 * 2.0 * PI * 5.0, "{} {}", m.volume, v1);
println!("pocket: {:?} vol {}", m.merged, m.volume);
}
#[test]
fn round_every_edge_of_a_box() {
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
let mut pts = Vec::new();
for e in b.edges(0.1).unwrap() {
pts.push(e.mid);
}
let f = fillet(&b, &pts, 3.0).unwrap();
let m = measure(&f).unwrap();
let r = 3.0f64;
let (a, bb, c) = (40.0 - 2.0 * r, 30.0 - 2.0 * r, 20.0 - 2.0 * r);
// Inner box grown by a ball (Steiner): V = abc + 2r(ab+bc+ca) + πr²(a+b+c) + 4/3πr³.
let want = a * bb * c + 2.0 * r * (a * bb + bb * c + c * a) + PI * r * r * (a + bb + c) + 4.0 / 3.0 * PI * r.powi(3);
assert!(rel(m.volume, want) < 1e-3, "{} vs {want}", m.volume);
assert_eq!((m.merged.faces, m.merged.edges, m.merged.vertices), (26, 48, 24), "{:?}", m.merged);
}
#[test]
#[ignore]
fn debug_heal_sphere_cut() {
let b = box_solid(Vec3::new(-25.0, -25.0, -20.0), Vec3::new(25.0, 25.0, 0.0)).unwrap();
let s = sphere(Vec3::ZERO, 15.0).unwrap();
let r = guard("t", || {
let mut sb = s.deep_copy();
sb.not();
Ok(truck_shapeops::and(&b.deep_copy(), &sb, 0.05))
})
.unwrap()
.unwrap();
let raw = Body::new(r.clone()).unwrap();
let healed = Body::new(crate::heal::heal(r, 60.0)).unwrap();
for (name, x) in [("raw", raw), ("healed", healed)] {
println!("{name}: vol {} faces {}", measure(&x).unwrap().volume, x.face_count());
for f in x.faces(0.05).unwrap() {
println!(" face {} area {:.2} c {:?}", f.index, f.area, f.centroid);
}
}
}