Skip to main content

energyml_export/
registry.rs

1//! The exporter trait and the registry that holds them.
2
3use std::io::Write;
4
5use energyml_domain::reproject::Reprojector;
6use energyml_geometry::{Placement, Point, SceneObject};
7
8use crate::error::{Error, Result};
9
10/// What a caller can ask of an export, beyond the object itself.
11#[derive(Default)]
12pub struct Options<'a> {
13    /// A geodesy backend, for the formats that need real coordinates.
14    ///
15    /// `None` is a supported state: an exporter that needs one says so by name
16    /// rather than writing projected metres into a longitude field.
17    pub reprojector: Option<&'a dyn Reprojector>,
18    /// Override the placement the exporter would choose.
19    ///
20    /// For a caller that knows better - a viewer that wants local coordinates
21    /// out of a format that would normally be projected.
22    pub placement: Option<Placement>,
23    /// Reproject into this EPSG system before writing.
24    ///
25    /// Overrides `placement`, which stops meaning anything once a datum is
26    /// crossed: reprojection starts from placed metres or it starts from
27    /// nowhere.
28    pub target_epsg: Option<u64>,
29    /// Write coordinates as they are stored, reprojecting nothing.
30    ///
31    /// The escape hatch for the formats whose specification fixes a coordinate
32    /// system - GeoJSON is WGS 84 by definition. With this set, such a format
33    /// writes the **placed native coordinates** instead, embeds the legacy
34    /// 2008 `crs` member when the EPSG code is known (QGIS and GDAL honour
35    /// it), and accepts objects that name no CRS at all.
36    ///
37    /// The file is then knowingly non-conformant. That is the point: for a
38    /// package under repair, coordinates one can look at beat a refusal - but
39    /// it has to be asked for, because a conformant-looking file with the
40    /// wrong coordinate system is the silent failure this crate is arranged
41    /// against.
42    pub native_crs: bool,
43    /// Which form to draw each type in - see [`crate::shapes`].
44    ///
45    /// Empty means every object in its natural shape, which is what every
46    /// export did before there was anything to ask.
47    pub shapes: crate::shapes::Shapes,
48    /// **Length units one second of vertical travel time stands for.**
49    ///
50    /// `None` - the default - keeps the refusal: a representation in a time
51    /// system cannot be written in metres, and saying so is the honest answer.
52    ///
53    /// # This is an equivalence, not a conversion
54    ///
55    /// A seismic surface has no depth. Getting one needs a velocity model the
56    /// package does not carry, and RESQML puts the time *kind* - two-way
57    /// against one-way - outside the CRS on purpose (TRG ยง12.9 leaves it to the
58    /// acquisition activity), so nothing in the file even says whether to
59    /// halve. Every number this produces is therefore **half or double** a real
60    /// depth depending on a fact the file withholds.
61    ///
62    /// What it buys is a mesh: a surface that can sit in the same file as the
63    /// depth objects around it, at a size somebody can look at. That is a
64    /// legitimate thing to want and this is the only honest way to give it -
65    /// the caller states the number, and the number is written into the file
66    /// beside the geometry so the mesh carries its own caveat. A mesh loses its
67    /// context the day it is exported; the export is the last moment anything
68    /// can still say what it holds.
69    ///
70    /// Metres per second, matching the metres every exporter writes.
71    pub time_equivalence: Option<f64>,
72}
73
74impl<'a> Options<'a> {
75    /// Nothing special.
76    pub fn new() -> Self {
77        Self::default()
78    }
79
80    /// Supply a geodesy backend.
81    pub fn with_reprojector(mut self, reprojector: &'a dyn Reprojector) -> Self {
82        self.reprojector = Some(reprojector);
83        self
84    }
85
86    /// Force a placement.
87    pub fn with_placement(mut self, placement: Placement) -> Self {
88        self.placement = Some(placement);
89        self
90    }
91
92    /// Write coordinates in this EPSG system. Needs a backend.
93    pub fn with_target_epsg(mut self, epsg: u64) -> Self {
94        self.target_epsg = Some(epsg);
95        self
96    }
97
98    /// Write native coordinates, reprojecting nothing. See [`Options::native_crs`].
99    pub fn with_native_crs(mut self) -> Self {
100        self.native_crs = true;
101        self
102    }
103
104    /// Choose, per type, what form to draw. See [`crate::shapes::Shapes`].
105    pub fn with_shapes(mut self, shapes: crate::shapes::Shapes) -> Self {
106        self.shapes = shapes;
107        self
108    }
109
110    /// State what one second of vertical travel time stands for, in metres, so
111    /// that time-domain objects can be written at all.
112    ///
113    /// See [`Options::time_equivalence`] for what this does and does not claim.
114    pub fn with_time_equivalence(mut self, metres_per_second: f64) -> Self {
115        self.time_equivalence = Some(metres_per_second);
116        self
117    }
118
119    /// Whether this object's vertical axis can be placed at all.
120    ///
121    /// `false` for a time-domain object with no equivalence stated - which is
122    /// the state every export was in until one could be. Asked *before*
123    /// writing, so such an object is **skipped and counted** rather than
124    /// failing the whole export at the first one: nine seismic horizons in
125    /// ALWYN used to abort a run that had forty perfectly writable surfaces in
126    /// it, and "the export failed" is not what a person needed to be told.
127    pub fn can_place_vertical(&self, object: &SceneObject) -> bool {
128        let Some(crs) = object.crs.as_ref() else {
129            return true;
130        };
131        if !crs.is_time() {
132            return true;
133        }
134        if self.time_equivalence.is_none() {
135            return false;
136        }
137        // And the axis has to state a unit that *is* a time. A package can
138        // contradict itself here - a 2.0.1 `LocalTime3dCrs` with no `TimeUom`
139        // falls back to `VerticalUom`, which is a length by design - and the
140        // equivalence has nothing to convert. Asked before writing so that one
141        // such object is skipped and counted, instead of aborting an export of
142        // forty that were fine: *"`m` is a Length unit, but a Time was expected
143        // here"* is true, unactionable, and arrives after the decision to
144        // export was already taken.
145        match crs.vertical_axis_uom() {
146            Some(unit) => energyml_domain::units::seconds(1.0, unit).is_ok(),
147            // No unit stated at all is the schema's own default, and for a time
148            // axis the default is the second. Taken at its word.
149            None => true,
150        }
151    }
152
153    /// The sentence an exporter writes into the file, and a caller prints.
154    ///
155    /// One place, so the comment in an OBJ, the field in a VTK and the line on
156    /// a terminal cannot drift into saying different things about the same
157    /// number. It names the equivalence *and* what it is not, because the
158    /// second half is what stops somebody measuring a depth off the result.
159    pub fn time_equivalence_note(&self, object: &SceneObject) -> Option<String> {
160        let per_second = self.time_equivalence?;
161        if !object.crs.as_ref().is_some_and(|crs| crs.is_time()) {
162            return None;
163        }
164        Some(format!(
165            "vertical axis is TRAVEL TIME, written with the stated equivalence \
166             1 s = {per_second} m. This is a display equivalence, not a depth: \
167             the velocity model is not in the package, and RESQML does not say \
168             whether the time is one-way or two-way, so a real depth is half or \
169             double this. Do not measure from it."
170        ))
171    }
172
173    /// The points an exporter should write, placed and reprojected as asked.
174    ///
175    /// The single funnel every exporter goes through, so that "which
176    /// coordinates am I writing" has one answer and not one per format.
177    pub fn points(&self, object: &SceneObject, placement: Placement) -> Result<Vec<Point>> {
178        // A time axis, before anything else: `Placement::Metres` refuses it by
179        // name and `points_in_epsg` starts from metres, so both roads end at
180        // the same refusal. With an equivalence stated there is something to
181        // write, and it is written here rather than in each format.
182        if let Some(per_second) = self.time_equivalence
183            && object.crs.as_ref().is_some_and(|crs| crs.is_time())
184        {
185            return self.time_points(object, per_second);
186        }
187        match self.target_epsg {
188            Some(target) => self.points_in_epsg(object, target),
189            None => Ok(object.points_in(placement)?),
190        }
191    }
192
193    /// A time-domain object, placed with a stated equivalence.
194    ///
195    /// The horizontal axes are metres like every other export - they are
196    /// lengths and there is nothing special about them. Only the vertical one
197    /// is converted twice: into **seconds** through the CRS's own vertical
198    /// unit, which is `ms` in every corpus package and would be a silent factor
199    /// of a thousand if assumed, and then into metres through the caller's
200    /// equivalence.
201    fn time_points(&self, object: &SceneObject, per_second: f64) -> Result<Vec<Point>> {
202        let Some(crs) = object.crs.as_ref() else {
203            return Err(Error::Placement(format!(
204                "`{}` has a time equivalence to apply and names no CRS",
205                object.title
206            )));
207        };
208        // `Projected`, not `Metres`: the offsets and the vertical flip apply to
209        // a time axis exactly as to a depth, and it is only the *unit* step
210        // that has nothing to say about seconds.
211        let placed = object.points_in(Placement::Projected)?;
212        let horizontal = |value: f64| -> Result<f64> {
213            Ok(match &crs.horizontal_uom {
214                Some(unit) => energyml_domain::units::metres(value, unit)?,
215                None => value,
216            })
217        };
218        let vertical = |value: f64| -> Result<f64> {
219            // `vertical_axis_uom`, never `vertical_uom`: a 2.0.1 time CRS
220            // states `VerticalUom` as a **length** - it describes the ZOffset -
221            // and puts the axis unit in `TimeUom`. ALWYN reads `m` for the
222            // first and `ms` for the second.
223            let seconds = match crs.vertical_axis_uom() {
224                Some(unit) => energyml_domain::units::seconds(value, unit)?,
225                // No unit stated is the schema's own default, and for a time
226                // axis the default is the second. Taken at its word rather than
227                // guessed at, same rule as `Crs::to_metres`.
228                None => value,
229            };
230            Ok(seconds * per_second)
231        };
232        placed
233            .into_iter()
234            .map(|[x, y, z]| Ok([horizontal(x)?, horizontal(y)?, vertical(z)?]))
235            .collect()
236    }
237
238    /// The points, in one named EPSG system.
239    ///
240    /// For a format whose coordinate system is fixed by its own specification -
241    /// GeoJSON is WGS 84 and nothing else - rather than chosen by the caller.
242    ///
243    /// Placement is **always** `Metres` here, and not the caller's choice:
244    /// a reprojection consumes projected metres. Feeding it local coordinates,
245    /// or metres that have already been reprojected, is the kind of mistake
246    /// that produces a plausible-looking file in the wrong hemisphere.
247    pub fn points_in_epsg(&self, object: &SceneObject, target: u64) -> Result<Vec<Point>> {
248        let placed = object.points_in(Placement::Metres)?;
249        let Some(source) = object.epsg() else {
250            return Err(Error::Placement(format!(
251                "`{}` names no EPSG code, so it cannot be put in EPSG:{target}",
252                object.title
253            )));
254        };
255        if source == target {
256            return Ok(placed);
257        }
258        let Some(backend) = self.reprojector else {
259            return Err(Error::Placement(format!(
260                "`{}` is in EPSG:{source} and this export wants EPSG:{target}; \
261                 supply a `Reprojector` - `energyml_proj::Proj4` is one",
262                object.title
263            )));
264        };
265        Ok(backend.reproject_all(&placed, source, target)?)
266    }
267}
268
269/// Writes geometry in one interchange format.
270///
271/// The contract is the crate's: **what cannot be written is an error naming
272/// it**. An exporter that quietly skips an object it does not understand
273/// produces a file that looks complete and is not, and nothing downstream can
274/// tell.
275pub trait Exporter: Send + Sync {
276    /// The name a caller asks for - `obj`, `geojson`. Lower case, no dot.
277    fn name(&self) -> &'static str;
278
279    /// The file extension, without the dot.
280    fn extension(&self) -> &'static str;
281
282    /// The MIME type, for a caller serving this over HTTP.
283    fn mime(&self) -> &'static str;
284
285    /// One line on what it is, for `energyml export --list`.
286    fn describe(&self) -> &'static str;
287
288    /// Which coordinates it wants, unless the caller overrides it.
289    fn placement(&self) -> Placement {
290        Placement::Metres
291    }
292
293    /// Whether it can write this object at all.
294    ///
295    /// Asked before anything is written, so a caller can skip or report rather
296    /// than find a half-written file.
297    fn handles(&self, object: &SceneObject) -> bool;
298
299    /// [`Exporter::handles`], for the exporters whose answer depends on the
300    /// options - GeoJSON accepts an object with no EPSG code once
301    /// [`Options::native_crs`] is set.
302    fn handles_with(&self, object: &SceneObject, options: &Options<'_>) -> bool {
303        self.handles(object) && options.can_place_vertical(object)
304    }
305
306    /// Write one object.
307    fn write(&self, object: &SceneObject, out: &mut dyn Write, options: &Options<'_>) -> Result<()>;
308
309    /// Write several into one file.
310    ///
311    /// The default writes them in order, which is right for the formats where
312    /// concatenation is the format - OBJ is one. A format with a single
313    /// enclosing document, like GeoJSON, overrides this.
314    fn write_all(&self, objects: &[SceneObject], out: &mut dyn Write, options: &Options<'_>) -> Result<()> {
315        for object in objects {
316            self.write(object, out, options)?;
317        }
318        Ok(())
319    }
320}
321
322/// The object an exporter should actually write: the same one, minus any node
323/// that has no position.
324///
325/// **Every exporter calls this first, and the rule has one implementation.** A
326/// node whose coordinates are NaN cannot be written in any of these formats -
327/// OBJ has no syntax for it, VTK's readers put it at the origin or refuse the
328/// file, and `NaN` is **not a JSON value**, so one null Z made a whole GeoJSON
329/// unparseable by `JSON.parse`. Leaving each writer to remember that separately
330/// is how two of the three would forget.
331///
332/// Borrowed when there is nothing to drop, which is the ordinary case: a package
333/// that carries no null pays one pass over its points and no copy.
334///
335/// What the standard does and does not say about those nulls is set out in
336/// [`energyml_geometry::defined`] - including the two RESQML flags that outrank
337/// the coordinates for column-layer grids.
338pub fn drawable(object: &SceneObject) -> std::borrow::Cow<'_, SceneObject> {
339    match object.drawable_only() {
340        Some(compacted) => std::borrow::Cow::Owned(compacted),
341        None => std::borrow::Cow::Borrowed(object),
342    }
343}
344
345/// The object an exporter should actually write: reshaped as the caller asked,
346/// then stripped of the nodes that have no position.
347///
348/// **Every exporter calls this first, and the order of the two steps matters.**
349/// Reshaping first is what makes a hole in a grid mean something: the lattice
350/// becomes quadrilaterals, and the compaction then drops the four cells around
351/// the null node instead of one dot among three hundred thousand. Compacting
352/// first would leave the reshaping a rectangle with a gap in it, which is not a
353/// rectangle.
354///
355/// A shape the object cannot take is an **error**, not a fallback: the caller
356/// wrote a rule, and a rule that silently does something else teaches them it
357/// worked. See [`crate::shapes`].
358pub fn prepare<'a>(
359    object: &'a SceneObject,
360    options: &Options<'_>,
361) -> Result<std::borrow::Cow<'a, SceneObject>> {
362    let shape = options.shapes.shape_for(&object.qualified_type);
363    let reshaped = object.as_shape(shape)?;
364    Ok(match reshaped {
365        std::borrow::Cow::Borrowed(same) => drawable(same),
366        std::borrow::Cow::Owned(owned) => std::borrow::Cow::Owned(owned.drawable_only().unwrap_or(owned)),
367    })
368}
369
370/// The exporters a build offers.
371pub struct Exporters {
372    entries: Vec<Box<dyn Exporter>>,
373}
374
375impl Default for Exporters {
376    fn default() -> Self {
377        Self::builtin()
378    }
379}
380
381impl Exporters {
382    /// None at all.
383    pub fn none() -> Self {
384        Self { entries: Vec::new() }
385    }
386
387    /// Everything this crate implements.
388    pub fn builtin() -> Self {
389        let mut exporters = Self::none();
390        exporters.entries.push(Box::new(crate::obj::ObjExporter));
391        exporters.entries.push(Box::new(crate::csv::CsvExporter));
392        exporters.entries.push(Box::new(crate::geojson::GeoJsonExporter));
393        exporters
394            .entries
395            .push(Box::new(crate::vtk::VtkExporter::legacy()));
396        exporters
397            .entries
398            .push(Box::new(crate::vtk::VtkExporter::poly_data()));
399        exporters
400            .entries
401            .push(Box::new(crate::vtk::VtkExporter::unstructured_grid()));
402        exporters
403    }
404
405    /// Add one, found **before** everything already there, so a caller can
406    /// replace a built-in as well as extend the set.
407    pub fn push(&mut self, exporter: Box<dyn Exporter>) -> &mut Self {
408        self.entries.insert(0, exporter);
409        self
410    }
411
412    /// The exporter for a format name, matched case-insensitively and with a
413    /// leading dot forgiven - `obj`, `OBJ` and `.obj` are one request.
414    pub fn find(&self, name: &str) -> Option<&dyn Exporter> {
415        let wanted = name.trim().trim_start_matches('.').to_ascii_lowercase();
416        self.entries
417            .iter()
418            .find(|e| e.name() == wanted || e.extension() == wanted)
419            .map(|e| e.as_ref())
420    }
421
422    /// The exporter for a format name, or an error listing what there is.
423    pub fn get(&self, name: &str) -> Result<&dyn Exporter> {
424        self.find(name).ok_or_else(|| Error::UnknownFormat {
425            name: name.to_string(),
426            available: self
427                .entries
428                .iter()
429                .map(|e| e.name())
430                .collect::<Vec<_>>()
431                .join(", "),
432        })
433    }
434
435    /// Every exporter, in the order they are searched.
436    pub fn all(&self) -> Vec<&dyn Exporter> {
437        self.entries.iter().map(|e| e.as_ref()).collect()
438    }
439
440    /// How many there are.
441    pub fn len(&self) -> usize {
442        self.entries.len()
443    }
444
445    /// Whether there are none.
446    pub fn is_empty(&self) -> bool {
447        self.entries.is_empty()
448    }
449}