git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commit7e4d8b92c8c9d8ad53441a970af75b0cce06adfe
parent9e8f30f26a
authorLucas Galante <[email protected]>
date2026-09-18 21:11
feat(export): STL and OBJ, so the work can leave the app

Everything the last eight commits built produced geometry that could only be
looked at inside this window. These are the two formats that matter for what
the tool is for: STL is what a printer and a mold chain read, OBJ is what
everything else reads.

They are not the same picture of a mesh, and the difference is the point. OBJ
keeps the TOPOLOGY — points written once, faces referencing them, a quad
staying a quad — so a mesh reimports as the mesh that was exported. STL keeps
only triangles: it has no notion of a shared point, so everything fans and
comes back welded-by-position at best. That is the format's design, not a
shortcoming of the writer, and the test asserts both halves: the same sheet is
2 quads in OBJ and 4 triangles in STL.

Three formats' worth of detail that matter and are easy to get wrong:

The binary STL header deliberately does not begin with "solid" — that word at
the start of a file is how readers guess at the ASCII form, and a binary file
opening with it is a well-known way to be misread. Every facet normal is
checked against its own winding, which STL stores separately and which can
disagree; a slicer trusting the normal would then see the surface inside out.
This is where the native generators' winding fix from Phase 3 pays off — the
check reads dot = 1.0000 exactly.

OBJ indices are 1-based, the single most common way to write a broken OBJ, and
a two-point primitive is written as `l` rather than `f` because Polygon
unfilled is a ring of them and a degenerate face is something for a reader to
choke on. Normals are written only when the geometry carries `N`, because a
stale normal from before a deform is worse than none.

Three ways in: an `export` NODE that is a pass-through writing when its button
is pressed (never on evaluation — that happens on every redraw and every frame
of a solve, and would fill a disk while you scrubbed), and a `--export` CLI
taking --frame, --node and --scale. The CLI exists for the same reason
--thumbnail does: work that can only leave through a window cannot be
scripted, diffed or checked.

Coordinates are written as they are. The World Unit is a declaration, not a
conversion, and export keeps that promise.

Verified by exporting a solved growth simulation at frame 20 — 3914 points,
7824 triangles — and reading the file back to check its structure.

Co-Authored-By: Claude Opus 5 <[email protected]>

 CLAUDE.md         |  29 ++++++++
 nodes/export.json |  13 ++++
 shapeshifter.md   |   9 +++
 src/app.rs        |  79 +++++++++++++++++++++
 src/export.rs     | 206 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/export_cli.rs |  82 ++++++++++++++++++++++
 src/geometry.rs   |  40 +++++++++++
 src/main.rs       | 201 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 8 files changed, 659 insertions(+)

