Skip to main content

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(&note) {
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}