M0: workspace, geometry foundation, sketch constraint solver, kernel boundary
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
dafce1f4e2
47 files changed
+5624
No files matched your search
@@ -0,0 +1,232 @@
|
||||
use monstertruck_meshing::prelude::*;
|
||||
use monstertruck_modeling as mt;
|
||||
use serde::Serialize;
|
||||
use solvecraft_geom::{Mesh, Vec3};
|
||||
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
pub(crate) type Solid = mt::Solid;
|
||||
|
||||
/// A solid body (one or more closed shells). Cheap to clone; never mutated in place.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Body {
|
||||
pub(crate) solid: std::sync::Arc<Solid>,
|
||||
}
|
||||
|
||||
/// A B-rep edge as seen by the rest of the application.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct EdgeInfo {
|
||||
pub index: usize,
|
||||
/// Polyline approximation.
|
||||
pub points: Vec<Vec3>,
|
||||
/// Point halfway along the polyline (used to re-find the edge).
|
||||
pub mid: Vec3,
|
||||
pub length: f64,
|
||||
}
|
||||
|
||||
/// A B-rep face.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct FaceInfo {
|
||||
pub index: usize,
|
||||
pub area: f64,
|
||||
pub centroid: Vec3,
|
||||
/// Unit normal if the face is planar.
|
||||
pub plane_normal: Option<Vec3>,
|
||||
}
|
||||
|
||||
pub(crate) fn p3(v: Vec3) -> mt::Point3 {
|
||||
mt::Point3::new(v.x, v.y, v.z)
|
||||
}
|
||||
pub(crate) fn v3(v: Vec3) -> mt::Vector3 {
|
||||
mt::Vector3::new(v.x, v.y, v.z)
|
||||
}
|
||||
pub(crate) fn from_p3(p: mt::Point3) -> Vec3 {
|
||||
Vec3::new(p.x, p.y, p.z)
|
||||
}
|
||||
|
||||
fn polyline_mid(pts: &[Vec3]) -> (Vec3, f64) {
|
||||
let len: f64 = pts.windows(2).map(|w| w.first().zip(w.get(1)).map(|(a, b)| a.dist(*b)).unwrap_or(0.0)).sum();
|
||||
let mut acc = 0.0;
|
||||
for w in pts.windows(2) {
|
||||
if let (Some(a), Some(b)) = (w.first(), w.get(1)) {
|
||||
let l = a.dist(*b);
|
||||
if acc + l >= len * 0.5 && l > 0.0 {
|
||||
return (a.lerp(*b, (len * 0.5 - acc) / l), len);
|
||||
}
|
||||
acc += l;
|
||||
}
|
||||
}
|
||||
(pts.first().copied().unwrap_or_default(), len)
|
||||
}
|
||||
|
||||
impl Body {
|
||||
pub(crate) fn new(mut solid: Solid) -> Result<Body> {
|
||||
if solid.is_empty() {
|
||||
return Err(KernelError::Failed("empty result".into()));
|
||||
}
|
||||
// Orient outward: a closed solid must have positive volume.
|
||||
let v = guard("orient", || Ok(solid.triangulation(1.0).to_polygon().volume()))?;
|
||||
if v < 0.0 {
|
||||
solid.not();
|
||||
}
|
||||
Ok(Body { solid: std::sync::Arc::new(solid) })
|
||||
}
|
||||
|
||||
/// A deep copy of the solid that can be mutated without affecting other bodies.
|
||||
pub(crate) fn deep_copy(&self) -> Solid {
|
||||
mt::builder::clone(&*self.solid)
|
||||
}
|
||||
|
||||
/// Characteristic size (bounding box diagonal) for tolerances.
|
||||
pub fn size(&self) -> f64 {
|
||||
let mut b = solvecraft_geom::Aabb3::EMPTY;
|
||||
for v in self.solid.vertex_iter() {
|
||||
b.add(from_p3(v.point()));
|
||||
}
|
||||
let d = b.diagonal();
|
||||
if d.is_finite() && d > 0.0 { d } else { 1.0 }
|
||||
}
|
||||
|
||||
/// Distinct edges in a stable order (the order of `edges()` and `tessellate().edges`).
|
||||
pub(crate) fn unique_edges(solid: &Solid) -> Vec<mt::Edge> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
solid.edge_iter().filter(|e| seen.insert(e.id())).collect()
|
||||
}
|
||||
|
||||
/// Triangulate with chord tolerance `tol` (mm). Triangles carry their face index.
|
||||
pub fn tessellate(&self, tol: f64) -> Result<Mesh> {
|
||||
let tol = if tol.is_finite() && tol > 0.0 { tol } else { 0.05 };
|
||||
let tol = tol.max(self.size() * 1e-6);
|
||||
guard("tessellate", || {
|
||||
let meshed = self.solid.triangulation(tol);
|
||||
let mut out = Mesh::default();
|
||||
for (fi, face) in meshed.face_iter().enumerate() {
|
||||
let Some(pm) = face.surface() else { continue };
|
||||
let flip = !face.orientation();
|
||||
let base = u32::try_from(out.positions.len()).unwrap_or(u32::MAX);
|
||||
let pos = pm.positions();
|
||||
let nor = pm.normals();
|
||||
// Expand to per-corner vertices so normals stay per face (crisp edges).
|
||||
for tri in pm.faces().triangle_iter() {
|
||||
let mut idx = [0u32; 3];
|
||||
for (k, v) in tri.iter().enumerate() {
|
||||
let Some(p) = pos.get(v.pos) else { continue };
|
||||
let n = v.nor.and_then(|ni| nor.get(ni)).map(|n| Vec3::new(n.x, n.y, n.z)).unwrap_or(Vec3::Z);
|
||||
let n = if flip { -n } else { n };
|
||||
out.positions.push(Vec3::new(p.x, p.y, p.z));
|
||||
out.normals.push(n);
|
||||
if let Some(slot) = idx.get_mut(k) {
|
||||
*slot = u32::try_from(out.positions.len() - 1).unwrap_or(0);
|
||||
}
|
||||
}
|
||||
let t = if flip { [idx[0], idx[2], idx[1]] } else { idx };
|
||||
out.triangles.push(t);
|
||||
out.tri_face.push(u32::try_from(fi).unwrap_or(u32::MAX));
|
||||
}
|
||||
let _ = base;
|
||||
}
|
||||
// Fill missing normals from geometry.
|
||||
for t in &out.triangles {
|
||||
let (Some(a), Some(b), Some(c)) =
|
||||
(out.positions.get(t[0] as usize), out.positions.get(t[1] as usize), out.positions.get(t[2] as usize))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let fnrm = (b - a).cross(c - a).normalized().unwrap_or(Vec3::Z);
|
||||
for k in t {
|
||||
if let Some(n) = out.normals.get_mut(*k as usize)
|
||||
&& (n.dot(fnrm) < -0.2 || n.len() < 0.5)
|
||||
{
|
||||
*n = fnrm;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for e in meshed.edge_iter() {
|
||||
if !seen.insert(e.id()) {
|
||||
continue;
|
||||
}
|
||||
let c = e.oriented_curve();
|
||||
out.edges.push(c.0.iter().map(|p| Vec3::new(p.x, p.y, p.z)).collect());
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
}
|
||||
|
||||
/// Edges with polylines (index = position in [`Body::tessellate`]'s `edges`).
|
||||
pub fn edges(&self, tol: f64) -> Result<Vec<EdgeInfo>> {
|
||||
let m = self.tessellate(tol)?;
|
||||
Ok(m.edges
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, points)| {
|
||||
let (mid, length) = polyline_mid(&points);
|
||||
EdgeInfo { index, points, mid, length }
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Faces with area and centroid.
|
||||
pub fn faces(&self, tol: f64) -> Result<Vec<FaceInfo>> {
|
||||
let m = self.tessellate(tol)?;
|
||||
let nf = self.solid.face_iter().count();
|
||||
let mut acc: Vec<(f64, Vec3, Vec3, bool)> = vec![(0.0, Vec3::ZERO, Vec3::ZERO, true); nf];
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
let Some([a, b, c]) = m.tri(t) else { continue };
|
||||
let cr = (b - a).cross(c - a);
|
||||
let ar = cr.len() * 0.5;
|
||||
if let Some(slot) = acc.get_mut(*f as usize) {
|
||||
slot.0 += ar;
|
||||
slot.1 += (a + b + c) * (ar / 3.0);
|
||||
if let Some(n) = cr.normalized() {
|
||||
if slot.2 == Vec3::ZERO {
|
||||
slot.2 = n;
|
||||
} else if slot.2.dot(n) < 1.0 - 1e-6 {
|
||||
slot.3 = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(acc
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, (area, c, n, planar))| FaceInfo {
|
||||
index,
|
||||
area,
|
||||
centroid: if area > 0.0 { c / area } else { Vec3::ZERO },
|
||||
plane_normal: (planar && n != Vec3::ZERO).then_some(n),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Index of the edge closest to `p` (by polyline distance).
|
||||
pub fn nearest_edge(&self, p: Vec3, tol: f64) -> Result<Option<(usize, f64)>> {
|
||||
let edges = self.edges(tol)?;
|
||||
let mut best: Option<(usize, f64)> = None;
|
||||
for e in &edges {
|
||||
for w in e.points.windows(2) {
|
||||
if let (Some(a), Some(b)) = (w.first(), w.get(1)) {
|
||||
let d = p.dist_to_segment(*a, *b);
|
||||
if best.is_none_or(|(_, bd)| d < bd) {
|
||||
best = Some((e.index, d));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(best)
|
||||
}
|
||||
|
||||
pub fn face_count(&self) -> usize {
|
||||
self.solid.face_iter().count()
|
||||
}
|
||||
pub fn edge_count(&self) -> usize {
|
||||
Self::unique_edges(&self.solid).len()
|
||||
}
|
||||
pub fn vertex_count(&self) -> usize {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
self.solid.vertex_iter().filter(|v| seen.insert(v.id())).count()
|
||||
}
|
||||
pub fn shell_count(&self) -> usize {
|
||||
self.solid.boundaries().len()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//! 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 crate::body::{Body, Solid, p3, v3};
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
const MAX_SEGS: usize = 20_000;
|
||||
|
||||
fn finite(x: f64, what: &str) -> Result<f64> {
|
||||
if x.is_finite() && x.abs() < 1e7 { Ok(x) } else { Err(KernelError::Invalid(format!("{what} = {x}"))) }
|
||||
}
|
||||
|
||||
/// A closed wire in 3D from a sketch loop.
|
||||
fn wire(plane: &Plane, lp: &Loop2) -> Result<mt::Wire> {
|
||||
let n = lp.segs.len();
|
||||
if n == 0 || n > MAX_SEGS {
|
||||
return Err(KernelError::Invalid(format!("loop with {n} segments")));
|
||||
}
|
||||
if !lp.is_closed(1e-6) {
|
||||
return Err(KernelError::Invalid("profile loop is not closed".into()));
|
||||
}
|
||||
let verts: Vec<mt::Vertex> = lp.segs.iter().map(|s| builder::vertex(p3(plane.to_world(s.start())))).collect();
|
||||
let mut edges = Vec::with_capacity(n);
|
||||
for (i, s) in lp.segs.iter().enumerate() {
|
||||
let (Some(a), Some(b)) = (verts.get(i), verts.get((i + 1) % n)) else { continue };
|
||||
let e: mt::Edge = match *s {
|
||||
Seg2::Line { .. } => builder::line(a, b),
|
||||
Seg2::Arc { sweep, .. } => {
|
||||
if sweep.abs() < 1e-9 {
|
||||
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()))?
|
||||
} else {
|
||||
builder::try_circle_arc(a, b, mt::builder::CircularArcConstraint::ThroughPoint(p3(mid)))
|
||||
.map_err(|e| KernelError::Invalid(e.to_string()))?
|
||||
}
|
||||
}
|
||||
};
|
||||
edges.push(e);
|
||||
}
|
||||
Ok(edges.into())
|
||||
}
|
||||
|
||||
/// A planar face from a region (outer loop with holes).
|
||||
pub(crate) fn face(plane: &Plane, r: &Region2) -> Result<mt::Face> {
|
||||
let mut wires = vec![wire(plane, &r.outer.ccw())?];
|
||||
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}"))))
|
||||
}
|
||||
|
||||
fn region_ok(r: &Region2) -> Result<()> {
|
||||
if r.area().abs() < 1e-9 {
|
||||
return Err(KernelError::Invalid("profile has no area".into()));
|
||||
}
|
||||
for s in std::iter::once(&r.outer).chain(&r.holes).flat_map(|l| &l.segs) {
|
||||
let ok = match *s {
|
||||
Seg2::Line { a, b } => a.is_finite() && b.is_finite(),
|
||||
Seg2::Arc { center, radius, start, sweep } => {
|
||||
center.is_finite() && radius.is_finite() && radius > 1e-9 && start.is_finite() && sweep.is_finite()
|
||||
}
|
||||
};
|
||||
if !ok {
|
||||
return Err(KernelError::Invalid("non-finite profile".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extrude each region from `start` to `end` along the plane normal (offsets in mm, `end > start`).
|
||||
pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result<Vec<Body>> {
|
||||
let (start, end) = (finite(start, "start")?, finite(end, "end")?);
|
||||
let (lo, hi) = if end >= start { (start, end) } else { (end, start) };
|
||||
if hi - lo < 1e-6 {
|
||||
return Err(KernelError::Invalid("extrude distance is zero".into()));
|
||||
}
|
||||
if regions.is_empty() {
|
||||
return Err(KernelError::Invalid("no profile selected".into()));
|
||||
}
|
||||
let n = plane.normal();
|
||||
let mut out = Vec::new();
|
||||
for r in regions {
|
||||
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)))))?;
|
||||
out.push(Body::new(solid)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Revolve each region about the axis through sketch point `axis_origin` with sketch direction
|
||||
/// `axis_dir` by `angle` radians (|angle| ≥ 2π = full revolution).
|
||||
pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: Vec2, angle: f64) -> Result<Vec<Body>> {
|
||||
let angle = finite(angle, "angle")?;
|
||||
if angle.abs() < 1e-6 {
|
||||
return Err(KernelError::Invalid("revolve angle is zero".into()));
|
||||
}
|
||||
let d = axis_dir.normalized().ok_or_else(|| KernelError::Invalid("revolve axis has no direction".into()))?;
|
||||
if regions.is_empty() {
|
||||
return Err(KernelError::Invalid("no profile selected".into()));
|
||||
}
|
||||
// The profile must lie on one side of the axis.
|
||||
let o = plane.to_world(axis_origin);
|
||||
let axis = plane.dir_to_world(d);
|
||||
let mut out = Vec::new();
|
||||
for r in regions {
|
||||
region_ok(r)?;
|
||||
let (mut neg, mut pos) = (false, false);
|
||||
for p in r.outer.polyline(1e-3) {
|
||||
let s = d.cross(p - axis_origin);
|
||||
neg |= s < -1e-7;
|
||||
pos |= s > 1e-7;
|
||||
}
|
||||
if neg && pos {
|
||||
return Err(KernelError::Invalid("the profile crosses the revolve axis".into()));
|
||||
}
|
||||
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)
|
||||
})
|
||||
})?;
|
||||
out.push(Body::new(solid)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Axis-aligned box between two corners.
|
||||
pub fn box_solid(a: Vec3, b: Vec3) -> Result<Body> {
|
||||
let (lo, hi) = (a.min(b), a.max(b));
|
||||
let s = hi - lo;
|
||||
if !(s.x > 1e-6 && s.y > 1e-6 && s.z > 1e-6 && s.is_finite() && s.len() < 1e7) {
|
||||
return Err(KernelError::Invalid("box needs a positive size in x, y and z".into()));
|
||||
}
|
||||
let r = Region2 {
|
||||
outer: Loop2::polygon(&[Vec2::new(lo.x, lo.y), Vec2::new(hi.x, lo.y), Vec2::new(hi.x, hi.y), Vec2::new(lo.x, hi.y)]),
|
||||
holes: vec![],
|
||||
};
|
||||
let mut v = extrude(&Plane::XY, &[r], lo.z, hi.z)?;
|
||||
v.pop().ok_or_else(|| KernelError::Failed("box".into()))
|
||||
}
|
||||
|
||||
/// Cylinder with base centre `base`, axis direction `axis`, radius and height.
|
||||
pub fn cylinder(base: Vec3, axis: Vec3, radius: f64, height: f64) -> Result<Body> {
|
||||
let plane = Plane::from_normal(base, axis).ok_or_else(|| KernelError::Invalid("cylinder axis".into()))?;
|
||||
let radius = finite(radius, "radius")?;
|
||||
if radius <= 1e-6 {
|
||||
return Err(KernelError::Invalid("radius must be positive".into()));
|
||||
}
|
||||
let r = Region2 { outer: Loop2::circle(Vec2::ZERO, radius), holes: vec![] };
|
||||
let mut v = extrude(&plane, &[r], 0.0, finite(height, "height")?)?;
|
||||
v.pop().ok_or_else(|| KernelError::Failed("cylinder".into()))
|
||||
}
|
||||
|
||||
/// Sphere: revolve a half disc.
|
||||
pub fn sphere(center: Vec3, radius: f64) -> Result<Body> {
|
||||
let radius = finite(radius, "radius")?;
|
||||
if radius <= 1e-6 {
|
||||
return Err(KernelError::Invalid("radius must be positive".into()));
|
||||
}
|
||||
let plane = Plane { origin: center, ..Plane::XZ };
|
||||
let half = Loop2 {
|
||||
segs: vec![
|
||||
Seg2::Arc { center: Vec2::ZERO, radius, start: -std::f64::consts::FRAC_PI_2, sweep: std::f64::consts::PI },
|
||||
Seg2::Line { a: Vec2::new(0.0, radius), b: Vec2::new(0.0, -radius) },
|
||||
],
|
||||
};
|
||||
let mut v = revolve(&plane, &[Region2 { outer: half, holes: vec![] }], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU)?;
|
||||
v.pop().ok_or_else(|| KernelError::Failed("sphere".into()))
|
||||
}
|
||||
|
||||
/// Torus about the Z axis through `center`.
|
||||
pub fn torus(center: Vec3, major: f64, minor: f64) -> Result<Body> {
|
||||
let (major, minor) = (finite(major, "major radius")?, finite(minor, "minor radius")?);
|
||||
if !(minor > 1e-6 && major > minor) {
|
||||
return Err(KernelError::Invalid("torus needs major > minor > 0".into()));
|
||||
}
|
||||
let plane = Plane { origin: center, ..Plane::XZ };
|
||||
let r = Region2 { outer: Loop2::circle(Vec2::new(major, 0.0), minor), holes: vec![] };
|
||||
let mut v = revolve(&plane, &[r], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU)?;
|
||||
v.pop().ok_or_else(|| KernelError::Failed("torus".into()))
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! The B-rep kernel boundary.
|
||||
//!
|
||||
//! 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.
|
||||
//!
|
||||
//! 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 body;
|
||||
mod build;
|
||||
mod measure;
|
||||
mod ops;
|
||||
mod step;
|
||||
mod topo;
|
||||
|
||||
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 step::step_export;
|
||||
pub use topo::{TopoCounts, merged_topology};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum KernelError {
|
||||
#[error("invalid input: {0}")]
|
||||
Invalid(String),
|
||||
#[error("the operation failed: {0}")]
|
||||
Failed(String),
|
||||
/// The third-party kernel panicked; the model is unchanged.
|
||||
#[error("kernel internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, KernelError>;
|
||||
|
||||
/// Run third-party kernel code, converting a panic into an error.
|
||||
pub fn guard<T>(what: &str, f: impl FnOnce() -> Result<T>) -> Result<T> {
|
||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
|
||||
Ok(r) => r,
|
||||
Err(p) => {
|
||||
let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
|
||||
log::warn!("kernel panic in {what}: {msg}");
|
||||
Err(KernelError::Internal(format!("{what}: {msg}")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,42 @@
|
||||
use serde::Serialize;
|
||||
use solvecraft_geom::{Aabb3, Vec3};
|
||||
|
||||
use crate::Result;
|
||||
use crate::body::Body;
|
||||
use crate::topo::{TopoCounts, merged_topology};
|
||||
|
||||
/// Measured properties of a body.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct BodyMeasure {
|
||||
pub volume: f64,
|
||||
pub area: f64,
|
||||
pub centroid: Vec3,
|
||||
pub bbox: Aabb3,
|
||||
/// Raw kernel topology counts (closed curves and surfaces may be split in pieces).
|
||||
pub faces: usize,
|
||||
pub edges: usize,
|
||||
pub vertices: usize,
|
||||
pub shells: usize,
|
||||
/// Counts after merging split pieces of the same analytic surface/curve (comparable with
|
||||
/// systems that use periodic faces and edges).
|
||||
pub merged: TopoCounts,
|
||||
}
|
||||
|
||||
/// Measure a body from a fine tessellation (chord tolerance 5e-5 of the body size). Curved
|
||||
/// faces are slightly under-estimated (relative error typically below 2e-4).
|
||||
pub fn measure(b: &Body) -> Result<BodyMeasure> {
|
||||
let size = b.size();
|
||||
let m2 = b.tessellate((size * 5e-5).max(1e-4))?;
|
||||
let a2 = m2.measure();
|
||||
Ok(BodyMeasure {
|
||||
volume: a2.volume,
|
||||
area: a2.area,
|
||||
centroid: a2.centroid,
|
||||
bbox: m2.bounds(),
|
||||
faces: b.face_count(),
|
||||
edges: b.edge_count(),
|
||||
vertices: b.vertex_count(),
|
||||
shells: b.shell_count(),
|
||||
merged: merged_topology(b, &m2)?,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Operations on bodies: booleans, fillets, chamfers, rigid transforms.
|
||||
|
||||
use monstertruck_modeling as mt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
use crate::body::{Body, Solid, v3};
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BoolOp {
|
||||
Union,
|
||||
Cut,
|
||||
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.
|
||||
pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
let size = a.size().max(b.size());
|
||||
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));
|
||||
}
|
||||
last = "degenerate result".into();
|
||||
}
|
||||
Err(e) => last = e.to_string(),
|
||||
}
|
||||
}
|
||||
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()) {
|
||||
return Err(KernelError::Invalid("non-finite transform".into()));
|
||||
}
|
||||
guard("transform", || {
|
||||
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));
|
||||
}
|
||||
if translate.len() > 0.0 {
|
||||
s = mt::builder::translated(&s, v3(translate));
|
||||
}
|
||||
Body::new(s)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
use monstertruck_io::step::save;
|
||||
|
||||
use crate::body::Body;
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
/// STEP (ISO 10303-21, AP203-style geometry) 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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use solvecraft_geom::{Loop2, Plane, Region2, Vec2, Vec3};
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn rel(a: f64, b: f64) -> f64 {
|
||||
(a - b).abs() / b.abs().max(1e-12)
|
||||
}
|
||||
|
||||
fn rect(w: f64, h: f64) -> Region2 {
|
||||
Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(w, 0.0), Vec2::new(w, h), Vec2::new(0.0, h)]), holes: vec![] }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_extrude_measures() {
|
||||
let b = extrude(&Plane::XY, &[rect(40.0, 30.0)], 0.0, 20.0).unwrap().pop().unwrap();
|
||||
let m = measure(&b).unwrap();
|
||||
assert!(rel(m.volume, 24000.0) < 1e-9, "{}", m.volume);
|
||||
assert!(rel(m.area, 5200.0) < 1e-9);
|
||||
assert_eq!((m.faces, m.edges, m.vertices), (6, 12, 8));
|
||||
assert_eq!((m.merged.faces, m.merged.edges, m.merged.vertices), (6, 12, 8));
|
||||
assert!(m.centroid.dist(Vec3::new(20.0, 15.0, 10.0)) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_measures_match_analytic() {
|
||||
let b = extrude(&Plane::XY, &[Region2 { outer: Loop2::circle(Vec2::ZERO, 15.0), holes: vec![] }], 0.0, 50.0).unwrap().pop().unwrap();
|
||||
let m = measure(&b).unwrap();
|
||||
let v = PI * 225.0 * 50.0;
|
||||
let a = 2.0 * PI * 225.0 + 2.0 * PI * 15.0 * 50.0;
|
||||
assert!(rel(m.volume, v) < 5e-4, "{} vs {v}", m.volume);
|
||||
assert!(rel(m.area, a) < 5e-4, "{} vs {a}", m.area);
|
||||
assert_eq!((m.merged.faces, m.merged.edges, m.merged.vertices), (3, 2, 2), "{:?}", m.merged);
|
||||
assert_eq!(m.merged.face_types.get("cylinder"), Some(&1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extrude_on_other_planes_and_offsets() {
|
||||
let b = extrude(&Plane::XZ, &[rect(10.0, 5.0)], -2.0, 3.0).unwrap().pop().unwrap();
|
||||
let m = measure(&b).unwrap();
|
||||
assert!(rel(m.volume, 250.0) < 1e-9);
|
||||
// XZ normal is -Y: the body spans y in [-3, 2].
|
||||
assert!((m.bbox.min.y + 3.0).abs() < 1e-6 && (m.bbox.max.y - 2.0).abs() < 1e-6, "{:?}", m.bbox);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_with_hole() {
|
||||
let r = Region2 { outer: rect(40.0, 30.0).outer, holes: vec![Loop2::circle(Vec2::new(20.0, 15.0), 5.0).reversed()] };
|
||||
let b = extrude(&Plane::XY, &[r], 0.0, 10.0).unwrap().pop().unwrap();
|
||||
let m = measure(&b).unwrap();
|
||||
assert!(rel(m.volume, (1200.0 - PI * 25.0) * 10.0) < 5e-4, "{}", m.volume);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_fillet_cut_volume() {
|
||||
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
|
||||
let f = fillet(&b, &[Vec3::new(0.0, 0.0, 10.0)], 3.0).unwrap();
|
||||
let mf = measure(&f).unwrap();
|
||||
let expect_f = 24000.0 - (9.0 - PI * 9.0 / 4.0) * 20.0;
|
||||
assert!(rel(mf.volume, expect_f) < 1e-4, "{} vs {expect_f}", mf.volume);
|
||||
assert_eq!(mf.merged.faces, 7);
|
||||
let hole = cylinder(Vec3::new(20.0, 15.0, -5.0), Vec3::Z, 5.0, 30.0).unwrap();
|
||||
let cut = boolean(&f, &hole, BoolOp::Cut).unwrap().unwrap();
|
||||
let mc = measure(&cut).unwrap();
|
||||
let expect = expect_f - PI * 25.0 * 20.0;
|
||||
assert!(rel(mc.volume, expect) < 1e-3, "{} vs {expect}", mc.volume);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn union_and_intersect() {
|
||||
let a = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
|
||||
let b = box_solid(Vec3::new(5.0, 5.0, 5.0), Vec3::new(15.0, 15.0, 15.0)).unwrap();
|
||||
let u = boolean(&a, &b, BoolOp::Union).unwrap().unwrap();
|
||||
assert!(rel(measure(&u).unwrap().volume, 2000.0 - 125.0) < 1e-6);
|
||||
let i = boolean(&a, &b, BoolOp::Intersect).unwrap().unwrap();
|
||||
assert!(rel(measure(&i).unwrap().volume, 125.0) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_washer_and_sphere() {
|
||||
let r =
|
||||
Region2 { outer: Loop2::polygon(&[Vec2::new(10.0, 0.0), Vec2::new(15.0, 0.0), Vec2::new(15.0, 5.0), Vec2::new(10.0, 5.0)]), holes: vec![] };
|
||||
let b = revolve(&Plane::XZ, std::slice::from_ref(&r), Vec2::ZERO, Vec2::Y, 2.0 * PI).unwrap().pop().unwrap();
|
||||
let m = measure(&b).unwrap();
|
||||
let v = PI * (225.0 - 100.0) * 5.0;
|
||||
assert!(rel(m.volume, v) < 5e-4, "{} vs {v}", m.volume);
|
||||
let half = revolve(&Plane::XZ, &[r], Vec2::ZERO, Vec2::Y, PI).unwrap().pop().unwrap();
|
||||
assert!(rel(measure(&half).unwrap().volume, v / 2.0) < 5e-4);
|
||||
let s = sphere(Vec3::new(1.0, 2.0, 3.0), 10.0).unwrap();
|
||||
let ms = measure(&s).unwrap();
|
||||
assert!(rel(ms.volume, 4.0 / 3.0 * PI * 1000.0) < 1e-3, "{}", ms.volume);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chamfer_and_step() {
|
||||
let b = box_solid(Vec3::ZERO, Vec3::new(20.0, 20.0, 20.0)).unwrap();
|
||||
let c = chamfer(&b, &[Vec3::new(20.0, 10.0, 20.0)], 2.0).unwrap();
|
||||
assert!(rel(measure(&c).unwrap().volume, 8000.0 - 2.0 * 20.0) < 1e-4);
|
||||
let s = step_export(&[&c], "SolveCraft").unwrap();
|
||||
assert!(s.starts_with("ISO-10303-21;"));
|
||||
assert!(s.contains("MANIFOLD_SOLID_BREP") || s.contains("CLOSED_SHELL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_inputs_are_errors() {
|
||||
assert!(extrude(&Plane::XY, &[rect(1.0, 1.0)], 0.0, 0.0).is_err());
|
||||
assert!(extrude(&Plane::XY, &[rect(1.0, 1.0)], 0.0, f64::NAN).is_err());
|
||||
assert!(extrude(&Plane::XY, &[], 0.0, 1.0).is_err());
|
||||
assert!(extrude(&Plane::XY, &[rect(0.0, 1.0)], 0.0, 1.0).is_err());
|
||||
assert!(box_solid(Vec3::ZERO, Vec3::new(1.0, 0.0, 1.0)).is_err());
|
||||
assert!(cylinder(Vec3::ZERO, Vec3::ZERO, 1.0, 1.0).is_err());
|
||||
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
|
||||
assert!(fillet(&b, &[Vec3::new(0.0, 0.0, 5.0)], -1.0).is_err());
|
||||
assert!(fillet(&b, &[Vec3::new(500.0, 0.0, 5.0)], 1.0).is_err());
|
||||
assert!(fillet(&b, &[Vec3::new(0.0, 0.0, 5.0)], 100.0).is_err());
|
||||
let r = Region2 { outer: Loop2::polygon(&[Vec2::new(-1.0, 0.0), Vec2::new(1.0, 0.0), Vec2::new(1.0, 1.0)]), holes: vec![] };
|
||||
assert!(revolve(&Plane::XZ, &[r], Vec2::ZERO, Vec2::Y, 1.0).is_err(), "crosses axis");
|
||||
assert!(transform(&b, Vec3::new(f64::NAN, 0.0, 0.0), Vec3::ZERO, Vec3::Z, 0.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn debug_convergence() {
|
||||
let b = extrude(&Plane::XY, &[Region2 { outer: Loop2::circle(Vec2::ZERO, 15.0), holes: vec![] }], 0.0, 50.0).unwrap().pop().unwrap();
|
||||
for t in [0.1, 0.05, 0.02, 0.01, 0.005, 0.002] {
|
||||
let m = b.tessellate(t).unwrap();
|
||||
let mm = m.measure();
|
||||
println!("tol {t}: tris {} vol {} err {:.3e}", m.triangles.len(), mm.volume, mm.volume / (PI * 225.0 * 50.0) - 1.0);
|
||||
}
|
||||
let m = b.tessellate(0.05).unwrap();
|
||||
for f in 0..4u32 {
|
||||
let ns: Vec<Vec3> =
|
||||
m.triangles.iter().zip(&m.tri_face).filter(|(_, ff)| **ff == f).flat_map(|(t, _)| t.iter().map(|k| m.normals[*k as usize])).collect();
|
||||
println!("face {f}: {} normals, first {:?}, max |n.z| {}", ns.len(), ns.first(), ns.iter().map(|n| n.z.abs()).fold(0.0, f64::max));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Topology counts comparable with kernels that use periodic faces and closed edges.
|
||||
//!
|
||||
//! Our kernel splits a full cylinder into two half faces and a circle into two arcs. Faces
|
||||
//! are classified from their tessellation (plane, cylinder, sphere, other); adjacent faces on
|
||||
//! the same analytic surface are merged, edges between merged faces disappear, and chains of
|
||||
//! edges through vertices that only join two pieces of the same edge are counted once.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use serde::Serialize;
|
||||
use solvecraft_geom::{Mesh, Vec3};
|
||||
|
||||
use crate::Result;
|
||||
use crate::body::{Body, from_p3};
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, PartialEq)]
|
||||
pub struct TopoCounts {
|
||||
pub faces: usize,
|
||||
pub edges: usize,
|
||||
pub vertices: usize,
|
||||
pub face_types: BTreeMap<String, usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Surf {
|
||||
Plane { n: Vec3, d: f64 },
|
||||
Cylinder { axis: Vec3, p: Vec3, r: f64 },
|
||||
Sphere { c: Vec3, r: f64 },
|
||||
Other(usize),
|
||||
}
|
||||
|
||||
impl Surf {
|
||||
fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Surf::Plane { .. } => "plane",
|
||||
Surf::Cylinder { .. } => "cylinder",
|
||||
Surf::Sphere { .. } => "sphere",
|
||||
Surf::Other(_) => "other",
|
||||
}
|
||||
}
|
||||
fn same(&self, o: &Surf, tol: f64) -> bool {
|
||||
match (*self, *o) {
|
||||
(Surf::Plane { n, d }, Surf::Plane { n: n2, d: d2 }) => n.dot(n2) > 1.0 - 1e-6 && (d - d2).abs() < tol,
|
||||
(Surf::Cylinder { axis, p, r }, Surf::Cylinder { axis: a2, p: p2, r: r2 }) => {
|
||||
let off = p2 - p;
|
||||
axis.dot(a2).abs() > 1.0 - 1e-6 && (r - r2).abs() < tol && (off - axis * off.dot(axis)).len() < tol
|
||||
}
|
||||
(Surf::Sphere { c, r }, Surf::Sphere { c: c2, r: r2 }) => c.dist(c2) < tol && (r - r2).abs() < tol,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Least-squares circle through 2D points (Kåsa fit): centre and radius.
|
||||
fn fit_circle(pts: &[(f64, f64)]) -> Option<(f64, f64, f64)> {
|
||||
let n = pts.len() as f64;
|
||||
if pts.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let (mx, my) = pts.iter().fold((0.0, 0.0), |a, p| (a.0 + p.0 / n, a.1 + p.1 / n));
|
||||
let (mut suu, mut suv, mut svv, mut suuu, mut svvv, mut suvv, mut svuu) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
|
||||
for (x, y) in pts {
|
||||
let (u, v) = (x - mx, y - my);
|
||||
suu += u * u;
|
||||
suv += u * v;
|
||||
svv += v * v;
|
||||
suuu += u * u * u;
|
||||
svvv += v * v * v;
|
||||
suvv += u * v * v;
|
||||
svuu += v * u * u;
|
||||
}
|
||||
let det = suu * svv - suv * suv;
|
||||
if det.abs() < 1e-18 {
|
||||
return None;
|
||||
}
|
||||
let b1 = 0.5 * (suuu + suvv);
|
||||
let b2 = 0.5 * (svvv + svuu);
|
||||
let uc = (b1 * svv - b2 * suv) / det;
|
||||
let vc = (suu * b2 - suv * b1) / det;
|
||||
let r = (uc * uc + vc * vc + (suu + svv) / n).sqrt();
|
||||
Some((uc + mx, vc + my, r))
|
||||
}
|
||||
|
||||
fn classify(pts: &[Vec3], nrm: &[Vec3], tol: f64, id: usize) -> Surf {
|
||||
let Some(&n0) = nrm.first() else { return Surf::Other(id) };
|
||||
let Some(&p0) = pts.first() else { return Surf::Other(id) };
|
||||
if nrm.iter().all(|n| n.dot(n0) > 1.0 - 1e-5) {
|
||||
let n = n0.normalized().unwrap_or(Vec3::Z);
|
||||
if pts.iter().all(|p| (p.dot(n) - p0.dot(n)).abs() < tol) {
|
||||
return Surf::Plane { n, d: p0.dot(n) };
|
||||
}
|
||||
}
|
||||
// Cylinder: all normals perpendicular to one axis.
|
||||
let far = nrm.iter().copied().min_by(|a, b| a.dot(n0).abs().total_cmp(&b.dot(n0).abs())).unwrap_or(n0);
|
||||
if let Some(axis) = n0.cross(far).normalized()
|
||||
&& nrm.iter().all(|n| n.dot(axis).abs() < 2e-3)
|
||||
{
|
||||
let u = axis.any_perp();
|
||||
let v = axis.cross(u);
|
||||
let pl: Vec<(f64, f64)> = pts.iter().map(|p| (p.dot(u), p.dot(v))).collect();
|
||||
if let Some((cu, cv, r)) = fit_circle(&pl)
|
||||
&& pl.iter().all(|(x, y)| ((x - cu).hypot(y - cv) - r).abs() < tol.max(r * 2e-3))
|
||||
{
|
||||
let axis = if axis.x + axis.y * 1e-3 + axis.z * 1e-6 < 0.0 { -axis } else { axis };
|
||||
return Surf::Cylinder { axis, p: u * cu + v * cv, r };
|
||||
}
|
||||
}
|
||||
// Sphere: points equidistant from the point where normals meet.
|
||||
let c_est = {
|
||||
let mut acc = Vec3::ZERO;
|
||||
let mut k = 0.0;
|
||||
for (p, n) in pts.iter().zip(nrm) {
|
||||
// Assume radius from the first pair.
|
||||
let r0 = (p0 - *p).len() / ((n0 - *n).len().max(1e-12));
|
||||
if r0.is_finite() && r0 > 0.0 && (n0 - *n).len() > 0.1 {
|
||||
acc += *p - *n * r0;
|
||||
k += 1.0;
|
||||
}
|
||||
}
|
||||
(k > 0.0).then(|| acc / k)
|
||||
};
|
||||
if let Some(c) = c_est {
|
||||
let r = pts.iter().map(|p| p.dist(c)).sum::<f64>() / pts.len().max(1) as f64;
|
||||
if pts.iter().all(|p| (p.dist(c) - r).abs() < tol.max(r * 2e-3)) {
|
||||
return Surf::Sphere { c, r };
|
||||
}
|
||||
}
|
||||
Surf::Other(id)
|
||||
}
|
||||
|
||||
fn find(p: &mut [usize], mut i: usize) -> usize {
|
||||
while let Some(&q) = p.get(i) {
|
||||
if q == i {
|
||||
break;
|
||||
}
|
||||
i = q;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Merged face/edge/vertex counts (see module docs). `mesh` must be `b.tessellate(..)`.
|
||||
pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
|
||||
let solid = &*b.solid;
|
||||
let nf = solid.face_iter().count();
|
||||
let tol = (b.size() * 1e-4).max(1e-6);
|
||||
let mut pts: Vec<Vec<Vec3>> = vec![Vec::new(); nf];
|
||||
let mut nrm: Vec<Vec<Vec3>> = vec![Vec::new(); nf];
|
||||
for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
for &k in t {
|
||||
if let (Some(p), Some(n), Some(pv), Some(nv)) =
|
||||
(mesh.positions.get(k as usize), mesh.normals.get(k as usize), pts.get_mut(*f as usize), nrm.get_mut(*f as usize))
|
||||
{
|
||||
pv.push(*p);
|
||||
nv.push(*n);
|
||||
}
|
||||
}
|
||||
}
|
||||
let surfs: Vec<Surf> = (0..nf)
|
||||
.map(|i| classify(pts.get(i).map(Vec::as_slice).unwrap_or(&[]), nrm.get(i).map(Vec::as_slice).unwrap_or(&[]), tol * 10.0, i))
|
||||
.collect();
|
||||
|
||||
// Edge → faces, edge → vertices.
|
||||
let mut edge_faces: HashMap<usize, Vec<usize>> = HashMap::new();
|
||||
let mut edge_verts: HashMap<usize, (usize, usize)> = HashMap::new();
|
||||
let mut eid: HashMap<String, usize> = HashMap::new();
|
||||
let mut vid: HashMap<String, usize> = HashMap::new();
|
||||
let mut vpos: Vec<Vec3> = Vec::new();
|
||||
for (fi, f) in solid.face_iter().enumerate() {
|
||||
for e in f.edge_iter() {
|
||||
let key = format!("{:?}", e.id());
|
||||
let n = eid.len();
|
||||
let ei = *eid.entry(key).or_insert(n);
|
||||
let faces = edge_faces.entry(ei).or_default();
|
||||
if !faces.contains(&fi) {
|
||||
faces.push(fi);
|
||||
}
|
||||
let mut vix = |v: &monstertruck_modeling::Vertex| {
|
||||
let k = format!("{:?}", v.id());
|
||||
let n = vid.len();
|
||||
let i = *vid.entry(k).or_insert(n);
|
||||
if i == vpos.len() {
|
||||
vpos.push(from_p3(v.point()));
|
||||
}
|
||||
i
|
||||
};
|
||||
let (a, bb) = (vix(e.front()), vix(e.back()));
|
||||
edge_verts.insert(ei, (a, bb));
|
||||
}
|
||||
}
|
||||
// Merge faces.
|
||||
let mut fp: Vec<usize> = (0..nf).collect();
|
||||
for faces in edge_faces.values() {
|
||||
if let [a, b] = faces[..]
|
||||
&& let (Some(sa), Some(sb)) = (surfs.get(a), surfs.get(b))
|
||||
&& sa.same(sb, tol * 10.0)
|
||||
{
|
||||
let (ra, rb) = (find(&mut fp, a), find(&mut fp, b));
|
||||
if let Some(s) = fp.get_mut(ra) {
|
||||
*s = rb;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut groups: BTreeMap<usize, usize> = BTreeMap::new();
|
||||
let mut face_types: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for f in 0..nf {
|
||||
let r = find(&mut fp, f);
|
||||
if groups.insert(r, f).is_none()
|
||||
&& let Some(s) = surfs.get(f)
|
||||
{
|
||||
*face_types.entry(s.name().into()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
// Remaining edges, keyed by merged face pair.
|
||||
let mut remaining: Vec<(usize, (usize, usize), (usize, usize))> = Vec::new(); // (edge, verts, face pair)
|
||||
let mut eids: Vec<&usize> = edge_faces.keys().collect();
|
||||
eids.sort();
|
||||
for e in eids {
|
||||
let faces = edge_faces.get(e).cloned().unwrap_or_default();
|
||||
let gs: Vec<usize> = faces.iter().map(|f| find(&mut fp, *f)).collect();
|
||||
let pair = match gs[..] {
|
||||
[a, b] if a == b => continue,
|
||||
[a, b] => (a.min(b), a.max(b)),
|
||||
[a] => (a, a),
|
||||
_ => (usize::MAX, usize::MAX),
|
||||
};
|
||||
if let Some(v) = edge_verts.get(e) {
|
||||
remaining.push((*e, *v, pair));
|
||||
}
|
||||
}
|
||||
// Vertex degree among remaining edges.
|
||||
let mut deg: HashMap<usize, Vec<usize>> = HashMap::new();
|
||||
for (i, (_, (a, b), _)) in remaining.iter().enumerate() {
|
||||
deg.entry(*a).or_default().push(i);
|
||||
if b != a {
|
||||
deg.entry(*b).or_default().push(i);
|
||||
}
|
||||
}
|
||||
let mut ep: Vec<usize> = (0..remaining.len()).collect();
|
||||
let mut merge_vertex: Vec<usize> = Vec::new();
|
||||
for (v, es) in ° {
|
||||
if let [i, j] = es[..]
|
||||
&& let (Some(a), Some(b)) = (remaining.get(i), remaining.get(j))
|
||||
&& a.2 == b.2
|
||||
{
|
||||
let (ri, rj) = (find(&mut ep, i), find(&mut ep, j));
|
||||
if let Some(s) = ep.get_mut(ri) {
|
||||
*s = rj;
|
||||
}
|
||||
merge_vertex.push(*v);
|
||||
}
|
||||
}
|
||||
let mut edge_groups: BTreeMap<usize, bool> = BTreeMap::new(); // root → has a real end vertex
|
||||
for (i, (_, (a, b), _)) in remaining.iter().enumerate() {
|
||||
let r = find(&mut ep, i);
|
||||
let real = !merge_vertex.contains(a) || !merge_vertex.contains(b);
|
||||
let e = edge_groups.entry(r).or_insert(false);
|
||||
*e |= real;
|
||||
}
|
||||
let real_vertices = deg.keys().filter(|v| !merge_vertex.contains(v)).count();
|
||||
let closed_loops = edge_groups.values().filter(|r| !**r).count();
|
||||
let _ = vpos;
|
||||
Ok(TopoCounts { faces: groups.len(), edges: edge_groups.len(), vertices: real_vertices + closed_loops, face_types })
|
||||
}
|
||||
Reference in new issue
Block a user