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 }