energyml_export/obj.rs
1//! Wavefront OBJ - triangles and polylines, in metres.
2//!
3//! The simplest useful export: every mesh viewer reads it, it is text, and it
4//! carries exactly what geometry has. What it does not carry is a coordinate
5//! system, so the numbers are placed and normalised to metres before writing -
6//! an OBJ of eastings in feet is a file nobody can combine with anything.
7//!
8//! The identity goes into comments. OBJ has no metadata section, and a `#` line
9//! is the only place the uuid can live where every reader will ignore it and
10//! every person will see it.
11
12use std::io::Write;
13
14use energyml_geometry::{Placement, SceneObject, Topology};
15
16use crate::error::{Error, Result};
17use crate::registry::{Exporter, Options};
18
19/// Writes Wavefront OBJ.
20pub struct ObjExporter;
21
22impl Exporter for ObjExporter {
23 fn name(&self) -> &'static str {
24 "obj"
25 }
26
27 fn extension(&self) -> &'static str {
28 "obj"
29 }
30
31 fn mime(&self) -> &'static str {
32 "model/obj"
33 }
34
35 fn describe(&self) -> &'static str {
36 "Wavefront OBJ: triangles and polylines, placed and in metres"
37 }
38
39 fn placement(&self) -> Placement {
40 Placement::Metres
41 }
42
43 fn handles(&self, object: &SceneObject) -> bool {
44 !object.geometry.is_empty()
45 }
46
47 fn write(&self, object: &SceneObject, out: &mut dyn Write, options: &Options<'_>) -> Result<()> {
48 if object.geometry.is_empty() {
49 return Err(Error::unsuitable(
50 "obj",
51 format!("`{}`, which has no points", object.title),
52 ));
53 }
54 self.write_all(std::slice::from_ref(object), out, options)
55 }
56
57 fn write_all(&self, objects: &[SceneObject], out: &mut dyn Write, options: &Options<'_>) -> Result<()> {
58 // One implementation, and `write` goes through it too. OBJ vertex
59 // indices are **1-based and file-global**, so writing several objects
60 // means carrying a running offset that a per-object function cannot
61 // know about - and two code paths for one format is how the single-object
62 // header came to say less than the multi-object one.
63 let mut base = 1u32;
64 for object in objects {
65 if !self.handles(object) {
66 continue;
67 }
68 // Reshaped as the caller's rules ask, then stripped of the nodes
69 // that have no position - one funnel, one order, for every format.
70 // `v NaN NaN NaN` is not something an OBJ reader can place, and a
71 // face indexed on one has no shape. See `registry::prepare`.
72 let object = crate::registry::prepare(object, options)?;
73 let object = object.as_ref();
74 let placement = options.placement.unwrap_or_else(|| self.placement());
75 let points = options.points(object, placement)?;
76 header(object, placement, options, out)?;
77 writeln!(out, "o {}", sanitise(&object.title, &object.uuid))?;
78 for point in &points {
79 writeln!(out, "v {} {} {}", point[0], point[1], point[2])?;
80 }
81 for patch in &object.geometry.patches {
82 match &patch.topology {
83 Topology::Triangles(triangles) => {
84 for triangle in triangles {
85 writeln!(
86 out,
87 "f {} {} {}",
88 triangle[0] + base,
89 triangle[1] + base,
90 triangle[2] + base
91 )?;
92 }
93 }
94 // A wireframe's edge: OBJ's `l` takes any two indices,
95 // consecutive or not.
96 Topology::Segments(pairs) => {
97 for pair in pairs {
98 writeln!(out, "l {} {}", pair[0] + base, pair[1] + base)?;
99 }
100 }
101 Topology::Polylines(runs) => {
102 let mut start = base;
103 for (rank, run) in runs.iter().enumerate() {
104 if *run >= 2 {
105 let mut indices: Vec<String> =
106 (0..*run).map(|i| (start + i).to_string()).collect();
107 // A ring the file calls closed ends on its first
108 // vertex: an OBJ `l` has no closing flag.
109 if patch.closed.get(rank) == Some(&true) {
110 indices.push(start.to_string());
111 }
112 writeln!(out, "l {}", indices.join(" "))?;
113 }
114 start += run;
115 }
116 }
117 // A cell becomes its six faces. Interior faces are written
118 // twice - once for each cell that owns them - which is
119 // wasteful and correct; deduplicating them would mean
120 // deciding which side is outside, and a faulted grid has no
121 // single answer to that.
122 Topology::Hexahedra { cells, .. } => {
123 for cell in cells {
124 for face in HEXAHEDRON_FACES {
125 writeln!(
126 out,
127 "f {} {} {} {}",
128 cell[face[0]] + base,
129 cell[face[1]] + base,
130 cell[face[2]] + base,
131 cell[face[3]] + base
132 )?;
133 }
134 }
135 }
136 // An unstructured cell becomes its own faces, whatever
137 // shape they are - OBJ takes a polygon of any size, so a
138 // quadrilateral stays one instead of becoming two
139 // triangles. Already wound outward, so the normals a viewer
140 // computes point out of the model rather than half in and
141 // half out.
142 Topology::Polyhedra(cells) => {
143 for cell in 0..cells.cell_count() {
144 for face in cells.faces_outward(cell) {
145 let nodes: Vec<String> =
146 face.iter().map(|node| (node + base).to_string()).collect();
147 writeln!(out, "f {}", nodes.join(" "))?;
148 }
149 }
150 }
151 // OBJ takes a polygon of any size, so a quadrilateral goes
152 // in whole rather than becoming two triangles. Already
153 // wound by `energyml_geometry::shape`, which measures the
154 // signed area instead of assuming a convention the file
155 // does not state.
156 Topology::Quads(quads) => {
157 for quad in quads {
158 writeln!(
159 out,
160 "f {} {} {} {}",
161 quad[0] + base,
162 quad[1] + base,
163 quad[2] + base,
164 quad[3] + base
165 )?;
166 }
167 }
168 // A ring of any length - OBJ's `f` takes as many indices as
169 // it is given, so an outline goes in as one face.
170 Topology::Polygons(rings) => {
171 for ring in rings {
172 let nodes: Vec<String> =
173 ring.iter().map(|node| (node + base).to_string()).collect();
174 writeln!(out, "f {}", nodes.join(" "))?;
175 }
176 }
177 // A lattice still asked for as a lattice, and a point set.
178 // `Shape::Surface` - the default for a grid - turns the
179 // first into `Quads` above.
180 Topology::Grid { .. } | Topology::Points => {
181 writeln!(out, "# {} point(s), no faces", patch.points.len())?;
182 }
183 }
184 base += patch.points.len() as u32;
185 }
186 }
187 Ok(())
188 }
189}
190
191/// Everything a person needs to find this object again.
192///
193/// OBJ has no metadata section, so a `#` line is the only place the uuid can
194/// live where every reader ignores it and every person sees it.
195fn header(
196 object: &SceneObject,
197 placement: Placement,
198 options: &Options<'_>,
199 out: &mut dyn Write,
200) -> Result<()> {
201 writeln!(out, "# {}", object.title)?;
202 writeln!(out, "# uuid {}", object.uuid)?;
203 writeln!(out, "# uri {}", object.uri)?;
204 writeln!(out, "# type {}", object.qualified_type)?;
205 if let Some(interpretation) = &object.interpretation {
206 writeln!(
207 out,
208 "# interpretation {} {}",
209 interpretation.uuid, interpretation.title
210 )?;
211 }
212 if let Some(feature) = &object.feature {
213 writeln!(out, "# feature {} {}", feature.uuid, feature.title)?;
214 }
215 // The caller's `--metadata` spec, one comment line each. Only what was
216 // found: a comment naming a key with nothing after it tells a reader
217 // nothing they could act on, and OBJ has no null to mean "asked for and
218 // absent" the way a GeoJSON property does.
219 for (key, value) in object.metadata.pairs() {
220 writeln!(out, "# {key} {value}")?;
221 }
222 match (&object.crs, placement) {
223 (Some(crs), Placement::Local) => writeln!(out, "# crs {} (local coordinates)", crs.uuid)?,
224 (Some(crs), _) => {
225 let system = match crs.projected.epsg() {
226 Some(code) => format!("EPSG:{code}"),
227 None => format!("{:?}", crs.projected),
228 };
229 // *"metres, z up"* would be a lie on a time axis, and the sort of
230 // lie a reader has no way to catch: the numbers look like depths.
231 let vertical = if crs.is_time() {
232 "z from travel time"
233 } else {
234 "metres, z up"
235 };
236 writeln!(out, "# crs {} {system}, {vertical}", crs.uuid)?;
237 }
238 (None, _) => writeln!(out, "# crs none stated; coordinates are as written")?,
239 }
240 // The equivalence, beside the geometry it produced. A mesh loses its
241 // context the day it is exported; this is the last place anything can still
242 // say what the Z means.
243 if let Some(note) = options.time_equivalence_note(object) {
244 for line in wrap_comment(¬e) {
245 writeln!(out, "# {line}")?;
246 }
247 }
248 Ok(())
249}
250
251/// The six faces of a hexahedron in VTK corner order, each wound so its normal
252/// points out of the cell.
253const HEXAHEDRON_FACES: [[usize; 4]; 6] = [
254 [0, 3, 2, 1], // the 0-1-2-3 face, reversed so the normal points outward
255 [4, 5, 6, 7],
256 [0, 1, 5, 4],
257 [1, 2, 6, 5],
258 [2, 3, 7, 6],
259 [3, 0, 4, 7],
260];
261
262/// An OBJ object name: no spaces, and never empty.
263fn sanitise(title: &str, uuid: &str) -> String {
264 let cleaned: String = title
265 .chars()
266 .map(|c| if c.is_whitespace() { '_' } else { c })
267 .filter(|c| !matches!(c, '#' | '\n' | '\r'))
268 .collect();
269 if cleaned.trim().is_empty() {
270 uuid.to_string()
271 } else {
272 cleaned
273 }
274}
275
276/// Break a sentence into comment-length lines.
277///
278/// An OBJ comment has no continuation and no length limit, but a 300-character
279/// `#` line is one a person scrolls past - and this one is the caveat that
280/// stops them measuring a depth off the file, so it has to be readable.
281fn wrap_comment(text: &str) -> Vec<String> {
282 const WIDTH: usize = 76;
283 let mut lines = Vec::new();
284 let mut line = String::new();
285 for word in text.split_whitespace() {
286 if !line.is_empty() && line.len() + 1 + word.len() > WIDTH {
287 lines.push(std::mem::take(&mut line));
288 }
289 if !line.is_empty() {
290 line.push(' ');
291 }
292 line.push_str(word);
293 }
294 if !line.is_empty() {
295 lines.push(line);
296 }
297 lines
298}