diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index d6668d5c..2777e0fe 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index cf0f8424..1452795f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 326b2da3..f43db44d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/README.md b/README.md index db643caa..2ec28fa3 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/ROADMAP.md b/ROADMAP.md index 72032b78..c470a2e1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/apps/solvecraft-cli/src/main.rs b/apps/solvecraft-cli/src/main.rs index c8550d90..a09bd359 100644 --- a/apps/solvecraft-cli/src/main.rs +++ b/apps/solvecraft-cli/src/main.rs @@ -1,7 +1,7 @@ //! SolveCraft headless command line. //! //! ```text -//! solvecraft-cli run [--in design|part.step] [--out FILE]... [--save FILE] [--quiet] +//! solvecraft-cli run [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] //! solvecraft-cli eval (alias: inspect) measurements as JSON //! solvecraft-cli snapshot --out shot.png [--width W] [--height H] [--view iso|front|top|…] //! solvecraft-cli exec [json-params] run one command on an empty design @@ -78,10 +78,10 @@ pub fn read_json(path: &str) -> Result { 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 { 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); } diff --git a/apps/solvecraft/src/main.rs b/apps/solvecraft/src/main.rs index d23901c1..ef15fe02 100644 --- a/apps/solvecraft/src/main.rs +++ b/apps/solvecraft/src/main.rs @@ -2,7 +2,8 @@ //! //! Usage: `solvecraft [--control ] [--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 ` (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()) diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 4af46190..c0359176 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -291,6 +291,25 @@ pub enum FeatureKind { #[serde(default, skip_serializing_if = "Vec::is_empty")] components: Vec, }, + /// 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, + }, +} + +/// 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, + /// Three vertex indices per triangle. + pub triangles: Vec, + #[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 } diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index bea056da..e65735bf 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -569,7 +569,13 @@ fn blend_target(state: &ModelState, body: &Option, 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, 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, f: &Feature, st: } } +/// Mesh bodies of a MeshImport feature. +fn mesh_bodies(meshes: &[crate::MeshData]) -> Result> { + let mut out = Vec::with_capacity(meshes.len()); + for m in meshes { + let positions: Vec = 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() }; diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 471a67b7..99f638cb 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -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 { 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 { + 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 { + 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 { let cmd = "FusionImportCommandFromToolbar"; let path = path_arg(p, cmd)?; @@ -78,11 +115,11 @@ fn insert_step(s: &mut Session, p: &Value) -> Result { fn open(s: &mut Session, p: &Value) -> Result { 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(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 056859c1..983b0047 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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); +} diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index f96c3845..25bf5d6f 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -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 } diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 2c13bd9c..5180c60a 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -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 { }) } +/// 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, + pub warnings: Vec, +} + +/// 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 { + 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 = 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 = 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::>()?; + 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::>()); + 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")); + } } diff --git a/crates/io/src/threemf.rs b/crates/io/src/threemf.rs new file mode 100644 index 00000000..5b007abd --- /dev/null +++ b/crates/io/src/threemf.rs @@ -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, + 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("&"), + '<' => o.push_str("<"), + '>' => o.push_str(">"), + '"' => o.push_str("""), + '\'' => o.push_str("'"), + 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, 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> = HashMap::new(); + let mut verts: Vec = 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 = 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("\n"); + let _ = writeln!(x, ""); + x.push_str(" SolveCraft\n \n"); + let colored = objects.iter().any(|o| o.color.is_some()); + // Base material group id 1; objects from 2. + if colored { + x.push_str(" \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, " ", esc(&o.name), c[0], c[1], c[2]); + } + x.push_str(" \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, " ", esc(&o.name)); + x.push_str(" \n \n"); + let (verts, tris) = indexed(o); + for v in &verts { + let _ = writeln!(x, " ", v[0], v[1], v[2]); + } + x.push_str(" \n \n"); + for t in &tris { + let _ = writeln!(x, " ", t[0], t[1], t[2]); + } + x.push_str(" \n \n \n"); + } + x.push_str(" \n \n"); + for k in 0..objects.len() { + let _ = writeln!(x, " ", k + 2); + } + x.push_str(" \n\n"); + x +} + +/// A 3MF package of meshes. +pub fn write_3mf(objects: &[MeshObject]) -> Result> { + if objects.is_empty() { + return Err(IoError::Empty); + } + let content_types = "\n\n \n \n\n"; + let rels = format!( + "\n\n \n\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(msg: impl Into) -> Result { + Err(IoError::Invalid(format!("3MF: {}", msg.into()))) +} + +fn read_part(zip: &mut zip::ZipArchive>, name: &str) -> Result>> { + 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 { + 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 { + let Some(s) = s else { return Ok(IDENTITY) }; + let v: Vec = s + .split_whitespace() + .map(|t| t.parse::().ok().filter(|x| x.is_finite() && x.abs() < 1e12)) + .collect::>() + .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, + triangles: Vec<[u32; 3]>, + components: Vec<(u32, Xf)>, + material: Option<(u32, usize)>, +} + +#[derive(Default)] +struct Model { + unit: f64, + objects: HashMap, + materials: HashMap>>, + build: Vec<(u32, Xf)>, + warnings: Vec, +} + +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 { + 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 { + 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 = 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::().ok()).filter(|v| v.is_finite()); + let id = |k: &str| a.get(k).and_then(|v| v.trim().parse::().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::().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 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, 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, Vec)> { + 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 { + 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 <&> "b"\"")); + assert_eq!(model.matches(" \"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#" + + + + +"#; + 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 = 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#""#; + let cases = [ + format!( + "{head}" + ), + format!( + "{head}" + ), + format!("{head}"), + format!("{head}"), + format!("{head}"), + r#""#.to_string(), + "".repeat(1000), "".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("", Some(""))) + .is_err() + ); + } +} diff --git a/crates/kernel/src/blend.rs b/crates/kernel/src/blend.rs index 3e94d10b..a81b7f94 100644 --- a/crates/kernel/src/blend.rs +++ b/crates/kernel/src/blend.rs @@ -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.require_brep("fillet")?; blend(body, edges, radius, Shape::Round, "fillet") } @@ -305,6 +306,7 @@ pub fn fillet(body: &Body, edges: &[Vec3], radius: f64) -> Result { /// 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.require_brep("chamfer")?; if !(distance.is_finite() && distance > 1e-6 && distance < 1e6) { return Err(KernelError::Invalid("chamfer size must be positive".into())); } diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index 6f9320f9..ea7e3025 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -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, + /// Set for mesh bodies (`solid` is then empty). + pub(crate) mesh: Option>, + /// 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 { 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 { 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> { 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(), + } } } diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index a124e1cf..32c0ef78 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -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}; diff --git a/crates/kernel/src/measure.rs b/crates/kernel/src/measure.rs index 4f4a6096..9b74d840 100644 --- a/crates/kernel/src/measure.rs +++ b/crates/kernel/src/measure.rs @@ -59,6 +59,25 @@ fn min_edge_radius(m: &Mesh) -> Option { /// 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 { + 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 diff --git a/crates/kernel/src/meshbody.rs b/crates/kernel/src/meshbody.rs new file mode 100644 index 00000000..787fe8bf --- /dev/null +++ b/crates/kernel/src/meshbody.rs @@ -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, + 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 { + 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 = 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 { + 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 }) + } +} diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index 37704a05..8ea98f69 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -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> { + 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 { 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> { + body.require_brep("split")?; let size = body.size(); let mut bb = solvecraft_geom::Aabb3::EMPTY; for v in body.solid.vertex_iter() { diff --git a/crates/kernel/src/polybool.rs b/crates/kernel/src/polybool.rs index 1f539b51..efd6463f 100644 --- a/crates/kernel/src/polybool.rs +++ b/crates/kernel/src/polybool.rs @@ -222,6 +222,8 @@ fn planar_polys(b: &Body) -> Option> { /// Boolean of two all-planar bodies. pub fn planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Result> { + 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())); }; diff --git a/crates/kernel/src/polyhedron.rs b/crates/kernel/src/polyhedron.rs index 0e0456da..9cdeeeb4 100644 --- a/crates/kernel/src/polyhedron.rs +++ b/crates/kernel/src/polyhedron.rs @@ -101,6 +101,7 @@ pub fn convex_polyhedron(hs: &[HalfSpace]) -> Result { /// The half-spaces of a convex body with only planar faces, or an error. pub fn planar_convex(b: &Body) -> Result> { + 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> { /// Hollow a convex planar body: walls of `thickness` inside, faces near `open` removed. pub fn shell(b: &Body, open: &[Vec3], thickness: f64) -> Result { + 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 { /// 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 { + 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())); } diff --git a/crates/kernel/src/step.rs b/crates/kernel/src/step.rs index 8cc4844d..0be173c4 100644 --- a/crates/kernel/src/step.rs +++ b/crates/kernel/src/step.rs @@ -8,6 +8,9 @@ pub fn step_export(bodies: &[&Body], system: &str) -> Result { 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(); diff --git a/crates/kernel/src/step_in/mod.rs b/crates/kernel/src/step_in/mod.rs index 9dc34846..14cd425c 100644 --- a/crates/kernel/src/step_in/mod.rs +++ b/crates/kernel/src/step_in/mod.rs @@ -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); diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index dfbd890d..303727bd 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -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()); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 9e356e28..2611bbf4 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -164,12 +164,12 @@ pub fn tool_definitions() -> Vec { 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 { 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, ), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a7ec23f3..9dfd5f30 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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); diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs index 21d51517..533b8b56 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -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", } } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index b11a3f70..2823dcd2 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -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; diff --git a/docs/mcp.md b/docs/mcp.md index 107776a9..846a6112 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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`