Export: ExportCommand/CLI --out/MCP export with .3mf write a 3MF Core package ([Content_Types].xml, _rels/.rels, 3D/3dmodel.model; millimetres), one mesh object and build item per body, named after it, vertices shared and welded watertight (T-junctions between faces split), base-material colours when bodies carry one (STEP colours now ride on bodies). Tessellation as for STL. File > Export 3MF. Import: doc.open/drag-drop/CLI/MCP open accept .3mf and .stl; Insert Mesh (ParaMeshInsertAlignCommand) adds them to a design as a MeshImport feature. Kernel mesh bodies render, measure exactly, move and export; solid features refuse them with a clear error. 3MF reading flattens components and build transforms, converts units, reads colours; sizes, entries, nesting and element counts are capped. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
240 lines
9.9 KiB
Rust
240 lines
9.9 KiB
Rust
//! Operations on bodies: booleans and rigid transforms.
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use solvecraft_geom::Vec3;
|
||
use truck_modeling as mt;
|
||
|
||
use crate::body::{Body, p3, 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,
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
|
||
/// Halton low-discrepancy value (base `b`) for index `i`.
|
||
fn halton(mut i: usize, b: usize) -> f64 {
|
||
let (mut f, mut r) = (1.0, 0.0);
|
||
while i > 0 {
|
||
f /= b as f64;
|
||
r += f * (i % b) as f64;
|
||
i /= b;
|
||
}
|
||
r
|
||
}
|
||
|
||
/// Fraction of sample points where `result` disagrees with `op` applied to `a` and `b`
|
||
/// (membership by ray parity). Samples cover the overlap box densely and the union box lightly.
|
||
fn mismatch(a: &solvecraft_geom::Mesh, b: &solvecraft_geom::Mesh, result: &solvecraft_geom::Mesh, op: BoolOp) -> Option<f64> {
|
||
if a.triangles.len() + b.triangles.len() + result.triangles.len() > 60_000 {
|
||
return None;
|
||
}
|
||
let (ba, bb) = (a.bounds(), b.bounds());
|
||
let lo = ba.min.max(bb.min);
|
||
let hi = ba.max.min(bb.max);
|
||
let all = ba.union(&bb);
|
||
let mut boxes = vec![(all, 300usize)];
|
||
if lo.x < hi.x && lo.y < hi.y && lo.z < hi.z {
|
||
boxes.push((solvecraft_geom::Aabb3 { min: lo, max: hi }, 900));
|
||
}
|
||
let (mut n, mut bad) = (0usize, 0usize);
|
||
for (bx, count) in boxes {
|
||
let s = bx.size();
|
||
for i in 1..=count {
|
||
let p = bx.min + Vec3::new(s.x * halton(i, 2), s.y * halton(i, 3), s.z * halton(i, 5));
|
||
let (ia, ib) = (a.contains(p), b.contains(p));
|
||
let want = match op {
|
||
BoolOp::Union => ia || ib,
|
||
BoolOp::Cut => ia && !ib,
|
||
BoolOp::Intersect => ia && ib,
|
||
};
|
||
n += 1;
|
||
if result.contains(p) != want {
|
||
bad += 1;
|
||
}
|
||
}
|
||
}
|
||
Some(bad as f64 / n.max(1) as f64)
|
||
}
|
||
|
||
/// 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>> {
|
||
a.require_brep("a boolean")?;
|
||
b.require_brep("a boolean")?;
|
||
let size = a.size().max(b.size());
|
||
let (va, vb) = (volume(a), volume(b));
|
||
let slack = 2e-3 * (va + vb) + 1e-9;
|
||
let tol_m = size * 5e-4;
|
||
let meshes = (a.tessellate(tol_m).ok(), b.tessellate(tol_m).ok());
|
||
let plausible = |v: f64, r: &Body| {
|
||
let bounds = 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,
|
||
};
|
||
// Membership check against the operands (catches misclassified pieces).
|
||
let consistent = match (&meshes, r.tessellate(tol_m)) {
|
||
((Some(ma), Some(mb)), Ok(mr)) => mismatch(ma, mb, &mr, op).is_none_or(|f| f < 0.004),
|
||
_ => true,
|
||
};
|
||
bounds && consistent
|
||
};
|
||
let mut last = String::new();
|
||
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);
|
||
}
|
||
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, &body) {
|
||
return Ok(Some(body));
|
||
}
|
||
last = format!("implausible result volume {v:.4}");
|
||
}
|
||
Err(e) => last = e.to_string(),
|
||
},
|
||
Err(e) => last = e.to_string(),
|
||
}
|
||
}
|
||
if empty_votes >= 2 && op != BoolOp::Union {
|
||
return Ok(None);
|
||
}
|
||
}
|
||
// Coincident faces defeat the B-rep boolean; bodies with only planar faces have an exact
|
||
// polygon fallback.
|
||
match crate::polybool::planar_boolean(a, b, op) {
|
||
Ok(Some(body)) => {
|
||
let v = volume(&body);
|
||
if v > 0.0 && plausible(v, &body) {
|
||
return Ok(Some(body));
|
||
}
|
||
last = format!("planar fallback gave an implausible volume {v:.4}");
|
||
}
|
||
Ok(None) => return Ok(None),
|
||
Err(e) => last = format!("{last}; {e}"),
|
||
}
|
||
Err(KernelError::Failed(format!("boolean {op:?}: {last}")))
|
||
}
|
||
|
||
/// Affine transform by a column-major 4×4 matrix (rotations, translations, reflections).
|
||
pub fn transform_matrix(body: &Body, m: [[f64; 4]; 4]) -> Result<Body> {
|
||
if m.iter().flatten().any(|x| !x.is_finite()) {
|
||
return Err(KernelError::Invalid("non-finite transform".into()));
|
||
}
|
||
if let Some(b) = body.mesh_transformed(&m) {
|
||
return Ok(b);
|
||
}
|
||
guard("transform", || {
|
||
let mat = mt::Matrix4::new(
|
||
m[0][0], m[0][1], m[0][2], m[0][3], m[1][0], m[1][1], m[1][2], m[1][3], m[2][0], m[2][1], m[2][2], m[2][3], m[3][0], m[3][1], m[3][2],
|
||
m[3][3],
|
||
);
|
||
Body::new(mt::builder::transformed(&body.deep_copy(), mat))
|
||
})
|
||
}
|
||
|
||
/// 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()));
|
||
}
|
||
if body.is_mesh() {
|
||
// Same motion as a matrix: rotate about the line through `origin`, then translate.
|
||
let ax = if angle.abs() > 1e-12 { axis.normalized().ok_or_else(|| KernelError::Invalid("rotation axis".into()))? } else { Vec3::Z };
|
||
let (c, s) = (angle.cos(), angle.sin());
|
||
let t = 1.0 - c;
|
||
let r = [
|
||
[t * ax.x * ax.x + c, t * ax.x * ax.y + s * ax.z, t * ax.x * ax.z - s * ax.y],
|
||
[t * ax.x * ax.y - s * ax.z, t * ax.y * ax.y + c, t * ax.y * ax.z + s * ax.x],
|
||
[t * ax.x * ax.z + s * ax.y, t * ax.y * ax.z - s * ax.x, t * ax.z * ax.z + c],
|
||
];
|
||
let ro = Vec3::new(
|
||
r[0][0] * origin.x + r[1][0] * origin.y + r[2][0] * origin.z,
|
||
r[0][1] * origin.x + r[1][1] * origin.y + r[2][1] * origin.z,
|
||
r[0][2] * origin.x + r[1][2] * origin.y + r[2][2] * origin.z,
|
||
);
|
||
let tr = origin - ro + translate;
|
||
let m = [[r[0][0], r[0][1], r[0][2], 0.0], [r[1][0], r[1][1], r[1][2], 0.0], [r[2][0], r[2][1], r[2][2], 0.0], [tr.x, tr.y, tr.z, 1.0]];
|
||
return body.mesh_transformed(&m).ok_or_else(|| KernelError::Failed("mesh 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, p3(origin), v3(ax), mt::Rad(angle));
|
||
}
|
||
if translate.len() > 0.0 {
|
||
s = mt::builder::translated(&s, v3(translate));
|
||
}
|
||
Body::new(s)
|
||
})
|
||
}
|
||
|
||
/// Split a body with a plane: the parts on the positive and the negative side of the plane
|
||
/// (only the non-empty ones).
|
||
pub fn split_by_plane(body: &Body, plane: &solvecraft_geom::Plane) -> Result<Vec<Body>> {
|
||
body.require_brep("split")?;
|
||
let size = body.size();
|
||
let mut bb = solvecraft_geom::Aabb3::EMPTY;
|
||
for v in body.solid.vertex_iter() {
|
||
bb.add(crate::body::from_p3(v.point()));
|
||
}
|
||
let reach = (bb.diagonal() + plane.origin.dist(bb.center())) * 2.0 + size;
|
||
let c = plane.to_local(bb.center());
|
||
let sq = solvecraft_geom::Region2 {
|
||
outer: solvecraft_geom::Loop2::polygon(&[
|
||
solvecraft_geom::Vec2::new(c.x - reach, c.y - reach),
|
||
solvecraft_geom::Vec2::new(c.x + reach, c.y - reach),
|
||
solvecraft_geom::Vec2::new(c.x + reach, c.y + reach),
|
||
solvecraft_geom::Vec2::new(c.x - reach, c.y + reach),
|
||
]),
|
||
holes: vec![],
|
||
};
|
||
let mut out = Vec::new();
|
||
for (lo, hi) in [(0.0, reach), (-reach, 0.0)] {
|
||
let half = crate::build::extrude(plane, std::slice::from_ref(&sq), lo, hi)?.pop().ok_or_else(|| KernelError::Failed("half space".into()))?;
|
||
if let Some(part) = boolean(body, &half, BoolOp::Intersect)? {
|
||
out.push(part);
|
||
}
|
||
}
|
||
Ok(out)
|
||
}
|