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

src/export.rs (7.4K)

  1 //! Writing geometry out: STL and OBJ.
  2 //!
  3 //! Everything the app builds has, until now, been something you could only
  4 //! look at inside it. These are the two formats that matter for what this tool
  5 //! is for: **STL** is what a printer and a mold chain read, and **OBJ** is what
  6 //! every other piece of software reads.
  7 //!
  8 //! ## What each format can carry
  9 //!
 10 //! They are not the same picture of a mesh, and the difference is worth
 11 //! knowing before choosing:
 12 //!
 13 //! - **OBJ keeps the topology.** Points are written once and faces reference
 14 //!   them, so a quad stays a quad and a shared point stays shared. Reimporting
 15 //!   an OBJ gives back the mesh that was exported.
 16 //! - **STL keeps only the triangles.** It has no notion of a shared point: every
 17 //!   triangle carries its own three corners, so a mesh comes back welded-by-
 18 //!   position at best and a quad comes back as two triangles always. That is
 19 //!   the format's design, not a shortcoming of this writer — it exists to feed
 20 //!   a machine that only needs a closed surface.
 21 //!
 22 //! Neither carries attributes. A simulation's state does not survive an
 23 //! export, which is what the project file and the sim cache are for.
 24 //!
 25 //! ## Units
 26 //!
 27 //! Coordinates are written exactly as they are, scaled only by the caller's
 28 //! Scale. The app's World Unit is a DECLARATION about what one unit means, not
 29 //! a conversion (see the Guides node), and export keeps that promise: a
 30 //! geometry modelled at 20 units across writes as 20, and it is the printer's
 31 //! slicer that is told those are millimetres.
 32 
 33 use crate::detail::Detail;
 34 use glam::Vec3;
 35 
 36 /// The triangles of a piece of geometry, with a face normal each.
 37 ///
 38 /// Both STL writers want exactly this, and the fan matches what the viewport
 39 /// and the path tracer draw — so what is exported is what was on screen.
 40 fn triangles(d: &Detail, scale: f32) -> Vec<([Vec3; 3], Vec3)> {
 41     d.triangulate(|pos, _| Vec3::from(pos) * scale)
 42         .chunks_exact(3)
 43         .map(|t| {
 44             let n = (t[1] - t[0]).cross(t[2] - t[0]).normalize_or_zero();
 45             ([t[0], t[1], t[2]], n)
 46         })
 47         .collect()
 48 }
 49 
 50 /// Binary STL: an 80-byte header, a triangle count, then 50 bytes each.
 51 ///
 52 /// The header deliberately does NOT begin with "solid" — that word at the
 53 /// start of a file is how readers guess a file is the ASCII form, and a binary
 54 /// file that opens with it is a well-known way to be misread.
 55 pub fn stl_binary(d: &Detail, scale: f32, name: &str) -> Vec<u8> {
 56     let tris = triangles(d, scale);
 57     let mut out = Vec::with_capacity(84 + tris.len() * 50);
 58 
 59     let mut header = [0u8; 80];
 60     let label = format!("cce-designer {name}");
 61     for (slot, b) in header.iter_mut().zip(label.bytes()) {
 62         *slot = b;
 63     }
 64     out.extend_from_slice(&header);
 65     out.extend_from_slice(&(tris.len() as u32).to_le_bytes());
 66 
 67     for (v, n) in &tris {
 68         for c in [n.x, n.y, n.z] {
 69             out.extend_from_slice(&c.to_le_bytes());
 70         }
 71         for p in v {
 72             for c in [p.x, p.y, p.z] {
 73                 out.extend_from_slice(&c.to_le_bytes());
 74             }
 75         }
 76         // The "attribute byte count", which nothing uses and everything
 77         // expects to be there.
 78         out.extend_from_slice(&0u16.to_le_bytes());
 79     }
 80     out
 81 }
 82 
 83 /// ASCII STL. Bigger and slower to read than the binary form, and the one to
 84 /// reach for when something downstream is being difficult and you want to look
 85 /// at the file.
 86 pub fn stl_ascii(d: &Detail, scale: f32, name: &str) -> String {
 87     let mut out = format!("solid {name}\n");
 88     for (v, n) in triangles(d, scale) {
 89         out.push_str(&format!("  facet normal {:e} {:e} {:e}\n", n.x, n.y, n.z));
 90         out.push_str("    outer loop\n");
 91         for p in v {
 92             out.push_str(&format!("      vertex {:e} {:e} {:e}\n", p.x, p.y, p.z));
 93         }
 94         out.push_str("    endloop\n  endfacet\n");
 95     }
 96     out.push_str(&format!("endsolid {name}\n"));
 97     out
 98 }
 99 
