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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-15 22:34:02 -04:00
1 parent 87710a83ab
commit 1d71a9f8af
12 files changed
+166 -5

No files matched your search

+1
View File
@@ -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 |
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
<title>mesh.export.stl</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M3 9 L9 5 L15 9 L9 13 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 9 L3 17 L9 21 L9 13 M9 21 L15 17 L15 9" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 9 L9 21 M9 13 L15 17" fill="none" stroke="#dde2ea" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15 15 L21 15 M18.19 16.53 L21 15 L18.19 13.47" fill="none" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 817 B

Binary file not shown.
+1
View File
@@ -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}]}
+1
View File
@@ -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(
+6
View File
@@ -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");
+114
View File
@@ -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<Value> {
let set: Option<String> = super::opt(p, "set")?;
let format: Option<String> = 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<Value> {
let bins = match p.get("bins") {
None => 10,
@@ -671,6 +728,63 @@ fn surface_check(s: &mut Session, p: &Value) -> Result<Value> {
#[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<serde_json::Value> = 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;
+1
View File
@@ -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,
+4
View File
@@ -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"}),
+12 -1
View File
@@ -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();
}
+14 -3
View File
@@ -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();
}
+2 -1
View File
@@ -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