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 <noreply@anthropic.com> 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
@@ -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 })
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user