3MF: export, and 3MF/STL import as mesh bodies
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
This commit is contained in:
1 parent
043a542e29
commit
ea3dd0ed6e
30 files changed
+1361
-47
No files matched your search
@@ -298,6 +298,7 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
|
||||
|
||||
/// Constant-radius fillet of the edges nearest to the given points.
|
||||
pub fn fillet(body: &Body, edges: &[Vec3], radius: f64) -> Result<Body> {
|
||||
body.require_brep("fillet")?;
|
||||
blend(body, edges, radius, Shape::Round, "fillet")
|
||||
}
|
||||
|
||||
@@ -305,6 +306,7 @@ pub fn fillet(body: &Body, edges: &[Vec3], radius: f64) -> Result<Body> {
|
||||
/// prism whose only face inside the body is the chamfer plane (so neighbouring chamfers meet in
|
||||
/// a mitre); the local operation is the fallback.
|
||||
pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result<Body> {
|
||||
body.require_brep("chamfer")?;
|
||||
if !(distance.is_finite() && distance > 1e-6 && distance < 1e6) {
|
||||
return Err(KernelError::Invalid("chamfer size must be positive".into()));
|
||||
}
|
||||
|
||||
@@ -7,10 +7,16 @@ 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.
|
||||
/// A solid body (one or more closed shells), or a mesh body (triangles only, like an imported
|
||||
/// 3MF or STL: it renders, measures and exports, but B-rep features cannot use it). Cheap to
|
||||
/// clone; never mutated in place.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Body {
|
||||
pub(crate) solid: std::sync::Arc<Solid>,
|
||||
/// Set for mesh bodies (`solid` is then empty).
|
||||
pub(crate) mesh: Option<std::sync::Arc<crate::meshbody::TriMesh>>,
|
||||
/// Display colour (linear RGB 0..1) from an imported file; operations drop it.
|
||||
pub(crate) color: Option<[f32; 3]>,
|
||||
}
|
||||
|
||||
/// A B-rep edge as seen by the rest of the application.
|
||||
@@ -85,7 +91,7 @@ impl Body {
|
||||
if v < 0.0 {
|
||||
solid.not();
|
||||
}
|
||||
Ok(Body { solid: std::sync::Arc::new(solid) })
|
||||
Ok(Body { solid: std::sync::Arc::new(solid), mesh: None, color: None })
|
||||
}
|
||||
|
||||
/// A deep copy of the solid that can be mutated without affecting other bodies.
|
||||
@@ -93,9 +99,39 @@ impl Body {
|
||||
mt::builder::clone(&*self.solid)
|
||||
}
|
||||
|
||||
/// Is this a mesh body (no B-rep)?
|
||||
pub fn is_mesh(&self) -> bool {
|
||||
self.mesh.is_some()
|
||||
}
|
||||
|
||||
/// Error for operations that need a B-rep.
|
||||
pub(crate) fn require_brep(&self, what: &str) -> Result<()> {
|
||||
if self.is_mesh() {
|
||||
Err(KernelError::Invalid(format!("{what} needs a solid body; mesh bodies can only be moved, measured and exported")))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Display colour from an imported file.
|
||||
pub fn color(&self) -> Option<[f32; 3]> {
|
||||
self.color
|
||||
}
|
||||
|
||||
/// The same body with a display colour.
|
||||
pub fn with_color(mut self, c: Option<[f32; 3]>) -> Body {
|
||||
self.color = c.filter(|c| c.iter().all(|x| x.is_finite())).map(|c| c.map(|x| x.clamp(0.0, 1.0)));
|
||||
self
|
||||
}
|
||||
|
||||
/// Characteristic size (bounding box diagonal) for tolerances.
|
||||
pub fn size(&self) -> f64 {
|
||||
let mut b = solvecraft_geom::Aabb3::EMPTY;
|
||||
if let Some(m) = &self.mesh {
|
||||
for p in &m.positions {
|
||||
b.add(*p);
|
||||
}
|
||||
}
|
||||
for v in self.solid.vertex_iter() {
|
||||
b.add(from_p3(v.point()));
|
||||
}
|
||||
@@ -113,6 +149,9 @@ impl Body {
|
||||
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);
|
||||
if let Some(m) = &self.mesh {
|
||||
return Ok(m.to_mesh());
|
||||
}
|
||||
guard("tessellate", || {
|
||||
let meshed = mesh_shells(&self.solid, tol);
|
||||
let mut out = Mesh::default();
|
||||
@@ -185,6 +224,9 @@ impl Body {
|
||||
/// Display mesh: like [`Body::tessellate`], with seam edges flagged.
|
||||
pub fn display_mesh(&self, tol: f64) -> Result<Mesh> {
|
||||
let mut m = self.tessellate(tol)?;
|
||||
if self.is_mesh() {
|
||||
return Ok(m);
|
||||
}
|
||||
m.seams = crate::topo::seam_flags(self, &m);
|
||||
Ok(m)
|
||||
}
|
||||
@@ -205,7 +247,7 @@ impl Body {
|
||||
/// 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 nf = self.face_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 };
|
||||
@@ -257,17 +299,32 @@ impl Body {
|
||||
self.tessellate(self.size() * 2e-3).map(|m| m.contains(p)).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// B-rep faces (a mesh body is one face; [`crate::measure`] counts its triangles).
|
||||
pub fn face_count(&self) -> usize {
|
||||
self.solid.face_iter().count()
|
||||
match &self.mesh {
|
||||
Some(_) => 1,
|
||||
None => self.solid.face_iter().count(),
|
||||
}
|
||||
}
|
||||
/// B-rep edges (none for a mesh body).
|
||||
pub fn edge_count(&self) -> usize {
|
||||
Self::unique_edges(&self.solid).len()
|
||||
match &self.mesh {
|
||||
Some(_) => 0,
|
||||
None => Self::unique_edges(&self.solid).len(),
|
||||
}
|
||||
}
|
||||
/// B-rep vertices (none for a mesh body).
|
||||
pub fn vertex_count(&self) -> usize {
|
||||
if self.mesh.is_some() {
|
||||
return 0;
|
||||
}
|
||||
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()
|
||||
match &self.mesh {
|
||||
Some(_) => 1,
|
||||
None => self.solid.boundaries().len(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ mod blend;
|
||||
mod body;
|
||||
mod build;
|
||||
mod measure;
|
||||
mod meshbody;
|
||||
mod ops;
|
||||
mod polybool;
|
||||
mod polyhedron;
|
||||
@@ -27,6 +28,7 @@ pub use blend::{chamfer, fillet};
|
||||
pub use body::{Body, EdgeInfo, FaceInfo};
|
||||
pub use build::{PathSeg, box_solid, cylinder, extrude, extrude_tapered, loft, revolve, sphere, sweep, torus};
|
||||
pub use measure::{BodyMeasure, measure};
|
||||
pub use meshbody::{MAX_TRIANGLES, mesh_body};
|
||||
pub use ops::{BoolOp, boolean, split_by_plane, transform, transform_matrix};
|
||||
pub use polybool::planar_boolean;
|
||||
pub use polyhedron::{HalfSpace, convex_polyhedron, draft, shell};
|
||||
|
||||
@@ -59,6 +59,25 @@ fn min_edge_radius(m: &Mesh) -> Option<f64> {
|
||||
/// small radii down to 5e-6). Faces whose fine tessellation disagrees with a coarse one (a
|
||||
/// meshing failure) use the coarse result.
|
||||
pub fn measure(b: &Body) -> Result<BodyMeasure> {
|
||||
if let Some((pos, tris)) = b.triangle_mesh() {
|
||||
// Exact for a mesh body; topology counts are facets, sides and vertices.
|
||||
let m = b.tessellate(1.0)?;
|
||||
let mm = m.measure();
|
||||
let edges = b.mesh.as_ref().map(|m| m.edge_count()).unwrap_or(0);
|
||||
let merged =
|
||||
TopoCounts { faces: tris.len(), edges, vertices: pos.len(), face_types: [("mesh".to_string(), tris.len())].into_iter().collect() };
|
||||
return Ok(BodyMeasure {
|
||||
volume: mm.volume,
|
||||
area: mm.area,
|
||||
centroid: mm.centroid,
|
||||
bbox: m.bounds(),
|
||||
faces: tris.len(),
|
||||
edges,
|
||||
vertices: pos.len(),
|
||||
shells: 1,
|
||||
merged,
|
||||
});
|
||||
}
|
||||
let size = b.size();
|
||||
let coarse = b.tessellate((size * 1e-3).max(1e-3))?;
|
||||
// Chord error relative to a radius sets the volume error: keep it near 3e-4 of the
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Mesh bodies: triangles without a B-rep (imported 3MF/STL). They render, measure, move and
|
||||
//! export; B-rep features refuse them.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use solvecraft_geom::{Mesh, Vec3};
|
||||
|
||||
use crate::body::Body;
|
||||
use crate::{KernelError, Result};
|
||||
|
||||
/// Most triangles in one mesh body.
|
||||
pub const MAX_TRIANGLES: usize = 20_000_000;
|
||||
|
||||
/// Indexed triangles with shared vertices, wound counter-clockwise seen from outside.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct TriMesh {
|
||||
pub positions: Vec<Vec3>,
|
||||
pub triangles: Vec<[u32; 3]>,
|
||||
}
|
||||
|
||||
impl TriMesh {
|
||||
/// Display/measure mesh: corners split per triangle so normals stay flat.
|
||||
pub(crate) fn to_mesh(&self) -> Mesh {
|
||||
let mut out = Mesh::default();
|
||||
out.positions.reserve(self.triangles.len() * 3);
|
||||
for t in &self.triangles {
|
||||
let (Some(a), Some(b), Some(c)) =
|
||||
(self.positions.get(t[0] as usize), self.positions.get(t[1] as usize), self.positions.get(t[2] as usize))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let n = (*b - *a).cross(*c - *a).normalized().unwrap_or(Vec3::Z);
|
||||
let base = u32::try_from(out.positions.len()).unwrap_or(u32::MAX);
|
||||
out.positions.extend([*a, *b, *c]);
|
||||
out.normals.extend([n, n, n]);
|
||||
out.triangles.push([base, base.saturating_add(1), base.saturating_add(2)]);
|
||||
out.tri_face.push(0);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Distinct undirected triangle sides.
|
||||
pub(crate) fn edge_count(&self) -> usize {
|
||||
let mut set = std::collections::HashSet::new();
|
||||
for t in &self.triangles {
|
||||
for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
|
||||
set.insert((a.min(b), a.max(b)));
|
||||
}
|
||||
}
|
||||
set.len()
|
||||
}
|
||||
|
||||
/// Signed volume ×6.
|
||||
fn volume6(&self) -> f64 {
|
||||
self.triangles
|
||||
.iter()
|
||||
.filter_map(|t| Some((self.positions.get(t[0] as usize)?, self.positions.get(t[1] as usize)?, self.positions.get(t[2] as usize)?)))
|
||||
.map(|(a, b, c)| a.dot(b.cross(*c)))
|
||||
.sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// A mesh body from triangles (`positions` in mm). Coincident vertices are merged, degenerate
|
||||
/// triangles dropped, and a closed mesh with inward winding is turned outward.
|
||||
pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result<Body> {
|
||||
if triangles.len() > MAX_TRIANGLES {
|
||||
return Err(KernelError::Invalid(format!("mesh with {} triangles (limit {MAX_TRIANGLES})", triangles.len())));
|
||||
}
|
||||
if positions.iter().any(|p| !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9)) {
|
||||
return Err(KernelError::Invalid("mesh with non-finite or huge coordinates".into()));
|
||||
}
|
||||
// Merge vertices that are exactly equal (bit pattern of the coordinates).
|
||||
let mut map: HashMap<[u64; 3], u32> = HashMap::new();
|
||||
let mut remap: Vec<u32> = Vec::with_capacity(positions.len());
|
||||
let mut out = TriMesh::default();
|
||||
for p in positions {
|
||||
let key = [(p.x + 0.0).to_bits(), (p.y + 0.0).to_bits(), (p.z + 0.0).to_bits()];
|
||||
let next = u32::try_from(out.positions.len()).map_err(|_| KernelError::Invalid("too many vertices".into()))?;
|
||||
let i = *map.entry(key).or_insert_with(|| {
|
||||
out.positions.push(*p);
|
||||
next
|
||||
});
|
||||
remap.push(i);
|
||||
}
|
||||
for t in triangles {
|
||||
let (Some(&a), Some(&b), Some(&c)) = (remap.get(t[0] as usize), remap.get(t[1] as usize), remap.get(t[2] as usize)) else {
|
||||
return Err(KernelError::Invalid("mesh triangle refers to a missing vertex".into()));
|
||||
};
|
||||
if a == b || b == c || c == a {
|
||||
continue;
|
||||
}
|
||||
out.triangles.push([a, b, c]);
|
||||
}
|
||||
if out.triangles.is_empty() {
|
||||
return Err(KernelError::Invalid("mesh has no triangles".into()));
|
||||
}
|
||||
if out.volume6() < 0.0 && is_closed(&out) {
|
||||
for t in &mut out.triangles {
|
||||
t.swap(1, 2);
|
||||
}
|
||||
}
|
||||
Ok(Body { solid: Arc::new(crate::body::Solid::new_unchecked(Vec::new())), mesh: Some(Arc::new(out)), color: None })
|
||||
}
|
||||
|
||||
/// Every triangle side is shared by exactly two triangles in opposite directions.
|
||||
fn is_closed(m: &TriMesh) -> bool {
|
||||
let mut count: HashMap<(u32, u32), i32> = HashMap::new();
|
||||
for t in &m.triangles {
|
||||
for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
|
||||
*count.entry((a, b)).or_default() += 1;
|
||||
}
|
||||
}
|
||||
count.iter().all(|(&(a, b), &n)| n == 1 && count.get(&(b, a)) == Some(&1))
|
||||
}
|
||||
|
||||
impl Body {
|
||||
/// Shared-vertex triangles of a mesh body.
|
||||
pub fn triangle_mesh(&self) -> Option<(&[Vec3], &[[u32; 3]])> {
|
||||
self.mesh.as_ref().map(|m| (m.positions.as_slice(), m.triangles.as_slice()))
|
||||
}
|
||||
/// Does a mesh body enclose a volume (every side shared by two triangles)?
|
||||
pub fn is_closed_mesh(&self) -> bool {
|
||||
self.mesh.as_ref().is_some_and(|m| is_closed(m))
|
||||
}
|
||||
|
||||
/// A mesh body moved by a column-major affine matrix (reflections keep outward winding).
|
||||
pub(crate) fn mesh_transformed(&self, m: &[[f64; 4]; 4]) -> Option<Body> {
|
||||
let tm = self.mesh.as_ref()?;
|
||||
let f = |p: Vec3| {
|
||||
Vec3::new(
|
||||
m[0][0] * p.x + m[1][0] * p.y + m[2][0] * p.z + m[3][0],
|
||||
m[0][1] * p.x + m[1][1] * p.y + m[2][1] * p.z + m[3][1],
|
||||
m[0][2] * p.x + m[1][2] * p.y + m[2][2] * p.z + m[3][2],
|
||||
)
|
||||
};
|
||||
let det = m[0][0] * (m[1][1] * m[2][2] - m[2][1] * m[1][2]) - m[1][0] * (m[0][1] * m[2][2] - m[2][1] * m[0][2])
|
||||
+ m[2][0] * (m[0][1] * m[1][2] - m[1][1] * m[0][2]);
|
||||
let mut out = TriMesh { positions: tm.positions.iter().map(|p| f(*p)).collect(), triangles: tm.triangles.clone() };
|
||||
if det < 0.0 {
|
||||
for t in &mut out.triangles {
|
||||
t.swap(1, 2);
|
||||
}
|
||||
}
|
||||
Some(Body { solid: self.solid.clone(), mesh: Some(Arc::new(out)), color: self.color })
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,8 @@ fn mismatch(a: &solvecraft_geom::Mesh, b: &solvecraft_geom::Mesh, result: &solve
|
||||
/// 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;
|
||||
@@ -157,6 +159,9 @@ 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],
|
||||
@@ -171,6 +176,25 @@ pub fn transform(body: &Body, translate: Vec3, origin: Vec3, axis: Vec3, angle:
|
||||
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 {
|
||||
@@ -187,6 +211,7 @@ pub fn transform(body: &Body, translate: Vec3, origin: Vec3, axis: Vec3, angle:
|
||||
/// 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() {
|
||||
|
||||
@@ -222,6 +222,8 @@ fn planar_polys(b: &Body) -> Option<Vec<Poly>> {
|
||||
|
||||
/// Boolean of two all-planar bodies.
|
||||
pub fn planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
a.require_brep("a boolean")?;
|
||||
b.require_brep("a boolean")?;
|
||||
let (Some(pa), Some(pb)) = (planar_polys(a), planar_polys(b)) else {
|
||||
return Err(KernelError::Failed("not supported yet: coincident faces on curved bodies".into()));
|
||||
};
|
||||
|
||||
@@ -101,6 +101,7 @@ pub fn convex_polyhedron(hs: &[HalfSpace]) -> Result<Body> {
|
||||
|
||||
/// The half-spaces of a convex body with only planar faces, or an error.
|
||||
pub fn planar_convex(b: &Body) -> Result<Vec<(HalfSpace, Vec3)>> {
|
||||
b.require_brep("this operation")?;
|
||||
let tol = (b.size() * 1e-3).max(1e-3);
|
||||
let faces = b.faces(tol)?;
|
||||
let mut out: Vec<(HalfSpace, Vec3)> = Vec::new();
|
||||
@@ -122,6 +123,7 @@ pub fn planar_convex(b: &Body) -> Result<Vec<(HalfSpace, Vec3)>> {
|
||||
|
||||
/// Hollow a convex planar body: walls of `thickness` inside, faces near `open` removed.
|
||||
pub fn shell(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
|
||||
b.require_brep("shell")?;
|
||||
if !(thickness.is_finite() && thickness > 1e-6) {
|
||||
return Err(KernelError::Invalid("shell thickness must be positive".into()));
|
||||
}
|
||||
@@ -144,6 +146,7 @@ pub fn shell(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
|
||||
/// Tilt the faces near `faces` by `angle` about their line on the neutral plane, so that they
|
||||
/// lean in (positive angle) going along `pull`.
|
||||
pub fn draft(b: &Body, faces: &[Vec3], neutral: &Plane, pull: Vec3, angle: f64) -> Result<Body> {
|
||||
b.require_brep("draft")?;
|
||||
if !angle.is_finite() || angle.abs() >= std::f64::consts::FRAC_PI_2 - 1e-3 {
|
||||
return Err(KernelError::Invalid("draft angle must be between −90° and 90°".into()));
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ pub fn step_export(bodies: &[&Body], system: &str) -> Result<String> {
|
||||
if bodies.is_empty() {
|
||||
return Err(KernelError::Invalid("nothing to export".into()));
|
||||
}
|
||||
for b in bodies {
|
||||
b.require_brep("STEP export")?;
|
||||
}
|
||||
guard("step export", || {
|
||||
let cs: Vec<_> = bodies.iter().map(|b| b.solid.compress()).collect();
|
||||
let models: out::StepModels<_, _, _> = cs.iter().collect();
|
||||
|
||||
@@ -455,14 +455,8 @@ impl<'a> Reader<'a> {
|
||||
let own = e.params().first().and_then(Param::as_str).unwrap_or("").trim().to_string();
|
||||
let name = if own.is_empty() { product.to_string() } else { own };
|
||||
out.push(self.bodies.len());
|
||||
self.bodies.push(ImportedBody {
|
||||
name,
|
||||
body,
|
||||
color: self.colors.get(&item).copied(),
|
||||
path: path.to_vec(),
|
||||
closed,
|
||||
file_faces,
|
||||
});
|
||||
let color = self.colors.get(&item).copied();
|
||||
self.bodies.push(ImportedBody { name, body: body.with_color(color), color, path: path.to_vec(), closed, file_faces });
|
||||
}
|
||||
Err(err) => {
|
||||
let label = self.label(item);
|
||||
|
||||
@@ -599,3 +599,35 @@ fn debug_cube_sphere_measure() {
|
||||
println!("face {} area {:.2} c {:?} planar {:?}", f.index, f.area, f.centroid, f.plane_normal.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_bodies_move_measure_and_refuse_solid_ops() {
|
||||
// Unit cube as 12 triangles with repeated corners (welded on construction).
|
||||
let c = |i: u32| Vec3::new((i & 1) as f64 * 10.0, ((i >> 1) & 1) as f64 * 10.0, ((i >> 2) & 1) as f64 * 10.0);
|
||||
let quads = [[0, 2, 3, 1], [4, 5, 7, 6], [0, 1, 5, 4], [2, 6, 7, 3], [0, 4, 6, 2], [1, 3, 7, 5]];
|
||||
let mut pos = Vec::new();
|
||||
let mut tris = Vec::new();
|
||||
for q in quads {
|
||||
for t in [[q[0], q[2], q[1]], [q[0], q[3], q[2]]] {
|
||||
let n = pos.len() as u32;
|
||||
pos.extend(t.iter().map(|i| c(*i)));
|
||||
tris.push([n, n + 1, n + 2]);
|
||||
}
|
||||
}
|
||||
let b = crate::mesh_body(&pos, &tris).unwrap();
|
||||
assert!(b.is_mesh() && b.is_closed_mesh());
|
||||
let m = measure(&b).unwrap();
|
||||
assert!((m.volume - 1000.0).abs() < 1e-9 && (m.area - 600.0).abs() < 1e-9, "{m:?}");
|
||||
assert_eq!((m.faces, m.edges, m.vertices), (12, 18, 8));
|
||||
let moved = crate::transform(&b, Vec3::new(5.0, 0.0, 0.0), Vec3::ZERO, Vec3::Z, 0.7).unwrap();
|
||||
assert!((measure(&moved).unwrap().volume - 1000.0).abs() < 1e-9);
|
||||
let mirrored = crate::transform_matrix(&b, [[-1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]).unwrap();
|
||||
assert!((measure(&mirrored).unwrap().volume - 1000.0).abs() < 1e-9);
|
||||
let solid = box_solid(Vec3::ZERO, Vec3::new(5.0, 5.0, 5.0)).unwrap();
|
||||
assert!(fillet(&b, &[Vec3::new(5.0, 0.0, 0.0)], 1.0).unwrap_err().to_string().contains("mesh"));
|
||||
assert!(boolean(&solid, &b, BoolOp::Union).is_err());
|
||||
assert!(crate::step_export(&[&b], "t").is_err());
|
||||
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());
|
||||
}
|
||||
Reference in new issue
Block a user