1use energyml_core::naming;
26use energyml_core::reflect::TypeKind;
27use energyml_core::registry::Registry;
28use serde::{Deserialize, Serialize};
29
30const SYMBOL_OWNER: &str = "GraphicalInformationForNodes";
32const SYMBOL_FIELD: &str = "Symbol";
34
35pub const CONTOUR: &str = "contour";
38
39pub const PLACES: [&str; 3] = ["head", "bottom", "md"];
41
42const 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
58pub fn is_drawn(id: &str) -> bool {
61 DRAWN.contains(&id)
62}
63
64#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct DeclaredShape {
68 pub id: String,
70 pub label: String,
72 pub label_key: String,
74 pub format: Vec<(String, String)>,
78 pub drawn: bool,
80}
81
82#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub struct DeclaredPlace {
86 pub id: String,
88 pub label: String,
90 pub label_key: String,
92 pub takes_value: bool,
94}
95
96pub 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
168pub const DEFAULT_SIZE_PX: f64 = 12.0;
170
171pub 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 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}