From 1d71a9f8af0f1bad5b0584e99d4b49db587e6b6a Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:33:04 -0400 Subject: [PATCH] engine: mesh.export.stl writes the outer surface or a set's faces Binary by default, ASCII on request; in the Mesh tab's Verify panel. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- ATTRIBUTION.md | 1 + assets/icons/mesh.export.stl.svg | 10 ++ assets/icons/mesh.export.stl.vectorcraft | Bin 0 -> 1735 bytes assets/icons/src/mesh.export.stl.json | 1 + crates/engine/src/catalogue.rs | 1 + crates/engine/src/cmd/forms.rs | 6 ++ crates/engine/src/cmd/mesh.rs | 114 +++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/lib.rs | 4 + crates/io/src/stl.rs | 13 ++- crates/model/src/contact.rs | 17 +++- docs/features.md | 3 +- 12 files changed, 166 insertions(+), 5 deletions(-) create mode 100644 assets/icons/mesh.export.stl.svg create mode 100644 assets/icons/mesh.export.stl.vectorcraft create mode 100644 assets/icons/src/mesh.export.stl.json diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 299ba7e..2154986 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -92,6 +92,7 @@ is committed and listed here with its exports. | assets/icons/mesh.order.quadratic.svg, assets/icons/mesh.order.quadratic.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.order.quadratic.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/mesh.quad.faces.svg, assets/icons/mesh.quad.faces.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.quad.faces.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/mesh.quality.svg, assets/icons/mesh.quality.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.quality.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | +| assets/icons/mesh.export.stl.svg, assets/icons/mesh.export.stl.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.export.stl.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/mesh.region.remesh.svg, assets/icons/mesh.region.remesh.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.region.remesh.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/mesh.size.adapt.svg, assets/icons/mesh.size.adapt.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.size.adapt.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/mesh.size.global.svg, assets/icons/mesh.size.global.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.size.global.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | diff --git a/assets/icons/mesh.export.stl.svg b/assets/icons/mesh.export.stl.svg new file mode 100644 index 0000000..a90713e --- /dev/null +++ b/assets/icons/mesh.export.stl.svg @@ -0,0 +1,10 @@ + + + mesh.export.stl + + + + + + + diff --git a/assets/icons/mesh.export.stl.vectorcraft b/assets/icons/mesh.export.stl.vectorcraft new file mode 100644 index 0000000000000000000000000000000000000000..0329ec98d2d545b8ce47c893a55d69ba1b0fe075 GIT binary patch literal 1735 zcmV;&1~~a2iwFP!00000|K(ciZre5#eicK%1w+)`0s5oMimqt47U|GpC<23)7@LbM zYLwK(3;gaoyg63lq!gVL9U368L{dDTzH{N>p>Dhj#@D#>PQ0(A=ol|JzUVw3?s!XC z?VZHFw9pCpqd=jG6?~*))K$B`f+hsbP|v+vkw)b+oGpJhLY4YD%sf#=B|T zRq#Y#)2@Y{5A>Q;Ei@FYVtl@4r5Jy zgy`$QI|-(N?`_P*HS$ivST^aat?#ufb~P^mE&+j%;H+)uD@=K-JGSS( zSK$rehk(2j9TLSQtxJKT+ccs>K4D%=w142FC4f^v=*YUMa7T8c8&2yEF9cg1@1dbs z(W>O6-iZu>kftG+=V1i53vnAqvk)cOcDwa=vf7TbOX4UkPsLxRrR8}VW^tm0%`_Ac=3UXU zTLIZq=qzv}41@KxnEh`?>)q}4e0z`m2E&uS`hnKAUx-UZ7ZA8&TA48Emk8_w?D$-~ zp16l=KiRuS$$OlDv&3u$jljzVC{$Nxzjk5=|1pm<9Ug54-5jDMD9KZYl3OIjI zF+gO+7Pz`yfE(8B0oJp8UV*-ybSB+lsec#pcWW#4mkJk`j+PQ=6A83&*h@VlrK8MQ zIE%p|O{hT19z%@)#nM`HYas8z*_LE-1fDn%5Mq;4Bm&=&a9p9JCT>b7iAi=8l)Wfw z1?A;>Arm_%BzYX6$ml4Y1!$Hc4I~sjVUkz_`HNH)yK+VX8D3&y*({QOvEfA9(nu(i z*PKAc>bTMk%q7nRQ_gta(Y33wB(cwg9&I=YmGM|3 zd9{WuTvZ+!(GsvwAjqU3p`ydyX8$C(Iub%03~U3oCc?e#L&?j3dhEJQ+PTRgjp%@9 z4dir#>!V!8Fp|pXX5vUH35iC*aOESFx(E#pZ4DZT&Q|Re^KZ||vBn|eYiRs6i=@4d zSoC{W1V}Eo$n2rfOd6(14~^egBY9Jobje(?s11B#3{`ndlo`!L8k{wnPn1=pb3|iM zpg|~=UYpqy@}cKVndI zhPy7cD$__GvJRE1*=vT@u8H#9q;+T_aVA5mz4vl<N|<-WJ6nY@ooOtoL&oP()R zMI&v-dLr+KUxv4?Vmz?#@pb-6<wG?ec8e%Nsz$U+7-f!~jM#KaAV(DAZ?VV)FUUuq8fgqqQA->`h*f2AERbW$ zXv1YnmjK3}-YFnQ)vfYD`Q0R2Gppkwc~lCP->hgcZ@W!3wBBFI1@0@MF*#!LQH=1O z?+c%U2cb`OE`IQ|^Y()TxrpnJro?Ao^98%spUd1iZJoUDy9!bq@5earw|%ehk@xzI z;IEs>8(AQG#?QB`>%HHDn|P0#=n-zj24i)Mx}WaE^{HsFKJ?a9g?m)R_oxyX?Pk4T zl^{dJlb)8uTyrWv9BD=NT!XYM6v}A(MM=L>@dYLf%wcMdTk)YIfvRB9wezN-W-MJp d9UDg7z^IyBcXKggy4~;C{s)(>(>Nb0000|oOZ5N% literal 0 HcmV?d00001 diff --git a/assets/icons/src/mesh.export.stl.json b/assets/icons/src/mesh.export.stl.json new file mode 100644 index 0000000..f08c245 --- /dev/null +++ b/assets/icons/src/mesh.export.stl.json @@ -0,0 +1 @@ +{"shapes": [{"d": "M3 9 L9 5 L15 9 L9 13 Z", "fill": "soft", "stroke": "line"}, {"d": "M3 9 L3 17 L9 21 L9 13 M9 21 L15 17 L15 9", "stroke": "line"}, {"d": "M3 9 L9 21 M9 13 L15 17", "stroke": "line", "width": 1}, {"d": "M15 15 L21 15 M18.19 16.53 L21 15 L18.19 13.47", "stroke": "accent", "width": 1.5}]} diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs index 14e55ed..632015d 100644 --- a/crates/engine/src/catalogue.rs +++ b/crates/engine/src/catalogue.rs @@ -399,6 +399,7 @@ pub static CATALOGUE: &[Capability] = &[ soon("mesh.import.orphan", "Orphan Mesh", Mesh, "Edit", "M6", "Import a mesh without geometry"), ready("mesh.quality", "Quality", Mesh, "Verify", "Quality metrics and the certified validity check, failing elements listed"), soon("mesh.verify", "Verify Set", Mesh, "Verify", "M2", "Quality and validity per set"), + ready("mesh.export.stl", "Export STL", Mesh, "Verify", "Write the mesh's outer surface (or a set's faces) as binary STL"), ready_more("mesh.tag", "Tag Geometry", Mesh, "Verify", "Tag elements, faces, edges and nodes with the geometry they mesh", &["mesh.tag.clear"]), // ---- Properties ---- ready_more( diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs index 8ede4e2..8db144e 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -1704,6 +1704,12 @@ pub const DECK: &[Field] = &[ references("steps", "Steps", C::Step).help("The steps to write; all when empty.").hint("all"), ]; +pub const EXPORT_STL: &[Field] = &[ + path("path", "File", true).help("Where to write the STL file.").req(), + reference("set", "Set", C::Set).help("Write only this set's element faces; the whole outer surface when empty.").hint("the outer surface"), + choice("format", "Format", &[("binary", "Binary"), ("ascii", "ASCII")]).help("Binary is compact; ASCII is readable text.").def("\"binary\""), +]; + // ---- Solving ---- const STEP: Field = reference("step", "Step", C::Step).help("The step to solve; the only one when empty.").hint("the only one"); diff --git a/crates/engine/src/cmd/mesh.rs b/crates/engine/src/cmd/mesh.rs index 4e9f614..9d5fe17 100644 --- a/crates/engine/src/cmd/mesh.rs +++ b/crates/engine/src/cmd/mesh.rs @@ -67,6 +67,16 @@ pub const QUALITY: CommandSpec = CommandSpec { run: quality_report, }; +pub const EXPORT_STL: CommandSpec = CommandSpec { + id: "mesh.export.stl", + label: "Export STL", + params: r#"{"path": "skin.stl", "set": "top", "format": "binary|ascii"} — writes the mesh's outer surface as STL: every solid element face no other solid shares, and the top side (face 1) of every shell; `set` (optional) writes only that set's element faces instead; `format` defaults to binary. Quadrilaterals are split in two, normals point out of the elements, and coordinates are rounded to single precision (STL stores f32). Replies {path, triangles, bytes}"#, + form: Some(super::forms::EXPORT_STL), + kind: CommandKind::Session, + requires: &[Requirement::Mesh], + run: export_stl, +}; + pub const DELAUNAY_POINTS: CommandSpec = CommandSpec { id: "mesh.delaunay.points", label: "Delaunay of Points", @@ -553,6 +563,53 @@ fn number(v: f64) -> Value { if v.is_finite() { json!(v) } else { json!(if v > 0.0 { "inf" } else { "-inf" }) } } +fn export_stl(s: &mut Session, p: &Value) -> Result { + let set: Option = super::opt(p, "set")?; + let format: Option = super::opt(p, "format")?; + let ascii = match format.as_deref() { + None | Some("binary") => false, + Some("ascii") => true, + Some(other) => { + let shown: String = other.chars().take(32).collect(); + return Err(EngineError::Params(format!("unknown STL format `{shown}`; known: binary, ascii"))); + } + }; + let path = super::file_param(s, p, "path", fieldcraft_io::roots::Access::Write)?; + let m = &s.model; + if m.elements().is_empty() { + return Err(EngineError::Params("the mesh has no elements".into())); + } + let faces = match &set { + Some(name) => { + let shown: String = name.chars().take(64).collect(); + if !m.sets().contains_key(name) { + return Err(EngineError::Params(format!("no set `{shown}`"))); + } + let faces = m.resolve_set(name)?.faces; + if faces.is_empty() { + return Err(EngineError::Params(format!("set `{shown}` has no element faces"))); + } + faces + } + None => { + let faces = m.skin_faces(); + if faces.is_empty() { + return Err(EngineError::Params("the mesh has only line elements: no surface to write".into())); + } + faces + } + }; + let bytes = if ascii { + let name = std::path::Path::new(&path).file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + fieldcraft_io::stl::write_ascii(m, &faces, &name)?.into_bytes() + } else { + fieldcraft_io::stl::write_binary(m, &faces)? + }; + let triangles = fieldcraft_io::stl::triangles(m, &faces)?.len(); + fieldcraft_io::fs::write_atomic(std::path::Path::new(&path), &bytes, false)?; + Ok(json!({"path": path, "triangles": triangles, "bytes": bytes.len()})) +} + fn quality_report(s: &mut Session, p: &Value) -> Result { let bins = match p.get("bins") { None => 10, @@ -671,6 +728,63 @@ fn surface_check(s: &mut Session, p: &Value) -> Result { #[cfg(test)] mod tests { + #[test] + fn export_stl_writes_the_skin_or_a_set() { + let dir = std::env::temp_dir().join(format!("fieldcraft-stl-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("skin.stl"); + let path_str = path.to_string_lossy().into_owned(); + let mut s = Session::default(); + assert!(s.execute("mesh.export.stl", &json!({"path": path_str})).is_err(), "no mesh"); + s.execute("file.sample", &json!({"name": "cantilever"})).unwrap(); + let journal = s.journal.len(); + let out = s.execute("mesh.export.stl", &json!({"path": path_str})).unwrap(); + let bytes = std::fs::read(&path).unwrap(); + assert!(!bytes.starts_with(b"solid")); + let skin = fieldcraft_io::stl::triangles(&s.model, &s.model.skin_faces()).unwrap().len(); + assert!(skin >= s.model.skin_faces().len() && skin > 0); + assert_eq!(out["triangles"], skin); + assert_eq!(bytes.len(), 84 + 50 * skin); + assert_eq!(out["bytes"], bytes.len()); + assert_eq!(s.journal.len(), journal, "exporting does not change the model"); + + // A set with faces writes only those; one without faces, or an unknown one, is refused. + let faces = s.model.skin_faces(); + let two: Vec = faces.iter().take(2).map(|f| json!({"element": f.element, "face": f.face})).collect(); + let first = *s.model.elements().keys().next().unwrap(); + s.execute("model.set.create", &json!({"name": "stl_faces", "faces": two})).unwrap(); + s.execute("model.set.create", &json!({"name": "stl_elements", "elements": [first]})).unwrap(); + let out = s.execute("mesh.export.stl", &json!({"path": path_str, "set": "stl_faces"})).unwrap(); + assert_eq!(out["triangles"], fieldcraft_io::stl::triangles(&s.model, &faces[..2]).unwrap().len()); + let other = dir.join("other.stl").to_string_lossy().into_owned(); + let e = s.execute("mesh.export.stl", &json!({"path": other, "set": "nope"})).unwrap_err().to_string(); + assert!(e.contains("no set `nope`"), "{e}"); + let e = s.execute("mesh.export.stl", &json!({"path": other, "set": "stl_elements"})).unwrap_err().to_string(); + assert!(e.contains("has no element faces"), "{e}"); + let e = s.execute("mesh.export.stl", &json!({"path": other, "format": "obj"})).unwrap_err().to_string(); + assert!(e.contains("known: binary, ascii"), "{e}"); + assert!(!dir.join("other.stl").exists()); + + let out = s.execute("mesh.export.stl", &json!({"path": path_str, "format": "ascii"})).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.starts_with("solid skin\n")); + assert_eq!(text.matches("facet normal").count(), skin); + assert_eq!(out["triangles"], skin); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn export_stl_refuses_a_mesh_of_lines() { + let mut s = Session::default(); + s.execute("mesh.node.add", &json!({"label": 1, "xyz": [0, 0, 0]})).unwrap(); + s.execute("mesh.node.add", &json!({"label": 2, "xyz": [1, 0, 0]})).unwrap(); + s.execute("mesh.element.add", &json!({"label": 1, "kind": "rod2", "nodes": [1, 2]})).unwrap(); + let path = std::env::temp_dir().join(format!("fieldcraft-stl-lines-{}.stl", std::process::id())); + let e = s.execute("mesh.export.stl", &json!({"path": path.to_string_lossy()})).unwrap_err().to_string(); + assert!(e.contains("only line elements"), "{e}"); + assert!(!path.exists()); + } + use serde_json::json; use crate::Session; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index debfc8b..e31668d 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -74,6 +74,7 @@ pub static ALL: &[CommandSpec] = &[ mesh::ELEMENT_REMOVE, replace::CLEAR, mesh::QUALITY, + mesh::EXPORT_STL, mesh::DELAUNAY_POINTS, mesh::SURFACE_CHECK, mesh::PLANAR, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e55c373..ab22c35 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -545,6 +545,10 @@ mod tests { json!({"format": "nastran", "path": "/no/such/dir/part.bdf", "steps": vec!["x".repeat(1000); 2000]}), json!({"format": "nastran", "path": "/no/such/dir/part.bdf", "steps": {}}), json!({"path": 7}), + json!({"path": "x\u{0}.stl"}), + json!({"path": "/no/such/dir/skin.stl", "set": 7}), + json!({"path": "/no/such/dir/skin.stl", "set": "x".repeat(100_000)}), + json!({"path": "/no/such/dir/skin.stl", "format": ["ascii"]}), // (`max_points` and `max_passes` keep mesh.tet.generate's quality stage small.) json!({"body": "Body1", "tolerance": 0.05, "max_points": 3000, "max_passes": 1}), json!({"body": null, "tolerance": "fine"}), diff --git a/crates/io/src/stl.rs b/crates/io/src/stl.rs index ab1eb36..fcc83f2 100644 --- a/crates/io/src/stl.rs +++ b/crates/io/src/stl.rs @@ -176,7 +176,18 @@ mod tests { #[test] fn tet10_and_wedge6_close_with_outward_normals() { let mut t = Model::default(); - let p = [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 1.], [0.5, 0., 0.], [0.5, 0.5, 0.], [0., 0.5, 0.], [0., 0., 0.5], [0.5, 0., 0.5], [0., 0.5, 0.5]]; + let p = [ + [0., 0., 0.], + [1., 0., 0.], + [0., 1., 0.], + [0., 0., 1.], + [0.5, 0., 0.], + [0.5, 0.5, 0.], + [0., 0.5, 0.], + [0., 0., 0.5], + [0.5, 0., 0.5], + [0., 0.5, 0.5], + ]; for (i, xyz) in p.iter().enumerate() { t.set_node(i as u32 + 1, *xyz).unwrap(); } diff --git a/crates/model/src/contact.rs b/crates/model/src/contact.rs index eab4586..3d2400f 100644 --- a/crates/model/src/contact.rs +++ b/crates/model/src/contact.rs @@ -403,9 +403,20 @@ mod tests { assert_eq!(m.skin_faces().len(), 10, "two hex8 sharing a face"); let mut t = Model::default(); - for (i, p) in [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 1.], [0.5, 0., 0.], [0.5, 0.5, 0.], [0., 0.5, 0.], [0., 0., 0.5], [0.5, 0., 0.5], [0., 0.5, 0.5]] - .iter() - .enumerate() + for (i, p) in [ + [0., 0., 0.], + [1., 0., 0.], + [0., 1., 0.], + [0., 0., 1.], + [0.5, 0., 0.], + [0.5, 0.5, 0.], + [0., 0.5, 0.], + [0., 0., 0.5], + [0.5, 0., 0.5], + [0., 0.5, 0.5], + ] + .iter() + .enumerate() { t.set_node(i as u32 + 1, *p).unwrap(); } diff --git a/docs/features.md b/docs/features.md index 679e250..4e706bd 100644 --- a/docs/features.md +++ b/docs/features.md @@ -6,7 +6,7 @@ Every entry is a toolbar button. **Ready** entries run their command; **coming s shown greyed with the roadmap milestone that brings them (ROADMAP.md). Scripts, the command line, the control channel and AI agents over MCP (docs/mcp.md) run the same commands. -73 ready, 39 coming soon. +74 ready, 39 coming soon. ## Geometry @@ -57,6 +57,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands. | Edit | Orphan Mesh — Import a mesh without geometry | coming soon (M6) | | | Verify | Quality — Quality metrics and the certified validity check, failing elements listed | ready | `mesh.quality` | | Verify | Verify Set — Quality and validity per set | coming soon (M2) | | +| Verify | Export STL — Write the mesh's outer surface (or a set's faces) as binary STL | ready | `mesh.export.stl` | | Verify | Tag Geometry — Tag elements, faces, edges and nodes with the geometry they mesh | ready | `mesh.tag`, `mesh.tag.clear` | ## Properties