100 /// Wavefront OBJ, keeping points shared and faces at their real arity.
101 ///
102 /// Indices are 1-based, which is the format's convention and the single most
103 /// common way to write a broken OBJ.
104 ///
105 /// Normals are written when the geometry carries `N` — the attribute the
106 /// Normal node publishes — and referenced per corner as `f v//n`. Without it
107 /// the faces are written bare and the reader computes its own, which is the
108 /// right default: a stale `N` from before a deform would be worse than none.
109 pub fn obj(d: &Detail, scale: f32, name: &str) -> String {
110     let mut out = format!("# cce-designer {name}\n");
111     out.push_str(&format!("o {name}\n"));
112 
113     for p in d.positions() {
114         out.push_str(&format!("v {:e} {:e} {:e}\n", p[0] * scale, p[1] * scale, p[2] * scale));
115     }
116 
117     let has_normals = d.points().has("N");
118     if has_normals {
119         for p in 0..d.num_points() {
120             let n = d.points().value("N", p).map(|v| v.as_vec3()).unwrap_or(Vec3::Y);
121             out.push_str(&format!("vn {:e} {:e} {:e}\n", n.x, n.y, n.z));
122         }
123     }
124 
125     for prim in 0..d.num_prims() {
126         let pts = d.prim_points(prim);
127         // A two-point primitive is a line, not a face. OBJ has `l` for exactly
128         // this, and writing it as a face would give readers a degenerate
129         // triangle to choke on.
130         let tag = if pts.len() < 3 { "l" } else { "f" };
131         out.push_str(tag);
132         for &p in pts {
133             let i = p + 1;
134             if has_normals && pts.len() >= 3 {
135                 out.push_str(&format!(" {i}//{i}"));
136             } else {
137                 out.push_str(&format!(" {i}"));
138             }
139         }
140         out.push('\n');
141     }
142     out
143 }
144 
145 /// Which writer a path's extension asks for.
146 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
147 pub enum Format {
148     StlBinary,
149     StlAscii,
150     Obj,
151 }
152 
153 impl Format {
154     /// Read off a file name, defaulting to binary STL — the form a printer
155     /// wants and the one an unrecognized name most likely meant.
156     pub fn from_path(path: &std::path::Path) -> Format {
157         match path
158             .extension()
159             .and_then(|e| e.to_str())
160             .unwrap_or("")
161             .to_ascii_lowercase()
162             .as_str()
163         {
164             "obj" => Format::Obj,
165             _ => Format::StlBinary,
166         }
167     }
168 
169     pub fn label(self) -> &'static str {
170         match self {
171             Format::StlBinary => "STL",
172             Format::StlAscii => "STL (ASCII)",
173             Format::Obj => "OBJ",
174         }
175     }
176 }
177 
178 /// Write `geom` to `path`.
179 ///
180 /// The parent directory is created if it is missing, because being told a
181 /// directory does not exist is a worse answer than making it, and every other
182 /// way this app writes a file does the same.
183 pub fn write(
184     geom: &Detail,
185     path: &std::path::Path,
186     format: Format,
187     scale: f32,
188 ) -> Result<usize, String> {
189     let name = path
190         .file_stem()
191         .and_then(|s| s.to_str())
192         .unwrap_or("geometry")
193         .to_string();
194     if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
195         std::fs::create_dir_all(dir)
196             .map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
197     }
198     let bytes = match format {
199         Format::StlBinary => stl_binary(geom, scale, &name),
200         Format::StlAscii => stl_ascii(geom, scale, &name).into_bytes(),
201         Format::Obj => obj(geom, scale, &name).into_bytes(),
202     };
203     let len = bytes.len();
204     std::fs::write(path, bytes).map_err(|e| format!("cannot write {}: {e}", path.display()))?;
205     Ok(len)
206 }