diff --git a/CLAUDE.md b/CLAUDE.md
index a8a344d..e049f7f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -41,6 +41,13 @@ cross-validation test compares backends and skips silently with no platform.
   is no timeline and simnets render at their seed; with it the solve runs to
   that frame (start frame 1, the playbar's default), which is the only way to
   look at a simulation without a Wayland session.
+- `cce-designer --export <project> <out.stl|out.obj> [--frame N] [--node NAME] [--scale S]`
+  — headless mesh export (`src/export_cli.rs`, formats in `src/export.rs`).
+  The format comes from the extension, defaulting to binary STL. Without
+  `--node` the whole visible scene is written; with it, that one node's output
+  is written whether or not it is visible, which is normal for an Export node
+  whose input something else already draws. Same frame contract as
+  `--thumbnail`.
 - `cce-designer --detached-network` — a separate network-pane-only window. It syncs
   with the main window by autosaving/polling `default_project.json` mtime (see the
   main loop in `src/main.rs`) — there is no socket between the two.
@@ -279,6 +286,28 @@ hidden parameter is simply not reported and comes back as it was.
 the UI metadata — the template owns when a control applies, the instance owns
 its value.
 
+### Mesh export
+
+`src/export.rs` writes STL (binary and ASCII) and OBJ; `src/export_cli.rs` is
+the `--export` mode; the `export` NODE is a pass-through that writes when its
+Export button is pressed — never on evaluation, which happens on every redraw
+and every frame of a solve.
+
+The formats are not the same picture of a mesh. **OBJ keeps the topology**:
+points are written once, faces reference them, a quad stays a quad. **STL keeps
+only triangles** — it has no shared points, so everything fans and comes back
+welded-by-position at best. Neither carries attributes; the project file and
+the sim cache are what preserve a simulation's state.
+
+Coordinates are written as they are, scaled only by the node's Scale.
+The World Unit is a DECLARATION, not a conversion (see the Guides node), and
+export keeps that promise: geometry modelled at 20 units across writes as 20,
+and the slicer is told those are millimetres.
+
+Buttons dispatch through `execute_menu_action` by LABEL, which carries no node
+— `run_export` resolves the node from the current selection, which is sound
+because the pressed button can only be on the node the pane is showing.
+
 ### Runtime paths point into the source tree
 
 Node templates (`nodes/*.json`) and `default_project.json` are located via
diff --git a/nodes/export.json b/nodes/export.json
new file mode 100644
index 0000000..8555fa9
--- /dev/null
+++ b/nodes/export.json
@@ -0,0 +1,13 @@
+{
+ "name": "Export",
+ "type": "export",
+ "inputs": 1,
+ "outputs": 1,
+ "params": [
+  { "name": "Input", "type": "text", "default": "" },
+  { "name": "File", "type": "text", "default": "" },
+  { "name": "Format", "type": "choice:STL,STL (ASCII),OBJ", "default": "STL" },
+  { "name": "Scale", "type": "slider", "default": "1.00", "min": 0.001, "max": 100.0, "step": 0.01 },
+  { "name": "Export", "type": "button", "default": "" }
+ ]
+}
diff --git a/shapeshifter.md b/shapeshifter.md
index 90cbf98..6221077 100644
--- a/shapeshifter.md
+++ b/shapeshifter.md
@@ -354,6 +354,15 @@ Touches: `shortcut.rs`, `app.rs`, `slots.rs`, `cce-ui`.
 
 *Largest. Needs Phases 0 and 3.*
 
+> **Mesh export landed early**, out of order, because everything Phases 0 to 4
+> build could until now only be looked at inside the app. `src/export.rs`
+> writes STL and OBJ, there is an `export` node and a `--export` CLI mode, and
+> a solved growth simulation can be written to a printable file.
+>
+> Still outstanding for this phase: the volume representation (SDF or sparse
+> grid) that shelling, offsetting and boolean work need, and the 2D page
+> context for the COP family.
+
 Furthest out because it needs infrastructure nothing else does: a **volume
 representation** (SDF or sparse grid) for shelling, offsetting and boolean work,
 without which mold, sprue and support tooling has nothing to stand on. Then
diff --git a/src/app.rs b/src/app.rs
index 6c301da..527a215 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -111,6 +111,18 @@ pub struct ParamDef {
 
 fn default_param_type() -> String { "string".to_string() }
 
+/// Expand a leading `~` to the home directory. A path typed into a text field
+/// is typed by a person, and `~/models/thing.stl` is what a person writes.
+pub fn shellexpand_home(path: &str) -> String {
+    match path.strip_prefix("~/") {
+        Some(rest) => match std::env::var_os("HOME") {
+            Some(home) => format!("{}/{}", home.to_string_lossy(), rest),
+            None => path.to_string(),
+        },
+        None => path.to_string(),
+    }
+}
+
 /// Whether a parameter's `show_when` condition holds, given its siblings.
 ///
 /// Values are compared case-insensitively against the sibling's CURRENT value
@@ -2210,8 +2222,75 @@ impl State {
     /// per triggered button by `sync_parameters_to_project`, and reachable directly
     /// through the `menu_action` tool (the index-matched `menu_click` cannot
     /// reach these). Returns false for an unrecognized label.
+    /// Write the selected Export node's input to its File.
+    ///
+    /// Evaluated fresh at the playbar's current frame rather than reusing the
+    /// scene: the scene is what is VISIBLE, and an Export node whose geometry
+    /// flag is off — which is the normal way to use one, since its input is
+    /// already drawn — contributes nothing to it.
+    pub fn run_export(&mut self) {
+        let Some(slot_idx) = self.param_editor_selected() else { return };
+        let dir = self.param_editor_dir();
+        let Some(node) = dir.children.get(slot_idx).filter(|c| c.node_type == "export") else {
+            return;
+        };
+        let node = node.clone();
+        let file = crate::geometry::node_param_str(&node, "File", "");
+        let file = file.trim().to_string();
+        if file.is_empty() {
+            self.update_status_text("Export: set a File first.");
+            return;
+        }
+        let (format, scale) = crate::geometry::export_settings(&node);
+
+        let (frame, start) = (self.sim_frame(), self.sim_start_frame());
+        let mut sim_cache = std::mem::take(&mut self.sim_cache);
+        let geom = {
+            let mut sim = crate::geometry::EvalSim::new(frame, start, &mut sim_cache);
+            let mut visited = Vec::new();
+            let mut err = None;
+            crate::geometry::generate_single_node_geometry_with_errors(
+                &self.fs_root,
+                &node,
+                &mut visited,
+                &mut err,
+                &mut sim,
+            )
+        };
+        self.sim_cache = sim_cache;
+        let Some(geom) = geom else {
+            self.update_status_text("Export: the node has no input geometry.");
+            return;
+        };
+        if geom.num_prims() == 0 {
+            // Writing an empty file is a worse answer than saying so: a
+            // zero-triangle STL is valid, and a slicer opening one reports
+            // nothing wrong.
+            self.update_status_text("Export: the geometry has no primitives.");
+            return;
+        }
+
+        let path = std::path::PathBuf::from(shellexpand_home(&file));
+        match crate::export::write(&geom, &path, format, scale) {
+            Ok(bytes) => self.update_status_text(&format!(
+                "Exported {} as {} ({} bytes) to {}",
+                node.name,
+                format.label(),
+                bytes,
+                path.display()
+            )),
+            Err(e) => self.update_status_text(&format!("Export failed: {e}")),
+        }
+    }
+
     pub fn execute_menu_action(&mut self, label: &str) -> bool {
         match label {
+            // An Export node's button. Buttons dispatch by LABEL, which has no
+            // node attached to it — but the pressed button can only be on the
+            // node the pane is showing, so the selection is the node.
+            "Export" => {
+                self.run_export();
+            }
             "Update Parameters" => {
                 if let Some(slot_idx) = self.graph().selected_node() {
                     let dir = self.current_dir_mut();
diff --git a/src/export.rs b/src/export.rs
new file mode 100644
index 0000000..d9b88d7
--- /dev/null
+++ b/src/export.rs
@@ -0,0 +1,206 @@
+//! Writing geometry out: STL and OBJ.
+//!
+//! Everything the app builds has, until now, been something you could only
+//! look at inside it. These are the two formats that matter for what this tool
+//! is for: **STL** is what a printer and a mold chain read, and **OBJ** is what
+//! every other piece of software reads.
+//!
+//! ## What each format can carry
+//!
+//! They are not the same picture of a mesh, and the difference is worth
+//! knowing before choosing:
+//!
+//! - **OBJ keeps the topology.** Points are written once and faces reference
+//!   them, so a quad stays a quad and a shared point stays shared. Reimporting
+//!   an OBJ gives back the mesh that was exported.
+//! - **STL keeps only the triangles.** It has no notion of a shared point: every
+//!   triangle carries its own three corners, so a mesh comes back welded-by-
+//!   position at best and a quad comes back as two triangles always. That is
+//!   the format's design, not a shortcoming of this writer — it exists to feed
+//!   a machine that only needs a closed surface.
+//!
+//! Neither carries attributes. A simulation's state does not survive an
+//! export, which is what the project file and the sim cache are for.
+//!
+//! ## Units
+//!
+//! Coordinates are written exactly as they are, scaled only by the caller's
+//! Scale. The app's World Unit is a DECLARATION about what one unit means, not
+//! a conversion (see the Guides node), and export keeps that promise: a
+//! geometry modelled at 20 units across writes as 20, and it is the printer's
+//! slicer that is told those are millimetres.
+
+use crate::detail::Detail;
+use glam::Vec3;
+
+/// The triangles of a piece of geometry, with a face normal each.
+///
+/// Both STL writers want exactly this, and the fan matches what the viewport
+/// and the path tracer draw — so what is exported is what was on screen.
+fn triangles(d: &Detail, scale: f32) -> Vec<([Vec3; 3], Vec3)> {
+    d.triangulate(|pos, _| Vec3::from(pos) * scale)
+        .chunks_exact(3)
+        .map(|t| {
+            let n = (t[1] - t[0]).cross(t[2] - t[0]).normalize_or_zero();
+            ([t[0], t[1], t[2]], n)
+        })
+        .collect()
+}
+
+/// Binary STL: an 80-byte header, a triangle count, then 50 bytes each.
+///
+/// The header deliberately does NOT begin with "solid" — that word at the
+/// start of a file is how readers guess a file is the ASCII form, and a binary
+/// file that opens with it is a well-known way to be misread.
+pub fn stl_binary(d: &Detail, scale: f32, name: &str) -> Vec<u8> {
+    let tris = triangles(d, scale);
+    let mut out = Vec::with_capacity(84 + tris.len() * 50);
+
+    let mut header = [0u8; 80];
+    let label = format!("cce-designer {name}");
+    for (slot, b) in header.iter_mut().zip(label.bytes()) {
+        *slot = b;
+    }
+    out.extend_from_slice(&header);
+    out.extend_from_slice(&(tris.len() as u32).to_le_bytes());
+
+    for (v, n) in &tris {
+        for c in [n.x, n.y, n.z] {
+            out.extend_from_slice(&c.to_le_bytes());
+        }
+        for p in v {
+            for c in [p.x, p.y, p.z] {
+                out.extend_from_slice(&c.to_le_bytes());
+            }
+        }
+        // The "attribute byte count", which nothing uses and everything
+        // expects to be there.
+        out.extend_from_slice(&0u16.to_le_bytes());
+    }
+    out
+}
+
+/// ASCII STL. Bigger and slower to read than the binary form, and the one to
+/// reach for when something downstream is being difficult and you want to look
+/// at the file.
+pub fn stl_ascii(d: &Detail, scale: f32, name: &str) -> String {
+    let mut out = format!("solid {name}\n");
+    for (v, n) in triangles(d, scale) {
+        out.push_str(&format!("  facet normal {:e} {:e} {:e}\n", n.x, n.y, n.z));
+        out.push_str("    outer loop\n");
+        for p in v {
+            out.push_str(&format!("      vertex {:e} {:e} {:e}\n", p.x, p.y, p.z));
+        }
+        out.push_str("    endloop\n  endfacet\n");
+    }
+    out.push_str(&format!("endsolid {name}\n"));
+    out
+}
+
+/// Wavefront OBJ, keeping points shared and faces at their real arity.
+///
+/// Indices are 1-based, which is the format's convention and the single most
+/// common way to write a broken OBJ.
+///
+/// Normals are written when the geometry carries `N` — the attribute the
+/// Normal node publishes — and referenced per corner as `f v//n`. Without it
+/// the faces are written bare and the reader computes its own, which is the
+/// right default: a stale `N` from before a deform would be worse than none.
+pub fn obj(d: &Detail, scale: f32, name: &str) -> String {
+    let mut out = format!("# cce-designer {name}\n");
+    out.push_str(&format!("o {name}\n"));
+
+    for p in d.positions() {
+        out.push_str(&format!("v {:e} {:e} {:e}\n", p[0] * scale, p[1] * scale, p[2] * scale));
+    }
+
+    let has_normals = d.points().has("N");
+    if has_normals {
+        for p in 0..d.num_points() {
+            let n = d.points().value("N", p).map(|v| v.as_vec3()).unwrap_or(Vec3::Y);
+            out.push_str(&format!("vn {:e} {:e} {:e}\n", n.x, n.y, n.z));
+        }
+    }
+
+    for prim in 0..d.num_prims() {
+        let pts = d.prim_points(prim);
+        // A two-point primitive is a line, not a face. OBJ has `l` for exactly
+        // this, and writing it as a face would give readers a degenerate
+        // triangle to choke on.
+        let tag = if pts.len() < 3 { "l" } else { "f" };
+        out.push_str(tag);
+        for &p in pts {
+            let i = p + 1;
+            if has_normals && pts.len() >= 3 {
+                out.push_str(&format!(" {i}//{i}"));
+            } else {
+                out.push_str(&format!(" {i}"));
+            }
+        }
+        out.push('\n');
+    }
+    out
+}
+
+/// Which writer a path's extension asks for.
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum Format {
+    StlBinary,
+    StlAscii,
+    Obj,
+}
+
+impl Format {
+    /// Read off a file name, defaulting to binary STL — the form a printer
+    /// wants and the one an unrecognized name most likely meant.
+    pub fn from_path(path: &std::path::Path) -> Format {
+        match path
+            .extension()
+            .and_then(|e| e.to_str())
+            .unwrap_or("")
+            .to_ascii_lowercase()
+            .as_str()
+        {
+            "obj" => Format::Obj,
+            _ => Format::StlBinary,
+        }
+    }
+
+    pub fn label(self) -> &'static str {
+        match self {
+            Format::StlBinary => "STL",
+            Format::StlAscii => "STL (ASCII)",
+            Format::Obj => "OBJ",
+        }
+    }
+}
+
+/// Write `geom` to `path`.
+///
+/// The parent directory is created if it is missing, because being told a
+/// directory does not exist is a worse answer than making it, and every other
+/// way this app writes a file does the same.
+pub fn write(
+    geom: &Detail,
+    path: &std::path::Path,
+    format: Format,
+    scale: f32,
+) -> Result<usize, String> {
+    let name = path
+        .file_stem()
+        .and_then(|s| s.to_str())
+        .unwrap_or("geometry")
+        .to_string();
+    if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
+        std::fs::create_dir_all(dir)
+            .map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
+    }
+    let bytes = match format {
+        Format::StlBinary => stl_binary(geom, scale, &name),
+        Format::StlAscii => stl_ascii(geom, scale, &name).into_bytes(),
+        Format::Obj => obj(geom, scale, &name).into_bytes(),
+    };
+    let len = bytes.len();
+    std::fs::write(path, bytes).map_err(|e| format!("cannot write {}: {e}", path.display()))?;
+    Ok(len)
+}
diff --git a/src/export_cli.rs b/src/export_cli.rs
new file mode 100644
index 0000000..cc9e0b3
--- /dev/null
+++ b/src/export_cli.rs
@@ -0,0 +1,82 @@
+//! `--export`: a project in, a mesh file out, no window.
+//!
+//! The counterpart of `--thumbnail`. Both exist for the same reason: work that
+//! can only leave the app through a window is work that cannot be scripted,
+//! diffed, or checked by a test.
+
+use crate::export::{self, Format};
+
+/// Evaluate `project` and write its geometry to `out`.
+///
+/// Without `--node` the whole visible scene is written, which is what the
+/// viewport shows. With it, that one node's output is written whether or not
+/// it is visible — an Export node's input is normally drawn by something else,
+/// so the node itself usually has its geometry flag off.
+pub fn run(
+    project: &std::path::Path,
+    out: &std::path::Path,
+    frame: Option<i32>,
+    node: Option<String>,
+    scale: f32,
+) -> Result<String, String> {
+    // Loaded the same way --thumbnail does: a project is a directory holding
+    // state.json, or that file directly.
+    let state_file = if project.is_dir() {
+        project.join("state.json")
+    } else {
+        project.to_path_buf()
+    };
+    let content = std::fs::read_to_string(&state_file)
+        .map_err(|e| format!("read {}: {e}", state_file.display()))?;
+    let mut proj: crate::app::Project = serde_json::from_str(&content)
+        .map_err(|e| format!("parse {}: {e}", state_file.display()))?;
+    let templates = crate::app::flatten_node_templates(&crate::app::load_fs_tree());
+    crate::app::merge_template_defs(&mut proj.root, &templates);
+
+    let mut ocl_error = None;
+    let mut sim_cache = crate::geometry::SimCache::default();
+    // Start frame 1, the playbar's default, so a frame number here means what
+    // it means in the window — the same contract --thumbnail makes.
+    let mut sim = crate::geometry::EvalSim::new(frame.unwrap_or(0), 1, &mut sim_cache);
+
+    let geom = match &node {
+        Some(name) => {
+            let target = crate::geometry::find_node_by_name(&proj.root, name)
+                .ok_or_else(|| format!("no node named '{name}'"))?
+                .clone();
+            let mut visited = Vec::new();
+            crate::geometry::generate_single_node_geometry_with_errors(
+                &proj.root,
+                &target,
+                &mut visited,
+                &mut ocl_error,
+                &mut sim,
+            )
+            .ok_or_else(|| format!("'{name}' produced no geometry"))?
+        }
+        None => crate::geometry::network_sphere_vertices_with_errors(
+            &proj.root,
+            &proj.root,
+            &mut ocl_error,
+            &mut sim,
+        ),
+    };
+    if let Some(e) = ocl_error {
+        // Non-fatal, like the thumbnail: the rest of the scene still exports,
+        // and a silent partial file would be worse than a warning.
+        eprintln!("cce-designer --export: OpenCL error (geometry partially skipped): {e}");
+    }
+    if geom.num_prims() == 0 {
+        return Err("the geometry has no primitives".to_string());
+    }
+
+    let format = Format::from_path(out);
+    let bytes = export::write(&geom, out, format, scale)?;
+    Ok(format!(
+        "{} points, {} primitives -> {} as {} ({bytes} bytes)",
+        geom.num_points(),
+        geom.num_prims(),
+        out.display(),
+        format.label()
+    ))
+}
diff --git a/src/geometry.rs b/src/geometry.rs
index 8287301..ab80745 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -764,6 +764,8 @@ pub fn generate_single_node_geometry_with_errors(
         resolve_valence_geometry_with_errors(root, target, visited, ocl_error, sim)
     } else if target.node_type.eq_ignore_ascii_case("deform") {
         resolve_deform_geometry_with_errors(root, target, visited, ocl_error, sim)
+    } else if target.node_type.eq_ignore_ascii_case("export") {
+        resolve_export_geometry_with_errors(root, target, visited, ocl_error, sim)
     } else if target.node_type.eq_ignore_ascii_case("subdivide") {
         resolve_subdivide_geometry_with_errors(root, target, visited, ocl_error, sim)
     } else if target.node_type.eq_ignore_ascii_case("detangle") {
@@ -1668,6 +1670,34 @@ pub fn resolve_cull_geometry_with_errors(
     Some(geom)
 }
 
+/// The Export node: geometry out of the app.
+///
+/// A pass-through in the chain — it hands its input straight on, so it can sit
+/// anywhere rather than only at the end — that writes a file when its Export
+/// button is pressed. NOT when it evaluates: evaluation happens on every
+/// redraw and every frame of a solve, and a node that wrote a file each time
+/// would fill a disk while you scrubbed the timeline.
+pub fn resolve_export_geometry_with_errors(
+    root: &FsNode,
+    target: &FsNode,
+    visited: &mut Vec<String>,
+    ocl_error: &mut Option<String>,
+    sim: &mut EvalSim,
+) -> Option<Detail> {
+    let input_node = find_node_by_name(root, &node_param_str(target, "Input", ""))?;
+    generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)
+}
+
+/// The format and scale an Export node is configured for.
+pub fn export_settings(target: &FsNode) -> (crate::export::Format, f32) {
+    let format = match node_param_str(target, "Format", "STL").as_str() {
+        "OBJ" => crate::export::Format::Obj,
+        "STL (ASCII)" => crate::export::Format::StlAscii,
+        _ => crate::export::Format::StlBinary,
+    };
+    (format, node_param_f32(target, "Scale", 1.0).max(1e-6))
+}
+
 /// The Grid node: a flat sheet of quads in the XZ plane.
 ///
 /// Native, where Plane is an OpenCL subnet. Both make a grid; this one costs
@@ -4806,6 +4836,7 @@ pub fn is_geometry_node_type(node_type: &str) -> bool {
         || nt == "suture"
         || nt == "detangle"
         || nt == "subdivide"
+        || nt == "export"
         || nt == "deform"
         || nt == "valence"
         || nt == "transfer"
@@ -5065,6 +5096,15 @@ pub fn network_sphere_vertices_with_errors(
                     out.merge(&geom);
                 }
             }
+        } else if node.node_type.eq_ignore_ascii_case("export") {
+            let _idx = *count;
+            *count += 1;
+            if is_visible {
+                let mut visited = Vec::new();
+                if let Some(geom) = resolve_export_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
+                    out.merge(&geom);
+                }
+            }
         } else if node.node_type.eq_ignore_ascii_case("subdivide") {
             let _idx = *count;
             *count += 1;
diff --git a/src/main.rs b/src/main.rs
index 29b174d..5967ecd 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -3,6 +3,8 @@ pub mod app;
 pub mod application;
 pub mod curve_tool;
 pub mod detail;
+pub mod export;
+pub mod export_cli;
 pub mod remesh;
 pub mod spatial;
 
@@ -65,6 +67,41 @@ fn main() {
         }
     }
 
+    // Headless export: evaluate a project and write its geometry to a file.
+    //   cce-designer --export <project> <out.stl|out.obj> [--frame N] [--node NAME] [--scale S]
+    //
+    // The counterpart of --thumbnail, and the same argument for existing: work
+    // that can only leave the app through a window is work that cannot be
+    // scripted, diffed or checked.
+    if let Some(i) = args.iter().position(|a| a == "--export") {
+        let _ = env_logger::try_init();
+        let (Some(project), Some(out)) = (args.get(i + 1), args.get(i + 2)) else {
+            eprintln!(
+                "usage: cce-designer --export <project> <out.stl|out.obj> [--frame N] [--node NAME] [--scale S]"
+            );
+            std::process::exit(2);
+        };
+        let flag = |name: &str| args.windows(2).find(|w| w[0] == name).map(|w| w[1].clone());
+        let frame = flag("--frame").and_then(|v| v.parse::<i32>().ok());
+        let scale = flag("--scale").and_then(|v| v.parse::<f32>().ok()).unwrap_or(1.0);
+        match export_cli::run(
+            std::path::Path::new(project),
+            std::path::Path::new(out),
+            frame,
+            flag("--node"),
+            scale,
+        ) {
+            Ok(msg) => {
+                println!("{msg}");
+                std::process::exit(0);
+            }
+            Err(e) => {
+                eprintln!("cce-designer --export: {e}");
+                std::process::exit(1);
+            }
+        }
+    }
+
     // Everything windowed runs on the cce-ui engine (application.rs holds the
     // Application impl; --detached-network is read there).
     cce_ui::engine::run::<app::State>();
@@ -3608,6 +3645,170 @@ mod tests {
         assert_eq!(param_display(&params)[1].1, "7.5", "and comes back as it was");
     }
 
+    // ---- Mesh export ----
+
+    /// A two-quad sheet: enough to tell a format that keeps topology from one
+    /// that does not.
+    fn sheet() -> Detail {
+        let mut d = Detail::new();
+        for (x, z) in [(0.0, 0.0), (1.0, 0.0), (2.0, 0.0), (0.0, 1.0), (1.0, 1.0), (2.0, 1.0)] {
+            d.add_point(Vec3::new(x, 0.0, z));
+        }
+        d.add_prim(&[0, 3, 4, 1]);
+        d.add_prim(&[1, 4, 5, 2]);
+        d
+    }
+
+    #[test]
+    fn test_stl_writes_a_file_a_printer_can_read() {
+        use crate::export::stl_binary;
+        let d = sheet();
+        let b = stl_binary(&d, 1.0, "sheet");
+
+        // 80-byte header, a count, 50 bytes a triangle. Two quads fan to four.
+        let count = u32::from_le_bytes(b[80..84].try_into().unwrap()) as usize;
+        assert_eq!(count, 4);
+        assert_eq!(b.len(), 84 + count * 50);
+        // The header must NOT begin with "solid": that word at the start of a
+        // file is how readers guess at the ASCII form, and a binary file that
+        // opens with it is a well-known way to be misread.
+        assert!(!b.starts_with(b"solid"), "binary STL must not look like ASCII");
+        assert!(b.starts_with(b"cce-designer sheet"));
+
+        // Every facet normal agrees with its own winding. STL stores both and
+        // they can disagree; a slicer that trusts the normal would then see
+        // the surface inside out.
+        for t in 0..count {
+            let at = 84 + t * 50;
+            let f: Vec<f32> = (0..12)
+                .map(|i| f32::from_le_bytes(b[at + i * 4..at + i * 4 + 4].try_into().unwrap()))
+                .collect();
+            let n = Vec3::new(f[0], f[1], f[2]);
+            let (a, bb, c) = (
+                Vec3::new(f[3], f[4], f[5]),
+                Vec3::new(f[6], f[7], f[8]),
+                Vec3::new(f[9], f[10], f[11]),
+            );
+            let geo = (bb - a).cross(c - a).normalize();
+            assert!(n.dot(geo) > 0.999, "facet {t}: normal {n:?} vs winding {geo:?}");
+            // The attribute byte count nothing uses and everything expects.
+            assert_eq!(u16::from_le_bytes(b[at + 48..at + 50].try_into().unwrap()), 0);
+        }
+
+        // Scale multiplies coordinates and nothing else.
+        let big = stl_binary(&d, 10.0, "sheet");
+        let vx = |blob: &[u8]| f32::from_le_bytes(blob[96..100].try_into().unwrap());
+        assert!((vx(&big) - vx(&b) * 10.0).abs() < 1e-4);
+
+        // Empty geometry is a valid file with no triangles, not a panic.
+        let empty = stl_binary(&Detail::new(), 1.0, "nothing");
+        assert_eq!(empty.len(), 84);
+        assert_eq!(u32::from_le_bytes(empty[80..84].try_into().unwrap()), 0);
+    }
+
+    #[test]
+    fn test_obj_keeps_the_topology_that_stl_throws_away() {
+        use crate::export::{obj, stl_binary};
+        let d = sheet();
+        let text = obj(&d, 1.0, "sheet");
+        let lines: Vec<&str> = text.lines().collect();
+
+        // One vertex per POINT and one face per PRIMITIVE: a quad stays a
+        // quad and a shared point stays shared. STL cannot say either — it
+        // fans to four triangles carrying twelve loose corners.
+        assert_eq!(lines.iter().filter(|l| l.starts_with("v ")).count(), d.num_points());
+        let faces: Vec<&&str> = lines.iter().filter(|l| l.starts_with("f ")).collect();
+        assert_eq!(faces.len(), d.num_prims());
+        for f in &faces {
+            assert_eq!(f.split_whitespace().count() - 1, 4, "a quad did not survive: {f}");
+        }
+        let count = u32::from_le_bytes(stl_binary(&d, 1.0, "s")[80..84].try_into().unwrap());
+        assert_eq!(count, 4, "STL fans the same mesh to triangles");
+
+        // Indices are 1-based and in range — the single most common way to
+        // write a broken OBJ.
+        for f in &faces {
+            for tok in f.split_whitespace().skip(1) {
+                let i: usize = tok.split('/').next().unwrap().parse().unwrap();
+                assert!(i >= 1 && i <= d.num_points(), "index {i} out of range");
+            }
+        }
+    }
+
+    #[test]
+    fn test_obj_writes_normals_only_when_the_geometry_has_them() {
+        use crate::export::obj;
+        let plain = obj(&sheet(), 1.0, "s");
+        assert!(!plain.contains("vn "), "normals appeared from nowhere");
+        assert!(plain.lines().any(|l| l.starts_with("f 1 4 5 2")), "{plain}");
+
+        // With N present they are written and referenced per corner. Without
+        // it the faces stay bare and the reader computes its own, which is the
+        // right default: a stale N from before a deform is worse than none.
+        let mut d = sheet();
+        d.points_mut().create("N", AttribValue::Float3([0.0, 1.0, 0.0]));
+        let with = obj(&d, 1.0, "s");
+        assert_eq!(with.lines().filter(|l| l.starts_with("vn ")).count(), d.num_points());
+        assert!(with.lines().any(|l| l.starts_with("f 1//1 4//4")), "{with}");
+    }
+
+    #[test]
+    fn test_obj_writes_a_two_point_primitive_as_a_line() {
+        use crate::export::obj;
+        // Polygon unfilled is a ring of two-point prims. OBJ has `l` for
+        // exactly this; writing them as faces would hand readers degenerate
+        // triangles.
+        let root = modelling_root(
+            "1.0",
+            vec![phase3_node("polygon", &[("Sides", "5"), ("Fill", "false")])],
+        );
+        let (ring, _) = eval_node(&root, "polygon 1");
+        let text = obj(&ring, 1.0, "ring");
+        assert_eq!(text.lines().filter(|l| l.starts_with("l ")).count(), 5);
+        assert_eq!(text.lines().filter(|l| l.starts_with("f ")).count(), 0);
+    }
+
+    #[test]
+    fn test_ascii_stl_is_the_same_mesh_in_words() {
+        use crate::export::{stl_ascii, stl_binary};
+        let d = sheet();
+        let text = stl_ascii(&d, 1.0, "sheet");
+        let facets = text.lines().filter(|l| l.trim_start().starts_with("facet normal")).count();
+        let verts = text.lines().filter(|l| l.trim_start().starts_with("vertex")).count();
+        let count = u32::from_le_bytes(stl_binary(&d, 1.0, "s")[80..84].try_into().unwrap()) as usize;
+        assert_eq!(facets, count);
+        assert_eq!(verts, count * 3);
+        assert!(text.starts_with("solid sheet\n") && text.trim_end().ends_with("endsolid sheet"));
+    }
+
+    #[test]
+    fn test_the_extension_picks_the_format() {
+        use crate::export::Format;
+        use std::path::Path;
+        assert_eq!(Format::from_path(Path::new("a/b.obj")), Format::Obj);
+        assert_eq!(Format::from_path(Path::new("a/b.OBJ")), Format::Obj);
+        assert_eq!(Format::from_path(Path::new("a/b.stl")), Format::StlBinary);
+        // Anything unrecognized is binary STL: the form a printer wants, and
+        // the one an unlabelled name most likely meant.
+        assert_eq!(Format::from_path(Path::new("a/b")), Format::StlBinary);
+    }
+
+    #[test]
+    fn test_write_creates_the_directory_and_reports_the_size() {
+        use crate::export::{write, Format};
+        let dir = std::env::temp_dir()
+            .join(format!("cce-export-test-{}", std::process::id()))
+            .join("nested");
+        let path = dir.join("thing.obj");
+        let n = write(&sheet(), &path, Format::Obj, 1.0).expect("write");
+        assert!(path.exists(), "the nested directory was not created");
+        assert_eq!(n, std::fs::metadata(&path).unwrap().len() as usize);
+
+        let _ = std::fs::remove_file(&path);
+        let _ = std::fs::remove_dir(&dir);
+        let _ = std::fs::remove_dir(dir.parent().unwrap());
+    }
+
     // ---- Phase 4: the modelling set ----
 
     /// Two spheres far apart: two connected pieces, the first much larger.