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 @@
+
+
diff --git a/assets/icons/mesh.export.stl.vectorcraft b/assets/icons/mesh.export.stl.vectorcraft
new file mode 100644
index 0000000..0329ec9
Binary files /dev/null and b/assets/icons/mesh.export.stl.vectorcraft differ
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