M0: truck-based kernel with local fillets and chamfers, parametric document timeline
The kernel moved from monstertruck to truck: truck's booleans handle blind holes, pockets and bosses that monstertruck's did not (see the ADR). Fillets and chamfers on straight convex edges between planar faces are our own exact local B-rep operation. The document holds parameters with unit-aware expressions and a timeline that re-evaluates incrementally. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dafce1f4e2
commit
a97af44979
18 files changed
+2740
-625
No files matched your search
@@ -0,0 +1,307 @@
|
||||
//! Edge fillets and chamfers as a local B-rep operation.
|
||||
//!
|
||||
//! Supported: a straight, convex edge between two planar faces whose two end vertices each
|
||||
//! touch exactly one more planar face perpendicular to the edge (the common case for edges of
|
||||
//! extruded and box-like parts). The two side faces are trimmed back, the end faces get an arc
|
||||
//! (fillet) or a line (chamfer) at the corner, and a cylindrical (or planar) face is inserted.
|
||||
//! The result is exact. Edges meeting at an already blended corner, curved edges and other
|
||||
//! configurations are reported as not supported yet.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use solvecraft_geom::Vec3;
|
||||
use truck_modeling::{self as mt, builder};
|
||||
|
||||
use crate::body::{Body, Solid, from_p3, p3, v3};
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Shape {
|
||||
Round,
|
||||
Flat,
|
||||
}
|
||||
|
||||
fn unsupported(msg: &str) -> KernelError {
|
||||
KernelError::Failed(format!("not supported yet: {msg}"))
|
||||
}
|
||||
|
||||
fn vtx(v: &mt::Vertex) -> Vec3 {
|
||||
from_p3(v.point())
|
||||
}
|
||||
|
||||
/// Outward unit normal of a planar face (from its oriented surface), if planar.
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace edges in a face's oriented boundary (by id, keeping orientation), and insert extra
|
||||
/// 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() {
|
||||
let mut edges: Vec<mt::Edge> = Vec::new();
|
||||
for e in w.edge_iter() {
|
||||
let ne = match subst.get(&e.id()) {
|
||||
Some(n) => {
|
||||
// `n` was built in the orientation of the stored edge; match this use.
|
||||
let stored_forward = e.front() == e.absolute_front();
|
||||
if stored_forward { n.clone() } else { n.inverse() }
|
||||
}
|
||||
None => e.clone(),
|
||||
};
|
||||
edges.push(ne);
|
||||
}
|
||||
// Close gaps with connector edges.
|
||||
let n = edges.len();
|
||||
let mut out: Vec<mt::Edge> = Vec::new();
|
||||
for i in 0..n {
|
||||
let (Some(cur), Some(next)) = (edges.get(i), edges.get((i + 1) % n)) else { continue };
|
||||
out.push(cur.clone());
|
||||
if cur.back() != next.front() {
|
||||
let c = connectors
|
||||
.iter()
|
||||
.find_map(|c| {
|
||||
if c.front() == cur.back() && c.back() == next.front() {
|
||||
Some(c.clone())
|
||||
} else if c.back() == cur.back() && c.front() == next.front() {
|
||||
Some(c.inverse())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| KernelError::Failed("blend: could not close a face boundary".into()))?;
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
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}")))
|
||||
}
|
||||
|
||||
fn blend_one(solid: &Solid, edge: &mt::Edge, size: f64, r: f64, shape: Shape) -> Result<Solid> {
|
||||
if !matches!(edge.curve(), mt::Curve::Line(_)) {
|
||||
return Err(unsupported("only straight edges can be blended"));
|
||||
}
|
||||
let shells = solid.boundaries();
|
||||
let (si, shell) = shells
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, sh)| sh.edge_iter().any(|e| e.id() == edge.id()))
|
||||
.ok_or_else(|| KernelError::Failed("edge not found".into()))?;
|
||||
let faces: Vec<&mt::Face> = shell.face_iter().collect();
|
||||
let on_edge: Vec<usize> = (0..faces.len()).filter(|i| faces.get(*i).is_some_and(|f| f.edge_iter().any(|e| e.id() == edge.id()))).collect();
|
||||
let [i1, i2] = on_edge[..] else { return Err(unsupported("the edge is not between two faces")) };
|
||||
let (Some(f1), Some(f2)) = (faces.get(i1), faces.get(i2)) else { return Err(KernelError::Failed("faces".into())) };
|
||||
let (Some(n1), Some(n2)) = (plane_normal(f1), plane_normal(f2)) else { return Err(unsupported("both faces next to the edge must be planar")) };
|
||||
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.
|
||||
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"));
|
||||
}
|
||||
blend_geometry(solid, si, &faces, (i1, i2), edge, (v0, v1), (p0, p1, d), (n1, t1, t2, phi), size, r, shape)
|
||||
}
|
||||
|
||||
/// Distance along each face from the edge to the blend boundary.
|
||||
fn setback(r: f64, phi: f64, shape: Shape) -> f64 {
|
||||
match shape {
|
||||
Shape::Round => r / (phi / 2.0).tan(),
|
||||
Shape::Flat => r,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn blend_geometry(
|
||||
solid: &Solid,
|
||||
si: usize,
|
||||
faces: &[&mt::Face],
|
||||
(i1, i2): (usize, usize),
|
||||
edge: &mt::Edge,
|
||||
(v0, v1): (mt::Vertex, mt::Vertex),
|
||||
(p0, p1, d): (Vec3, Vec3, Vec3),
|
||||
(n1, t1, t2, phi): (Vec3, Vec3, Vec3, f64),
|
||||
size: f64,
|
||||
r: f64,
|
||||
shape: Shape,
|
||||
) -> Result<Solid> {
|
||||
let s = setback(r, phi, shape);
|
||||
let tol = (size * 1e-7).max(1e-9);
|
||||
// End faces and the side edges at each end.
|
||||
struct End {
|
||||
v: mt::Vertex,
|
||||
g: usize,
|
||||
e1: mt::Edge,
|
||||
e2: mt::Edge,
|
||||
}
|
||||
let mut ends: Vec<End> = Vec::new();
|
||||
for (v, p) in [(&v0, p0), (&v1, p1)] {
|
||||
let at_v: Vec<usize> = (0..faces.len()).filter(|i| faces.get(*i).is_some_and(|f| f.vertex_iter().any(|x| &x == v))).collect();
|
||||
let others: Vec<usize> = at_v.iter().copied().filter(|i| *i != i1 && *i != i2).collect();
|
||||
let [g] = others[..] else { return Err(unsupported("each end of the edge must touch exactly one more face")) };
|
||||
let gf = faces.get(g).ok_or_else(|| KernelError::Failed("face".into()))?;
|
||||
let ng = plane_normal(gf).ok_or_else(|| unsupported("the faces at the edge ends must be planar"))?;
|
||||
if ng.dot(d).abs() < 1.0 - 1e-9 {
|
||||
return Err(unsupported("the faces at the edge ends must be perpendicular to the edge"));
|
||||
}
|
||||
// Edges at v shared by g with f1 and with f2.
|
||||
let shared = |fi: usize| -> Result<mt::Edge> {
|
||||
let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?;
|
||||
f.edge_iter()
|
||||
.find(|e| e.id() != edge.id() && (e.front() == v || e.back() == v) && gf.edge_iter().any(|x| x.id() == e.id()))
|
||||
.ok_or_else(|| unsupported("unexpected corner topology"))
|
||||
};
|
||||
let (e1, e2) = (shared(i1)?, shared(i2)?);
|
||||
for (e, t) in [(&e1, t1), (&e2, t2)] {
|
||||
if !matches!(e.curve(), mt::Curve::Line(_)) {
|
||||
return Err(unsupported("the edges at the corners must be straight"));
|
||||
}
|
||||
let other = if e.front() == v { vtx(e.back()) } else { vtx(e.front()) };
|
||||
let along = (other - p).dot(t);
|
||||
if (other - p - t * along).len() > tol * 100.0 + 1e-7 || along <= s + tol {
|
||||
return Err(unsupported("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
}
|
||||
ends.push(End { v: v.clone(), g, e1, e2 });
|
||||
}
|
||||
let [end0, end1] = &ends[..] else { return Err(KernelError::Failed("ends".into())) };
|
||||
if end0.g == end1.g {
|
||||
return Err(unsupported("both ends touch the same face"));
|
||||
}
|
||||
let a0 = builder::vertex(p3(p0 + t1 * s));
|
||||
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()))?;
|
||||
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::Flat => builder::line(a, b),
|
||||
}
|
||||
};
|
||||
let c0 = corner(&a0, &b0, p0);
|
||||
let c1 = corner(&a1, &b1, p1);
|
||||
let la = builder::line(&a0, &a1);
|
||||
let lb = builder::line(&b0, &b1);
|
||||
|
||||
// Substitutions: side edges at the ends now stop at A/B; the blended edge moves to A or B.
|
||||
let mut subst: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
|
||||
for (end, a, b) in [(end0, &a0, &b0), (end1, &a1, &b1)] {
|
||||
for (e, nv) in [(&end.e1, a), (&end.e2, b)] {
|
||||
let (f, bk) = (e.absolute_front(), e.absolute_back());
|
||||
let ne = if f == &end.v { builder::line(nv, bk) } else { builder::line(f, nv) };
|
||||
subst.insert(e.id(), ne);
|
||||
}
|
||||
}
|
||||
let mut new_faces: Vec<mt::Face> = Vec::with_capacity(faces.len() + 1);
|
||||
for (i, f) in faces.iter().enumerate() {
|
||||
let nf = if i == i1 {
|
||||
let mut m = subst.clone();
|
||||
m.insert(edge.id(), la.clone());
|
||||
rebuild_face(f, &m, &[])?
|
||||
} else if i == i2 {
|
||||
let mut m = subst.clone();
|
||||
m.insert(edge.id(), lb.clone());
|
||||
rebuild_face(f, &m, &[])?
|
||||
} else if i == end0.g {
|
||||
rebuild_face(f, &subst, std::slice::from_ref(&c0))?
|
||||
} else if i == end1.g {
|
||||
rebuild_face(f, &subst, std::slice::from_ref(&c1))?
|
||||
} else {
|
||||
(*f).clone()
|
||||
};
|
||||
new_faces.push(nf);
|
||||
}
|
||||
// The blend face uses each new edge opposite to its neighbour.
|
||||
let f1_forward = faces.get(i1).is_some_and(|f| f.boundaries().iter().any(|w| w.edge_iter().any(|e| e.id() == edge.id() && e.front() == &v0)));
|
||||
let wire: mt::Wire = if f1_forward {
|
||||
vec![c0.clone(), lb.clone(), c1.inverse(), la.inverse()].into()
|
||||
} else {
|
||||
vec![la.clone(), c1.clone(), lb.inverse(), c0.inverse()].into()
|
||||
};
|
||||
let blend_face = match shape {
|
||||
Shape::Flat => builder::try_attach_plane(&[wire]).map_err(|e| KernelError::Failed(format!("chamfer face: {e}")))?,
|
||||
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;
|
||||
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 {
|
||||
surf = mt::Invertible::inverse(&surf);
|
||||
}
|
||||
}
|
||||
mt::Face::try_new(vec![wire], surf).map_err(|e| KernelError::Failed(format!("fillet face: {e}")))?
|
||||
}
|
||||
};
|
||||
new_faces.push(blend_face);
|
||||
let mut shells = solid.boundaries().clone();
|
||||
let new_shell: mt::Shell = new_faces.into();
|
||||
if let Some(sh) = shells.get_mut(si) {
|
||||
*sh = new_shell;
|
||||
}
|
||||
Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("blend produced an invalid solid: {e}")))
|
||||
}
|
||||
|
||||
fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Result<Body> {
|
||||
if !(r.is_finite() && r > 1e-6 && r < 1e6) {
|
||||
return Err(KernelError::Invalid(format!("{what} size must be positive")));
|
||||
}
|
||||
if edges.is_empty() {
|
||||
return Err(KernelError::Invalid("no edges selected".into()));
|
||||
}
|
||||
if edges.len() > 1000 {
|
||||
return Err(KernelError::Invalid("too many edges".into()));
|
||||
}
|
||||
let mut cur = body.clone();
|
||||
for p in edges {
|
||||
let size = cur.size();
|
||||
let tol = (size * 2e-3).max(1e-3);
|
||||
let Some((idx, dist)) = cur.nearest_edge(*p, tol)? else { return Err(KernelError::Invalid("the body has no edges".into())) };
|
||||
if dist > size * 0.05 + 1e-3 {
|
||||
return Err(KernelError::Invalid(format!("no edge near {:?}", [p.x, p.y, p.z])));
|
||||
}
|
||||
let solid = cur.deep_copy();
|
||||
let edge = Body::unique_edges(&solid).into_iter().nth(idx).ok_or_else(|| KernelError::Invalid("edge index".into()))?;
|
||||
let out = guard(what, || blend_one(&solid, &edge, size, r, shape))?;
|
||||
cur = Body::new(out)?;
|
||||
}
|
||||
Ok(cur)
|
||||
}
|
||||
|
||||
/// Constant-radius fillet of the edges nearest to the given points.
|
||||
pub fn fillet(body: &Body, edges: &[Vec3], radius: f64) -> Result<Body> {
|
||||
blend(body, edges, radius, Shape::Round, "fillet")
|
||||
}
|
||||
|
||||
/// Equal-distance chamfer of the edges nearest to the given points.
|
||||
pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result<Body> {
|
||||
blend(body, edges, distance, Shape::Flat, "chamfer")
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use monstertruck_meshing::prelude::*;
|
||||
use monstertruck_modeling as mt;
|
||||
use serde::Serialize;
|
||||
use solvecraft_geom::{Mesh, Vec3};
|
||||
use truck_meshalgo::prelude::*;
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
@@ -61,7 +61,7 @@ fn polyline_mid(pts: &[Vec3]) -> (Vec3, f64) {
|
||||
|
||||
impl Body {
|
||||
pub(crate) fn new(mut solid: Solid) -> Result<Body> {
|
||||
if solid.is_empty() {
|
||||
if solid.boundaries().is_empty() {
|
||||
return Err(KernelError::Failed("empty result".into()));
|
||||
}
|
||||
// Orient outward: a closed solid must have positive volume.
|
||||
@@ -216,6 +216,11 @@ impl Body {
|
||||
Ok(best)
|
||||
}
|
||||
|
||||
/// Is the point inside the solid?
|
||||
pub fn contains(&self, p: Vec3) -> bool {
|
||||
self.tessellate(self.size() * 2e-3).map(|m| m.contains(p)).unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn face_count(&self) -> usize {
|
||||
self.solid.face_iter().count()
|
||||
}
|
||||
|
||||
+26
-24
@@ -1,8 +1,8 @@
|
||||
//! Building bodies: extrude and revolve planar regions, primitives.
|
||||
|
||||
use monstertruck_modeling as mt;
|
||||
use mt::builder;
|
||||
use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::body::{Body, Solid, p3, v3};
|
||||
use crate::{KernelError, Result, guard};
|
||||
@@ -33,20 +33,12 @@ fn wire(plane: &Plane, lp: &Loop2) -> Result<mt::Wire> {
|
||||
return Err(KernelError::Invalid("zero-length arc".into()));
|
||||
}
|
||||
// Split big arcs so every piece is well conditioned.
|
||||
let mid = plane.to_world(s.mid());
|
||||
if sweep.abs() > std::f64::consts::PI * 1.5 {
|
||||
let vm = builder::vertex(p3(mid));
|
||||
let q1 = plane.to_world(s.point_at(0.25));
|
||||
let q3 = plane.to_world(s.point_at(0.75));
|
||||
edges.push(
|
||||
builder::try_circle_arc(a, &vm, mt::builder::CircularArcConstraint::ThroughPoint(p3(q1)))
|
||||
.map_err(|e| KernelError::Invalid(e.to_string()))?,
|
||||
);
|
||||
builder::try_circle_arc(&vm, b, mt::builder::CircularArcConstraint::ThroughPoint(p3(q3)))
|
||||
.map_err(|e| KernelError::Invalid(e.to_string()))?
|
||||
let vm = builder::vertex(p3(plane.to_world(s.mid())));
|
||||
edges.push(builder::circle_arc(a, &vm, p3(plane.to_world(s.point_at(0.25)))));
|
||||
builder::circle_arc(&vm, b, p3(plane.to_world(s.point_at(0.75))))
|
||||
} else {
|
||||
builder::try_circle_arc(a, b, mt::builder::CircularArcConstraint::ThroughPoint(p3(mid)))
|
||||
.map_err(|e| KernelError::Invalid(e.to_string()))?
|
||||
builder::circle_arc(a, b, p3(plane.to_world(s.mid())))
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -61,7 +53,7 @@ pub(crate) fn face(plane: &Plane, r: &Region2) -> Result<mt::Face> {
|
||||
for h in &r.holes {
|
||||
wires.push(wire(plane, &h.ccw().reversed())?);
|
||||
}
|
||||
guard("face", || mt::profile::attach_plane_normalized(wires).map_err(|e| KernelError::Invalid(format!("profile: {e}"))))
|
||||
guard("face", || builder::try_attach_plane(&wires).map_err(|e| KernelError::Invalid(format!("profile: {e}"))))
|
||||
}
|
||||
|
||||
fn region_ok(r: &Region2) -> Result<()> {
|
||||
@@ -98,7 +90,7 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu
|
||||
region_ok(r)?;
|
||||
let base = plane.offset(lo);
|
||||
let f = face(&base, r)?;
|
||||
let solid: Solid = guard("extrude", || Ok(builder::extrude(&f, v3(n * (hi - lo)))))?;
|
||||
let solid: Solid = guard("extrude", || Ok(builder::tsweep(&f, v3(n * (hi - lo)))))?;
|
||||
out.push(Body::new(solid)?);
|
||||
}
|
||||
Ok(out)
|
||||
@@ -130,16 +122,26 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir:
|
||||
if neg && pos {
|
||||
return Err(KernelError::Invalid("the profile crosses the revolve axis".into()));
|
||||
}
|
||||
// Points on the axis (within solver tolerance) go exactly onto it, so the sweep does not
|
||||
// create hair-thin faces.
|
||||
let snap = |p: Vec2| {
|
||||
let along = (p - axis_origin).dot(d);
|
||||
let q = axis_origin + d * along;
|
||||
if p.dist(q) < 1e-7 { q } else { p }
|
||||
};
|
||||
let snap_loop = |l: &Loop2| Loop2 {
|
||||
segs: l
|
||||
.segs
|
||||
.iter()
|
||||
.map(|s| match *s {
|
||||
Seg2::Line { a, b } => Seg2::Line { a: snap(a), b: snap(b) },
|
||||
arc => arc,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let r = &Region2 { outer: snap_loop(&r.outer), holes: r.holes.iter().map(snap_loop).collect() };
|
||||
let f = face(plane, r)?;
|
||||
let full = angle.abs() >= std::f64::consts::TAU - 1e-9;
|
||||
let solid: Solid = guard("revolve", || {
|
||||
Ok(if full {
|
||||
builder::revolve(&f, p3(o), v3(axis), builder::SweepAngle::Closed, 4)
|
||||
} else {
|
||||
let div = ((angle.abs() / std::f64::consts::FRAC_PI_2).ceil() as usize).clamp(1, 8);
|
||||
builder::revolve(&f, p3(o), v3(axis), builder::SweepAngle::Partial(mt::Rad(angle)), div)
|
||||
})
|
||||
})?;
|
||||
let solid: Solid = guard("revolve", || Ok(builder::rsweep(&f, p3(o), v3(axis), mt::Rad(angle))))?;
|
||||
out.push(Body::new(solid)?);
|
||||
}
|
||||
Ok(out)
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
//!
|
||||
//! Everything SolveCraft does with solids goes through this crate: building bodies from planar
|
||||
//! regions (extrude, revolve) and primitives, booleans, fillets and chamfers, tessellation,
|
||||
//! measurement and STEP export. The implementation uses the `monstertruck` crates (Apache-2.0,
|
||||
//! a maintained fork of `truck`; see `plan/adr/0001-geometry-kernel.md`), but no monstertruck
|
||||
//! type appears in the public API, so the kernel can be replaced without touching the rest of
|
||||
//! the workspace.
|
||||
//! measurement and STEP export. The implementation uses the `truck` crates (Apache-2.0; see
|
||||
//! `plan/adr/0001-geometry-kernel.md`) plus our own local operations (edge fillets and
|
||||
//! chamfers in `blend.rs`), but no truck type appears in the public API, so the kernel can be
|
||||
//! replaced without touching the rest of the workspace.
|
||||
//!
|
||||
//! Every call into the third-party kernel runs under [`guard`]: a panic inside it becomes a
|
||||
//! [`KernelError::Internal`] instead of taking the application down.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod blend;
|
||||
mod body;
|
||||
mod build;
|
||||
mod measure;
|
||||
@@ -19,10 +20,11 @@ mod ops;
|
||||
mod step;
|
||||
mod topo;
|
||||
|
||||
pub use blend::{chamfer, fillet};
|
||||
pub use body::{Body, EdgeInfo, FaceInfo};
|
||||
pub use build::{box_solid, cylinder, extrude, revolve, sphere, torus};
|
||||
pub use measure::{BodyMeasure, measure};
|
||||
pub use ops::{BoolOp, boolean, chamfer, fillet, transform};
|
||||
pub use ops::{BoolOp, boolean, transform};
|
||||
pub use step::step_export;
|
||||
pub use topo::{TopoCounts, merged_topology};
|
||||
|
||||
|
||||
+63
-72
@@ -1,10 +1,10 @@
|
||||
//! Operations on bodies: booleans, fillets, chamfers, rigid transforms.
|
||||
//! Operations on bodies: booleans and rigid transforms.
|
||||
|
||||
use monstertruck_modeling as mt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solvecraft_geom::Vec3;
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::body::{Body, Solid, v3};
|
||||
use crate::body::{Body, p3, v3};
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -15,84 +15,75 @@ pub enum BoolOp {
|
||||
Intersect,
|
||||
}
|
||||
|
||||
/// Boolean of two bodies. Retries with a few tolerances; the result may be empty (`Ok(None)`)
|
||||
/// for a cut that removes everything or an intersection of disjoint bodies.
|
||||
/// Shifts applied to both operands on retries. The boolean classifies some faces by casting a
|
||||
/// ray whose direction is derived from point coordinates; moving both solids (and the result
|
||||
/// back) leaves the geometry unchanged but changes those rays.
|
||||
const JITTER: [[f64; 3]; 4] = [[0.0, 0.0, 0.0], [0.1373, 0.2719, 0.3911], [-0.5127, 0.0833, 0.2291], [0.6911, -0.3337, -0.6127]];
|
||||
|
||||
fn volume(b: &Body) -> f64 {
|
||||
b.tessellate(b.size() * 2e-3).map(|m| m.measure().volume).unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
/// Boolean of two bodies. The result may be empty (`Ok(None)`) for a cut that removes
|
||||
/// everything or an intersection of disjoint bodies. Results are checked against volume bounds
|
||||
/// and retried with shifted copies and other tolerances when they fail or look wrong.
|
||||
pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
let size = a.size().max(b.size());
|
||||
let (va, vb) = (volume(a), volume(b));
|
||||
let slack = 2e-3 * (va + vb) + 1e-9;
|
||||
let plausible = |v: f64| match op {
|
||||
BoolOp::Union => v >= va.max(vb) - slack && v <= va + vb + slack,
|
||||
BoolOp::Cut => v >= va - vb - slack && v <= va + slack,
|
||||
BoolOp::Intersect => v <= va.min(vb) + slack,
|
||||
};
|
||||
let mut last = String::new();
|
||||
for k in [1e-4, 1e-3, 5e-5, 5e-3] {
|
||||
let tol = (size * k).max(1e-6);
|
||||
let r = guard("boolean", || {
|
||||
let (sa, sb) = (a.deep_copy(), b.deep_copy());
|
||||
let res = match op {
|
||||
BoolOp::Union => monstertruck_solid::or(&sa, &sb, tol),
|
||||
BoolOp::Cut => monstertruck_solid::difference(&sa, &sb, tol),
|
||||
BoolOp::Intersect => monstertruck_solid::and(&sa, &sb, tol),
|
||||
};
|
||||
res.map_err(|e| KernelError::Failed(format!("{e:?}")))
|
||||
});
|
||||
match r {
|
||||
Ok(s) if s.is_empty() => return Ok(None),
|
||||
Ok(s) => {
|
||||
let body = Body::new(s)?;
|
||||
if body.tessellate(size * 1e-2).map(|m| m.measure().volume > 0.0).unwrap_or(false) {
|
||||
return Ok(Some(body));
|
||||
let mut empty_votes = 0;
|
||||
for (attempt, j) in JITTER.iter().enumerate() {
|
||||
let shift = mt::Vector3::new(j[0], j[1], j[2]) * (size * 0.01);
|
||||
for k in [5e-4, 2e-3] {
|
||||
let tol = (size * k).max(1e-5);
|
||||
let r = guard("boolean", || {
|
||||
let (mut sa, mut sb) = (a.deep_copy(), b.deep_copy());
|
||||
if attempt > 0 {
|
||||
sa = mt::builder::translated(&sa, shift);
|
||||
sb = mt::builder::translated(&sb, shift);
|
||||
}
|
||||
last = "degenerate result".into();
|
||||
let res = match op {
|
||||
BoolOp::Union => truck_shapeops::or(&sa, &sb, tol),
|
||||
BoolOp::Cut => {
|
||||
sb.not();
|
||||
truck_shapeops::and(&sa, &sb, tol)
|
||||
}
|
||||
BoolOp::Intersect => truck_shapeops::and(&sa, &sb, tol),
|
||||
};
|
||||
let s = res.ok_or_else(|| KernelError::Failed("no result".into()))?;
|
||||
Ok(if attempt > 0 { mt::builder::translated(&s, -shift) } else { s })
|
||||
});
|
||||
match r {
|
||||
Ok(s) if s.boundaries().is_empty() || s.face_iter().next().is_none() => {
|
||||
empty_votes += 1;
|
||||
last = "empty result".into();
|
||||
}
|
||||
Ok(s) => match Body::new(s) {
|
||||
Ok(body) => {
|
||||
let v = volume(&body);
|
||||
if v > 0.0 && plausible(v) {
|
||||
return Ok(Some(body));
|
||||
}
|
||||
last = format!("implausible result volume {v:.4}");
|
||||
}
|
||||
Err(e) => last = e.to_string(),
|
||||
},
|
||||
Err(e) => last = e.to_string(),
|
||||
}
|
||||
Err(e) => last = e.to_string(),
|
||||
}
|
||||
if empty_votes >= 2 && op != BoolOp::Union {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
Err(KernelError::Failed(format!("boolean {op:?}: {last}")))
|
||||
}
|
||||
|
||||
fn blend(body: &Body, edges: &[Vec3], radius: f64, profile: mt::FilletProfile, what: &str) -> Result<Body> {
|
||||
if !(radius.is_finite() && radius > 1e-6 && radius < 1e6) {
|
||||
return Err(KernelError::Invalid(format!("{what} size must be positive")));
|
||||
}
|
||||
if edges.is_empty() {
|
||||
return Err(KernelError::Invalid("no edges selected".into()));
|
||||
}
|
||||
let tol = (body.size() * 2e-3).max(1e-3);
|
||||
let mut cur = body.clone();
|
||||
// One edge at a time: re-find each edge on the current body by its reference point.
|
||||
for p in edges {
|
||||
let Some((idx, d)) = cur.nearest_edge(*p, tol)? else { return Err(KernelError::Invalid("body has no edges".into())) };
|
||||
if d > cur.size() * 0.05 + 1e-3 {
|
||||
return Err(KernelError::Invalid(format!("no edge near {:?}", [p.x, p.y, p.z])));
|
||||
}
|
||||
let mut solid = cur.deep_copy();
|
||||
let shape = profile.clone();
|
||||
let res = guard(what, || {
|
||||
let edge = Body::unique_edges(&solid).into_iter().nth(idx).ok_or_else(|| KernelError::Invalid("edge index".into()))?;
|
||||
let shell_i = solid
|
||||
.boundaries()
|
||||
.iter()
|
||||
.position(|sh| sh.edge_iter().any(|e| e.id() == edge.id()))
|
||||
.ok_or_else(|| KernelError::Failed("edge not in a shell".into()))?;
|
||||
let mut shells = solid.boundaries().clone();
|
||||
let shell = shells.get_mut(shell_i).ok_or_else(|| KernelError::Failed("shell".into()))?;
|
||||
let opts = mt::FilletOptions { radius: mt::RadiusSpec::Constant(radius), profile: shape, ..Default::default() };
|
||||
mt::fillet_edges(shell, &[edge], Some(&opts)).map_err(|e| KernelError::Failed(format!("{what}: {e:?}")))?;
|
||||
solid = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("{what} left an open shell: {e:?}")))?;
|
||||
Ok(())
|
||||
});
|
||||
res?;
|
||||
cur = Body::new(solid)?;
|
||||
}
|
||||
Ok(cur)
|
||||
}
|
||||
|
||||
/// Constant-radius fillet of the edges nearest to the given points.
|
||||
pub fn fillet(body: &Body, edges: &[Vec3], radius: f64) -> Result<Body> {
|
||||
blend(body, edges, radius, mt::FilletProfile::Round, "fillet")
|
||||
}
|
||||
|
||||
/// Equal-distance chamfer of the edges nearest to the given points.
|
||||
pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result<Body> {
|
||||
blend(body, edges, distance, mt::FilletProfile::Chamfer, "chamfer")
|
||||
}
|
||||
|
||||
/// Rigid transform: rotate about `axis` through `origin` by `angle` radians, then translate.
|
||||
pub fn transform(body: &Body, translate: Vec3, origin: Vec3, axis: Vec3, angle: f64) -> Result<Body> {
|
||||
if !(translate.is_finite() && origin.is_finite() && angle.is_finite()) {
|
||||
@@ -102,7 +93,7 @@ pub fn transform(body: &Body, translate: Vec3, origin: Vec3, axis: Vec3, angle:
|
||||
let mut s = body.deep_copy();
|
||||
if angle.abs() > 1e-12 {
|
||||
let ax = axis.normalized().ok_or_else(|| KernelError::Invalid("rotation axis".into()))?;
|
||||
s = mt::builder::rotated(&s, crate::body::p3(origin), v3(ax), mt::Rad(angle));
|
||||
s = mt::builder::rotated(&s, p3(origin), v3(ax), mt::Rad(angle));
|
||||
}
|
||||
if translate.len() > 0.0 {
|
||||
s = mt::builder::translated(&s, v3(translate));
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
use monstertruck_io::step::save;
|
||||
use truck_stepio::out;
|
||||
|
||||
use crate::body::Body;
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
/// STEP (ISO 10303-21, AP203-style geometry) text for the given bodies.
|
||||
/// STEP (ISO 10303-21) text for the given bodies.
|
||||
pub fn step_export(bodies: &[&Body], system: &str) -> Result<String> {
|
||||
if bodies.is_empty() {
|
||||
return Err(KernelError::Invalid("nothing to export".into()));
|
||||
}
|
||||
guard("step export", || {
|
||||
let cs: Vec<_> = bodies.iter().map(|b| b.solid.compress()).collect();
|
||||
let models = save::StepModels::from_iter(cs.iter());
|
||||
let header = save::StepHeaderDescriptor { organization_system: system.to_owned(), ..Default::default() };
|
||||
let out = save::CompleteStepDisplay::new(models, header).to_string();
|
||||
Ok(out)
|
||||
let models: out::StepModels<_, _, _> = cs.iter().collect();
|
||||
let header = out::StepHeaderDescriptor { organization_system: system.to_owned(), ..Default::default() };
|
||||
Ok(out::CompleteStepDisplay::new(models, header).to_string())
|
||||
})
|
||||
}
|
||||
@@ -119,6 +119,32 @@ fn hostile_inputs_are_errors() {
|
||||
assert!(transform(&b, Vec3::new(f64::NAN, 0.0, 0.0), Vec3::ZERO, Vec3::Z, 0.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fillets_on_several_edges_and_angles() {
|
||||
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
|
||||
// Two opposite vertical edges, then a horizontal top edge between them is not touching either.
|
||||
let f = fillet(&b, &[Vec3::new(0.0, 0.0, 10.0), Vec3::new(40.0, 30.0, 10.0)], 4.0).unwrap();
|
||||
let corner = 16.0 - PI * 4.0;
|
||||
assert!(rel(measure(&f).unwrap().volume, 24000.0 - 2.0 * corner * 20.0) < 2e-4);
|
||||
let top = fillet(&b, &[Vec3::new(20.0, 0.0, 20.0)], 2.0).unwrap();
|
||||
assert!(rel(measure(&top).unwrap().volume, 24000.0 - (4.0 - PI) * 40.0) < 2e-4);
|
||||
// Triangular prism: 60° edges.
|
||||
let tri = Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(30.0, 0.0), Vec2::new(15.0, 25.980762113533)]), holes: vec![] };
|
||||
let p = extrude(&Plane::XY, &[tri], 0.0, 10.0).unwrap().pop().unwrap();
|
||||
let r = 2.0;
|
||||
let fp = fillet(&p, &[Vec3::new(0.0, 0.0, 5.0)], r).unwrap();
|
||||
// Removed area for interior angle φ: r²(cot(φ/2)) − r²(π − φ)/2.
|
||||
let phi = PI / 3.0;
|
||||
let removed = r * r / (phi / 2.0).tan() - r * r * (PI - phi) / 2.0;
|
||||
let v0 = measure(&p).unwrap().volume;
|
||||
assert!(rel(measure(&fp).unwrap().volume, v0 - removed * 10.0) < 2e-4, "{} vs {}", measure(&fp).unwrap().volume, v0 - removed * 10.0);
|
||||
// Adjacent edges at a blended corner are not supported yet (an error, not a crash).
|
||||
assert!(fillet(&b, &[Vec3::new(0.0, 0.0, 10.0), Vec3::new(0.0, 15.0, 20.0)], 2.0).is_err());
|
||||
// Concave edge: not supported.
|
||||
let u = boolean(&b, &box_solid(Vec3::new(10.0, 10.0, 15.0), Vec3::new(20.0, 20.0, 30.0)).unwrap(), BoolOp::Union).unwrap().unwrap();
|
||||
assert!(fillet(&u, &[Vec3::new(15.0, 10.0, 20.0)], 1.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn debug_convergence() {
|
||||
@@ -135,3 +161,58 @@ fn debug_convergence() {
|
||||
println!("face {f}: {} normals, first {:?}, max |n.z| {}", ns.len(), ns.first(), ns.iter().map(|n| n.z.abs()).fold(0.0, f64::max));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn debug_coplanar() {
|
||||
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
|
||||
for (z0, z1) in [(0.0, 20.0), (-5.0, 20.0), (0.0, 25.0), (-5.0, 25.0), (5.0, 20.0), (5.0, 25.0)] {
|
||||
let c = cylinder(Vec3::new(20.0, 15.0, z0), Vec3::Z, 5.0, z1 - z0).unwrap();
|
||||
let r = boolean(&b, &c, BoolOp::Cut);
|
||||
println!("cut z {z0}..{z1}: {:?}", r.map(|o| o.map(|b| measure(&b).unwrap().volume)));
|
||||
let r = boolean(&b, &c, BoolOp::Union);
|
||||
println!("union z {z0}..{z1}: {:?}", r.map(|o| o.map(|b| measure(&b).unwrap().volume)));
|
||||
}
|
||||
let r =
|
||||
Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(40.0, 0.0), Vec2::new(40.0, 30.0), Vec2::new(0.0, 30.0)]), holes: vec![] };
|
||||
for ang in [PI / 2.0, PI, 1.5 * PI, 2.0 * PI] {
|
||||
let q = revolve(&Plane::XY, std::slice::from_ref(&r), Vec2::ZERO, Vec2::Y, ang).unwrap().pop().unwrap();
|
||||
let m = measure(&q).unwrap();
|
||||
println!("revolve {ang}: vol {} expect {} faces {}", m.volume, ang / 2.0 * 1600.0 * 30.0, m.faces);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn debug_blind() {
|
||||
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
|
||||
let t = box_solid(Vec3::new(10.0, 10.0, 5.0), Vec3::new(20.0, 20.0, 25.0)).unwrap();
|
||||
println!("box pocket: {:?}", boolean(&b, &t, BoolOp::Cut).map(|o| o.map(|b| measure(&b).unwrap().volume)));
|
||||
let t = box_solid(Vec3::new(10.0, 10.0, 5.0), Vec3::new(20.0, 20.0, 15.0)).unwrap();
|
||||
println!("box void: {:?}", boolean(&b, &t, BoolOp::Cut).map(|o| o.map(|b| measure(&b).unwrap().volume)));
|
||||
for r in [5.0, 4.3] {
|
||||
for z in [5.0, 7.3, 13.1] {
|
||||
let c = cylinder(Vec3::new(20.3, 15.1, z), Vec3::Z, r, 30.0).unwrap();
|
||||
println!("cyl r{r} z{z}: {:?}", boolean(&b, &c, BoolOp::Cut).map(|o| o.map(|b| measure(&b).unwrap().volume)));
|
||||
}
|
||||
}
|
||||
let c = cylinder(Vec3::new(20.0, 15.0, 25.0), -Vec3::Z, 5.0, 20.0).unwrap();
|
||||
println!("cyl down: {:?}", boolean(&b, &c, BoolOp::Cut).map(|o| o.map(|b| measure(&b).unwrap().volume)));
|
||||
let s = sphere(Vec3::new(20.0, 15.0, 20.0), 5.0).unwrap();
|
||||
println!("sphere: {:?}", boolean(&b, &s, BoolOp::Cut).map(|o| o.map(|b| measure(&b).unwrap().volume)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn debug_revolve_doc_case() {
|
||||
let r =
|
||||
Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(40.0, 0.0), Vec2::new(40.0, 30.0), Vec2::new(0.0, 30.0)]), holes: vec![] };
|
||||
for ang in [PI / 2.0, -PI / 2.0] {
|
||||
let q = revolve(&Plane::XY, std::slice::from_ref(&r), Vec2::ZERO, Vec2::Y, ang).unwrap().pop().unwrap();
|
||||
let m = measure(&q).unwrap();
|
||||
println!("ang {ang}: vol {} bbox {:?}", m.volume, m.bbox);
|
||||
}
|
||||
let r2 = Region2 { outer: r.outer.reversed(), holes: vec![] };
|
||||
let q = revolve(&Plane::XY, std::slice::from_ref(&r2), Vec2::ZERO, Vec2::Y, PI / 2.0).unwrap().pop().unwrap();
|
||||
println!("reversed: {}", measure(&q).unwrap().volume);
|
||||
}
|
||||
@@ -174,7 +174,7 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
|
||||
if !faces.contains(&fi) {
|
||||
faces.push(fi);
|
||||
}
|
||||
let mut vix = |v: &monstertruck_modeling::Vertex| {
|
||||
let mut vix = |v: &truck_modeling::Vertex| {
|
||||
let k = format!("{:?}", v.id());
|
||||
let n = vid.len();
|
||||
let i = *vid.entry(k).or_insert(n);
|
||||
|
||||
Reference in new issue
Block a user