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 crate::runner::{MeasureKind, MeasureQuery};
28use serde_json::Value;
29use std::collections::BTreeMap;
30
31/// The default density (mass per unit volume) when an object carries no
32/// `density` metadata: unit density, so `weight == volume`.
33pub const DEFAULT_DENSITY: f64 = 1.0;
34
35/// The name-keyed metadata store: `object name → { attribute → value }`, string
36/// values, deterministic iteration (a `BTreeMap` so the persisted JSON is
37/// stable). Empty records are never retained (removing the last attribute drops
38/// the whole record).
39#[derive(Debug, Clone, Default)]
40pub struct MetadataStore {
41    entries: BTreeMap<String, BTreeMap<String, String>>,
42}
43
44impl MetadataStore {
45    pub fn new() -> Self {
46        Self::default()
47    }
48
49    /// Whether the store holds no records at all.
50    pub fn is_empty(&self) -> bool {
51        self.entries.is_empty()
52    }
53
54    /// Set (or overwrite) one attribute of `name`'s record. An empty object name
55    /// or key is ignored (no phantom records).
56    pub fn set_attribute(&mut self, name: &str, key: &str, value: &str) {
57        if name.is_empty() || key.is_empty() {
58            return;
59        }
60        self.entries
61            .entry(name.to_string())
62            .or_default()
63            .insert(key.to_string(), value.to_string());
64    }
65
66    /// Remove one attribute of `name`'s record, dropping the record if it becomes
67    /// empty. Returns whether the attribute existed.
68    pub fn remove_attribute(&mut self, name: &str, key: &str) -> bool {
69        let Some(record) = self.entries.get_mut(name) else {
70            return false;
71        };
72        let existed = record.remove(key).is_some();
73        if record.is_empty() {
74            self.entries.remove(name);
75        }
76        existed
77    }
78
79    /// One attribute's value (`None` if the object or key is unknown).
80    pub fn attribute(&self, name: &str, key: &str) -> Option<&str> {
81        self.entries.get(name)?.get(key).map(String::as_str)
82    }
83
84    /// One object's whole record (empty map if the object has no metadata).
85    pub fn record(&self, name: &str) -> BTreeMap<String, String> {
86        self.entries.get(name).cloned().unwrap_or_default()
87    }
88
89    /// The whole store (all records), read-only.
90    pub fn all(&self) -> &BTreeMap<String, BTreeMap<String, String>> {
91        &self.entries
92    }
93
94    /// The resolved density (mass per mm³) for `name`: its `density` attribute
95    /// parsed as a positive finite number, else [`DEFAULT_DENSITY`].
96    pub fn density(&self, name: &str) -> f64 {
97        self.attribute(name, "density")
98            .and_then(|value| value.trim().parse::<f64>().ok())
99            .filter(|density| density.is_finite() && *density > 0.0)
100            .unwrap_or(DEFAULT_DENSITY)
101    }
102
103    /// Drop every record (a part load replaces the store wholesale).
104    pub fn clear(&mut self) {
105        self.entries.clear();
106    }
107
108    /// One object's record as a JSON object `{ key: value, ... }` (`{}` when the
109    /// object has no metadata).
110    pub fn record_json(&self, name: &str) -> String {
111        Value::Object(
112            self.record(name)
113                .into_iter()
114                .map(|(key, value)| (key, Value::String(value)))
115                .collect(),
116        )
117        .to_string()
118    }
119
120    /// The whole store as a JSON value `{ name: { key: value, ... }, ... }` — the
121    /// persisted shape and the whole-store getter.
122    pub fn to_json(&self) -> Value {
123        Value::Object(
124            self.entries
125                .iter()
126                .map(|(name, record)| {
127                    let object = record
128                        .iter()
129                        .map(|(key, value)| (key.clone(), Value::String(value.clone())))
130                        .collect();
131                    (name.clone(), Value::Object(object))
132                })
133                .collect(),
134        )
135    }
136
137    /// The whole store as a JSON string (the whole-store getter's string form).
138    pub fn to_json_string(&self) -> String {
139        self.to_json().to_string()
140    }
141
142    /// Replace the whole store from a persisted `metadata` value (or `None`, which
143    /// clears it — loading a part with no metadata). Non-string leaf values are
144    /// coerced to their JSON text so a legacy document never fails the load.
145    pub fn load_json(&mut self, value: Option<&Value>) {
146        self.entries.clear();
147        let Some(Value::Object(objects)) = value else {
148            return;
149        };
150        for (name, record) in objects {
151            let Value::Object(attributes) = record else {
152                continue;
153            };
154            let map: BTreeMap<String, String> = attributes
155                .iter()
156                .map(|(key, value)| (key.clone(), value_to_string(value)))
157                .collect();
158            if !map.is_empty() {
159                self.entries.insert(name.clone(), map);
160            }
161        }
162    }
163}
164
165/// Coerce a persisted metadata leaf to a string value (strings verbatim, `null`
166/// to empty, everything else to its JSON text).
167fn value_to_string(value: &Value) -> String {
168    match value {
169        Value::String(text) => text.clone(),
170        Value::Null => String::new(),
171        other => other.to_string(),
172    }
173}
174
175/// The kind an object NAME resolves to in the current scene.
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177enum ObjectKind {
178    Solid,
179    Face,
180    Edge,
181}
182
183// --- EngineState: metadata store API (a SEPARATE impl block, appended, so
184//     concurrent edits to the primary block don't conflict) -------------------
185impl EngineState {
186    /// One object's metadata record as JSON `{ key: value, ... }` (`{}` if none).
187    pub fn object_metadata_json(&self, name: &str) -> String {
188        self.metadata.record_json(name)
189    }
190
191    /// Set (or overwrite) one metadata attribute of an object by NAME. String
192    /// value; the well-known `density` key drives the object's weight.
193    pub fn set_metadata_attribute(&mut self, name: &str, key: &str, value: &str) {
194        self.metadata.set_attribute(name, key, value);
195        // A metadata edit (notably `density`) feeds the object-info output but does
196        // NOT trigger a history rerun, so drop this object's cached info so a
197        // re-selection re-measures with the new attribute.
198        self.info_cache.remove(name);
199    }
200
201    /// Remove one metadata attribute of an object. Returns whether it existed.
202    pub fn remove_metadata_attribute(&mut self, name: &str, key: &str) -> bool {
203        let existed = self.metadata.remove_attribute(name, key);
204        // Same rerun-less invalidation as `set_metadata_attribute`.
205        self.info_cache.remove(name);
206        existed
207    }
208
209    /// The whole metadata store as JSON `{ name: { key: value } }`.
210    pub fn metadata_json(&self) -> String {
211        self.metadata.to_json_string()
212    }
213}
214
215// --- EngineState: measurements + provenance (SEPARATE impl block) ------------
216impl EngineState {
217    /// Resolve an object NAME to `(kind, owning solid name)` via the display
218    /// scene: a solid resolves to itself; a face/edge resolves to its owning
219    /// solid. `None` for an empty or unknown name (vertices carry no kernel name).
220    fn resolve_object(&self, name: &str) -> Option<(ObjectKind, String)> {
221        if name.is_empty() {
222            return None;
223        }
224        if self.scene.solid(name).is_some() {
225            return Some((ObjectKind::Solid, name.to_string()));
226        }
227        for solid in self.scene.solids() {
228            if solid.faces.iter().any(|face| face.name == name) {
229                return Some((ObjectKind::Face, solid.name.clone()));
230            }
231        }
232        for solid in self.scene.solids() {
233            if solid.edges.iter().any(|edge| edge.name == name) {
234                return Some((ObjectKind::Edge, solid.name.clone()));
235            }
236        }
237        None
238    }
239
240    /// The feature that PRODUCED an object, as `(feature id, feature type)`. For a
241    /// face/edge the owning solid's producer is reported. `None` if the name is
242    /// unknown or has no known producer. Reads the EAGER provenance the last run
243    /// shipped (`name → creating-feature id`), so it never re-runs the history — the
244    /// freeze side-door `context_bar` hit every selected frame is now O(1).
245    pub fn creating_feature(&self, name: &str) -> Option<(String, String)> {
246        let (_, owner) = self.resolve_object(name)?;
247        let id = self.provenance.get(&owner)?.clone();
248        Some((id.clone(), self.feature_type_of(&id)))
249    }
250
251    /// A feature's type token by id (empty string if the id is not in the history).
252    fn feature_type_of(&self, id: &str) -> String {
253        self.history
254            .index_of(id)
255            .and_then(|index| self.history.feature_type(index))
256            .unwrap_or_default()
257    }
258
259    /// The `{ id, type }` provenance JSON for a solid `owner` (or `null` if it has
260    /// no known producer), read from the eager provenance map.
261    fn creating_feature_value(&self, owner: &str) -> Value {
262        match self.provenance.get(owner) {
263            Some(id) => serde_json::json!({ "id": id, "type": self.feature_type_of(id) }),
264            None => Value::Null,
265        }
266    }
267
268    /// The full Properties-panel info for an object by NAME: its resolved kind,
269    /// the right measurements, and provenance. Units are millimetres.
270    ///
271    /// - **Solid** — `{ ok, name, kind:"solid", volume, surfaceArea,
272    ///   edgeLengthTotal, density, weight, creatingFeature }`, where
273    ///   `weight = density · volume` and `density` comes from the object's
274    ///   metadata (default [`DEFAULT_DENSITY`]).
275    /// - **Face** — `{ ok, name, kind:"face", solid, area, edgeLengthTotal,
276    ///   creatingFeature }` (`edgeLengthTotal` = its boundary edges).
277    /// - **Edge** — `{ ok, name, kind:"edge", solid, length, creatingFeature }`.
278    ///
279    /// `{ ok:false, name, message }` for an empty/unknown name, a non-resident
280    /// solid, or a kernel measurement failure.
281    ///
282    /// The real solid/face/edge MEASUREMENT is routed to the [`HistoryRunner`] (so
283    /// the warm-registry runner answers it, never the potentially-cold main side)
284    /// and CACHED here keyed by name — fired once per selection, served from the
285    /// cache every subsequent frame. For the synchronous
286    /// [`InlineRunner`](crate::runner::InlineRunner) the submit → `pump_queries`
287    /// resolves same-call, so this returns the merged JSON immediately and stays
288    /// byte-identical to the pre-seam in-process result; a background
289    /// [`ThreadRunner`](crate::runner::ThreadRunner) returns a `pending` placeholder
290    /// for the frame(s) until its reply lands (drained by `pump_queries`). The cache
291    /// is invalidated on any geometry change (`apply_run_output`) or metadata edit
292    /// (`set_metadata_attribute`).
293    ///
294    /// [`HistoryRunner`]: crate::runner::HistoryRunner
295    pub fn object_info_json(&mut self, name: &str) -> String {
296        let Some((kind, owner)) = self.resolve_object(name) else {
297            // A construction datum/plane carries no resident geometry (no volume /
298            // area / length), so it never resolves as a solid/face/edge. Return a
299            // graceful minimal record — name + kind + creating feature — rather than
300            // erroring, so the Properties Info tab renders for a selected datum and
301            // its name flows into the (name-keyed) Metadata tab.
302            if let Some((feature_id, feature_type)) = self.datum_feature_for_name(name) {
303                let kind_label = if feature_type == "P" { "plane" } else { "datum" };
304                return serde_json::json!({
305                    "ok": true,
306                    "name": name,
307                    "kind": kind_label,
308                    "creatingFeature": { "id": feature_id, "type": feature_type },
309                })
310                .to_string();
311            }
312            return serde_json::json!({
313                "ok": false, "name": name, "message": "unknown object",
314            })
315            .to_string();
316        };
317        // A committed-sketch SHEET is a scene solid with NO kernel handle, so the
318        // handle-based measurement path can't serve it. Measure straight off its
319        // synthesized display (planar-mesh triangle areas / edge polylines) and
320        // report `kind:"sketch"` — no volume. Handle-less ⇒ synchronous, no query.
321        if let Some(solid) = self.scene.solid(&owner) {
322            if solid.is_sketch {
323                return self.sketch_info_json(name, kind, solid);
324            }
325        }
326
327        // Real geometry: serve from the info cache, else fire a measurement query at
328        // the runner (deduped: never submit a second query for a name already in
329        // flight), pump, and return the resolved JSON — or a pending placeholder.
330        if let Some(cached) = self.info_cache.get(name) {
331            return cached.clone();
332        }
333        if !self.pending_query.values().any(|(pending, _)| pending == name) {
334            self.next_query_id += 1;
335            let id = self.next_query_id;
336            let measure_kind = match kind {
337                ObjectKind::Solid => MeasureKind::Solid,
338                ObjectKind::Face => MeasureKind::Face,
339                ObjectKind::Edge => MeasureKind::Edge,
340            };
341            let density = self.metadata.density(name);
342            self.pending_query.insert(id, (name.to_string(), owner.clone()));
343            self.runner.submit_query(MeasureQuery {
344                id,
345                kind: measure_kind,
346                owner,
347                entity: name.to_string(),
348                density,
349            });
350        }
351        self.pump_queries();
352        match self.info_cache.get(name) {
353            Some(resolved) => resolved.clone(),
354            None => serde_json::json!({ "ok": false, "name": name, "pending": true }).to_string(),
355        }
356    }
357
358    /// Drain every completed measurement reply from the runner and fold it into the
359    /// name-keyed info cache — the query counterpart of [`EngineState::pump`]. Called
360    /// once per frame from `pump` AND synchronously from
361    /// [`Self::object_info_json`] (so the Inline runner resolves same-call). Each
362    /// reply is MERGED with the main-injected `name` + `creatingFeature` (from the
363    /// eager provenance) into the final object-info JSON.
364    pub fn pump_queries(&mut self) {
365        while let Some(reply) = self.runner.poll_query() {
366            let Some((name, owner)) = self.pending_query.remove(&reply.id) else {
367                // A reply whose request was invalidated (a rerun cleared the pending
368                // set) — drop it; the re-selection re-queries against fresh geometry.
369                continue;
370            };
371            let fragment: Value = serde_json::from_str(&reply.result).unwrap_or(Value::Null);
372            let merged = self.merge_info(&name, &owner, &fragment);
373            self.info_cache.insert(name, merged);
374        }
375    }
376
377    /// Merge a runner measurement FRAGMENT with the main-side `name` +
378    /// `creatingFeature` into the final object-info JSON, preserving the exact field
379    /// ORDER the pre-seam `object_info_json` emitted (so the output is
380    /// byte-identical): `ok`, then `name`, then the fragment's remaining fields in
381    /// order (`kind`, the measurements — or `message` on error), then
382    /// `creatingFeature` (only when `ok`, since an error record carries none).
383    fn merge_info(&self, name: &str, owner: &str, fragment: &Value) -> String {
384        let object = fragment.as_object();
385        let ok = object
386            .and_then(|map| map.get("ok"))
387            .and_then(Value::as_bool)
388            .unwrap_or(false);
389        let mut merged = serde_json::Map::new();
390        merged.insert("ok".to_string(), Value::Bool(ok));
391        merged.insert("name".to_string(), Value::String(name.to_string()));
392        if let Some(map) = object {
393            for (key, value) in map {
394                if key == "ok" {
395                    continue;
396                }
397                merged.insert(key.clone(), value.clone());
398            }
399        }
400        if ok {
401            merged.insert(
402                "creatingFeature".to_string(),
403                self.creating_feature_value(owner),
404            );
405        }
406        Value::Object(merged).to_string()
407    }
408
409    /// Properties info for a committed-sketch SHEET object (the sheet solid, its
410    /// planar face, or a boundary edge), measured off the synthesized display — a
411    /// sketch carries no resident kernel geometry. `kind:"sketch"` for the whole
412    /// sheet (area + total edge length, NO volume); `"face"` for its planar face;
413    /// `"edge"` for a boundary edge. Provenance is the sketch feature itself.
414    fn sketch_info_json(
415        &self,
416        name: &str,
417        kind: ObjectKind,
418        solid: &crate::scene::SolidDisplay,
419    ) -> String {
420        let creating = serde_json::json!({
421            "id": solid.name,
422            "type": self.feature_type_of(&solid.name),
423        });
424        let edge_total: f64 = solid.edges.iter().map(|e| polyline_length(&e.polyline)).sum();
425        match kind {
426            ObjectKind::Solid => serde_json::json!({
427                "ok": true,
428                "name": name,
429                "kind": "sketch",
430                "area": sheet_mesh_area(solid),
431                "edgeLengthTotal": edge_total,
432                "creatingFeature": creating,
433            })
434            .to_string(),
435            ObjectKind::Face => serde_json::json!({
436                "ok": true,
437                "name": name,
438                "kind": "face",
439                "solid": solid.name,
440                "area": sheet_mesh_area(solid),
441                "edgeLengthTotal": edge_total,
442                "creatingFeature": creating,
443            })
444            .to_string(),
445            ObjectKind::Edge => {
446                let length = solid
447                    .edges
448                    .iter()
449                    .find(|e| e.name == name)
450                    .map(|e| polyline_length(&e.polyline))
451                    .unwrap_or(0.0);
452                serde_json::json!({
453                    "ok": true,
454                    "name": name,
455                    "kind": "edge",
456                    "solid": solid.name,
457                    "length": length,
458                    "creatingFeature": creating,
459                })
460                .to_string()
461            }
462        }
463    }
464}
465
466/// Total surface area (mm²) of a synthesized sheet's planar display mesh — the
467/// sum of its triangle areas.
468fn sheet_mesh_area(solid: &crate::scene::SolidDisplay) -> f64 {
469    let p = &solid.mesh.positions;
470    solid
471        .mesh
472        .indices
473        .chunks_exact(3)
474        .map(|t| {
475            let a = p[t[0] as usize];
476            let b = p[t[1] as usize];
477            let c = p[t[2] as usize];
478            let ab = [
479                (b[0] - a[0]) as f64,
480                (b[1] - a[1]) as f64,
481                (b[2] - a[2]) as f64,
482            ];
483            let ac = [
484                (c[0] - a[0]) as f64,
485                (c[1] - a[1]) as f64,
486                (c[2] - a[2]) as f64,
487            ];
488            let cross = [
489                ab[1] * ac[2] - ab[2] * ac[1],
490                ab[2] * ac[0] - ab[0] * ac[2],
491                ab[0] * ac[1] - ab[1] * ac[0],
492            ];
493            (cross[0] * cross[0] + cross[1] * cross[1] + cross[2] * cross[2]).sqrt() * 0.5
494        })
495        .sum()
496}
497
498/// Arc length (mm) of a sampled edge polyline — the sum of its segment lengths.
499fn polyline_length(polyline: &[[f32; 3]]) -> f64 {
500    polyline
501        .windows(2)
502        .map(|w| {
503            let d = [
504                (w[1][0] - w[0][0]) as f64,
505                (w[1][1] - w[0][1]) as f64,
506                (w[1][2] - w[0][2]) as f64,
507            ];
508            (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
509        })
510        .sum()
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516
517    fn cube_history(name: &str, side: f64) -> String {
518        serde_json::json!({
519            "expressions": "",
520            "configurator": {},
521            "features": [{
522                "type": "P.CU",
523                "inputParams": {
524                    "id": name,
525                    "sizeX": side, "sizeY": side, "sizeZ": side,
526                    "transform": {
527                        "position": [0.0, 0.0, 0.0],
528                        "rotationEuler": [0.0, 0.0, 0.0],
529                        "scale": [1.0, 1.0, 1.0]
530                    },
531                    "boolean": { "targets": [], "operation": "NONE" }
532                },
533                "persistentData": {}
534            }]
535        })
536        .to_string()
537    }
538
539    /// The same 3-feature seed the app boots with: a 20 mm cube `Box`, a cylinder
540    /// `Pin`, and `Cut` = SUBTRACT(Box, [Pin]). The SUBTRACT result reuses the
541    /// target's name, so the final solid is `Box`, produced by the `Cut` feature.
542    fn seed_history() -> String {
543        serde_json::json!({
544            "expressions": "",
545            "configurator": {},
546            "features": [
547                {
548                    "type": "P.CU",
549                    "inputParams": {
550                        "id": "Box",
551                        "sizeX": 20.0, "sizeY": 20.0, "sizeZ": 20.0,
552                        "transform": {
553                            "position": [0.0, 0.0, 0.0],
554                            "rotationEuler": [0.0, 0.0, 0.0],
555                            "scale": [1.0, 1.0, 1.0]
556                        },
557                        "boolean": { "targets": [], "operation": "NONE" }
558                    },
559                    "persistentData": {}
560                },
561                {
562                    "type": "P.CY",
563                    "inputParams": {
564                        "id": "Pin",
565                        "radius": 6.0, "height": 30.0,
566                        "transform": {
567                            "position": [10.0, -5.0, 10.0],
568                            "rotationEuler": [0.0, 0.0, 0.0],
569                            "scale": [1.0, 1.0, 1.0]
570                        },
571                        "boolean": { "targets": [], "operation": "NONE" }
572                    },
573                    "persistentData": {}
574                },
575                {
576                    "type": "B",
577                    "inputParams": {
578                        "id": "Cut",
579                        "targetSolid": "Box",
580                        "boolean": { "operation": "SUBTRACT", "targets": ["Pin"] }
581                    },
582                    "persistentData": {}
583                }
584            ]
585        })
586        .to_string()
587    }
588
589    /// First named entity of a kind on the resident `Box` (cube entities are all
590    /// named; any one works since a cube's edges/faces are congruent).
591    fn first_named_face(engine: &EngineState) -> String {
592        engine
593            .scene
594            .solid("Box")
595            .unwrap()
596            .faces
597            .iter()
598            .find(|face| !face.name.is_empty())
599            .expect("a named face")
600            .name
601            .clone()
602    }
603
604    fn first_named_edge(engine: &EngineState) -> String {
605        engine
606            .scene
607            .solid("Box")
608            .unwrap()
609            .edges
610            .iter()
611            .find(|edge| !edge.name.is_empty())
612            .expect("a named edge")
613            .name
614            .clone()
615    }
616
617    #[test]
618    fn metadata_set_get_remove_and_density() {
619        let mut store = MetadataStore::new();
620        assert!(store.is_empty());
621        // Default density is unit density.
622        assert_eq!(store.density("Box"), DEFAULT_DENSITY);
623
624        store.set_attribute("Box", "material", "steel");
625        store.set_attribute("Box", "density", "7.85");
626        assert_eq!(store.attribute("Box", "material"), Some("steel"));
627        assert_eq!(store.density("Box"), 7.85);
628        // Record JSON carries both attributes.
629        let record: Value = serde_json::from_str(&store.record_json("Box")).unwrap();
630        assert_eq!(record["material"], "steel");
631        assert_eq!(record["density"], "7.85");
632
633        // Empty name / key are ignored (no phantom records).
634        store.set_attribute("", "k", "v");
635        store.set_attribute("Box", "", "v");
636        assert_eq!(store.all().len(), 1);
637
638        // Remove one attribute; the record survives.
639        assert!(store.remove_attribute("Box", "material"));
640        assert_eq!(store.attribute("Box", "material"), None);
641        assert!(!store.is_empty());
642        // Removing an absent attribute reports false.
643        assert!(!store.remove_attribute("Box", "material"));
644        // Removing the last attribute drops the whole record.
645        assert!(store.remove_attribute("Box", "density"));
646        assert!(store.is_empty());
647    }
648
649    #[test]
650    fn metadata_round_trips_through_save_and_load() {
651        let mut engine = EngineState::new();
652        engine.set_history_json(&cube_history("Box", 10.0)).unwrap();
653        engine.set_metadata_attribute("Box", "material", "aluminium");
654        engine.set_metadata_attribute("Box", "density", "2.7");
655
656        // The engine metadata getter reflects the writes.
657        let all: Value = serde_json::from_str(&engine.metadata_json()).unwrap();
658        assert_eq!(all["Box"]["material"], "aluminium");
659
660        // Persist → the document carries a top-level `metadata` field.
661        let saved = engine.history_request_json();
662        let document: Value = serde_json::from_str(&saved).unwrap();
663        assert_eq!(document["metadata"]["Box"]["density"], "2.7");
664
665        // Load into a FRESH engine → the store is restored.
666        let mut reopened = EngineState::new();
667        reopened.set_history_json(&saved).unwrap();
668        assert_eq!(reopened.object_metadata_json("Box"), engine.object_metadata_json("Box"));
669        assert_eq!(reopened.metadata.density("Box"), 2.7);
670
671        // Loading a document with NO metadata clears the store wholesale.
672        reopened.set_history_json(&cube_history("Box", 10.0)).unwrap();
673        assert!(reopened.metadata.is_empty());
674        assert_eq!(reopened.object_metadata_json("Box"), "{}");
675    }
676
677    #[test]
678    fn unannotated_model_persists_without_metadata_field() {
679        let mut engine = EngineState::new();
680        engine.set_history_json(&cube_history("Box", 10.0)).unwrap();
681        let document: Value = serde_json::from_str(&engine.history_request_json()).unwrap();
682        assert!(document.get("metadata").is_none());
683    }
684
685    #[test]
686    fn edge_length_of_cube_edge_equals_side() {
687        let mut engine = EngineState::new();
688        engine.set_history_json(&cube_history("Box", 10.0)).unwrap();
689        let edge = first_named_edge(&engine);
690        let info: Value = serde_json::from_str(&engine.object_info_json(&edge)).unwrap();
691        assert_eq!(info["ok"], true);
692        assert_eq!(info["kind"], "edge");
693        assert_eq!(info["solid"], "Box");
694        assert!(
695            (info["length"].as_f64().unwrap() - 10.0).abs() < 1e-6,
696            "edge length {} != side 10",
697            info["length"]
698        );
699    }
700
701    #[test]
702    fn face_area_and_boundary_of_cube_face() {
703        let mut engine = EngineState::new();
704        engine.set_history_json(&cube_history("Box", 10.0)).unwrap();
705        let face = first_named_face(&engine);
706        let info: Value = serde_json::from_str(&engine.object_info_json(&face)).unwrap();
707        assert_eq!(info["ok"], true);
708        assert_eq!(info["kind"], "face");
709        // A 10 mm cube face: area 100, boundary = 4 edges · 10 = 40.
710        assert!((info["area"].as_f64().unwrap() - 100.0).abs() < 1e-6);
711        assert!((info["edgeLengthTotal"].as_f64().unwrap() - 40.0).abs() < 1e-6);
712    }
713
714    #[test]
715    fn solid_measurements_and_weight_with_density() {
716        let mut engine = EngineState::new();
717        engine.set_history_json(&cube_history("Box", 10.0)).unwrap();
718
719        // Default density → weight == volume.
720        let info: Value = serde_json::from_str(&engine.object_info_json("Box")).unwrap();
721        assert_eq!(info["ok"], true);
722        assert_eq!(info["kind"], "solid");
723        assert!((info["volume"].as_f64().unwrap() - 1000.0).abs() < 1e-6);
724        assert!((info["surfaceArea"].as_f64().unwrap() - 600.0).abs() < 1e-6);
725        assert!((info["edgeLengthTotal"].as_f64().unwrap() - 120.0).abs() < 1e-6);
726        assert!((info["density"].as_f64().unwrap() - 1.0).abs() < 1e-12);
727        assert!((info["weight"].as_f64().unwrap() - 1000.0).abs() < 1e-6);
728
729        // Density 2 → weight == 2 · volume.
730        engine.set_metadata_attribute("Box", "density", "2");
731        let heavy: Value = serde_json::from_str(&engine.object_info_json("Box")).unwrap();
732        assert!((heavy["density"].as_f64().unwrap() - 2.0).abs() < 1e-12);
733        assert!((heavy["weight"].as_f64().unwrap() - 2000.0).abs() < 1e-6);
734    }
735
736    #[test]
737    fn creating_feature_of_seed_box_and_cut() {
738        let mut engine = EngineState::new();
739        engine.set_history_json(&seed_history()).unwrap();
740
741        // The full seed leaves one solid, `Box`, produced by the `Cut` boolean.
742        let info: Value = serde_json::from_str(&engine.object_info_json("Box")).unwrap();
743        assert_eq!(info["creatingFeature"]["id"], "Cut");
744        assert_eq!(info["creatingFeature"]["type"], "B");
745        // The standalone provenance accessor agrees.
746        assert_eq!(
747            engine.creating_feature("Box"),
748            Some(("Cut".to_string(), "B".to_string()))
749        );
750
751        // Rolled back to step 0, `Box` is the plain cube produced by its own P.CU.
752        engine.roll_to(0);
753        let seed: Value = serde_json::from_str(&engine.object_info_json("Box")).unwrap();
754        assert_eq!(seed["creatingFeature"]["id"], "Box");
755        assert_eq!(seed["creatingFeature"]["type"], "P.CU");
756    }
757
758    #[test]
759    fn unknown_object_reports_not_ok() {
760        let mut engine = EngineState::new();
761        engine.set_history_json(&cube_history("Box", 10.0)).unwrap();
762        let info: Value = serde_json::from_str(&engine.object_info_json("Nope")).unwrap();
763        assert_eq!(info["ok"], false);
764        assert!(engine.creating_feature("Nope").is_none());
765    }
766}