Skip to main content

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}