Skip to main content

energyml_section/
markers.rs

1//! The markers a section can carry, **declared** - the shapes read from the
2//! model, the places listed (ADR-0015).
3//!
4//! The house rule, as for [`crate::style::declared`]: *what the library
5//! declares is the front end's form*. [`declared`] lists every shape a marker can
6//! take and every place it can sit at, each with a readable label and a
7//! translation key; the front end builds its chooser from the list and spells no
8//! value.
9//!
10//! # The shapes are not typed here
11//!
12//! RESQML already has a vocabulary of symbols: `GraphicalInformationForNodes`
13//! has a `Symbol` field whose enumeration lists them (`circle`, `cross`, `cube`,
14//! `diamond`, `plus`, `point`, `pyramid`, `sphere`, `star`, `tetrahedron`). They
15//! are read **by reflection**, from every release the registry holds
16//! (`FieldType::allowed_values`), and each shape remembers the pairs
17//! *(qualified type of the enumeration, value)* that write it - what an
18//! attachment of the marker to a package would write. A release that
19//! adds a value brings the shape with no line here; what it cannot bring is the
20//! way to **draw** it, so a value [`is_drawn`] does not know is listed with
21//! `drawn: false` and refused by name when a marker asks for it - never a
22//! default circle. [`CONTOUR`] is the tool's own shape (a wide line under the
23//! well), with no pair.
24
25use energyml_core::naming;
26use energyml_core::reflect::TypeKind;
27use energyml_core::registry::Registry;
28use serde::{Deserialize, Serialize};
29
30/// The type whose `Symbol` field holds the shapes.
31const SYMBOL_OWNER: &str = "GraphicalInformationForNodes";
32/// Its field.
33const SYMBOL_FIELD: &str = "Symbol";
34
35/// The tool's own shape: a wide coloured line under the object, the
36/// highlight with a name and a colour.
37pub const CONTOUR: &str = "contour";
38
39/// The places a marker can sit at, in the order the form lists them.
40pub const PLACES: [&str; 3] = ["head", "bottom", "md"];
41
42/// The shapes the drawer knows how to write - the one list the drawing and
43/// [`DeclaredShape::drawn`] both read.
44const DRAWN: [&str; 11] = [
45    "circle",
46    "cross",
47    "cube",
48    "diamond",
49    "plus",
50    "point",
51    "pyramid",
52    "sphere",
53    "star",
54    "tetrahedron",
55    CONTOUR,
56];
57
58/// Whether the drawer knows this shape: a value the model lists that is not here
59/// is declared and refused by its name.
60pub fn is_drawn(id: &str) -> bool {
61    DRAWN.contains(&id)
62}
63
64/// One shape a marker can take.
65#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct DeclaredShape {
68    /// What a marker's `shape` holds: the model's value (`star`), or [`CONTOUR`].
69    pub id: String,
70    /// A readable label, in English.
71    pub label: String,
72    /// The key the front end translates for the label (`sectionView.markers.shape.star`).
73    pub label_key: String,
74    /// Every `(qualified type of the enumeration, value)` the model writes it
75    /// as, one per release that has it (`resqml22.NodeSymbolExt`, `star`).
76    /// Empty for the tool's own shapes.
77    pub format: Vec<(String, String)>,
78    /// Whether this build draws it.
79    pub drawn: bool,
80}
81
82/// One place a marker can sit at.
83#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub struct DeclaredPlace {
86    /// The `kind` of the place's JSON (`MarkerPlace`).
87    pub id: String,
88    /// A readable label, in English.
89    pub label: String,
90    /// The key the front end translates for the label (`sectionView.markers.place.md`).
91    pub label_key: String,
92    /// Whether the place carries a number (`value`, in the well's MD unit).
93    pub takes_value: bool,
94}
95
96/// The shapes the registry's releases offer, and the places - what the front end's
97/// form is built from.
98///
99/// Shapes come in the order the registry met them, once each however many
100/// releases write them, and [`CONTOUR`] last.
101pub fn declared(registry: &Registry) -> (Vec<DeclaredShape>, Vec<DeclaredPlace>) {
102    let mut shapes: Vec<DeclaredShape> = Vec::new();
103    for info in registry
104        .types()
105        .filter(|t| t.kind == TypeKind::Struct && t.name == SYMBOL_OWNER)
106    {
107        let Some(field) = info.field(SYMBOL_FIELD) else {
108            continue;
109        };
110        let Some(enumeration) = field.ty.type_info() else {
111            continue;
112        };
113        let qualified = format!(
114            "{}{}.{}",
115            info.package,
116            naming::compact_version(naming::release_of(enumeration.version)),
117            enumeration.name
118        );
119        for variant in field.ty.allowed_values().iter().filter(|v| !v.is_other) {
120            let pair = (qualified.clone(), variant.value.to_string());
121            match shapes.iter_mut().find(|s| s.id == variant.value) {
122                Some(shape) => {
123                    if !shape.format.contains(&pair) {
124                        shape.format.push(pair);
125                    }
126                }
127                None => shapes.push(shape(variant.value, vec![pair])),
128            }
129        }
130    }
131    shapes.push(shape(CONTOUR, Vec::new()));
132    let places = PLACES
133        .iter()
134        .map(|&id| DeclaredPlace {
135            id: id.to_string(),
136            label: match id {
137                "head" => "Top of the well",
138                "bottom" => "Bottom of the well",
139                _ => "At a measured depth",
140            }
141            .to_string(),
142            label_key: format!("sectionView.markers.place.{id}"),
143            takes_value: id == "md",
144        })
145        .collect();
146    (shapes, places)
147}
148
149fn shape(id: &str, format: Vec<(String, String)>) -> DeclaredShape {
150    let label = if id == CONTOUR {
151        "Contour (a wide line under the object)".to_string()
152    } else {
153        let mut chars = id.chars();
154        chars
155            .next()
156            .map(|first| first.to_uppercase().chain(chars).collect())
157            .unwrap_or_default()
158    };
159    DeclaredShape {
160        id: id.to_string(),
161        label,
162        label_key: format!("sectionView.markers.shape.{id}"),
163        format,
164        drawn: is_drawn(id),
165    }
166}
167
168/// The size of a symbol when nothing says otherwise, pixels.
169pub const DEFAULT_SIZE_PX: f64 = 12.0;
170
171/// A marker asked for in words, against the objects of a catalogue - what the
172/// command line's `--marker` gives (08/10, Lutetia's BSS wells).
173///
174/// `NAME:shape=diamond,colour=#1565c0,at=bottom,type=WellboreTrajectory,title=BSS`.
175/// `shape`, `colour` and `at` (`head`, `bottom`, `md` with `md=VALUE`) are
176/// required; `size` defaults to [`DEFAULT_SIZE_PX`]. The objects are the
177/// catalogue's rows `shown_in` that section - drawn there, or merged into a
178/// line drawn there; `None`: on any section - whose qualified type contains
179/// `type` (several joined by
180/// `|`, any of them) and whose title contains `title`, case ignored - the
181/// storage's own [`type_matches`](energyml_storage::resource::type_matches) and
182/// [`title_matches`](energyml_storage::resource::title_matches). At least one
183/// of the two is asked for, and a marker that matches no object is refused: a
184/// legend entry for nothing reads like a symbol that failed to draw.
185pub fn from_spec(
186    text: &str,
187    rows: &[crate::model::ObjectRow],
188    shown_in: Option<crate::model::SectionKey>,
189) -> Result<crate::model::Marker, String> {
190    use crate::model::{Marker, MarkerPlace, ObjectRow, Outcome};
191    use energyml_storage::resource::{title_matches, type_matches};
192
193    let (name, rest) = text
194        .split_once(':')
195        .ok_or_else(|| format!("`{text}`: expected NAME:shape=…,colour=…,at=…"))?;
196    let name = name.trim();
197    if name.is_empty() {
198        return Err(format!("`{text}`: a marker needs a name, the legend shows it"));
199    }
200    let (mut shape, mut colour, mut at, mut md, mut size, mut types, mut title) =
201        (None, None, None, None, None, None, None);
202    for pair in rest.split(',').map(str::trim).filter(|p| !p.is_empty()) {
203        let (key, value) = pair
204            .split_once('=')
205            .ok_or_else(|| format!("`{pair}`: expected name=value"))?;
206        let value = value.trim().to_string();
207        match key.trim() {
208            "shape" => shape = Some(value),
209            "colour" | "color" => colour = Some(value),
210            "at" => at = Some(value),
211            "md" => {
212                md = Some(
213                    value
214                        .parse::<f64>()
215                        .map_err(|_| format!("`md={value}` is not a number"))?,
216                )
217            }
218            "size" => {
219                size = Some(
220                    value
221                        .parse::<f64>()
222                        .map_err(|_| format!("`size={value}` is not a number"))?,
223                )
224            }
225            "type" => types = Some(value),
226            "title" => title = Some(value),
227            other => {
228                return Err(format!(
229                    "a marker has no parameter `{other}`: it has [shape, colour, at, md, size, type, title]"
230                ));
231            }
232        }
233    }
234    let shape = shape.ok_or("a marker needs `shape=`")?;
235    if !is_drawn(&shape) {
236        return Err(format!(
237            "shape `{shape}` is not one this build draws: one of {}",
238            DRAWN.join(", ")
239        ));
240    }
241    let colour = colour.ok_or("a marker needs `colour=` (#rrggbb or #rrggbbaa)")?;
242    let hex = colour.strip_prefix('#').unwrap_or("");
243    if !matches!(hex.len(), 6 | 8) || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
244        return Err(format!("colour `{colour}`: expected #rrggbb or #rrggbbaa"));
245    }
246    let at = match (at.as_deref(), md) {
247        (Some("head"), None) => MarkerPlace::Head,
248        (Some("bottom"), None) => MarkerPlace::Bottom,
249        (Some("md"), Some(value)) => MarkerPlace::Md { value },
250        (Some("md"), None) => return Err("`at=md` needs `md=VALUE`, in the well's MD unit".into()),
251        (Some("head" | "bottom"), Some(_)) => return Err("`md=` goes with `at=md` only".into()),
252        (Some(other), _) => return Err(format!("`at={other}`: one of {}", PLACES.join(", "))),
253        (None, _) => return Err(format!("a marker needs `at=`: one of {}", PLACES.join(", "))),
254    };
255    let size_px = size.unwrap_or(DEFAULT_SIZE_PX);
256    if !(size_px.is_finite() && size_px > 0.0) {
257        return Err(format!("`size={size_px}`: a size is a positive number of pixels"));
258    }
259    if types.is_none() && title.is_none() {
260        return Err("a marker names its objects: give `type=` or `title=` (or both)".into());
261    }
262    // What the section shows: drawn there, or folded into the line of another
263    // object drawn there (the marker lands on that line). A refused or missed
264    // object, or one drawn on another section only, has no place in the drawing
265    // - naming it would only add a note that nothing is there.
266    let drawn_in = |row: &ObjectRow| match &row.outcome {
267        Outcome::Drawn { sections } => shown_in.is_none_or(|key| sections.contains(&key)),
268        _ => false,
269    };
270    let shown = |row: &ObjectRow| match &row.outcome {
271        Outcome::Merged { into } => rows.iter().any(|other| other.object == *into && drawn_in(other)),
272        _ => drawn_in(row),
273    };
274    let keys: Vec<String> = rows
275        .iter()
276        .filter(|row| shown(row))
277        .filter(|row| {
278            types.as_deref().is_none_or(|types| {
279                types
280                    .split('|')
281                    .any(|one| type_matches(&row.object.qualified_type, one.trim()))
282            }) && title
283                .as_deref()
284                .is_none_or(|title| title_matches(&row.object.title, title))
285        })
286        .map(|row| row.object.key())
287        .collect();
288    if keys.is_empty() {
289        return Err(format!("marker `{name}` matches no object of the section"));
290    }
291    Ok(Marker {
292        name: name.to_string(),
293        shape,
294        colour,
295        at,
296        keys,
297        size_px,
298        hidden: false,
299    })
300}