Skip to main content

brep_render/
metadata.rs

1//! The Properties-panel DATA layer (UI comes later): a name-keyed metadata
2//! store, per-entity measurements, and object provenance — all engine-native,
3//! all crossing the R3 boundary as plain JSON/scalars.
4//!
5//! # Metadata store ([`MetadataStore`])
6//!
7//! `object name → { attribute → value }`, keyed by the KERNEL OBJECT NAME (a
8//! solid / face / edge name), **not** a feature id. This loose coupling is the
9//! whole point: a record survives feature edits, rollback and re-tessellation as
10//! long as the object's name persists (edge/face names ARE propagated through
11//! booleans, splits and welds by the kernel). Values are strings; the well-known
12//! `density` attribute (mass units per mm³) drives a solid's weight. The store is
13//! persisted WITH the model — [`crate::engine_state::EngineState::history_request_json`]
14//! folds it in as a top-level `metadata` field and
15//! [`crate::engine_state::EngineState::set_history_json`] lifts it back out, so it
16//! round-trips through save/open.
17//!
18//! # Measurements + provenance
19//!
20//! The [`EngineState`] methods below resolve an object NAME to its kind (solid /
21//! face / edge, via the scene) and return the right measurements from the
22//! kernel's exact integrators (volume, surface area, arc length), plus the
23//! feature that produced the object (provenance, via the history's per-feature
24//! output solids). Units are millimetres (the kernel length convention).
25
26use crate::engine_state::EngineState;
27use brep_kernel::COLOR_METADATA_KEY;
28use crate::runner::{MeasureKind, MeasureQuery};
29use serde_json::Value;
30use std::collections::BTreeMap;
31
32/// The default density (mass per unit volume) when an object carries no
33/// `density` metadata: unit density, so `weight == volume`.
34pub const DEFAULT_DENSITY: f64 = 1.0;
35
36/// The name-keyed metadata store: `object name → { attribute → value }`, string
37/// values, deterministic iteration (a `BTreeMap` so the persisted JSON is
38/// stable). Empty records are never retained (removing the last attribute drops
39/// the whole record).
40#[derive(Debug, Clone, Default)]
41pub struct MetadataStore {
42    entries: BTreeMap<String, BTreeMap<String, String>>,
43}
44
45impl MetadataStore {
46    pub fn new() -> Self {
47        Self::default()
48    }
49
50    /// Whether the store holds no records at all.
51    pub fn is_empty(&self) -> bool {
52        self.entries.is_empty()
53    }
54
55    /// Set (or overwrite) one attribute of `name`'s record. An empty object name
56    /// or key is ignored (no phantom records).
57    pub fn set_attribute(&mut self, name: &str, key: &str, value: &str) {
58        if name.is_empty() || key.is_empty() {
59            return;
60        }
61        self.entries
62            .entry(name.to_string())
63            .or_default()
64            .insert(key.to_string(), value.to_string());
65    }
66
67    /// Remove one attribute of `name`'s record, dropping the record if it becomes
68    /// empty. Returns whether the attribute existed.
69    pub fn remove_attribute(&mut self, name: &str, key: &str) -> bool {
70        let Some(record) = self.entries.get_mut(name) else {
71            return false;
72        };
73        let existed = record.remove(key).is_some();
74        if record.is_empty() {
75            self.entries.remove(name);
76        }
77        existed
78    }
79
80    /// One attribute's value (`None` if the object or key is unknown).
81    pub fn attribute(&self, name: &str, key: &str) -> Option<&str> {
82        self.entries.get(name)?.get(key).map(String::as_str)
83    }
84
85    /// One object's whole record (empty map if the object has no metadata).
86    pub fn record(&self, name: &str) -> BTreeMap<String, String> {
87        self.entries.get(name).cloned().unwrap_or_default()
88    }
89
90    /// The whole store (all records), read-only.
91    pub fn all(&self) -> &BTreeMap<String, BTreeMap<String, String>> {
92        &self.entries
93    }
94
95    /// The resolved density (mass per mm³) for `name`: its `density` attribute
96    /// parsed as a positive finite number, else [`DEFAULT_DENSITY`].
97    pub fn density(&self, name: &str) -> f64 {
98        self.attribute(name, "density")
99            .and_then(|value| value.trim().parse::<f64>().ok())
100            .filter(|density| density.is_finite() && *density > 0.0)
101            .unwrap_or(DEFAULT_DENSITY)
102    }
103
104    /// Drop every record (a part load replaces the store wholesale).
105    pub fn clear(&mut self) {
106        self.entries.clear();
107    }
108
109    /// One object's record as a JSON object `{ key: value, ... }` (`{}` when the
110    /// object has no metadata).
111    pub fn record_json(&self, name: &str) -> String {
112        Value::Object(
113            self.record(name)
114                .into_iter()
115                .map(|(key, value)| (key, Value::String(value)))
116                .collect(),
117        )
118        .to_string()
119    }
120
121    /// The whole store as a JSON value `{ name: { key: value, ... }, ... }` — the
122    /// persisted shape and the whole-store getter.
123    pub fn to_json(&self) -> Value {
124        Value::Object(
125            self.entries
126                .iter()
127                .map(|(name, record)| {
128                    let object = record
129                        .iter()
130                        .map(|(key, value)| (key.clone(), Value::String(value.clone())))
131                        .collect();
132                    (name.clone(), Value::Object(object))
133                })
134                .collect(),
135        )
136    }
137
138    /// The whole store as a JSON string (the whole-store getter's string form).
139    pub fn to_json_string(&self) -> String {
140        self.to_json().to_string()
141    }
142
143    /// Replace the whole store from a persisted `metadata` value (or `None`, which
144    /// clears it — loading a part with no metadata). Non-string leaf values are
145    /// coerced to their JSON text so a legacy document never fails the load.
146    pub fn load_json(&mut self, value: Option<&Value>) {
147        self.entries.clear();
148        let Some(Value::Object(objects)) = value else {
149            return;
150        };
151        for (name, record) in objects {
152            let Value::Object(attributes) = record else {
153                continue;
154            };
155            let map: BTreeMap<String, String> = attributes
156                .iter()
157                .map(|(key, value)| (key.clone(), value_to_string(value)))
158                .collect();
159            if !map.is_empty() {
160                self.entries.insert(name.clone(), map);
161            }
162        }
163    }
164}
165
166/// Coerce a persisted metadata leaf to a string value (strings verbatim, `null`
167/// to empty, everything else to its JSON text).
168fn value_to_string(value: &Value) -> String {
169    match value {
170        Value::String(text) => text.clone(),
171        Value::Null => String::new(),
172        other => other.to_string(),
173    }
174}
175
176/// The kind an object NAME resolves to in the current scene.
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178enum ObjectKind {
179    Solid,
180    Face,
181    Edge,
182}
183
184// --- EngineState: metadata store API (a SEPARATE impl block, appended, so
185//     concurrent edits to the primary block don't conflict) -------------------
186impl EngineState {
187    /// One object's metadata record as JSON `{ key: value, ... }` (`{}` if none).
188    pub fn object_metadata_json(&self, name: &str) -> String {
189        self.metadata.record_json(name)
190    }
191
192    /// Set (or overwrite) one metadata attribute of an object by NAME. String
193    /// value; the well-known `density` key drives the object's weight and the
194    /// well-known `color` key drives its shaded colour.
195    pub fn set_metadata_attribute(&mut self, name: &str, key: &str, value: &str) {
196        self.metadata.set_attribute(name, key, value);
197        // A metadata edit (notably `density`) feeds the object-info output but does
198        // NOT trigger a history rerun, so drop this object's cached info so a
199        // re-selection re-measures with the new attribute.
200        self.info_cache.remove(name);
201        // `color` is the one attribute the VIEWPORT reads, and there is no rerun
202        // to carry it through — push it to the display right here so recolouring
203        // a body is immediate.
204        if key == COLOR_METADATA_KEY {
205            self.sync_colors_from_metadata();
206        }
207    }
208
209    /// Remove one metadata attribute of an object. Returns whether it existed.
210    pub fn remove_metadata_attribute(&mut self, name: &str, key: &str) -> bool {
211        let existed = self.metadata.remove_attribute(name, key);
212        // Same rerun-less invalidation as `set_metadata_attribute`.
213        self.info_cache.remove(name);
214        if existed && key == COLOR_METADATA_KEY {
215            self.sync_colors_from_metadata();
216        }
217        existed
218    }
219
220    /// Re-derive the display scene's colours from the metadata store — the ONE
221    /// call that connects the durable `color` attribute to the renderer.
222    ///
223    /// Every colour the viewport shows comes from here: a STEP import's stamped
224    /// body/face colours, a colour typed or picked in the Info window, a colour
225    /// restored from a saved document. The store is the authority; the display's
226    /// `color_override` fields are a derived cache of it, rebuilt after every
227    /// history apply ([`EngineState::finish_apply`]), on every document load, on
228    /// a metadata edit, and whenever the display setting flips.
229    ///
230    /// [`crate::style::RenderSettings::override_model_colors`] is honoured HERE
231    /// rather than in the renderer, and it is deliberately a READ of the store,
232    /// never a write: ticking the box resolves every colour to `None` so the
233    /// viewport falls back to `faceColorMode`, while the stored attributes stay
234    /// exactly as they were. Unticking restores them from the same records.
235    ///
236    /// Returns whether anything changed, and marks the engine dirty only then —
237    /// the no-op case must stay free, since this runs after every single run.
238    pub fn sync_colors_from_metadata(&mut self) -> bool {
239        // Disjoint field borrows: the closure reads `self.metadata` while
240        // `self.scene` is borrowed mutably.
241        let ignore = self.settings.override_model_colors;
242        let metadata = &self.metadata;
243        let changed = self.scene.apply_metadata_colors(|name| {
244            if ignore {
245                return None;
246            }
247            metadata
248                .attribute(name, COLOR_METADATA_KEY)
249                .and_then(crate::style::parse_css_hex)
250        });
251        if changed {
252            self.dirty = true;
253        }
254        changed
255    }
256
257    /// The whole metadata store as JSON `{ name: { key: value } }`.
258    pub fn metadata_json(&self) -> String {
259        self.metadata.to_json_string()
260    }
261}
262
263// --- EngineState: measurements + provenance (SEPARATE impl block) ------------
264impl EngineState {
265    /// Resolve an object NAME to `(kind, owning solid name)` via the display
266    /// scene: a solid resolves to itself; a face/edge resolves to its owning
267    /// solid. `None` for an empty or unknown name (vertices carry no kernel name).
268    fn resolve_object(&self, name: &str) -> Option<(ObjectKind, String)> {
269        if name.is_empty() {
270            return None;
271        }
272        if self.scene.solid(name).is_some() {
273            return Some((ObjectKind::Solid, name.to_string()));
274        }
275        for solid in self.scene.solids() {
276            if solid.faces.iter().any(|face| face.name == name) {
277                return Some((ObjectKind::Face, solid.name.clone()));
278            }
279        }
280        for solid in self.scene.solids() {
281            if solid.edges.iter().any(|edge| edge.name == name) {
282                return Some((ObjectKind::Edge, solid.name.clone()));
283            }
284        }
285        None
286    }
287
288    /// The feature that an object ORIGINATES from, as `(feature id, feature type)`.
289    /// For a FACE/EDGE this is the feature that first gave it its name (its true
290    /// origin, from the eager `entity_origin` first-writer map) — NOT the owning
291    /// solid's last producer — so "Edit owning feature" rolls back to where the
292    /// entity was born. For a SOLID it's the solid's producer (last writer, the
293    /// eager `provenance` map). `None` if the name is unknown or has no known
294    /// producer. Reads only the eager maps the last run shipped, so it never re-runs
295    /// the history — the freeze side-door `context_bar` hit every selected frame is
296    /// now O(1).
297    pub fn creating_feature(&self, name: &str) -> Option<(String, String)> {
298        let Some((kind, owner)) = self.resolve_object(name) else {
299            // A datum / construction PLANE (or one plane of a datum) is not a
300            // resident solid/face/edge — it's a named frame. Its producer is the
301            // D/P feature that emitted the frame (same fallback object_info_json
302            // uses), so "Edit owning feature" resolves for a lone plane pick too.
303            return self.datum_feature_for_name(name);
304        };
305        let id = match kind {
306            // A face/edge resolves to its ORIGIN. Fall back to the owning solid's
307            // producer when the name is somehow absent from `entity_origin` (keeps
308            // the "Edit owning feature" button from vanishing).
309            ObjectKind::Face | ObjectKind::Edge => self
310                .entity_origin
311                .get(name)
312                .cloned()
313                .or_else(|| self.provenance.get(&owner).cloned())?,
314            // A solid keeps its existing last-writer producer semantics.
315            ObjectKind::Solid => self.provenance.get(&owner)?.clone(),
316        };
317        Some((id.clone(), self.feature_type_of(&id)))
318    }
319
320    /// Whether the object `name` (a solid, or a face/edge owned by a solid) sits
321    /// on a SHEET-METAL body — its owning display solid carries the sheet-metal
322    /// marker the pipeline stamped from the resident handle's `SheetTree`. Reads
323    /// only the display scene, so it is O(1) and thread-safe (no `SheetTree`
324    /// thread-local touched on the UI thread — [`crate::scene::SolidDisplay::
325    /// is_sheet_metal`]). The gate for the sheet-metal edit features (SM Flange /
326    /// Fillet / Chamfer). `false` for an unknown name or a synthesized sketch
327    /// sheet (no resident handle, no tree).
328    pub fn is_sheet_metal_object(&self, name: &str) -> bool {
329        self.resolve_object(name)
330            .and_then(|(_, owner)| self.scene.solid(&owner))
331            .is_some_and(|solid| solid.is_sheet_metal)
332    }
333
334    /// A feature's type token by id (empty string if the id is not in the history).
335    fn feature_type_of(&self, id: &str) -> String {
336        self.history
337            .index_of(id)
338            .and_then(|index| self.history.feature_type(index))
339            .unwrap_or_default()
340    }
341
342    /// The `{ id, type }` provenance JSON for an object by NAME (or `null` if it has
343    /// no known producer). Delegates to [`creating_feature`](Self::creating_feature)
344    /// so a face/edge reports its ORIGIN (first-writer) and a solid its producer
345    /// (last-writer) — one resolver, so the Info tab and the context bar agree.
346    fn creating_feature_value(&self, name: &str) -> Value {
347        match self.creating_feature(name) {
348            Some((id, ty)) => serde_json::json!({ "id": id, "type": ty }),
349            None => Value::Null,
350        }
351    }
352
353    /// The full Properties-panel info for an object by NAME: its resolved kind,
354    /// the right measurements, and provenance. Units are millimetres.
355    ///
356    /// - **Solid** — `{ ok, name, kind:"solid", volume, surfaceArea,
357    ///   edgeLengthTotal, density, weight, creatingFeature }`, where
358    ///   `weight = density · volume` and `density` comes from the object's
359    ///   metadata (default [`DEFAULT_DENSITY`]).
360    /// - **Face** — `{ ok, name, kind:"face", solid, surfaceType, area,
361    ///   edgeLengthTotal, creatingFeature }` (`edgeLengthTotal` = its boundary
362    ///   edges; `surfaceType` = the carrier-surface classification, e.g.
363    ///   `"Plane"`/`"Cylinder"`/`"Cone"`/`"Sphere"`/`"Torus"`/`"NURBS"`).
364    /// - **Edge** — `{ ok, name, kind:"edge", solid, length, creatingFeature }`.
365    ///
366    /// `{ ok:false, name, message }` for an empty/unknown name, a non-resident
367    /// solid, or a kernel measurement failure.
368    ///
369    /// The real solid/face/edge MEASUREMENT is routed to the [`HistoryRunner`] (so
370    /// the warm-registry runner answers it, never the potentially-cold main side)
371    /// and CACHED here keyed by name — fired once per selection, served from the
372    /// cache every subsequent frame. For the synchronous
373    /// [`InlineRunner`](crate::runner::InlineRunner) the submit → `pump_queries`
374    /// resolves same-call, so this returns the merged JSON immediately and stays
375    /// byte-identical to the pre-seam in-process result; a background
376    /// [`ThreadRunner`](crate::runner::ThreadRunner) returns a `pending` placeholder
377    /// for the frame(s) until its reply lands (drained by `pump_queries`). The cache
378    /// is invalidated on any geometry change (`apply_run_output`) or metadata edit
379    /// (`set_metadata_attribute`).
380    ///
381    /// [`HistoryRunner`]: crate::runner::HistoryRunner
382    pub fn object_info_json(&mut self, name: &str) -> String {
383        let Some((kind, owner)) = self.resolve_object(name) else {
384            // A construction datum/plane carries no resident geometry (no volume /
385            // area / length), so it never resolves as a solid/face/edge. Return a
386            // graceful minimal record — name + kind + creating feature — rather than
387            // erroring, so the Properties Info tab renders for a selected datum and
388            // its name flows into the (name-keyed) Metadata tab.
389            if let Some((feature_id, feature_type)) = self.datum_feature_for_name(name) {
390                let kind_label = if feature_type == "P" { "plane" } else { "datum" };
391                return serde_json::json!({
392                    "ok": true,
393                    "name": name,
394                    "kind": kind_label,
395                    "creatingFeature": { "id": feature_id, "type": feature_type },
396                })
397                .to_string();
398            }
399            return serde_json::json!({
400                "ok": false, "name": name, "message": "unknown object",
401            })
402            .to_string();
403        };
404        // A committed-sketch SHEET is a scene solid with NO kernel handle, so the
405        // handle-based measurement path can't serve it. Measure straight off its
406        // synthesized display (planar-mesh triangle areas / edge polylines) and
407        // report `kind:"sketch"` — no volume. Handle-less ⇒ synchronous, no query.
408        if let Some(solid) = self.scene.solid(&owner) {
409            if solid.is_sketch {
410                return self.sketch_info_json(name, kind, solid);
411            }
412        }
413
414        // Real geometry: serve from the info cache, else fire a measurement query at
415        // the runner (deduped: never submit a second query for a name already in
416        // flight), pump, and return the resolved JSON — or a pending placeholder.
417        if let Some(cached) = self.info_cache.get(name) {
418            return cached.clone();
419        }
420        if !self.pending_query.values().any(|pending| pending == name) {
421            self.next_query_id += 1;
422            let id = self.next_query_id;
423            let measure_kind = match kind {
424                ObjectKind::Solid => MeasureKind::Solid,
425                ObjectKind::Face => MeasureKind::Face,
426                ObjectKind::Edge => MeasureKind::Edge,
427            };
428            let density = self.metadata.density(name);
429            self.pending_query.insert(id, name.to_string());
430            self.runner.submit_query(MeasureQuery {
431                id,
432                kind: measure_kind,
433                owner,
434                entity: name.to_string(),
435                density,
436            });
437        }
438        self.pump_queries();
439        match self.info_cache.get(name) {
440            Some(resolved) => resolved.clone(),
441            None => serde_json::json!({ "ok": false, "name": name, "pending": true }).to_string(),
442        }
443    }
444
445    /// Drain every completed measurement reply from the runner and fold it into the
446    /// name-keyed info cache — the query counterpart of [`EngineState::pump`]. Called
447    /// once per frame from `pump` AND synchronously from
448    /// [`Self::object_info_json`] (so the Inline runner resolves same-call). Each
449    /// reply is MERGED with the main-injected `name` + `creatingFeature` (from the
450    /// eager provenance) into the final object-info JSON.
451    pub fn pump_queries(&mut self) {
452        while let Some(reply) = self.runner.poll_query() {
453            let Some(name) = self.pending_query.remove(&reply.id) else {
454                // A reply whose request was invalidated (a rerun cleared the pending
455                // set) — drop it; the re-selection re-queries against fresh geometry.
456                continue;
457            };
458            let fragment: Value = serde_json::from_str(&reply.result).unwrap_or(Value::Null);
459            let merged = self.merge_info(&name, &fragment);
460            self.info_cache.insert(name, merged);
461        }
462    }
463
464    /// Merge a runner measurement FRAGMENT with the main-side `name` +
465    /// `creatingFeature` into the final object-info JSON, preserving the exact field
466    /// ORDER the pre-seam `object_info_json` emitted (so the output is
467    /// byte-identical): `ok`, then `name`, then the fragment's remaining fields in
468    /// order (`kind`, the measurements — or `message` on error), then
469    /// `creatingFeature` (only when `ok`, since an error record carries none).
470    fn merge_info(&self, name: &str, fragment: &Value) -> String {
471        let object = fragment.as_object();
472        let ok = object
473            .and_then(|map| map.get("ok"))
474            .and_then(Value::as_bool)
475            .unwrap_or(false);
476        let mut merged = serde_json::Map::new();
477        merged.insert("ok".to_string(), Value::Bool(ok));
478        merged.insert("name".to_string(), Value::String(name.to_string()));
479        if let Some(map) = object {
480            for (key, value) in map {
481                if key == "ok" {
482                    continue;
483                }
484                merged.insert(key.clone(), value.clone());
485            }
486        }
487        if ok {
488            merged.insert(
489                "creatingFeature".to_string(),
490                // Resolve by ENTITY NAME (not `owner`): a face/edge reports its
491                // ORIGIN, a solid its producer — the same correction the context bar
492                // gets, so the Info tab's `creatingFeature` agrees.
493                self.creating_feature_value(name),
494            );
495        }
496        Value::Object(merged).to_string()
497    }
498
499    /// Properties info for a committed-sketch SHEET object (the sheet solid, its
500    /// planar face, or a boundary edge), measured off the synthesized display — a
501    /// sketch carries no resident kernel geometry. `kind:"sketch"` for the whole
502    /// sheet (area + total edge length, NO volume); `"face"` for its planar face;
503    /// `"edge"` for a boundary edge. Provenance is the sketch feature itself.
504    fn sketch_info_json(
505        &self,
506        name: &str,
507        kind: ObjectKind,
508        solid: &crate::scene::SolidDisplay,
509    ) -> String {
510        let creating = serde_json::json!({
511            "id": solid.name,
512            "type": self.feature_type_of(&solid.name),
513        });
514        let edge_total: f64 = solid.edges.iter().map(|e| polyline_length(&e.polyline)).sum();
515        match kind {
516            ObjectKind::Solid => serde_json::json!({
517                "ok": true,
518                "name": name,
519                "kind": "sketch",
520                "area": sheet_mesh_area(solid),
521                "edgeLengthTotal": edge_total,
522                "creatingFeature": creating,
523            })
524            .to_string(),
525            ObjectKind::Face => serde_json::json!({
526                "ok": true,
527                "name": name,
528                "kind": "face",
529                "solid": solid.name,
530                // A committed sketch's sheet face is planar by construction.
531                "surfaceType": "Plane",
532                "area": sheet_mesh_area(solid),
533                "edgeLengthTotal": edge_total,
534                "creatingFeature": creating,
535            })
536            .to_string(),
537            ObjectKind::Edge => {
538                let length = solid
539                    .edges
540                    .iter()
541                    .find(|e| e.name == name)
542                    .map(|e| polyline_length(&e.polyline))
543                    .unwrap_or(0.0);
544                serde_json::json!({
545                    "ok": true,
546                    "name": name,
547                    "kind": "edge",
548                    "solid": solid.name,
549                    "length": length,
550                    "creatingFeature": creating,
551                })
552                .to_string()
553            }
554        }
555    }
556}
557
558/// Total surface area (mm²) of a synthesized sheet's planar display mesh — the
559/// sum of its triangle areas.
560fn sheet_mesh_area(solid: &crate::scene::SolidDisplay) -> f64 {
561    let p = &solid.mesh.positions;
562    solid
563        .mesh
564        .indices
565        .chunks_exact(3)
566        .map(|t| {
567            let a = p[t[0] as usize];
568            let b = p[t[1] as usize];
569            let c = p[t[2] as usize];
570            let ab = [
571                (b[0] - a[0]) as f64,
572                (b[1] - a[1]) as f64,
573                (b[2] - a[2]) as f64,
574            ];
575            let ac = [
576                (c[0] - a[0]) as f64,
577                (c[1] - a[1]) as f64,
578                (c[2] - a[2]) as f64,
579            ];
580            let cross = [
581                ab[1] * ac[2] - ab[2] * ac[1],
582                ab[2] * ac[0] - ab[0] * ac[2],
583                ab[0] * ac[1] - ab[1] * ac[0],
584            ];
585            (cross[0] * cross[0] + cross[1] * cross[1] + cross[2] * cross[2]).sqrt() * 0.5
586        })
587        .sum()
588}
589
590/// Arc length (mm) of a sampled edge polyline — the sum of its segment lengths.
591fn polyline_length(polyline: &[[f32; 3]]) -> f64 {
592    polyline
593        .windows(2)
594        .map(|w| {
595            let d = [
596                (w[1][0] - w[0][0]) as f64,
597                (w[1][1] - w[0][1]) as f64,
598                (w[1][2] - w[0][2]) as f64,
599            ];
600            (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
601        })
602        .sum()
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608
609    fn cube_history(name: &str, side: f64) -> String {
610        serde_json::json!({
611            "expressions": "",
612            "configurator": {},
613            "features": [{
614                "type": "P.CU",
615                "inputParams": {
616                    "id": name,
617                    "sizeX": side, "sizeY": side, "sizeZ": side,
618                    "transform": {
619                        "position": [0.0, 0.0, 0.0],
620                        "rotationEuler": [0.0, 0.0, 0.0],
621                        "scale": [1.0, 1.0, 1.0]
622                    },
623                    "boolean": { "targets": [], "operation": "NONE" }
624                },
625                "persistentData": {}
626            }]
627        })
628        .to_string()
629    }
630
631    /// The same 3-feature seed the app boots with: a 20 mm cube `Box`, a cylinder
632    /// `Pin`, and `Cut` = SUBTRACT(Box, [Pin]). The SUBTRACT result reuses the
633    /// target's name, so the final solid is `Box`, produced by the `Cut` feature.
634    fn seed_history() -> String {
635        serde_json::json!({
636            "expressions": "",
637            "configurator": {},
638            "features": [
639                {
640                    "type": "P.CU",
641                    "inputParams": {
642                        "id": "Box",
643                        "sizeX": 20.0, "sizeY": 20.0, "sizeZ": 20.0,
644                        "transform": {
645                            "position": [0.0, 0.0, 0.0],
646                            "rotationEuler": [0.0, 0.0, 0.0],
647                            "scale": [1.0, 1.0, 1.0]
648                        },
649                        "boolean": { "targets": [], "operation": "NONE" }
650                    },
651                    "persistentData": {}
652                },
653                {
654                    "type": "P.CY",
655                    "inputParams": {
656                        "id": "Pin",
657                        "radius": 6.0, "height": 30.0,
658                        "transform": {
659                            "position": [10.0, -5.0, 10.0],
660                            "rotationEuler": [0.0, 0.0, 0.0],
661                            "scale": [1.0, 1.0, 1.0]
662                        },
663                        "boolean": { "targets": [], "operation": "NONE" }
664                    },
665                    "persistentData": {}
666                },
667                {
668                    "type": "B",
669                    "inputParams": {
670                        "id": "Cut",
671                        "targetSolid": "Box",
672                        "boolean": { "operation": "SUBTRACT", "targets": ["Pin"] }
673                    },
674                    "persistentData": {}
675                }
676            ]
677        })
678        .to_string()
679    }
680
681    /// First named entity of a kind on the resident `Box` (cube entities are all
682    /// named; any one works since a cube's edges/faces are congruent).
683    fn first_named_face(engine: &EngineState) -> String {
684        engine
685            .scene
686            .solid("Box")
687            .unwrap()
688            .faces
689            .iter()
690            .find(|face| !face.name.is_empty())
691            .expect("a named face")
692            .name
693            .clone()
694    }
695
696    fn first_named_edge(engine: &EngineState) -> String {
697        engine
698            .scene
699            .solid("Box")
700            .unwrap()
701            .edges
702            .iter()
703            .find(|edge| !edge.name.is_empty())
704            .expect("a named edge")
705            .name
706            .clone()
707    }
708
709    #[test]
710    fn metadata_set_get_remove_and_density() {
711        let mut store = MetadataStore::new();
712        assert!(store.is_empty());
713        // Default density is unit density.
714        assert_eq!(store.density("Box"), DEFAULT_DENSITY);
715
716        store.set_attribute("Box", "material", "steel");
717        store.set_attribute("Box", "density", "7.85");
718        assert_eq!(store.attribute("Box", "material"), Some("steel"));
719        assert_eq!(store.density("Box"), 7.85);
720        // Record JSON carries both attributes.
721        let record: Value = serde_json::from_str(&store.record_json("Box")).unwrap();
722        assert_eq!(record["material"], "steel");
723        assert_eq!(record["density"], "7.85");
724
725        // Empty name / key are ignored (no phantom records).
726        store.set_attribute("", "k", "v");
727        store.set_attribute("Box", "", "v");
728        assert_eq!(store.all().len(), 1);
729
730        // Remove one attribute; the record survives.
731        assert!(store.remove_attribute("Box", "material"));
732        assert_eq!(store.attribute("Box", "material"), None);
733        assert!(!store.is_empty());
734        // Removing an absent attribute reports false.
735        assert!(!store.remove_attribute("Box", "material"));
736        // Removing the last attribute drops the whole record.
737        assert!(store.remove_attribute("Box", "density"));
738        assert!(store.is_empty());
739    }
740
741    #[test]
742    fn metadata_round_trips_through_save_and_load() {
743        let mut engine = EngineState::new();
744        engine.set_history_json(&cube_history("Box", 10.0)).unwrap();
745        engine.set_metadata_attribute("Box", "material", "aluminium");
746        engine.set_metadata_attribute("Box", "density", "2.7");
747
748        // The engine metadata getter reflects the writes.
749        let all: Value = serde_json::from_str(&engine.metadata_json()).unwrap();
750        assert_eq!(all["Box"]["material"], "aluminium");
751
752        // Persist → the document carries a top-level `metadata` field.
753        let saved = engine.history_request_json();
754        let document: Value = serde_json::from_str(&saved).unwrap();
755        assert_eq!(document["metadata"]["Box"]["density"], "2.7");
756
757        // Load into a FRESH engine → the store is restored.
758        let mut reopened = EngineState::new();
759        reopened.set_history_json(&saved).unwrap();
760        assert_eq!(reopened.object_metadata_json("Box"), engine.object_metadata_json("Box"));
761        assert_eq!(reopened.metadata.density("Box"), 2.7);
762
763        // Loading a document with NO metadata clears the store wholesale.
764        reopened.set_history_json(&cube_history("Box", 10.0)).unwrap();
765        assert!(reopened.metadata.is_empty());
766        assert_eq!(reopened.object_metadata_json("Box"), "{}");
767    }
768
769    #[test]
770    fn unannotated_model_persists_without_metadata_field() {
771        let mut engine = EngineState::new();
772        engine.set_history_json(&cube_history("Box", 10.0)).unwrap();
773        let document: Value = serde_json::from_str(&engine.history_request_json()).unwrap();
774        assert!(document.get("metadata").is_none());
775    }
776
777    #[test]
778    fn edge_length_of_cube_edge_equals_side() {
779        let mut engine = EngineState::new();
780        engine.set_history_json(&cube_history("Box", 10.0)).unwrap();
781        let edge = first_named_edge(&engine);
782        let info: Value = serde_json::from_str(&engine.object_info_json(&edge)).unwrap();
783        assert_eq!(info["ok"], true);
784        assert_eq!(info["kind"], "edge");
785        assert_eq!(info["solid"], "Box");
786        assert!(
787            (info["length"].as_f64().unwrap() - 10.0).abs() < 1e-6,
788            "edge length {} != side 10",
789            info["length"]
790        );
791    }
792
793    #[test]
794    fn face_area_and_boundary_of_cube_face() {
795        let mut engine = EngineState::new();
796        engine.set_history_json(&cube_history("Box", 10.0)).unwrap();
797        let face = first_named_face(&engine);
798        let info: Value = serde_json::from_str(&engine.object_info_json(&face)).unwrap();
799        assert_eq!(info["ok"], true);
800        assert_eq!(info["kind"], "face");
801        // A cube face rides a planar carrier.
802        assert_eq!(info["surfaceType"], "Plane");
803        // A 10 mm cube face: area 100, boundary = 4 edges · 10 = 40.
804        assert!((info["area"].as_f64().unwrap() - 100.0).abs() < 1e-6);
805        assert!((info["edgeLengthTotal"].as_f64().unwrap() - 40.0).abs() < 1e-6);
806    }
807
808    #[test]
809    fn surface_type_of_cylinder_faces_through_the_full_path() {
810        // The headline case, driven through the ENTIRE UI path (runner →
811        // face_measurements_native → NurbsSurface::analytic → kind_label): a
812        // real cylinder primitive's side rides a cylindrical carrier and its two
813        // caps ride planar ones — never the "NURBS" freeform fallback.
814        let history = serde_json::json!({
815            "expressions": "",
816            "configurator": {},
817            "features": [{
818                "type": "P.CY",
819                "inputParams": {
820                    "id": "Pin",
821                    "radius": 5.0, "height": 12.0,
822                    "transform": {
823                        "position": [0.0, 0.0, 0.0],
824                        "rotationEuler": [0.0, 0.0, 0.0],
825                        "scale": [1.0, 1.0, 1.0]
826                    },
827                    "boolean": { "targets": [], "operation": "NONE" }
828                },
829                "persistentData": {}
830            }]
831        })
832        .to_string();
833        let mut engine = EngineState::new();
834        engine.set_history_json(&history).unwrap();
835
836        let names: Vec<String> = engine
837            .scene
838            .solid("Pin")
839            .unwrap()
840            .faces
841            .iter()
842            .filter(|face| !face.name.is_empty())
843            .map(|face| face.name.clone())
844            .collect();
845        let mut cylinders = 0usize;
846        let mut planes = 0usize;
847        for name in names {
848            let info: Value = serde_json::from_str(&engine.object_info_json(&name)).unwrap();
849            assert_eq!(info["kind"], "face");
850            match info["surfaceType"].as_str().unwrap() {
851                "Cylinder" => cylinders += 1,
852                "Plane" => planes += 1,
853                other => panic!("unexpected surface type {other:?} for face {name}"),
854            }
855        }
856        assert!(cylinders >= 1, "the cylindrical side face reads Cylinder");
857        assert_eq!(planes, 2, "the two end caps read Plane");
858    }
859
860    #[test]
861    fn solid_measurements_and_weight_with_density() {
862        let mut engine = EngineState::new();
863        engine.set_history_json(&cube_history("Box", 10.0)).unwrap();
864
865        // Default density → weight == volume.
866        let info: Value = serde_json::from_str(&engine.object_info_json("Box")).unwrap();
867        assert_eq!(info["ok"], true);
868        assert_eq!(info["kind"], "solid");
869        assert!((info["volume"].as_f64().unwrap() - 1000.0).abs() < 1e-6);
870        assert!((info["surfaceArea"].as_f64().unwrap() - 600.0).abs() < 1e-6);
871        assert!((info["edgeLengthTotal"].as_f64().unwrap() - 120.0).abs() < 1e-6);
872        assert!((info["density"].as_f64().unwrap() - 1.0).abs() < 1e-12);
873        assert!((info["weight"].as_f64().unwrap() - 1000.0).abs() < 1e-6);
874
875        // Density 2 → weight == 2 · volume.
876        engine.set_metadata_attribute("Box", "density", "2");
877        let heavy: Value = serde_json::from_str(&engine.object_info_json("Box")).unwrap();
878        assert!((heavy["density"].as_f64().unwrap() - 2.0).abs() < 1e-12);
879        assert!((heavy["weight"].as_f64().unwrap() - 2000.0).abs() < 1e-6);
880    }
881
882    #[test]
883    fn creating_feature_of_seed_box_and_cut() {
884        let mut engine = EngineState::new();
885        engine.set_history_json(&seed_history()).unwrap();
886
887        // The full seed leaves one solid, `Box`, produced by the `Cut` boolean.
888        let info: Value = serde_json::from_str(&engine.object_info_json("Box")).unwrap();
889        assert_eq!(info["creatingFeature"]["id"], "Cut");
890        assert_eq!(info["creatingFeature"]["type"], "B");
891        // The standalone provenance accessor agrees.
892        assert_eq!(
893            engine.creating_feature("Box"),
894            Some(("Cut".to_string(), "B".to_string()))
895        );
896
897        // Rolled back to step 0, `Box` is the plain cube produced by its own P.CU.
898        engine.roll_to(0);
899        let seed: Value = serde_json::from_str(&engine.object_info_json("Box")).unwrap();
900        assert_eq!(seed["creatingFeature"]["id"], "Box");
901        assert_eq!(seed["creatingFeature"]["type"], "P.CU");
902    }
903
904    #[test]
905    fn creating_feature_of_face_is_its_origin_not_solid_last_producer() {
906        // The seed leaves one solid `Box`, produced by the `Cut` boolean, but its
907        // faces/edges must resolve to where they were BORN — the "Edit owning
908        // feature" fix. (The SOLID still resolves to `Cut`; see
909        // `creating_feature_of_seed_box_and_cut`.)
910        let mut engine = EngineState::new();
911        engine.set_history_json(&seed_history()).unwrap();
912
913        // A surviving ORIGINAL box side face originates from `Box` (P.CU) — NOT the
914        // `Cut` boolean that last produced the solid (the bug being fixed).
915        assert_eq!(
916            engine.creating_feature("Box_NX"),
917            Some(("Box".to_string(), "P.CU".to_string())),
918        );
919        // The Info tab's `creatingFeature` agrees (same single resolver).
920        let face_info: Value = serde_json::from_str(&engine.object_info_json("Box_NX")).unwrap();
921        assert_eq!(face_info["creatingFeature"]["id"], "Box");
922        assert_eq!(face_info["creatingFeature"]["type"], "P.CU");
923
924        // The BORE-wall face is the SUBTRACT tool's own face `Pin_S`, so under the
925        // first-writer rule it keeps the cylinder's origin, `Pin` (P.CY) — observed,
926        // not guessed. (If the boolean instead RENAMED the bore wall, this would be
927        // the boolean feature; the rule is the spec, this is what it produced here.)
928        assert_eq!(
929            engine.creating_feature("Pin_S"),
930            Some(("Pin".to_string(), "P.CY".to_string())),
931        );
932
933        // An edge BORN at the boolean (a new box↔bore intersection curve) has NO
934        // earlier writer, so it correctly originates from the `Cut` boolean itself.
935        assert_eq!(
936            engine.creating_feature("Box_NY|Pin_S"),
937            Some(("Cut".to_string(), "B".to_string())),
938        );
939        // A plain box edge still originates from `Box`.
940        assert_eq!(
941            engine.creating_feature("Box_NX|Box_NY[0]"),
942            Some(("Box".to_string(), "P.CU".to_string())),
943        );
944    }
945
946    #[test]
947    fn unknown_object_reports_not_ok() {
948        let mut engine = EngineState::new();
949        engine.set_history_json(&cube_history("Box", 10.0)).unwrap();
950        let info: Value = serde_json::from_str(&engine.object_info_json("Nope")).unwrap();
951        assert_eq!(info["ok"], false);
952        assert!(engine.creating_feature("Nope").is_none());
953    }
954}