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}