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:
bherbruckandClaude Opus 5.5 committed 2026-10-07 21:30:38 +00:00
1 parent 043a542e29
commit ea3dd0ed6e
30 files changed
+1361 -47

No files matched your search

+1
View File
@@ -30,6 +30,7 @@ Generated-in-code assets are original and have no file to list:
| crates/ui-egui/src/gpu.rs (paint-callback structure) | CADCraft `crates/ui-egui/src/gpu.rs` | MIT OR Apache-2.0 |
| crates/mcp/src/server.rs, crates/mcp/src/backend.rs (JSON-RPC framing, MCP lifecycle, control-port bridge) | CADCraft `crates/mcp/src/{server,backend}.rs` | MIT OR Apache-2.0 |
| crates/mcp/src/tools.rs (tool schema helpers and argument checking) | CADCraft `crates/mcp/src/tools.rs`, GridCraft `crates/mcp/src/tools.rs` | MIT OR Apache-2.0 |
| crates/io/src/threemf.rs (bounded zip part reading and writing) | GridCraft `crates/xlsx/src/package.rs` | MIT OR Apache-2.0 |
| LICENSE-MIT, LICENSE-APACHE (licence text) | CADCraft | — |
## Vendored third-party code
Generated
+57
View File
@@ -183,6 +183,15 @@ dependencies = [
"num-traits",
]
[[package]]
name = "arbitrary"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arboard"
version = "3.6.1"
@@ -774,6 +783,17 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "derive_arbitrary"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "derive_more"
version = "0.99.20"
@@ -2574,6 +2594,15 @@ dependencies = [
"serde",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
dependencies = [
"memchr 2.8.3",
]
[[package]]
name = "quick-xml"
version = "0.41.0"
@@ -3115,10 +3144,12 @@ dependencies = [
name = "solvecraft-io"
version = "0.1.0"
dependencies = [
"quick-xml 0.38.4",
"solvecraft-doc",
"solvecraft-geom",
"solvecraft-kernel",
"thiserror 2.0.21",
"zip",
]
[[package]]
@@ -4629,6 +4660,20 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "zip"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308"
dependencies = [
"arbitrary",
"crc32fast",
"flate2",
"indexmap",
"memchr 2.8.3",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"
@@ -4641,6 +4686,18 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zune-core"
version = "0.5.3"
+4
View File
@@ -37,6 +37,10 @@ truck-meshalgo = { version = "0.4", default-features = false, features = ["tesse
truck-stepio = { version = "0.3", default-features = false }
truck-topology = "0.6"
# packages (3MF)
zip = { version = "3", default-features = false, features = ["deflate"] }
quick-xml = "0.38"
# images
image = { version = "0.25", default-features = false, features = ["png"] }
+4 -2
View File
@@ -29,7 +29,9 @@ storytold "craft" family.
changed feature; it can be rolled back, and features can be suppressed, renamed, edited and
deleted.
- **Parameters** with unit-aware expressions (`2 * width + 5 mm`, `angle / 2`).
- **Files**: designs as JSON (`.solvecraft`), export to STEP, STL (binary/ASCII) and OBJ;
- **Files**: designs as JSON (`.solvecraft`), export to STEP, 3MF, STL (binary/ASCII) and OBJ;
3MF and STL import as mesh bodies (they render, measure, move and export; solid features need
B-rep bodies);
STEP import (AP203/AP214/AP242 solids, assemblies, units, names, colours) as a base feature
that later features build on — open a `.step`/`.stp` file, insert one into a design, or drop it
on the window ([docs/step-import.md](docs/step-import.md)).
@@ -88,7 +90,7 @@ configuration and an example session: [docs/mcp.md](docs/mcp.md).
| L0 | `geom` | vectors, planes, profiles, meshes and their measures |
| L1 | `sketch`, `kernel`, `render` | constraint solver and profiles; the B-rep kernel boundary (truck); view math and CPU rasterizer |
| L2 | `doc` | parameters, expressions, the feature timeline and its incremental evaluation |
| L3 | `io` | design files, STL/OBJ/STEP export, STEP import features |
| L3 | `io` | design files, STL/OBJ/STEP/3MF export, STEP and 3MF/STL import features |
| L4 | `engine` | the session and the command registry (everything is a command) |
| L5 | `ui-egui`, `mcp` | the swappable desktop front end; the MCP server (headless or bridged to the app) |
| apps | `solvecraft`, `solvecraft-cli` | desktop app, headless CLI |
+1 -1
View File
@@ -23,7 +23,7 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
| M1 | Sketch depth, oracle sketch cases (arcs, taper, two-sided, sketch modify tools) | in progress | 16 |
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
| M3 | General fillets and chamfers (curved edges, chains, vertex blends) | planned | 30 |
| M4 | Files: STEP import (done: all 29 Fusion STEP files — [docs/step-import.md](docs/step-import.md)), mesh import, DXF sketches, 3MF | in progress | 10 |
| M4 | Files: STEP import (done: all 29 Fusion STEP files — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), DXF sketches, OBJ import | in progress | 6 |
| M5 | Persistent naming of faces and edges | planned | 20 |
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references), live previews, measure tool, section view, drag-solve | in progress | 16 |
| M7 | Sweep, loft, rib, web, emboss, thread, coil, pipe | planned | 24 |
+3 -3
View File
@@ -1,7 +1,7 @@
//! SolveCraft headless command line.
//!
//! ```text
//! solvecraft-cli run <script.json|design.solvecraft|part.step> [--in design|part.step] [--out FILE]... [--save FILE] [--quiet]
//! solvecraft-cli run <script.json|design.solvecraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet]
//! solvecraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect) measurements as JSON
//! solvecraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|top|…]
//! solvecraft-cli exec <command> [json-params] run one command on an empty design
@@ -78,10 +78,10 @@ pub fn read_json(path: &str) -> Result<Value, String> {
serde_json::from_str(&s).map_err(|e| format!("{path}: {e}"))
}
/// A session from a design file, a STEP file, a command script, or an oracle recipe.
/// A session from a design file, a STEP/3MF/STL file, a command script, or an oracle recipe.
pub fn load(path: &str) -> Result<Session, String> {
let mut s = Session::default();
if solvecraft_engine::io::is_step_path(path) {
if solvecraft_engine::io::is_step_path(path) || solvecraft_engine::io::is_mesh_path(path) {
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
return Ok(s);
}
+4 -2
View File
@@ -2,7 +2,8 @@
//!
//! Usage: `solvecraft [--control <port>] [--sample] [design.solvecraft | part.step]`
//!
//! A `.step`/`.stp` file opens as a new design holding the file's bodies (an Import feature).
//! A `.step`/`.stp` file opens as a new design holding the file's bodies (an Import feature); a
//! `.3mf`/`.stl` file as a new design holding mesh bodies.
//!
//! `--control <port>` (or `SOLVECRAFT_CONTROL_PORT`) starts a localhost JSON-lines control
//! server: `{"id":1,"method":"engine.execute","params":{"command":"PrimitiveBox","params":{…}}}`.
@@ -37,9 +38,10 @@ fn services() -> Services {
Services {
pick_open: Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter("Designs and STEP", &["solvecraft", "step", "stp", "STEP", "STP"])
.add_filter("Designs, STEP and meshes", &["solvecraft", "step", "stp", "STEP", "STP", "3mf", "3MF", "stl", "STL"])
.add_filter("SolveCraft design", &["solvecraft"])
.add_filter("STEP", &["step", "stp", "STEP", "STP"])
.add_filter("Mesh (3MF, STL)", &["3mf", "3MF", "stl", "STL"])
.add_filter("All files", &["*"])
.pick_file()
.map(|p| p.to_string_lossy().to_string())
+22 -1
View File
@@ -291,6 +291,25 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
components: Vec<solvecraft_kernel::StepNode>,
},
/// A non-parametric base feature: mesh bodies imported from a 3MF or STL file (kept in the
/// design). Mesh bodies render, measure, move and export; solid features cannot use them.
MeshImport {
/// Source file name (display only).
file: String,
meshes: Vec<MeshData>,
},
}
/// Triangles of one imported mesh body (millimetres).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MeshData {
pub name: String,
/// x, y, z per vertex.
pub positions: Vec<f64>,
/// Three vertex indices per triangle.
pub triangles: Vec<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<[f32; 3]>,
}
/// One loft section: profiles of a sketch.
@@ -391,6 +410,7 @@ impl FeatureKind {
FeatureKind::Split { .. } => "SplitBodyFeature",
FeatureKind::Move { .. } => "MoveFeature",
FeatureKind::Import { .. } => "BaseFeature",
FeatureKind::MeshImport { .. } => "MeshFeature",
}
}
/// Default name prefix (`Extrude` → `Extrude1`).
@@ -418,6 +438,7 @@ impl FeatureKind {
FeatureKind::Split { .. } => "Split",
FeatureKind::Move { .. } => "Move",
FeatureKind::Import { .. } => "Import",
FeatureKind::MeshImport { .. } => "Mesh",
}
}
/// Every expression the feature uses.
@@ -478,7 +499,7 @@ impl FeatureKind {
v.push(a);
}
}
FeatureKind::Import { .. } => {}
FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } => {}
}
v
}
+26 -1
View File
@@ -569,7 +569,13 @@ fn blend_target(state: &ModelState, body: &Option<String>, edges: &[Vec3]) -> Re
best = Some((i, d));
}
}
best.map(|(i, _)| i).ok_or_else(|| DocError::Invalid("there is no body to modify".into()))
best.map(|(i, _)| i).ok_or_else(|| {
if state.bodies.iter().any(|b| b.body.is_mesh()) {
DocError::Invalid("there is no solid body to modify (mesh bodies have no edges)".into())
} else {
DocError::Invalid("there is no body to modify".into())
}
})
}
fn revolve_axis(ss: &SolvedSketch, axis: &AxisRef) -> Result<(Vec2, Vec2)> {
@@ -1100,6 +1106,13 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
Ok(())
}
FeatureKind::MeshImport { meshes, .. } => {
for (name, b) in mesh_bodies(meshes)? {
let name = unique_body_name(st, &name);
st.bodies.push(ModelBody::new(name, b, f.id));
}
Ok(())
}
FeatureKind::Import { step, .. } => {
let imp = kernel::step_import_shared(step)?;
for (k, b) in imp.bodies.iter().enumerate() {
@@ -1118,6 +1131,18 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
}
/// Mesh bodies of a MeshImport feature.
fn mesh_bodies(meshes: &[crate::MeshData]) -> Result<Vec<(String, Body)>> {
let mut out = Vec::with_capacity(meshes.len());
for m in meshes {
let positions: Vec<Vec3> = m.positions.as_chunks::<3>().0.iter().map(|c| Vec3::new(c[0], c[1], c[2])).collect();
let triangles: Vec<[u32; 3]> = m.triangles.as_chunks::<3>().0.to_vec();
let b = kernel::mesh_body(&positions, &triangles).map_err(|e| DocError::Invalid(format!("mesh `{}`: {e}", m.name)))?;
out.push((m.name.clone(), b.with_color(m.color)));
}
Ok(out)
}
/// `base`, or `base (2)`, `base (3)`… if a body already has that name.
fn unique_body_name(st: &ModelState, base: &str) -> String {
let base = if base.trim().is_empty() { "Body" } else { base.trim() };
+40 -3
View File
@@ -19,12 +19,16 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "INSERT")
.icon("import")
.params("path: .step/.stp file (its bodies join the design as an Import base feature); name?"),
CommandSpec::new("ParaMeshInsertAlignCommand", "Insert Mesh", insert_mesh)
.at("SOLID", "INSERT")
.icon("import")
.params("path: .3mf or .stl file (its meshes join the design as mesh bodies); name?"),
CommandSpec::new("SaveDocumentCommand", "Save", save).icon("save").key("Ctrl+S").noundo().params("path? (default: current file)"),
CommandSpec::new("SaveDocumentAsCommand", "Save As", save_as).icon("save").noundo().params("path"),
CommandSpec::new("ExportCommand", "Export", export)
.icon("export")
.noundo()
.params("path; format?: stl|stla|obj|step (default from extension); bodies?: [names]"),
.params("path; format?: stl|stla|obj|step|3mf (default from extension); bodies?: [names]"),
CommandSpec::new("FusionSaveAsSTLCommand", "Save As Mesh", save_stl).icon("export").noundo().params("path; bodies?: [names]; ascii?: bool"),
];
@@ -67,6 +71,39 @@ fn add_step(s: &mut Session, path: &str, name: Option<&str>) -> Result<Value> {
Ok(json!({"path": path, "feature": id, "name": fname, "bodies": bodies, "warnings": sf.warnings}))
}
/// Add a MeshImport feature for a 3MF or STL file.
fn add_mesh(s: &mut Session, path: &str, name: Option<&str>) -> Result<Value> {
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta.len() > solvecraft_io::MAX_3MF_BYTES as u64 {
return Err(EngineError::Other(format!("{path}: file too large")));
}
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let mf = solvecraft_io::mesh_import_feature(&bytes, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let fname = name.filter(|n| !n.trim().is_empty()).unwrap_or(&mf.name).to_string();
let id = s.doc_mut().add_feature(mf.kind, Some(&fname))?;
if let Some(f) = s.doc_mut().feature_mut(id) {
f.body_names = mf.body_names;
}
s.active_sketch = None;
s.refresh();
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
return Err(EngineError::Other(e));
}
let st = s.model.state();
let bodies: Vec<&str> = st.bodies.iter().filter(|b| b.feature == id).map(|b| b.name.as_str()).collect();
let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
Ok(json!({"path": path, "feature": id, "name": fname, "bodies": bodies, "warnings": mf.warnings}))
}
fn insert_mesh(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ParaMeshInsertAlignCommand";
let path = path_arg(p, cmd)?;
if !solvecraft_io::is_mesh_path(path) {
return Err(bad(cmd, "only 3MF and STL files (.3mf, .stl) can be inserted as meshes"));
}
add_mesh(s, path, str_(p, "name"))
}
fn insert_step(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionImportCommandFromToolbar";
let path = path_arg(p, cmd)?;
@@ -78,11 +115,11 @@ fn insert_step(s: &mut Session, p: &Value) -> Result<Value> {
fn open(s: &mut Session, p: &Value) -> Result<Value> {
let path = path_arg(p, "doc.open")?;
if solvecraft_io::is_step_path(path) {
if solvecraft_io::is_step_path(path) || solvecraft_io::is_mesh_path(path) {
// A new, unsaved design holding the file's bodies.
let stem = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled".into());
let mut fresh = Session::new(Document::new(&stem));
let r = add_step(&mut fresh, path, None)?;
let r = if solvecraft_io::is_step_path(path) { add_step(&mut fresh, path, None)? } else { add_mesh(&mut fresh, path, None)? };
fresh.undo.clear();
*s = fresh;
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
+40
View File
@@ -548,3 +548,43 @@ fn extrude_a_body_face() {
assert!(rel(v - before, (1200.0 - PI * 25.0) * 10.0) < 1e-3, "{v} {before}");
assert!(s.execute("Extrude", &json!({"face": [500, 5, 20], "distance": 10})).is_err());
}
/// Export 3MF; open and insert it as mesh bodies; mesh bodies move and export but refuse
/// solid features.
#[test]
fn threemf_export_open_insert() {
let dir = std::env::temp_dir().join(format!("solvecraft-3mf-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 20}));
run(&mut s, "PrimitiveCylinder", json!({"radius": 5, "height": 10, "base": [100, 0, 0]}));
let p = dir.join("Parts.3MF");
run(&mut s, "ExportCommand", json!({"path": p.to_string_lossy()}));
let want = volume(&mut s);
let mut m = Session::default();
let r = run(&mut m, "doc.open", json!({"path": p.to_string_lossy()}));
assert_eq!(r["bodies"].as_array().unwrap().len(), 2, "{r}");
assert_eq!(m.doc.features[0].kind.type_name(), "MeshFeature");
assert!(rel(volume(&mut m), want) < 2e-3, "{} vs {want}", volume(&mut m));
let meas = run(&mut m, "MeasureCommand", json!({}));
assert!(meas["bodies"][0]["face_types"]["mesh"].as_u64().unwrap() >= 12, "{meas}");
let e = m.execute("FusionFilletEdgesCommand", &json!({"edges": [[0, 0, 10]], "radius": 2})).unwrap_err().to_string();
assert!(e.contains("mesh"), "{e}");
assert!(m.execute("ExportCommand", &json!({"path": dir.join("x.step").to_string_lossy()})).is_err());
let stl = dir.join("again.stl");
run(&mut m, "ExportCommand", json!({"path": stl.to_string_lossy()}));
// Insert the STL into the solid design; names stay unique.
run(&mut s, "ParaMeshInsertAlignCommand", json!({"path": stl.to_string_lossy()}));
assert_eq!(s.model.state().bodies.len(), 3);
assert!(rel(volume(&mut s), 2.0 * want) < 2e-3);
assert!(s.execute("ParaMeshInsertAlignCommand", &json!({"path": dir.join("x.step").to_string_lossy()})).is_err());
let junk = dir.join("junk.3mf");
std::fs::write(&junk, b"PK\x03\x04 not really").unwrap();
let before = s.doc.clone();
assert!(s.execute("ParaMeshInsertAlignCommand", &json!({"path": junk.to_string_lossy()})).is_err());
assert!(s.execute("doc.open", &json!({"path": junk.to_string_lossy()})).is_err());
assert_eq!(*s.doc, *before);
let _ = std::fs::remove_dir_all(&dir);
}
+3 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "solvecraft-io"
description = "SolveCraft file formats: designs (JSON), STL, OBJ and STEP export"
description = "SolveCraft file formats: designs (JSON), STL, OBJ, STEP and 3MF"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -13,3 +13,5 @@ solvecraft-geom = { workspace = true }
solvecraft-kernel = { workspace = true }
solvecraft-doc = { workspace = true }
thiserror = { workspace = true }
zip = { workspace = true }
quick-xml = { workspace = true }
+153 -4
View File
@@ -1,11 +1,16 @@
//! File formats. Designs are saved as JSON (`.solvecraft`): the parametric document only, the
//! model is recomputed on load. Bodies export as STL (binary or ASCII), OBJ and STEP; STEP files
//! import as a base feature (see [`step_import_feature`]).
//! model is recomputed on load. Bodies export as STL (binary or ASCII), OBJ, STEP and 3MF; STEP
//! files import as a base feature (see [`step_import_feature`]), 3MF and STL files as mesh bodies
//! (see [`mesh_import_feature`]).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod threemf;
pub use threemf::{MAX_3MF_BYTES, MeshObject, model_xml, read_3mf, weld, write_3mf};
use solvecraft_doc::{Document, ModelState};
use solvecraft_geom::Mesh;
use solvecraft_geom::{Mesh, Vec3};
/// Design file extension.
pub const DESIGN_EXT: &str = "solvecraft";
@@ -14,7 +19,7 @@ pub const MAX_DESIGN_BYTES: usize = 256 << 20;
#[derive(Debug, thiserror::Error)]
pub enum IoError {
#[error("unsupported format `{0}` (use stl, obj, step or solvecraft)")]
#[error("unsupported format `{0}` (use stl, obj, step, 3mf or solvecraft)")]
Format(String),
#[error("nothing to export: the design has no bodies")]
Empty,
@@ -76,6 +81,64 @@ pub fn step_import_feature(bytes: &[u8], file: &str) -> Result<StepFeature> {
})
}
/// Is this a mesh file we import (`.3mf`, `.stl`, any case)?
pub fn is_mesh_path(path: &str) -> bool {
std::path::Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("3mf") || e.eq_ignore_ascii_case("stl"))
}
/// A 3MF or STL file read as a mesh import feature.
pub struct MeshFeature {
/// Timeline name: the only mesh's name, else the file name.
pub name: String,
pub kind: solvecraft_doc::FeatureKind,
pub body_names: Vec<String>,
pub warnings: Vec<String>,
}
/// Read 3MF or STL bytes (the format from `file`'s extension) into a MeshImport feature.
pub fn mesh_import_feature(bytes: &[u8], file: &str) -> Result<MeshFeature> {
if bytes.len() > MAX_3MF_BYTES {
return Err(IoError::Invalid(format!("mesh file too large ({} MB)", bytes.len() >> 20)));
}
let path = std::path::Path::new(file);
let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.trim().is_empty()).unwrap_or_else(|| "Mesh".into());
let is_stl = path.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("stl"));
let (objects, warnings) = if is_stl {
let tris = read_stl(bytes)?;
if tris.is_empty() {
return Err(IoError::Invalid("STL has no triangles".into()));
}
let positions: Vec<Vec3> = tris.iter().flatten().map(|v| Vec3::new(v[0] as f64, v[1] as f64, v[2] as f64)).collect();
let n = u32::try_from(tris.len()).map_err(|_| IoError::Invalid("STL too large".into()))?;
let triangles = (0..n).map(|i| [i * 3, i * 3 + 1, i * 3 + 2]).collect();
(vec![MeshObject { name: stem.clone(), positions, triangles, color: None }], Vec::new())
} else {
read_3mf(bytes)?
};
// Check every mesh now, so the command fails rather than the timeline.
let mut meshes = Vec::with_capacity(objects.len());
for o in &objects {
solvecraft_kernel::mesh_body(&o.positions, &o.triangles).map_err(|e| IoError::Invalid(format!("mesh `{}`: {e}", o.name)))?;
meshes.push(solvecraft_doc::MeshData {
name: o.name.clone(),
positions: o.positions.iter().flat_map(|p| [p.x, p.y, p.z]).collect(),
triangles: o.triangles.iter().flatten().copied().collect(),
color: o.color,
});
}
let name = match objects.as_slice() {
[only] if !only.name.trim().is_empty() => only.name.clone(),
_ => stem,
};
let file_name = path.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| file.to_string());
Ok(MeshFeature {
name,
body_names: objects.iter().map(|o| o.name.clone()).collect(),
kind: solvecraft_doc::FeatureKind::MeshImport { file: file_name, meshes },
warnings,
})
}
/// Export formats.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Format {
@@ -83,6 +146,7 @@ pub enum Format {
StlAscii,
Obj,
Step,
ThreeMf,
Design,
}
@@ -95,6 +159,7 @@ impl Format {
"stla" | "stl-ascii" => Format::StlAscii,
"obj" => Format::Obj,
"step" | "stp" => Format::Step,
"3mf" => Format::ThreeMf,
"solvecraft" | "json" => Format::Design,
_ => return Err(IoError::Format(ext)),
})
@@ -188,6 +253,18 @@ pub fn export(state: &ModelState, bodies: &[String], format: Format, name: &str)
let bs: Vec<&solvecraft_kernel::Body> = sel.iter().map(|b| &b.body).collect();
solvecraft_kernel::step_export(&bs, "SolveCraft")?.into_bytes()
}
Format::ThreeMf => {
let objs: Vec<MeshObject> = sel
.iter()
.map(|b| {
let m = export_mesh(&b.body)?;
let eps = (b.body.size() * 1e-6).max(1e-7);
let (positions, triangles) = threemf::weld(&m.positions, &m.triangles, eps);
Ok(MeshObject { name: b.name.clone(), positions, triangles, color: b.body.color() })
})
.collect::<Result<_>>()?;
write_3mf(&objs)?
}
Format::Design => return Err(IoError::Format("solvecraft (save the document instead)".into())),
})
}
@@ -301,4 +378,76 @@ mod tests {
fake[80..84].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(read_stl(&fake).is_err());
}
fn stl_volume(bytes: &[u8]) -> f64 {
read_stl(bytes)
.unwrap()
.iter()
.map(|t| {
let p = |k: usize| Vec3::new(t[k][0] as f64, t[k][1] as f64, t[k][2] as f64);
p(0).dot(p(1).cross(p(2))) / 6.0
})
.sum()
}
/// Build → export 3MF → import → the same volume as the STL export, closed meshes, names.
#[test]
fn threemf_round_trip() {
let mut doc = Document::new("t");
doc.add_feature(
FeatureKind::Box { corner: Vec3::ZERO, length: "40".into(), width: "30".into(), height: "10".into(), operation: Operation::NewBody },
None,
)
.unwrap();
doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(0.0, 0.0, 5.0)], radius: "4".into(), body: None }, None).unwrap();
doc.add_feature(
FeatureKind::Cylinder {
base: Vec3::new(80.0, 0.0, 0.0),
axis: Vec3::Z,
radius: "6".into(),
height: "15".into(),
operation: Operation::NewBody,
},
None,
)
.unwrap();
doc.add_feature(
FeatureKind::Torus { center: Vec3::new(0.0, 80.0, 0.0), major: "12".into(), minor: "3".into(), operation: Operation::NewBody },
None,
)
.unwrap();
let mut m = Model::new();
m.evaluate(&doc);
let st = m.state();
assert_eq!(st.bodies.len(), 3);
let bytes = export(&st, &[], Format::ThreeMf, "t").unwrap();
let f = mesh_import_feature(&bytes, "/tmp/parts.3MF").unwrap();
assert_eq!(f.body_names, st.bodies.iter().map(|b| b.name.clone()).collect::<Vec<_>>());
let mut d2 = Document::new("m");
let id = d2.add_feature(f.kind, Some(&f.name)).unwrap();
let mut m2 = Model::new();
m2.evaluate(&d2);
assert!(m2.result(id).unwrap().error.is_none());
let st2 = m2.state();
assert_eq!(st2.bodies.len(), 3);
for (a, b) in st.bodies.iter().zip(&st2.bodies) {
assert_eq!(a.name, b.name);
assert!(b.body.is_mesh() && b.body.is_closed_mesh(), "{} is not watertight", b.name);
let v_stl = stl_volume(&export(&st, std::slice::from_ref(&a.name), Format::StlBinary, "t").unwrap());
let v = solvecraft_kernel::measure(&b.body).unwrap().volume;
assert!((v - v_stl).abs() <= 1e-6 * v_stl.abs(), "{}: {v} vs {v_stl}", a.name);
}
// Mesh bodies export again (3MF, STL, OBJ) but not as STEP.
assert!(export(&st2, &[], Format::ThreeMf, "t").is_ok());
assert!(export(&st2, &[], Format::StlAscii, "t").is_ok());
assert!(export(&st2, &[], Format::Obj, "t").is_ok());
assert!(export(&st2, &[], Format::Step, "t").is_err());
// STL imports the same way.
let stl = export(&st, &[], Format::StlBinary, "t").unwrap();
let fs = mesh_import_feature(&stl, "all.stl").unwrap();
assert_eq!(fs.name, "all");
assert!(mesh_import_feature(b"PK", "x.3mf").is_err());
assert!(mesh_import_feature(&[0u8; 84], "x.stl").is_err());
assert!(is_mesh_path("a.3MF") && is_mesh_path("b.stl") && !is_mesh_path("c.step"));
}
}
+676
View File
@@ -0,0 +1,676 @@
//! 3MF (3D Manufacturing Format, Core specification 1.3) packages: a zip with
//! `[Content_Types].xml`, `_rels/.rels` and the model part `3D/3dmodel.model`.
//!
//! Writing: one mesh object per body (shared vertices, outward winding), named after the body,
//! one build item each, colours as base materials. Reading: every build item becomes one mesh
//! (components flattened with their transforms), converted to millimetres. Packages are
//! untrusted: sizes, entry counts, mesh sizes and component nesting are capped.
use std::collections::HashMap;
use std::fmt::Write as _;
use std::io::{Cursor, Read, Write};
use quick_xml::Reader;
use quick_xml::events::{BytesStart, Event};
use solvecraft_geom::Vec3;
use crate::{IoError, Result};
/// Largest 3MF file we read.
pub const MAX_3MF_BYTES: usize = 512 << 20;
/// Largest uncompressed part we read (zip bombs stop here).
const MAX_PART: u64 = 1 << 30;
const MAX_ENTRIES: usize = 10_000;
/// Most vertices or triangles in one file.
const MAX_ELEMENTS: usize = 20_000_000;
const MAX_COMPONENT_DEPTH: usize = 16;
const MAX_XML_DEPTH: usize = 256;
const CORE_NS: &str = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02";
const MODEL_REL: &str = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel";
/// A named triangle mesh (millimetres, counter-clockwise seen from outside).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct MeshObject {
pub name: String,
pub positions: Vec<Vec3>,
pub triangles: Vec<[u32; 3]>,
pub color: Option<[f32; 3]>,
}
fn esc(s: &str) -> String {
let mut o = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => o.push_str("&amp;"),
'<' => o.push_str("&lt;"),
'>' => o.push_str("&gt;"),
'"' => o.push_str("&quot;"),
'\'' => o.push_str("&apos;"),
c if (c as u32) < 0x20 && !matches!(c, '\t' | '\n' | '\r') => {}
c => o.push(c),
}
}
o
}
/// Shared vertices (merged at single precision, as written) and non-degenerate triangles.
fn indexed(m: &MeshObject) -> (Vec<[f32; 3]>, Vec<[u32; 3]>) {
let mut map: HashMap<[u32; 3], u32> = HashMap::new();
let mut verts: Vec<[f32; 3]> = Vec::new();
let mut remap = Vec::with_capacity(m.positions.len());
for p in &m.positions {
let v = [p.x as f32 + 0.0, p.y as f32 + 0.0, p.z as f32 + 0.0];
let key = v.map(f32::to_bits);
let next = u32::try_from(verts.len()).unwrap_or(u32::MAX);
let i = *map.entry(key).or_insert_with(|| {
verts.push(v);
next
});
remap.push(i);
}
let tris = m
.triangles
.iter()
.filter_map(|t| {
let (a, b, c) = (*remap.get(t[0] as usize)?, *remap.get(t[1] as usize)?, *remap.get(t[2] as usize)?);
(a != b && b != c && c != a).then_some([a, b, c])
})
.collect();
(verts, tris)
}
/// Make a tessellation watertight: merge vertices closer than `eps` and split triangles at
/// vertices lying on their open sides (T-junctions between separately meshed faces).
pub fn weld(positions: &[Vec3], triangles: &[[u32; 3]], eps: f64) -> (Vec<Vec3>, Vec<[u32; 3]>) {
// Merge on a grid of cell `eps` (a vertex joins one already in its own or a neighbour cell).
let cell = |p: Vec3| [(p.x / eps).round() as i64, (p.y / eps).round() as i64, (p.z / eps).round() as i64];
let mut grid: HashMap<[i64; 3], Vec<u32>> = HashMap::new();
let mut verts: Vec<Vec3> = Vec::new();
let mut remap = Vec::with_capacity(positions.len());
for p in positions {
let c = cell(*p);
let mut found = None;
'search: for dx in -1..=1 {
for dy in -1..=1 {
for dz in -1..=1 {
for &i in grid.get(&[c[0] + dx, c[1] + dy, c[2] + dz]).into_iter().flatten() {
if verts.get(i as usize).is_some_and(|q| q.dist(*p) <= eps) {
found = Some(i);
break 'search;
}
}
}
}
}
let i = match found {
Some(i) => i,
None => {
let i = u32::try_from(verts.len()).unwrap_or(u32::MAX);
verts.push(*p);
grid.entry(c).or_default().push(i);
i
}
};
remap.push(i);
}
let mut tris: Vec<[u32; 3]> = triangles
.iter()
.filter_map(|t| {
let (a, b, c) = (*remap.get(t[0] as usize)?, *remap.get(t[1] as usize)?, *remap.get(t[2] as usize)?);
(a != b && b != c && c != a).then_some([a, b, c])
})
.collect();
// T-junctions: split open sides at open-side vertices lying on them.
for _ in 0..8 {
let mut half: HashMap<(u32, u32), usize> = HashMap::new();
for t in &tris {
for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
*half.entry((a, b)).or_default() += 1;
}
}
let open: Vec<(u32, u32)> = half.keys().filter(|(a, b)| !half.contains_key(&(*b, *a))).copied().collect();
if open.is_empty() || open.len() > 200_000 {
break;
}
let mut cand: Vec<u32> = open.iter().flat_map(|(a, b)| [*a, *b]).collect();
cand.sort_unstable();
cand.dedup();
// Vertices on each open side, ordered from its start.
let mut splits: HashMap<(u32, u32), Vec<(f64, u32)>> = HashMap::new();
for &(a, b) in &open {
let (Some(pa), Some(pb)) = (verts.get(a as usize), verts.get(b as usize)) else { continue };
let d = *pb - *pa;
let l2 = d.dot(d);
if !(l2 > 0.0) {
continue;
}
let mut on: Vec<(f64, u32)> = cand
.iter()
.filter(|v| **v != a && **v != b)
.filter_map(|v| {
let p = *verts.get(*v as usize)?;
let t = (p - *pa).dot(d) / l2;
(t > 1e-9 && t < 1.0 - 1e-9 && (*pa + d * t).dist(p) <= eps * 4.0).then_some((t, *v))
})
.collect();
if !on.is_empty() {
on.sort_by(|x, y| x.0.total_cmp(&y.0));
splits.insert((a, b), on);
}
}
if splits.is_empty() {
break;
}
let mut next = Vec::with_capacity(tris.len() + splits.len() * 2);
for t in &tris {
// Rotate so the split side (if any) is (t0, t1); split one side per pass.
let rot = [[t[0], t[1], t[2]], [t[1], t[2], t[0]], [t[2], t[0], t[1]]];
match rot.iter().find_map(|r| splits.get(&(r[0], r[1])).map(|s| (r, s))) {
Some((r, s)) => {
let mut prev = r[0];
for (_, v) in s {
next.push([prev, *v, r[2]]);
prev = *v;
}
next.push([prev, r[1], r[2]]);
}
None => next.push(*t),
}
}
tris = next;
}
(verts, tris)
}
/// The model part's XML.
pub fn model_xml(objects: &[MeshObject]) -> String {
let mut x = String::new();
x.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
let _ = writeln!(x, "<model unit=\"millimeter\" xml:lang=\"en-US\" xmlns=\"{CORE_NS}\">");
x.push_str(" <metadata name=\"Application\">SolveCraft</metadata>\n <resources>\n");
let colored = objects.iter().any(|o| o.color.is_some());
// Base material group id 1; objects from 2.
if colored {
x.push_str(" <basematerials id=\"1\">\n");
for o in objects {
let c = o.color.unwrap_or([0.7, 0.7, 0.7]).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8);
let _ = writeln!(x, " <base name=\"{}\" displaycolor=\"#{:02X}{:02X}{:02X}\"/>", esc(&o.name), c[0], c[1], c[2]);
}
x.push_str(" </basematerials>\n");
}
for (k, o) in objects.iter().enumerate() {
let id = k + 2;
let mat = if colored { format!(" pid=\"1\" pindex=\"{k}\"") } else { String::new() };
let _ = writeln!(x, " <object id=\"{id}\" type=\"model\" name=\"{}\"{mat}>", esc(&o.name));
x.push_str(" <mesh>\n <vertices>\n");
let (verts, tris) = indexed(o);
for v in &verts {
let _ = writeln!(x, " <vertex x=\"{}\" y=\"{}\" z=\"{}\"/>", v[0], v[1], v[2]);
}
x.push_str(" </vertices>\n <triangles>\n");
for t in &tris {
let _ = writeln!(x, " <triangle v1=\"{}\" v2=\"{}\" v3=\"{}\"/>", t[0], t[1], t[2]);
}
x.push_str(" </triangles>\n </mesh>\n </object>\n");
}
x.push_str(" </resources>\n <build>\n");
for k in 0..objects.len() {
let _ = writeln!(x, " <item objectid=\"{}\"/>", k + 2);
}
x.push_str(" </build>\n</model>\n");
x
}
/// A 3MF package of meshes.
pub fn write_3mf(objects: &[MeshObject]) -> Result<Vec<u8>> {
if objects.is_empty() {
return Err(IoError::Empty);
}
let content_types = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">\n <Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>\n <Default Extension=\"model\" ContentType=\"application/vnd.ms-package.3dmanufacturing-3dmodel+xml\"/>\n</Types>\n";
let rels = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\n <Relationship Target=\"/3D/3dmodel.model\" Id=\"rel0\" Type=\"{MODEL_REL}\"/>\n</Relationships>\n"
);
let model = model_xml(objects);
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
for (name, data) in [("[Content_Types].xml", content_types.as_bytes()), ("_rels/.rels", rels.as_bytes()), ("3D/3dmodel.model", model.as_bytes())]
{
let opts = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated)
.last_modified_time(zip::DateTime::default())
.large_file(data.len() as u64 >= u32::MAX as u64);
zip.start_file(name, opts).map_err(|e| IoError::Invalid(format!("3MF: {e}")))?;
zip.write_all(data).map_err(|e| IoError::Invalid(format!("3MF: {e}")))?;
}
zip.finish().map(|c| c.into_inner()).map_err(|e| IoError::Invalid(format!("3MF: {e}")))
}
// ---------------------------------------------------------------- reading
fn bad<T>(msg: impl Into<String>) -> Result<T> {
Err(IoError::Invalid(format!("3MF: {}", msg.into())))
}
fn read_part(zip: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str) -> Result<Option<Vec<u8>>> {
let want = name.trim_start_matches('/').to_ascii_lowercase();
let Some(idx) =
(0..zip.len()).find(|i| zip.name_for_index(*i).is_some_and(|n| n.replace('\\', "/").trim_start_matches('/').to_ascii_lowercase() == want))
else {
return Ok(None);
};
let mut f = zip.by_index(idx).map_err(|e| IoError::Invalid(format!("3MF {name}: {e}")))?;
if f.size() > MAX_PART {
return bad(format!("{name} expands to {} bytes", f.size()));
}
let mut out = Vec::with_capacity(f.size().min(16 << 20) as usize);
(&mut f).take(MAX_PART + 1).read_to_end(&mut out).map_err(|e| IoError::Invalid(format!("3MF {name}: {e}")))?;
if out.len() as u64 > MAX_PART {
return bad(format!("{name} expands to more than 1 GB"));
}
Ok(Some(out))
}
fn attrs(e: &BytesStart) -> HashMap<String, String> {
let mut m = HashMap::new();
for a in e.attributes().flatten() {
let k = String::from_utf8_lossy(a.key.local_name().as_ref()).to_string();
let v = a.unescape_value().map(|v| v.to_string()).unwrap_or_else(|_| String::from_utf8_lossy(&a.value).to_string());
m.insert(k, v);
}
m
}
/// Row-vector affine transform `[m00 m01 m02 m10 … m32]` (3MF order).
type Xf = [f64; 12];
const IDENTITY: Xf = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0];
fn parse_xf(s: Option<&String>) -> Result<Xf> {
let Some(s) = s else { return Ok(IDENTITY) };
let v: Vec<f64> = s
.split_whitespace()
.map(|t| t.parse::<f64>().ok().filter(|x| x.is_finite() && x.abs() < 1e12))
.collect::<Option<_>>()
.ok_or_else(|| IoError::Invalid(format!("3MF: bad transform `{s}`")))?;
let mut m = IDENTITY;
if v.len() != 12 {
return bad(format!("transform with {} numbers", v.len()));
}
m.copy_from_slice(&v);
Ok(m)
}
fn apply(m: &Xf, p: Vec3) -> Vec3 {
Vec3::new(p.x * m[0] + p.y * m[3] + p.z * m[6] + m[9], p.x * m[1] + p.y * m[4] + p.z * m[7] + m[10], p.x * m[2] + p.y * m[5] + p.z * m[8] + m[11])
}
/// `a` then `b`.
fn compose(a: &Xf, b: &Xf) -> Xf {
let r = |i: usize| Vec3::new(a[i * 3], a[i * 3 + 1], a[i * 3 + 2]);
let lin = |v: Vec3| Vec3::new(v.x * b[0] + v.y * b[3] + v.z * b[6], v.x * b[1] + v.y * b[4] + v.z * b[7], v.x * b[2] + v.y * b[5] + v.z * b[8]);
let (x, y, z, t) = (lin(r(0)), lin(r(1)), lin(r(2)), apply(b, r(3)));
[x.x, x.y, x.z, y.x, y.y, y.z, z.x, z.y, z.z, t.x, t.y, t.z]
}
fn det(m: &Xf) -> f64 {
m[0] * (m[4] * m[8] - m[5] * m[7]) - m[1] * (m[3] * m[8] - m[5] * m[6]) + m[2] * (m[3] * m[7] - m[4] * m[6])
}
#[derive(Default)]
struct Object {
name: String,
positions: Vec<Vec3>,
triangles: Vec<[u32; 3]>,
components: Vec<(u32, Xf)>,
material: Option<(u32, usize)>,
}
#[derive(Default)]
struct Model {
unit: f64,
objects: HashMap<u32, Object>,
materials: HashMap<u32, Vec<Option<[f32; 3]>>>,
build: Vec<(u32, Xf)>,
warnings: Vec<String>,
}
fn color(s: &str) -> Option<[f32; 3]> {
let h = s.trim().strip_prefix('#')?;
if h.len() != 6 && h.len() != 8 {
return None;
}
let c = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).map(|v| v as f32 / 255.0);
Some([c(0)?, c(2)?, c(4)?])
}
fn unit_mm(u: &str) -> Option<f64> {
Some(match u {
"micron" => 1e-3,
"millimeter" => 1.0,
"centimeter" => 10.0,
"inch" => 25.4,
"foot" => 304.8,
"meter" => 1000.0,
_ => return None,
})
}
fn parse_model(xml: &[u8]) -> Result<Model> {
let mut rd = Reader::from_reader(xml);
rd.config_mut().trim_text(true);
let mut buf = Vec::new();
let mut m = Model { unit: 1.0, ..Default::default() };
let mut depth = 0usize;
let mut cur: Option<(u32, Object)> = None;
let mut cur_mat: Option<u32> = None;
let mut elements = 0usize;
let mut seen_model = false;
loop {
let ev = rd.read_event_into(&mut buf).map_err(|e| IoError::Invalid(format!("3MF model XML: {e}")))?;
let (e, empty) = match &ev {
Event::Start(e) => (Some(e.clone()), false),
Event::Empty(e) => (Some(e.clone()), true),
Event::End(end) => {
depth = depth.saturating_sub(1);
match end.local_name().as_ref() {
b"object" => {
if let Some((id, o)) = cur.take() {
m.objects.insert(id, o);
}
}
b"basematerials" => cur_mat = None,
_ => {}
}
(None, false)
}
Event::Eof => break,
_ => (None, false),
};
let Some(e) = e else {
buf.clear();
continue;
};
if !empty {
depth += 1;
if depth > MAX_XML_DEPTH {
return bad("XML nested too deeply");
}
}
elements += 1;
if elements > MAX_ELEMENTS * 2 {
return bad("model too large");
}
let a = attrs(&e);
let num = |k: &str| a.get(k).and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite());
let id = |k: &str| a.get(k).and_then(|v| v.trim().parse::<u32>().ok());
match e.local_name().as_ref() {
b"model" => {
seen_model = true;
if let Some(u) = a.get("unit") {
m.unit = unit_mm(u).ok_or_else(|| IoError::Invalid(format!("3MF: unknown unit `{u}`")))?;
}
}
b"basematerials" => {
let gid = id("id").ok_or_else(|| IoError::Invalid("3MF: base materials without id".into()))?;
m.materials.insert(gid, Vec::new());
cur_mat = if empty { None } else { Some(gid) };
}
b"base" => {
if let Some(g) = cur_mat.and_then(|g| m.materials.get_mut(&g)) {
g.push(a.get("displaycolor").and_then(|c| color(c)));
}
}
b"object" => {
let oid = id("id").ok_or_else(|| IoError::Invalid("3MF: object without id".into()))?;
let o = Object {
name: a.get("name").cloned().unwrap_or_default(),
material: id("pid").map(|p| (p, a.get("pindex").and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(0))),
..Default::default()
};
if empty {
m.objects.insert(oid, o);
} else {
cur = Some((oid, o));
}
}
b"vertex" => {
let Some((_, o)) = cur.as_mut() else { return bad("vertex outside an object") };
if o.positions.len() >= MAX_ELEMENTS {
return bad("too many vertices");
}
let (Some(x), Some(y), Some(z)) = (num("x"), num("y"), num("z")) else { return bad("vertex without x, y, z") };
o.positions.push(Vec3::new(x, y, z));
}
b"triangle" => {
let Some((_, o)) = cur.as_mut() else { return bad("triangle outside an object") };
if o.triangles.len() >= MAX_ELEMENTS {
return bad("too many triangles");
}
let (Some(a1), Some(a2), Some(a3)) = (id("v1"), id("v2"), id("v3")) else { return bad("triangle without v1, v2, v3") };
o.triangles.push([a1, a2, a3]);
}
b"component" => {
let Some((_, o)) = cur.as_mut() else { return bad("component outside an object") };
if a.keys().any(|k| k == "path") {
m.warnings.push("components in other model parts (production extension) are not read".into());
buf.clear();
continue;
}
let target = id("objectid").ok_or_else(|| IoError::Invalid("3MF: component without objectid".into()))?;
o.components.push((target, parse_xf(a.get("transform"))?));
}
b"item" => {
if a.keys().any(|k| k == "path") {
m.warnings.push("build items in other model parts (production extension) are not read".into());
buf.clear();
continue;
}
let target = id("objectid").ok_or_else(|| IoError::Invalid("3MF: build item without objectid".into()))?;
m.build.push((target, parse_xf(a.get("transform"))?));
}
_ => {}
}
buf.clear();
}
if !seen_model {
return bad("no <model> element");
}
Ok(m)
}
/// Append object `id` (and its components) placed by `xf` to `out`.
fn flatten(m: &Model, id: u32, xf: &Xf, out: &mut MeshObject, stack: &mut Vec<u32>, total: &mut usize) -> Result<()> {
if stack.len() > MAX_COMPONENT_DEPTH || stack.contains(&id) {
return bad("components nested too deeply or recursive");
}
let o = m.objects.get(&id).ok_or_else(|| IoError::Invalid(format!("3MF: missing object {id}")))?;
*total += o.triangles.len();
if *total > MAX_ELEMENTS {
return bad("too many triangles after placing components");
}
let base = u32::try_from(out.positions.len()).map_err(|_| IoError::Invalid("3MF: too many vertices".into()))?;
let n = o.positions.len();
out.positions.extend(o.positions.iter().map(|p| apply(xf, *p) * m.unit));
let flip = det(xf) < 0.0;
for t in &o.triangles {
if t.iter().any(|v| *v as usize >= n) {
return bad(format!("object {id}: triangle refers to a missing vertex"));
}
let t = t.map(|v| v + base);
out.triangles.push(if flip { [t[0], t[2], t[1]] } else { t });
}
if out.color.is_none() {
out.color = o.material.and_then(|(g, i)| m.materials.get(&g).and_then(|g| g.get(i).copied().flatten()));
}
stack.push(id);
for (c, cx) in &o.components {
flatten(m, *c, &compose(cx, xf), out, stack, total)?;
}
stack.pop();
Ok(())
}
/// Meshes of a 3MF package (one per build item, in millimetres) and warnings.
pub fn read_3mf(bytes: &[u8]) -> Result<(Vec<MeshObject>, Vec<String>)> {
if bytes.len() > MAX_3MF_BYTES {
return bad(format!("file too large ({} MB)", bytes.len() >> 20));
}
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| IoError::Invalid(format!("3MF is not a zip package: {e}")))?;
if zip.len() > MAX_ENTRIES {
return bad(format!("{} zip entries", zip.len()));
}
// The model part named by the package relationships, else the usual name.
let mut root = "3D/3dmodel.model".to_string();
if let Some(rels) = read_part(&mut zip, "_rels/.rels")? {
let mut rd = Reader::from_reader(rels.as_slice());
let mut buf = Vec::new();
loop {
match rd.read_event_into(&mut buf) {
Ok(Event::Start(e)) | Ok(Event::Empty(e)) if e.local_name().as_ref() == b"Relationship" => {
let a = attrs(&e);
if a.get("Type").is_some_and(|t| t.ends_with("/3dmodel"))
&& let Some(t) = a.get("Target")
{
root = t.trim_start_matches('/').to_string();
break;
}
}
Ok(Event::Eof) | Err(_) => break,
_ => {}
}
buf.clear();
}
}
let xml = read_part(&mut zip, &root)?.ok_or_else(|| IoError::Invalid(format!("3MF: no model part `{root}`")))?;
let m = parse_model(&xml)?;
let mut out = Vec::new();
let mut warnings = m.warnings.clone();
let mut total = 0usize;
for (k, (id, xf)) in m.build.iter().enumerate() {
let mut mo = MeshObject::default();
match flatten(&m, *id, xf, &mut mo, &mut Vec::new(), &mut total) {
Ok(()) if !mo.triangles.is_empty() => {
let name = m.objects.get(id).map(|o| o.name.trim().to_string()).filter(|n| !n.is_empty());
mo.name = name.unwrap_or_else(|| format!("Mesh{}", k + 1));
out.push(mo);
}
Ok(()) => warnings.push(format!("build item {} has no triangles", k + 1)),
Err(e) => warnings.push(format!("build item {}: {e}", k + 1)),
}
}
if out.is_empty() {
return bad(warnings.first().cloned().unwrap_or_else(|| "no meshes to build".into()));
}
Ok((out, warnings))
}
#[cfg(test)]
mod tests {
use super::*;
fn tetra(name: &str) -> MeshObject {
MeshObject {
name: name.into(),
positions: vec![Vec3::ZERO, Vec3::new(10.0, 0.0, 0.0), Vec3::new(0.0, 10.0, 0.0), Vec3::new(0.0, 0.0, 10.0)],
triangles: vec![[0, 2, 1], [0, 1, 3], [1, 2, 3], [0, 3, 2]],
color: None,
}
}
fn package(model: &str, rels: Option<&str>) -> Vec<u8> {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default();
if let Some(r) = rels {
zip.start_file("_rels/.rels", o).unwrap();
zip.write_all(r.as_bytes()).unwrap();
}
zip.start_file("3D/3dmodel.model", o).unwrap();
zip.write_all(model.as_bytes()).unwrap();
zip.finish().unwrap().into_inner()
}
#[test]
fn package_is_well_formed() {
let mut t = tetra("A <&> \"b\"");
t.color = Some([1.0, 0.5, 0.0]);
let bytes = write_3mf(&[t, tetra("B")]).unwrap();
let mut zip = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
for part in ["[Content_Types].xml", "_rels/.rels", "3D/3dmodel.model"] {
let data = read_part(&mut zip, part).unwrap().unwrap();
// Every part parses as XML to the end.
let mut rd = Reader::from_reader(data.as_slice());
let mut buf = Vec::new();
let mut depth = 0i32;
loop {
match rd.read_event_into(&mut buf).unwrap() {
Event::Start(_) => depth += 1,
Event::End(_) => depth -= 1,
Event::Eof => break,
_ => {}
}
buf.clear();
}
assert_eq!(depth, 0, "{part}");
}
let model = String::from_utf8(read_part(&mut zip, "3D/3dmodel.model").unwrap().unwrap()).unwrap();
assert!(model.contains("unit=\"millimeter\"") && model.contains(CORE_NS));
assert!(model.contains("displaycolor=\"#FF8000\"") && model.contains("pid=\"1\" pindex=\"0\""));
assert!(model.contains("name=\"A &lt;&amp;&gt; &quot;b&quot;\""));
assert_eq!(model.matches("<item ").count(), 2);
let (objs, w) = read_3mf(&bytes).unwrap();
assert!(w.is_empty());
assert_eq!(objs.len(), 2);
assert_eq!(objs[0].name, "A <&> \"b\"");
assert_eq!(objs[0].color, Some([1.0, 128.0 / 255.0, 0.0]));
assert_eq!(objs[0].triangles, tetra("").triangles);
}
#[test]
fn components_transforms_and_units() {
let model = r#"<?xml version="1.0"?>
<model unit="centimeter" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"><resources>
<object id="1" type="model" name="Tet"><mesh><vertices><vertex x="0" y="0" z="0"/><vertex x="1" y="0" z="0"/><vertex x="0" y="1" z="0"/><vertex x="0" y="0" z="1"/></vertices>
<triangles><triangle v1="0" v2="2" v3="1"/><triangle v1="0" v2="1" v3="3"/><triangle v1="1" v2="2" v3="3"/><triangle v1="0" v2="3" v3="2"/></triangles></mesh></object>
<object id="2" type="model" name="Pair"><components><component objectid="1"/><component objectid="1" transform="1 0 0 0 1 0 0 0 1 5 0 0"/></components></object>
</resources><build><item objectid="2" transform="-1 0 0 0 1 0 0 0 1 0 0 2"/></build></model>"#;
let (objs, _) = read_3mf(&package(model, None)).unwrap();
assert_eq!(objs.len(), 1);
let o = &objs[0];
assert_eq!(o.name, "Pair");
assert_eq!(o.triangles.len(), 8);
// Mirrored by the item, in mm: x in [-60, 0], z in [20, 30]; winding flipped back.
let xs: Vec<f64> = o.positions.iter().map(|p| p.x).collect();
assert!(xs.iter().any(|x| (*x + 60.0).abs() < 1e-9) && xs.iter().all(|x| *x <= 1e-9));
assert!(o.positions.iter().all(|p| p.z >= 20.0 - 1e-9 && p.z <= 30.0 + 1e-9));
let v6: f64 = o.triangles.iter().map(|t| o.positions[t[0] as usize].dot(o.positions[t[1] as usize].cross(o.positions[t[2] as usize]))).sum();
assert!((v6 / 6.0 - 2.0 * 1000.0 / 6.0).abs() < 1e-6, "{}", v6 / 6.0);
}
#[test]
fn hostile_packages_are_errors() {
let head = r#"<model unit="millimeter" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"><resources>"#;
let cases = [
format!(
"{head}<object id=\"1\"><components><component objectid=\"1\"/></components></object></resources><build><item objectid=\"1\"/></build></model>"
),
format!(
"{head}<object id=\"1\"><mesh><vertices><vertex x=\"0\" y=\"0\" z=\"0\"/></vertices><triangles><triangle v1=\"0\" v2=\"7\" v3=\"9\"/></triangles></mesh></object></resources><build><item objectid=\"1\"/></build></model>"
),
format!("{head}<object id=\"1\"><mesh><vertices><vertex x=\"NaN\" y=\"0\" z=\"0\"/></vertices></mesh></object></resources></model>"),
format!("{head}</resources><build><item objectid=\"9\"/></build></model>"),
format!("{head}<object id=\"1\"><components><component objectid=\"1\" transform=\"1 2\"/></components></object></resources></model>"),
r#"<model unit="parsec"/>"#.to_string(),
"<model><resources><object".to_string(),
format!("{}{}", "<a>".repeat(1000), "</a>".repeat(1000)),
"not xml at all".to_string(),
];
for c in &cases {
assert!(read_3mf(&package(c, None)).is_err(), "{c}");
}
assert!(read_3mf(b"").is_err());
assert!(read_3mf(b"PK\x03\x04garbage").is_err());
assert!(
read_3mf(&package("<model/>", Some("<Relationships><Relationship Type=\"x/3dmodel\" Target=\"/missing.model\"/></Relationships>")))
.is_err()
);
}
}
+2
View File
@@ -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()));
}
+63 -6
View File
@@ -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(),
}
}
}
+2
View File
@@ -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};
+19
View File
@@ -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
+147
View File
@@ -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 })
}
}
+25
View File
@@ -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() {
+2
View File
@@ -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()));
};
+3
View File
@@ -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()));
}
+3
View File
@@ -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();
+2 -8
View File
@@ -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);
+32
View File
@@ -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());
}
+7 -7
View File
@@ -164,12 +164,12 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"export",
"Export",
"Export bodies to STEP (.step/.stp), binary STL (.stl), ASCII STL (format stla) or OBJ. The format comes from \
the extension unless given.",
"Export bodies to STEP (.step/.stp), 3MF (.3mf), binary STL (.stl), ASCII STL (format stla) or OBJ. The format \
comes from the extension unless given.",
obj(
json!({
"path": string("Output file"),
"format": {"type": "string", "enum": ["step", "stl", "stla", "obj"]},
"format": {"type": "string", "enum": ["step", "3mf", "stl", "stla", "obj"]},
"bodies": {"type": "array", "items": {"type": "string"}, "description": "Body names (default: all)"},
}),
&["path"],
@@ -190,10 +190,10 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"open",
"Open a design",
"Open a .solvecraft design file, or a STEP file (.step/.stp: a new design whose bodies come from the file, as an \
Import base feature; insert into the current design with the FusionImportCommandFromToolbar command). Replaces \
the current design.",
obj(json!({"path": string("Path to a .solvecraft or .step/.stp file")}), &["path"]),
"Open a .solvecraft design file, a STEP file (.step/.stp: a new design whose bodies come from the file, as an \
Import base feature) or a 3MF/STL mesh (.3mf/.stl: mesh bodies). Replaces the current design; insert into it \
instead with the FusionImportCommandFromToolbar (STEP) or ParaMeshInsertAlignCommand (3MF/STL) commands.",
obj(json!({"path": string("Path to a .solvecraft, .step/.stp, .3mf or .stl file")}), &["path"]),
false,
true,
),
+9 -5
View File
@@ -170,7 +170,7 @@ impl SolveApp {
/// that have them, otherwise run it with defaults.
pub fn start(&mut self, id: &str) {
self.tool = None;
if id == "FusionImportCommandFromToolbar" {
if matches!(id, "FusionImportCommandFromToolbar" | "ParaMeshInsertAlignCommand") {
if let Some(p) = self.services.pick_open.as_ref().and_then(|f| f()) {
self.insert_path(&p);
}
@@ -250,9 +250,11 @@ impl SolveApp {
}
}
/// Insert a STEP file's bodies into the current design (an Import base feature).
/// Insert a STEP file's bodies (an Import base feature) or a 3MF/STL file's meshes into the
/// current design.
pub fn insert_path(&mut self, path: &str) {
if let Ok(r) = self.run("FusionImportCommandFromToolbar", json!({ "path": path })) {
let cmd = if solvecraft_engine::io::is_mesh_path(path) { "ParaMeshInsertAlignCommand" } else { "FusionImportCommandFromToolbar" };
if let Ok(r) = self.run(cmd, json!({ "path": path })) {
self.fit_view();
if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) {
self.set_status(format!("imported with {} warning(s): {}", w.len(), w.first().and_then(Value::as_str).unwrap_or("")), false);
@@ -260,9 +262,11 @@ impl SolveApp {
}
}
/// A dropped file: a STEP file joins a design that has features, anything else opens.
/// A dropped file: a STEP, 3MF or STL file joins a design that has features, anything else
/// opens.
pub fn drop_path(&mut self, path: &str) {
if solvecraft_engine::io::is_step_path(path) && !self.session.doc.features.is_empty() {
let import = solvecraft_engine::io::is_step_path(path) || solvecraft_engine::io::is_mesh_path(path);
if import && !self.session.doc.features.is_empty() {
self.insert_path(path);
} else {
self.open_path(path);
+1 -1
View File
@@ -30,7 +30,7 @@ fn icon_of(k: &FeatureKind) -> &'static str {
FeatureKind::Draft { .. } => "draft",
FeatureKind::Split { .. } => "split",
FeatureKind::Move { .. } => "move",
FeatureKind::Import { .. } => "import",
FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } => "import",
}
}
+8
View File
@@ -131,6 +131,10 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
app.start("FusionImportCommandFromToolbar");
close = true;
}
if item(ui, "Insert Mesh…", "") {
app.start("ParaMeshInsertAlignCommand");
close = true;
}
if item(ui, "Save", "Ctrl+S") {
save(app);
close = true;
@@ -148,6 +152,10 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
export(app, "step");
close = true;
}
if item(ui, "Export 3MF…", "") {
export(app, "3mf");
close = true;
}
if item(ui, "Export OBJ…", "") {
export(app, "obj");
close = true;
+2 -2
View File
@@ -56,10 +56,10 @@ Claude Desktop (`claude_desktop_config.json`), bridged to a running app:
| `body_topology` | `body` | Edges (index, midpoint, length, ends) and faces (index, area, centroid, normal). An edge midpoint is the `[x,y,z]` that picks that edge for fillet and chamfer. |
| `set_parameter` | `name`, `value` (number or expression), `unit?`, `comment?` | Create or change a user parameter and recompute; reports features that now fail. |
| `screenshot` | `view?` (iso, top, front, back, bottom, left, right, home, fit), `path?`, `width?`, `height?`, `source?` (window, model) | A PNG image content block. Headless: CPU render fitted to the model. Bridged: the app window (default) or a model render with the app's camera. |
| `export` | `path`, `format?` (step, stl, stla, obj), `bodies?` | STEP or mesh export. |
| `export` | `path`, `format?` (step, 3mf, stl, stla, obj), `bodies?` | STEP, 3MF or mesh export. |
| `undo`, `redo` | — | Undo / redo one change. |
| `new_design` | `name?` | Start an empty design. |
| `open` | `path` | Open a `.solvecraft` design, or a STEP file (`.step`/`.stp`) as a new design holding its bodies (an Import base feature). |
| `open` | `path` | Open a `.solvecraft` design, a STEP file (`.step`/`.stp`) as a new design holding its bodies (an Import base feature), or a 3MF/STL file as mesh bodies. |
| `save` | `path?` | Save the design (to its current file without `path`). |
Resources: `solvecraft://design` (inspect JSON with measurements) and `solvecraft://commands`