energyml_geometry/scene.rs
1//! What an exporter is given: shape, place, and identity.
2//!
3//! # Why identity travels with the geometry
4//!
5//! An exported surface that is only a set of triangles is a dead end. The
6//! question asked of an export is almost never "what shape is this" alone - it
7//! is "which horizon is this, which interpretation does it come from, and where
8//! do I look it up again". So a [`SceneObject`] carries the uuid and the ETP
9//! URI alongside the points, and the interpretation and feature above them when
10//! the package names them.
11//!
12//! That chain is a RESQML habit worth knowing: a representation *represents* an
13//! interpretation, and an interpretation *interprets* a feature. Three objects
14//! for one horizon - the shape, the reading of it, and the thing itself. An
15//! export that drops the top two produces a file nobody can trace back.
16//!
17//! # Why the CRS is carried and not applied
18//!
19//! [`SceneObject::points_in`] places the points on request, because different
20//! exporters want different answers: OBJ wants metres near the origin, GeoJSON
21//! wants degrees of longitude and latitude, and a viewer may want the local
22//! system untouched. Applying a placement here would make that choice once, for
23//! everybody, invisibly.
24
25use energyml_core::value::{FieldValue, Reflect};
26use energyml_domain::crs::{self, Crs};
27use energyml_domain::metadata::{Metadata, Spec};
28
29use crate::error::Result;
30use crate::geometry::{self, Geometry};
31use crate::points::Point;
32use crate::readers::Readers;
33use crate::shape::Shape;
34use crate::source::ValueSource;
35
36/// Which coordinates an exporter wants.
37#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
38pub enum Placement {
39 /// The representation's own axes and units, untouched.
40 Local,
41 /// Placed in the projected system: rotation, offset, and the depth flip.
42 #[default]
43 Projected,
44 /// Placed and normalised to metres.
45 ///
46 /// Fails rather than guessing when the CRS states a unit this build cannot
47 /// convert - a model in feet read as metres is 3.28 times too far out with
48 /// every coordinate still looking plausible.
49 Metres,
50}
51
52impl Placement {
53 /// The name a caller types, and the one this prints back.
54 ///
55 /// Here rather than in whatever binding needs it first: the command line,
56 /// the front end and the Python bridge must not each invent their own spelling
57 /// of *projected*, or the same model comes out differently depending on
58 /// which door it left by.
59 pub fn name(self) -> &'static str {
60 match self {
61 Placement::Local => "local",
62 Placement::Projected => "projected",
63 Placement::Metres => "metres",
64 }
65 }
66
67 /// A placement by name, or a refusal that lists what is accepted.
68 ///
69 /// `meters` is taken too, because half the world spells it that way and a
70 /// refusal over an `r` teaches nobody anything. **Anything else is
71 /// refused**: a placement quietly defaulting to the wrong one moves a model
72 /// by its CRS offset and still draws something plausible.
73 pub fn parse(name: &str) -> std::result::Result<Placement, String> {
74 match name {
75 "local" => Ok(Placement::Local),
76 "projected" => Ok(Placement::Projected),
77 "metres" | "meters" => Ok(Placement::Metres),
78 other => Err(format!(
79 "unknown placement `{other}`: one of `local` (the representation's own axes), \
80`projected` (rotation, offset and depth flip applied) or `metres` (and refused when the CRS \
81states a unit this build cannot convert)"
82 )),
83 }
84 }
85}
86
87/// One representation, ready to be written out.
88#[derive(Clone, Debug)]
89pub struct SceneObject {
90 /// The representation's uuid.
91 pub uuid: String,
92 /// Its ETP URI - what an application looks it up by.
93 pub uri: String,
94 /// Its citation title, or an empty string when it has none.
95 pub title: String,
96 /// Its qualified type, e.g. `resqml20.obj_TriangulatedSetRepresentation`.
97 pub qualified_type: String,
98 /// The shape.
99 pub geometry: Geometry,
100 /// Where it is, when it names a coordinate system.
101 pub crs: Option<Crs>,
102 /// The interpretation this represents, when it names one.
103 pub interpretation: Option<Related>,
104 /// The feature that interpretation interprets, when it can be followed.
105 pub feature: Option<Related>,
106 /// Whatever a [`Spec`] was asked to collect.
107 ///
108 /// Empty unless the caller supplied one. Exporters write these verbatim, so
109 /// the same spec produces the same keys in an OBJ comment, a GeoJSON
110 /// property and a VTK field array - one configuration, every format.
111 pub metadata: Metadata,
112 /// Values hanging on this shape: one number per node, per cell, per
113 /// triangle.
114 ///
115 /// Empty unless the caller filled it with [`attributes_for`], because
116 /// reading them means reading every property of the package and their
117 /// external arrays - which an export of pure geometry has no reason to
118 /// pay for.
119 pub attributes: Vec<Attribute>,
120}
121
122/// A property's values, lined up with the points or the cells of a shape.
123///
124/// What a viewer colours by. The scope is decided from the property's
125/// `IndexableElement` and **checked against the geometry** - a mismatch is
126/// reported and the attribute dropped rather than written against the wrong
127/// elements, where it would draw a plausible picture of nothing.
128#[derive(Clone, Debug, PartialEq)]
129pub struct Attribute {
130 /// Uuid of the property object.
131 pub uuid: String,
132 /// A name for the array: the property's title, or its uuid.
133 pub name: String,
134 /// What it measures, when the kind could be resolved.
135 pub kind: Option<String>,
136 /// Its unit, when it has one.
137 pub uom: Option<String>,
138 /// Whether the values hang on the points or on the cells.
139 pub scope: AttributeScope,
140 /// Values per element: 1 for a scalar property, more for a vector one.
141 pub components: u64,
142 /// The values, widened to `f64` and in element order.
143 pub values: Vec<f64>,
144 /// A colour ramp for these values, sampled from the package's own colour
145 /// map when it has one: RGBA rows in 0-1, low value first.
146 ///
147 /// Empty when the package states no colour for this property, and empty is
148 /// the right answer - the standard defines no default ramp, and inventing
149 /// one would draw a picture the file does not describe. Filled by the
150 /// caller, because resolving a colour needs the whole package (see
151 /// `energyml_domain::color::Cascade`).
152 pub lut: Vec<[f64; 4]>,
153}
154
155/// Which elements an attribute's values belong to.
156#[derive(Clone, Copy, PartialEq, Eq, Debug)]
157pub enum AttributeScope {
158 /// One value per point.
159 Points,
160 /// One value per cell - a triangle, a line segment, a hexahedron.
161 Cells,
162}
163
164/// An object named by a reference, resolved far enough to label it.
165#[derive(Clone, Debug, PartialEq, Eq)]
166pub struct Related {
167 /// Its uuid.
168 pub uuid: String,
169 /// Its title, when the reference or the object carries one.
170 pub title: String,
171 /// Its type, as the reference spells it.
172 pub qualified_type: String,
173}
174
175impl SceneObject {
176 /// The points of every patch, placed as asked, in one array.
177 ///
178 /// The order matches [`Geometry::merged`], so the indices that call returns
179 /// address these points.
180 pub fn points_in(&self, placement: Placement) -> Result<Vec<Point>> {
181 let (points, _) = self.geometry.merged();
182 self.place(points, placement)
183 }
184
185 /// The shape itself, every patch placed as asked - its topology untouched.
186 ///
187 /// What an operation computing **for the scene** must read: the scene draws
188 /// the projected placement, so a geometry computed in the local frame and
189 /// handed back as an artefact lands where the file's numbers are, not where
190 /// the object is drawn - offsets, rotation and the vertical flip missing.
191 /// The same `place` as every other placement here, so the two cannot drift.
192 pub fn geometry_in(&self, placement: Placement) -> Result<Geometry> {
193 let mut out = self.geometry.clone();
194 for patch in &mut out.patches {
195 patch.points = self.place(std::mem::take(&mut patch.points), placement)?;
196 }
197 Ok(out)
198 }
199
200 /// Points and triangles together, placed as asked.
201 pub fn mesh_in(&self, placement: Placement) -> Result<(Vec<Point>, Vec<[u32; 3]>)> {
202 let (points, triangles) = self.geometry.merged();
203 Ok((self.place(points, placement)?, triangles))
204 }
205
206 /// Points, triangles **and line segments**, placed.
207 ///
208 /// What a viewer wants: [`mesh_in`] answers only for triangles, so a grid
209 /// and a trajectory came back with no indices at all and were drawn as
210 /// point clouds. See [`crate::geometry::Geometry::drawable`].
211 ///
212 /// [`mesh_in`]: SceneObject::mesh_in
213 pub fn drawable_in(&self, placement: Placement) -> Result<crate::geometry::Drawable> {
214 let drawn = self.geometry.drawable();
215 Ok(crate::geometry::Drawable {
216 points: self.place(drawn.points, placement)?,
217 triangles: drawn.triangles,
218 lines: drawn.lines,
219 dropped: drawn.dropped,
220 triangle_cells: drawn.triangle_cells,
221 node_cells: drawn.node_cells,
222 })
223 }
224
225 /// Place points that are not part of this object's own geometry - in the
226 /// same coordinate system, through the same `place` every patch goes
227 /// through, so a caller drawing something *alongside* the shape (a
228 /// frame's head portion, `crate::well::head_portion`) cannot place it
229 /// differently from the shape itself.
230 pub fn place_points(&self, points: Vec<Point>, placement: Placement) -> Result<Vec<Point>> {
231 self.place(points, placement)
232 }
233
234 fn place(&self, points: Vec<Point>, placement: Placement) -> Result<Vec<Point>> {
235 let Some(crs) = &self.crs else {
236 // No CRS is not an error: plenty of representations name none, and
237 // their local coordinates are the only answer there is.
238 return Ok(points);
239 };
240 match placement {
241 Placement::Local => Ok(points),
242 Placement::Projected => Ok(points.into_iter().map(|p| crs.to_projected(p)).collect()),
243 Placement::Metres => points
244 .into_iter()
245 .map(|p| crs.to_metres(p))
246 .collect::<std::result::Result<Vec<_>, _>>()
247 .map_err(Into::into),
248 }
249 }
250
251 /// The EPSG code of the system these coordinates sit in, when there is one.
252 pub fn epsg(&self) -> Option<u64> {
253 self.crs.as_ref().and_then(|crs| crs.projected.epsg())
254 }
255
256 /// This object drawn in the shape asked for.
257 ///
258 /// Borrowed when the shape is already what the object naturally is, which
259 /// is the ordinary case - [`Shape::Auto`] on anything, or a rule that
260 /// happens to agree with the file.
261 ///
262 /// **The points never move**, because every conversion in [`crate::shape`]
263 /// is arithmetic on indices. A point-scoped property therefore comes
264 /// through untouched. A **cell**-scoped one may not: asking a hexahedral
265 /// grid for its surface replaces a million cells with the faces around
266 /// them, and there is no value per face to be had. Such an attribute is
267 /// dropped rather than written against elements it was not measured on -
268 /// the same rule as everywhere else here, and the reason it is safe for the
269 /// case that matters is that a 2D grid drawn as quadrilaterals has exactly
270 /// as many cells as before, so nothing is lost there.
271 pub fn as_shape(&self, shape: Shape) -> Result<std::borrow::Cow<'_, SceneObject>> {
272 if self
273 .geometry
274 .patches
275 .iter()
276 .all(|patch| patch.topology.is_already(shape))
277 {
278 return Ok(std::borrow::Cow::Borrowed(self));
279 }
280
281 let cells_before = self.geometry.cell_count();
282 let points_before = self.geometry.point_count();
283 let geometry = self.geometry.as_shape(shape)?;
284
285 // A **summary** - an outline, a centroid - replaces the points, so
286 // nothing that hangs on them survives it. Four corners saying where a
287 // horizon is cannot carry a value per node of the horizon, and keeping
288 // the array would line it up against corners it was never measured on.
289 let attributes = if shape.is_summary() || geometry.point_count() != points_before {
290 Vec::new()
291 } else if geometry.cell_count() == cells_before {
292 self.attributes.clone()
293 } else {
294 // The points held, the cells did not - asking a hexahedral grid for
295 // its outside. A per-node property still lines up; a per-cell one
296 // has no value for a face.
297 self.attributes
298 .iter()
299 .filter(|attribute| attribute.scope == AttributeScope::Points)
300 .cloned()
301 .collect()
302 };
303
304 Ok(std::borrow::Cow::Owned(SceneObject {
305 geometry,
306 attributes,
307 ..self.clone()
308 }))
309 }
310
311 /// This object with every node that has no position removed - or `None`
312 /// when every node has one, which is the ordinary case and costs one pass
313 /// and no copy.
314 ///
315 /// **What [`crate::geometry::of`] returns is what the file says; this is
316 /// what can be drawn.** The two are kept apart on purpose: a package with
317 /// 34 000 null Z values should be *reported* as holding them and *exported*
318 /// without them. Reading and drawing are two questions, and answering the
319 /// first with the second's answer is how a node count silently stops
320 /// matching the file. See [`crate::defined`] for what the standard does and
321 /// does not say about those nulls.
322 ///
323 /// The attributes come along. Their values go through the same index maps
324 /// the geometry was compacted with, so a property still lines up with the
325 /// elements it was written for - leaving them alone would colour the
326 /// survivors with their neighbours' numbers, which is the worst of the three
327 /// possible outcomes because it is the one that looks right.
328 ///
329 /// An attribute whose length never matched the geometry is passed through
330 /// untouched, to be refused by the check that already exists rather than
331 /// silently reshaped here.
332 pub fn drawable_only(&self) -> Option<SceneObject> {
333 let compaction = self.geometry.defined_only()?;
334
335 let points_before = self.geometry.point_count();
336 let cells_before = self.geometry.cell_count();
337 let attributes = self
338 .attributes
339 .iter()
340 .map(|attribute| {
341 let (map, expected) = match attribute.scope {
342 AttributeScope::Points => (&compaction.points, points_before),
343 AttributeScope::Cells => (&compaction.cells, cells_before),
344 };
345 let components = attribute.components.max(1) as usize;
346 if attribute.values.len() != expected * components {
347 return attribute.clone();
348 }
349 let mut values = Vec::with_capacity(map.len() * components);
350 for element in map {
351 let start = element * components;
352 values.extend_from_slice(&attribute.values[start..start + components]);
353 }
354 Attribute {
355 values,
356 ..attribute.clone()
357 }
358 })
359 .collect();
360
361 Some(SceneObject {
362 geometry: compaction.geometry,
363 attributes,
364 ..self.clone()
365 })
366 }
367}
368
369/// Read one representation into a scene object.
370///
371/// Everything an exporter needs, gathered in one pass: the geometry through the
372/// reader registry, the CRS through the store, and the interpretation and
373/// feature by following the references the representation carries.
374pub fn object_of(representation: &dyn Reflect, source: &dyn ValueSource, uri: &str) -> Result<SceneObject> {
375 object_of_with(
376 crate::readers::default_readers(),
377 representation,
378 source,
379 uri,
380 None,
381 )
382}
383
384/// The same, with a reader registry and a metadata spec the caller controls.
385pub fn object_of_with(
386 readers: &Readers,
387 representation: &dyn Reflect,
388 source: &dyn ValueSource,
389 uri: &str,
390 spec: Option<&Spec>,
391) -> Result<SceneObject> {
392 let info = representation.type_info();
393 let geometry = geometry::of_with(readers, representation, source)?;
394 // The CRS is read even when it is absent or unreadable: a shape with no
395 // stated place is still a shape, and refusing to export it would be worse
396 // than exporting it unplaced.
397 let crs = crs::of(representation, source).unwrap_or(None);
398
399 let interpretation = related(representation, INTERPRETS);
400 // The feature hangs off the interpretation, not the representation, so it
401 // needs one more hop through the store - and it is worth it: the feature is
402 // the name a person recognises.
403 //
404 // Followed by the **reference**, so the version it names is the version
405 // read: a package holding the interpretation twice has two features to
406 // tell apart.
407 let feature = interpretation_of(representation, source)
408 .and_then(|object| related(object.as_reflect(), REPRESENTS_FEATURE));
409
410 // Evaluated here rather than in the exporter, because this is where the
411 // store is: a path that crosses a reference needs to resolve it, and an
412 // exporter is given an object rather than a package.
413 let metadata = match spec {
414 Some(spec) => spec.read(representation, source),
415 None => Metadata::default(),
416 };
417
418 Ok(SceneObject {
419 uuid: energyml_core::identity::uuid_of_reflect(representation)
420 .unwrap_or_default()
421 .to_string(),
422 uri: uri.to_string(),
423 title: title_of(representation),
424 // **The qualified type, not the class name**, which is what this field
425 // has always been documented as and what every hand-built
426 // `SceneObject` in the export tests holds. It was the Rust type name,
427 // so an OBJ comment, a GeoJSON property and a VTK field array all
428 // carried `PointSetRepresentation` where the package says
429 // `resqml20.obj_PointSetRepresentation` - a name that does not say
430 // which release it came from, in the one line of an export meant to
431 // lead back to the package.
432 qualified_type: energyml_core::json::qualified_type_of(info),
433 geometry,
434 crs,
435 interpretation,
436 feature,
437 metadata,
438 attributes: Vec::new(),
439 })
440}
441
442/// Where a property's colour decision comes from - **the seam that keeps this
443/// crate below the caches**.
444///
445/// A scene needs *what colour the package gives this property*, and the answer
446/// is the inheritance order (`energyml_domain::color_inherit`) over the package
447/// index, which a cache holds (`energyml-cache`, above this crate). A caller
448/// that holds one passes it here, and its colours are the front end's, to the byte;
449/// a caller that does not gets [`Cascade::resolve`](energyml_domain::color::Cascade::resolve),
450/// the shorter order that predates the index and ignores the collections and
451/// the model.
452pub trait Decide {
453 /// The decision for one request against the package's merged set, or
454 /// `None` when no level answers (or the walk could not be made).
455 fn decide(
456 &self,
457 set: &energyml_domain::color::GraphicalSet,
458 request: &energyml_domain::color::Request<'_>,
459 ) -> Option<energyml_domain::color::Decision>;
460}
461
462/// The colour ramp of one attribute, sampled from whatever colour map the
463/// package states for its property.
464///
465/// 256 rows over the values actually present, which is what a legacy VTK
466/// lookup table is. Empty when nothing states a colour: the standard defines
467/// no default ramp, and inventing one draws something the file does not say.
468///
469/// `colours` decides the property's colour ([`Decide`]); `None` falls back on
470/// the cascade's own `resolve`.
471pub fn colour_ramp(
472 objects: &dyn energyml_domain::source::ObjectSource,
473 cascade: &energyml_domain::color::Cascade<'_>,
474 colours: Option<&dyn Decide>,
475 kinds: &energyml_domain::property_kind::Resolver<'_>,
476 object: &SceneObject,
477 attribute: &Attribute,
478) -> Vec<[f64; 4]> {
479 use energyml_domain::color::{Paint, Request, Scope};
480 use energyml_domain::property;
481
482 // The property object has to outlive the borrow of what is read from it.
483 let Some(object_of_property) = objects.object(&attribute.uuid) else {
484 return Vec::new();
485 };
486 let Ok(read) = property::read(object_of_property.as_reflect()) else {
487 return Vec::new();
488 };
489
490 let chain: Vec<String> = read
491 .kind
492 .as_ref()
493 .and_then(|reference| kinds.resolve(reference).ok())
494 .map(|resolution| {
495 resolution
496 .chain
497 .iter()
498 .map(|kind| kind.uuid.clone())
499 .filter(|uuid| !uuid.is_empty())
500 .collect()
501 })
502 .unwrap_or_default();
503
504 let asked = Request {
505 // By uri: two versions of one representation are two requests.
506 representation: &object.uri,
507 property: Some(&attribute.uuid),
508 kind_chain: &chain,
509 element: Scope::WholeObject,
510 };
511 let decision = match colours {
512 Some(colours) => colours.decide(cascade.set, &asked),
513 None => cascade.resolve(&asked, objects).ok().flatten(),
514 };
515 let Some(decision) = decision else {
516 return Vec::new();
517 };
518 let Paint::Mapped { map, scaling, .. } = &decision.paint else {
519 // A constant colour is not a ramp: it colours the object, not its
520 // values, and the object's colour is already `energyml colors`.
521 return Vec::new();
522 };
523
524 let finite: Vec<f64> = attribute
525 .values
526 .iter()
527 .copied()
528 .filter(|v| v.is_finite())
529 .collect();
530 let (low, high) = match (
531 finite.iter().copied().reduce(f64::min),
532 finite.iter().copied().reduce(f64::max),
533 ) {
534 (Some(low), Some(high)) if high > low => (low, high),
535 _ => return Vec::new(),
536 };
537
538 (0..256)
539 .filter_map(|step| {
540 let value = low + (high - low) * (step as f64) / 255.0;
541 map.sample(Some(value), scaling)
542 .map(|rgba| [rgba.r, rgba.g, rgba.b, rgba.a])
543 })
544 .collect()
545}
546
547/// `n - 1` interval values as `n` node values.
548///
549/// **Each node takes exactly one interval** - the one it opens - and the
550/// deepest node takes the one it closes. That is what makes this a
551/// correspondence rather than a reduction: nothing is averaged, no majority is
552/// taken and there is no tie to break, which matters because these values are
553/// categories. The mean of two soil types is a third rock, not a shade between
554/// them.
555///
556/// The rule is **this build's**, not a quotation: a marker at a depth names the
557/// unit beneath it, which is how a depth-indexed log is read. Callers report it
558/// rather than applying it silently.
559fn intervals_onto_nodes(values: &[f64], components: usize, point_count: usize, segments: usize) -> Vec<f64> {
560 let mut out = Vec::with_capacity(point_count * components);
561 for node in 0..point_count {
562 let interval = node.min(segments.saturating_sub(1));
563 let at = interval * components;
564 out.extend_from_slice(&values[at..at + components]);
565 }
566 out
567}
568
569/// A package's properties, grouped by the representation they hang on.
570///
571/// **Built once, asked per object.** An export handed [`attributes_for`] every
572/// property of the package for every object it wrote, and that function reads
573/// each one to learn what it hangs on: 2 697 wellbore frames against the
574/// ~56 000 properties of the SIGEOL package is some 150 million reads for an
575/// answer an index holds (an OBJ export of the 2 000+ wells and their surfaces
576/// took far too long). The same quadratic shape as a front end's
577/// `colourChains` (×1 870).
578///
579/// The grouping rule is [`energyml_domain::property::Index`]'s - case-blind
580/// `SupportingRepresentation` - not a second one written here; this keeps the
581/// objects beside it, because [`attributes_for`] reads their values.
582pub struct PropertiesOf<'a> {
583 index: energyml_domain::property::Index,
584 by_uuid: std::collections::HashMap<String, &'a dyn Reflect>,
585 unread: Vec<String>,
586}
587
588impl<'a> PropertiesOf<'a> {
589 /// Read every property once.
590 pub fn new(properties: &[&'a dyn Reflect]) -> Self {
591 let (index, unread) = energyml_domain::property::Index::build(properties.iter().copied());
592 let by_uuid = properties
593 .iter()
594 .filter_map(|property| {
595 energyml_core::identity::uuid_of_reflect(*property)
596 .map(|uuid| (uuid.to_ascii_lowercase(), *property))
597 })
598 .collect();
599 Self {
600 index,
601 by_uuid,
602 unread,
603 }
604 }
605
606 /// The properties hanging on this representation, in package order -
607 /// what to hand [`attributes_for`] for it.
608 pub fn of(&self, representation_uuid: &str) -> Vec<&'a dyn Reflect> {
609 self.index
610 .of(representation_uuid)
611 .iter()
612 .filter_map(|entry| self.by_uuid.get(&entry.uuid.to_ascii_lowercase()).copied())
613 .collect()
614 }
615
616 /// The properties that could not be read - said once, not once per object.
617 pub fn unread(&self) -> &[String] {
618 &self.unread
619 }
620}
621
622/// Line a package's properties up with a shape.
623///
624/// The properties are passed in rather than searched for: nothing in a
625/// representation lists them - every link points the other way - so finding
626/// them means walking the package, which is the caller's job and not this
627/// function's (see `energyml_domain::property::Index`).
628///
629/// Returns what could be attached and, separately, **why each of the others
630/// could not**. A property silently missing from an export is the failure this
631/// whole path exists to avoid: the picture looks finished and is not.
632pub fn attributes_for(
633 object: &SceneObject,
634 properties: &[&dyn Reflect],
635 source: &dyn ValueSource,
636) -> (Vec<Attribute>, Vec<String>) {
637 use energyml_domain::property::{self, IndexableElement};
638
639 let point_count = object.geometry.point_count();
640 // Not the triangles of the merged mesh: a per-cell property of an IJK grid
641 // has one value per **hexahedron**, and of a polyline set one per line.
642 let cell_count = object.geometry.cell_count();
643 // **One line, and only one**: a wellbore frame, whose `intervals` are its
644 // segments (RESQML 2.2 Usage Guide §4.2.1, Table 1, p. 38: *"intervals -
645 // wellbore frames, column-layer grids"*). Its geometry counts the whole
646 // line as one cell, so `n - 1` interval values have no array of their
647 // length until they are moved onto the nodes. A set of several lines is
648 // left out on purpose: "interval `i` is the one node `i` opens" only holds
649 // when the nodes are one run.
650 let one_line = matches!(
651 object.geometry.patches.as_slice(),
652 [patch] if matches!(&patch.topology, crate::Topology::Polylines(runs) if runs.len() == 1)
653 );
654 let mut out = Vec::new();
655 let mut refused = Vec::new();
656
657 for property in properties {
658 let read = match property::read(*property) {
659 Ok(read) => read,
660 Err(e) => {
661 refused.push(format!("a property did not read: {e}"));
662 continue;
663 }
664 };
665 if read
666 .supporting_representation
667 .as_deref()
668 .map(str::to_ascii_lowercase)
669 != Some(object.uuid.to_ascii_lowercase())
670 {
671 continue;
672 }
673
674 // The indexable element decides which array it goes into. Anything
675 // that is neither a point nor a cell of the *merged* geometry is
676 // refused by name: writing it anyway would line values up against
677 // elements they do not belong to.
678 let scope = match read.indexable {
679 IndexableElement::Nodes => AttributeScope::Points,
680 // A frame's `intervals`: the same correspondence as `intervals
681 // from datum` below. They used to go to the cells - one, the
682 // whole line - and every interval property of a frame was refused
683 // (*« `BSS000NSYT — formation` has 3 value(s) for 1 cell(s) »*),
684 // which is what the well column and its colours asked for.
685 IndexableElement::Intervals if one_line => AttributeScope::Points,
686 IndexableElement::Cells
687 | IndexableElement::Faces
688 | IndexableElement::Triangles
689 | IndexableElement::Columns
690 | IndexableElement::Intervals => AttributeScope::Cells,
691 // **A frame’s intervals are its segments, and they are moved onto
692 // its nodes rather than refused.**
693 //
694 // `Topology::Polylines` counts *lines*, so a frame of `n` node
695 // depths is one cell and `n - 1` intervals: there is no array of
696 // that length to attach to, and lining them up against the line
697 // itself would put every value on one element.
698 //
699 // What makes this a correspondence rather than a reduction is that
700 // **each node takes exactly one interval** - the one it opens, and
701 // for the deepest node the one it closes. So there is no average,
702 // no majority and no tie to break, which matters because these are
703 // categories: a soil type between two others is a third rock, not
704 // a shade of them. `transfer` is deliberately not used, because it
705 // reduces over the geometry’s own cells, and a polyline’s cell is
706 // the whole line.
707 //
708 // The rule is **ours**, not a quotation: a marker at a depth names
709 // the unit beneath it, which is how a depth-indexed log is read.
710 // It is reported alongside the attachment so nobody has to guess
711 // which end of the interval a colour came from.
712 IndexableElement::IntervalsFromDatum => AttributeScope::Points,
713 other => {
714 refused.push(format!(
715 "`{}` hangs on {other}, which is neither the points nor the cells of the \
716 exported geometry; it was not attached",
717 read.title.as_deref().unwrap_or(&read.uuid)
718 ));
719 continue;
720 }
721 };
722
723 let mut values = Vec::new();
724 let mut failed = None;
725 for patch in &read.patches {
726 match patch.read(source) {
727 Ok(read) => match (read.floating(), read.integer()) {
728 (Some(v), _) => values.extend_from_slice(v),
729 (_, Some(v)) => values.extend(v.iter().map(|n| *n as f64)),
730 _ => failed = Some("its values are neither numbers nor integers".to_string()),
731 },
732 Err(e) => failed = Some(e.to_string()),
733 }
734 }
735 if let Some(reason) = failed {
736 refused.push(format!(
737 "`{}`: {reason}",
738 read.title.as_deref().unwrap_or(&read.uuid)
739 ));
740 continue;
741 }
742
743 // The interval → node correspondence, done before anything counts:
744 // `n - 1` interval values become `n` node values, each node taking the
745 // interval it opens and the deepest one the interval it closes. Only
746 // where the numbers say the property really is per segment - otherwise
747 // the count check below is left to report the disagreement, which is
748 // its job.
749 let frame_intervals = read.indexable == IndexableElement::IntervalsFromDatum
750 || (read.indexable == IndexableElement::Intervals && one_line);
751 if frame_intervals {
752 let components = read.values_per_element().max(1) as usize;
753 let segments = point_count.saturating_sub(cell_count);
754 if segments > 0 && values.len() == segments * components {
755 values = intervals_onto_nodes(&values, components, point_count, segments);
756 refused.push(format!(
757 "`{}` hangs on {}: its {segments} interval value(s) were moved onto \
758 the {point_count} node(s), each taking the interval below it and the \
759 deepest one the interval above. Nothing was averaged - these are \
760 categories - but the correspondence is this build's rule, not the \
761 standard's.",
762 read.title.as_deref().unwrap_or(&read.uuid),
763 read.indexable
764 ));
765 }
766 }
767
768 let expected = match scope {
769 AttributeScope::Points => point_count,
770 AttributeScope::Cells => cell_count,
771 };
772 let mut components = read.values_per_element().max(1);
773 // The corpus contains a property that declares two values per node and
774 // stores one - the producer ignoring its own
775 // `ValueCountPerIndexableElement`. The values are usable as scalars,
776 // and refusing them would lose a real array over a wrong number, so
777 // they are attached **as scalars with the disagreement reported**.
778 if components > 1 && expected > 0 && values.len() == expected {
779 refused.push(format!(
780 "`{}` declares {components} values per element and holds exactly one; it is \
781 attached as a scalar array, and the document's own count is what is wrong",
782 read.title.as_deref().unwrap_or(&read.uuid)
783 ));
784 components = 1;
785 }
786 if expected == 0 || values.len() != expected * components as usize {
787 // Reported, not repaired: padding or truncating would put values
788 // on the wrong elements, which is exactly the failure this check
789 // exists for.
790 refused.push(format!(
791 "`{}` has {} value(s) for {expected} {} × {components} component(s); it was not \
792 attached, because lining it up would put values on elements they do not belong to",
793 read.title.as_deref().unwrap_or(&read.uuid),
794 values.len(),
795 match scope {
796 AttributeScope::Points => "point(s)",
797 AttributeScope::Cells => "cell(s)",
798 }
799 ));
800 continue;
801 }
802
803 out.push(Attribute {
804 name: read.title.clone().unwrap_or_else(|| read.uuid.clone()),
805 kind: match &read.kind {
806 Some(energyml_domain::property::PropertyKindRef::Standard { name }) => Some(name.clone()),
807 Some(energyml_domain::property::PropertyKindRef::Object { title, .. }) => title.clone(),
808 None => None,
809 },
810 uom: read.uom.clone(),
811 scope,
812 components,
813 values,
814 uuid: read.uuid.clone(),
815 lut: Vec::new(),
816 });
817 }
818
819 (out, refused)
820}
821
822/// The field names a representation uses to name its interpretation.
823///
824/// Two spellings, both in the corpus: RESQML 2.0.1 says `RepresentedInterpretation`,
825/// 2.2 says `RepresentedObject` - the second is wider, since 2.2 lets a
826/// representation represent things that are not interpretations.
827const INTERPRETS: &[&str] = &["RepresentedInterpretation", "RepresentedObject"];
828
829/// And the ones an interpretation uses to name its feature.
830const REPRESENTS_FEATURE: &[&str] = &["InterpretedFeature", "InterpretedObject"];
831
832/// The interpretation a representation names, followed **by its reference**:
833/// the version the reference states is the version read, where asking the
834/// store for the uuid alone would find two objects (or the wrong one) in a
835/// package holding the interpretation twice. `None` when the representation
836/// names none or the store does not hold it.
837pub fn interpretation_of(
838 representation: &dyn Reflect,
839 source: &dyn energyml_domain::source::ObjectSource,
840) -> Option<Box<dyn energyml_core::registry::DynObject>> {
841 INTERPRETS
842 .iter()
843 .find_map(|field| match representation.field(field) {
844 Some(FieldValue::Object(reference)) => crate::follow::object_of(source, reference),
845 _ => None,
846 })
847}
848
849fn related(object: &dyn Reflect, fields: &[&str]) -> Option<Related> {
850 for field in fields {
851 let Some(FieldValue::Object(reference)) = object.field(field) else {
852 continue;
853 };
854 let uuid = energyml_core::identity::uuid_of_reflect(reference)?;
855 if uuid.is_empty() {
856 continue;
857 }
858 return Some(Related {
859 uuid: uuid.to_string(),
860 title: string_field(reference, "Title").unwrap_or_default(),
861 qualified_type: string_field(reference, "QualifiedType")
862 .or_else(|| string_field(reference, "ContentType"))
863 .unwrap_or_default(),
864 });
865 }
866 None
867}
868
869fn title_of(object: &dyn Reflect) -> String {
870 match object.field("Citation") {
871 Some(FieldValue::Object(citation)) => string_field(citation, "Title").unwrap_or_default(),
872 _ => String::new(),
873 }
874}
875
876fn string_field(object: &dyn Reflect, field: &str) -> Option<String> {
877 match object.field(field)? {
878 FieldValue::Scalar(scalar) => scalar.as_str().map(str::to_string).filter(|s| !s.is_empty()),
879 _ => None,
880 }
881}
882
883#[cfg(test)]
884mod tests {
885 use super::*;
886
887 /// A node takes the interval below it; the deepest takes the one above.
888 ///
889 /// Reported from the front end as *"`SP170 nom_geol` hangs on intervals from
890 /// datum, which is neither the points nor the cells"*. The refusal was
891 /// right - a frame of `n` node depths is **one** polyline cell and `n - 1`
892 /// intervals, so there was no array of that length to attach to - and the
893 /// answer is to move the values onto the nodes rather than to invent a
894 /// cell for them.
895 ///
896 /// The values below are indices, not measurements, which is the whole
897 /// reason this is a correspondence and not an average: `(1 + 2) / 2` is not
898 /// a soil type.
899 #[test]
900 fn each_node_takes_one_interval_and_nothing_is_averaged() {
901 // Three nodes, two intervals: sand then clay.
902 let moved = intervals_onto_nodes(&[1.0, 2.0], 1, 3, 2);
903 assert_eq!(
904 moved,
905 vec![1.0, 2.0, 2.0],
906 "node 0 opens interval 0, node 1 opens interval 1, node 2 closes it"
907 );
908 // Every output value came from the input: no mean was taken anywhere.
909 assert!(moved.iter().all(|v| *v == 1.0 || *v == 2.0));
910 }
911
912 /// The shortest frame there can be: two nodes, one interval.
913 #[test]
914 fn two_nodes_and_one_interval_both_take_it() {
915 assert_eq!(intervals_onto_nodes(&[7.0], 1, 2, 1), vec![7.0, 7.0]);
916 }
917
918 /// A vector-valued property moves whole tuples, never a stride of one.
919 ///
920 /// Getting this wrong would shift every component by one node and produce
921 /// an array of exactly the right length - the shape of error no count check
922 /// can see.
923 #[test]
924 fn components_travel_together() {
925 // Two intervals, three components each.
926 let values = [10.0, 11.0, 12.0, 20.0, 21.0, 22.0];
927 assert_eq!(
928 intervals_onto_nodes(&values, 3, 3, 2),
929 vec![10.0, 11.0, 12.0, 20.0, 21.0, 22.0, 20.0, 21.0, 22.0]
930 );
931 }
932}
933
934// -- Reading a whole package ---------------------------------------------------
935
936/// Which properties to hang on the shapes.
937///
938/// [`Properties::None`] by default, and the default is the point: attaching
939/// them means reading **every** property of the package and its external
940/// arrays, which an export of pure geometry has no reason to pay for.
941#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
942pub enum Properties<'a> {
943 /// None at all.
944 #[default]
945 None,
946 /// Every property the package holds.
947 All,
948 /// Only these uuids, compared without case.
949 Only(&'a [String]),
950}
951
952/// What to read out of a package, and how much of it.
953#[derive(Clone, Copy, Default)]
954pub struct Request<'a> {
955 /// Keep only representations whose qualified type matches
956 /// ([`energyml_storage::resource::type_matches`]). `None` keeps every one.
957 ///
958 /// Applied to the **summary**, before anything is deserialized: a filtered
959 /// run neither reads what it will discard nor reports it as unreadable.
960 pub of_type: Option<&'a str>,
961 /// Keep only these representations, by uuid, compared without case.
962 pub only: &'a [String],
963 /// The reader registry. `None` means the built-in one.
964 pub readers: Option<&'a Readers>,
965 /// What to collect as metadata on each object.
966 pub spec: Option<&'a Spec>,
967 /// Which properties to attach.
968 pub properties: Properties<'a>,
969 /// Move the values onto the points or onto the cells when the file hangs
970 /// them elsewhere. `None` leaves them where the file put them.
971 pub onto: Option<crate::transfer::Scope>,
972 /// How to move them. Ignored without `onto`.
973 pub method: Option<crate::transfer::Method>,
974 /// Where the colour of a property comes from. `None` is the cascade's own
975 /// `resolve`; a face that holds a cache passes it, and colours as the front end
976 /// does ([`Decide`]).
977 pub colours: Option<&'a dyn Decide>,
978}
979
980/// A package read into shapes, **with what could not be read**.
981///
982/// Three lists and not one, because they are three different pieces of news
983/// repaired in three different places: a part that will not deserialize is a
984/// schema problem, a shape that cannot be evaluated is a geometry this build
985/// does not support or a file whose numbers are missing, and a property that
986/// will not attach is a count disagreeing with the shape it names.
987#[derive(Debug, Default)]
988pub struct Scene {
989 /// The shapes, in the order the package lists them.
990 pub objects: Vec<SceneObject>,
991 /// Parts that would not deserialize: uuid, type, and why.
992 pub unreadable: Vec<String>,
993 /// Shapes that could not be evaluated: uuid, type, and why.
994 pub refused: Vec<String>,
995 /// Properties that could not be placed on any shape, and why.
996 pub unattached: Vec<String>,
997}
998
999/// Read a package into scene objects, with their properties and their colours.
1000///
1001/// # Why this is here rather than in each caller
1002///
1003/// It was written three times - `energyml export`, `energyml geometry` and the
1004/// Python bridge - and the three had begun to differ: the colour ramps were
1005/// resolved by one of them and reachable from neither of the others. A piece of
1006/// reading repeated in every face is a piece of reading that answers
1007/// differently depending on the door somebody came in by, which is the fault
1008/// this workspace hunts everywhere else.
1009///
1010/// # What it does not decide
1011///
1012/// Placement, reprojection, tube sweeping and the choice of an exporter stay
1013/// with the caller: they are what to *do* with the shapes, and a command line
1014/// that exports and a viewer that draws want different answers.
1015///
1016/// The source is a [`ValueSource`] and a list of summaries rather than a store,
1017/// so an ETP dataspace reads the way a file does - the rule that holds for
1018/// every reader here (ADR-0002).
1019pub fn of_package(
1020 resources: &[energyml_storage::ObjectSummary],
1021 source: &dyn ValueSource,
1022 request: &Request<'_>,
1023) -> Scene {
1024 // A `match` and not `unwrap_or_else`: the built-in registry is `'static`,
1025 // and the closure form makes the borrow checker demand that the caller's be
1026 // `'static` too - which no caller holding its own registry can give.
1027 let readers = match request.readers {
1028 Some(readers) => readers,
1029 None => crate::readers::default_readers(),
1030 };
1031 let mut out = Scene::default();
1032
1033 for summary in resources {
1034 if let Some(filter) = request.of_type
1035 && !energyml_storage::resource::type_matches(&summary.qualified_type, filter)
1036 {
1037 continue;
1038 }
1039 if !request.only.is_empty()
1040 && !request
1041 .only
1042 .iter()
1043 .any(|uuid| uuid.eq_ignore_ascii_case(&summary.uuid))
1044 {
1045 continue;
1046 }
1047 // `read_object` and not `object`: a walk over a whole package has to
1048 // say **why** a part did not come back, because that is the sentence
1049 // naming the switch that would help.
1050 let object = match source.read_object_at(&summary.uri) {
1051 Ok(object) => object,
1052 Err(why) => {
1053 out.unreadable
1054 .push(format!("{} {} {why}", summary.uuid, summary.qualified_type));
1055 continue;
1056 }
1057 };
1058 // Not a refusal and not a silence: a package is mostly objects that are
1059 // not shapes at all - a CRS, an interpretation, a property - and
1060 // listing those as refused would bury the ones that really are.
1061 if !geometry::is_supported(object.as_reflect()) {
1062 continue;
1063 }
1064 match object_of_with(readers, object.as_reflect(), source, &summary.uri, request.spec) {
1065 Ok(built) => out.objects.push(built),
1066 Err(why) => out
1067 .refused
1068 .push(format!("{} {} {why}", summary.uuid, summary.qualified_type)),
1069 }
1070 }
1071
1072 attach(resources, source, request, &mut out);
1073 out
1074}
1075
1076/// The properties, and the colours that go with them.
1077///
1078/// Read **once for the package** and offered to every shape, because nothing in
1079/// a representation lists its properties: every link points the other way, so
1080/// finding them per shape would be one pass over the package per shape.
1081fn attach(
1082 resources: &[energyml_storage::ObjectSummary],
1083 source: &dyn ValueSource,
1084 request: &Request<'_>,
1085 out: &mut Scene,
1086) {
1087 let wanted: &[String] = match request.properties {
1088 Properties::None => return,
1089 Properties::All => &[],
1090 Properties::Only(only) => only,
1091 };
1092
1093 let mut properties: Vec<Box<dyn energyml_core::registry::DynObject>> = Vec::new();
1094 for summary in resources {
1095 if !summary.qualified_type.ends_with("Property") {
1096 continue;
1097 }
1098 if !wanted.is_empty() && !wanted.iter().any(|uuid| uuid.eq_ignore_ascii_case(&summary.uuid)) {
1099 continue;
1100 }
1101 match source.read_object_at(&summary.uri) {
1102 Ok(object) => properties.push(object),
1103 Err(why) => out.unattached.push(format!("{} {why}", summary.uuid)),
1104 }
1105 }
1106 let reflected: Vec<&dyn Reflect> = properties.iter().map(|one| one.as_reflect()).collect();
1107 let by_representation = PropertiesOf::new(&reflected);
1108 out.unattached.extend(by_representation.unread().iter().cloned());
1109
1110 // The colour ramp comes from the package's own maps, so the cascade needs
1111 // the whole package - which is why it is resolved here rather than by an
1112 // exporter, which is handed one object at a time.
1113 // Every set, in package order, read as one (`GraphicalSet::merged`).
1114 let sets: Vec<energyml_domain::color::GraphicalSet> = resources
1115 .iter()
1116 .filter(|summary| summary.qualified_type.ends_with("GraphicalInformationSet"))
1117 .filter_map(|summary| source.object_at(&summary.uri))
1118 .map(|object| energyml_domain::color::GraphicalSet::read(object.as_reflect()))
1119 .collect();
1120 let set = (!sets.is_empty()).then(|| energyml_domain::color::GraphicalSet::merged(sets));
1121 let kinds = energyml_domain::property_kind::Resolver::new(source);
1122
1123 for object in &mut out.objects {
1124 let (mut attributes, refused) = attributes_for(object, &by_representation.of(&object.uuid), source);
1125 out.unattached.extend(refused);
1126 if let Some(target) = request.onto {
1127 let method = request.method.unwrap_or(crate::transfer::Method::Average);
1128 out.unattached
1129 .extend(move_attributes(&mut attributes, target, method, &object.geometry));
1130 }
1131 if let Some(set) = &set {
1132 let cascade = energyml_domain::color::Cascade::new(set);
1133 for attribute in &mut attributes {
1134 attribute.lut = colour_ramp(source, &cascade, request.colours, &kinds, object, attribute);
1135 }
1136 }
1137 object.attributes = attributes;
1138 }
1139}
1140
1141/// Move values from the elements the file hangs them on to the ones a caller
1142/// wants, and **say so in the name**.
1143///
1144/// A `facies` written on the faces and a `facies` written on the nodes are two
1145/// different arrays: a viewer that shows both must not label them the same, nor
1146/// let anyone think the file said either.
1147pub fn move_attributes(
1148 attributes: &mut [Attribute],
1149 target: crate::transfer::Scope,
1150 method: crate::transfer::Method,
1151 geometry: &Geometry,
1152) -> Vec<String> {
1153 use crate::transfer::{self, Scope};
1154
1155 let mut refused = Vec::new();
1156 for attribute in attributes.iter_mut() {
1157 let from = match attribute.scope {
1158 AttributeScope::Points => Scope::Points,
1159 AttributeScope::Cells => Scope::Cells,
1160 };
1161 if from == target {
1162 continue;
1163 }
1164 match transfer::transfer(
1165 &attribute.values,
1166 attribute.components as usize,
1167 from,
1168 target,
1169 method,
1170 geometry,
1171 ) {
1172 Ok(values) => {
1173 attribute.values = values;
1174 attribute.scope = match target {
1175 Scope::Points => AttributeScope::Points,
1176 Scope::Cells => AttributeScope::Cells,
1177 };
1178 // ASCII, and no brackets: a legacy VTK array name is a token,
1179 // and ParaView shows a name with arrows in it as one it cannot
1180 // select. The suffix is readable and it survives the trip
1181 // through every reader.
1182 attribute.name = format!("{}__{from}_to_{target}_by_{}", attribute.name, method.name());
1183 }
1184 Err(why) => refused.push(format!(
1185 "`{}` was not moved onto the {target}: {why}",
1186 attribute.name
1187 )),
1188 }
1189 }
1190 refused
1191}