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 an object ORIGINATES from, as `(feature id, feature type)`.
241 /// For a FACE/EDGE this is the feature that first gave it its name (its true
242 /// origin, from the eager `entity_origin` first-writer map) — NOT the owning
243 /// solid's last producer — so "Edit owning feature" rolls back to where the
244 /// entity was born. For a SOLID it's the solid's producer (last writer, the
245 /// eager `provenance` map). `None` if the name is unknown or has no known
246 /// producer. Reads only the eager maps the last run shipped, so it never re-runs
247 /// the history — the freeze side-door `context_bar` hit every selected frame is
248 /// now O(1).
249 pub fn creating_feature(&self, name: &str) -> Option<(String, String)> {
250 let Some((kind, owner)) = self.resolve_object(name) else {
251 // A datum / construction PLANE (or one plane of a datum) is not a
252 // resident solid/face/edge — it's a named frame. Its producer is the
253 // D/P feature that emitted the frame (same fallback object_info_json
254 // uses), so "Edit owning feature" resolves for a lone plane pick too.
255 return self.datum_feature_for_name(name);
256 };
257 let id = match kind {
258 // A face/edge resolves to its ORIGIN. Fall back to the owning solid's
259 // producer when the name is somehow absent from `entity_origin` (keeps
260 // the "Edit owning feature" button from vanishing).
261 ObjectKind::Face | ObjectKind::Edge => self
262 .entity_origin
263 .get(name)
264 .cloned()
265 .or_else(|| self.provenance.get(&owner).cloned())?,
266 // A solid keeps its existing last-writer producer semantics.
267 ObjectKind::Solid => self.provenance.get(&owner)?.clone(),
268 };
269 Some((id.clone(), self.feature_type_of(&id)))
270 }
271
272 /// Whether the object `name` (a solid, or a face/edge owned by a solid) sits
273 /// on a SHEET-METAL body — its owning display solid carries the sheet-metal
274 /// marker the pipeline stamped from the resident handle's `SheetTree`. Reads
275 /// only the display scene, so it is O(1) and thread-safe (no `SheetTree`
276 /// thread-local touched on the UI thread — [`crate::scene::SolidDisplay::
277 /// is_sheet_metal`]). The gate for the sheet-metal edit features (SM Flange /
278 /// Fillet / Chamfer). `false` for an unknown name or a synthesized sketch
279 /// sheet (no resident handle, no tree).
280 pub fn is_sheet_metal_object(&self, name: &str) -> bool {
281 self.resolve_object(name)
282 .and_then(|(_, owner)| self.scene.solid(&owner))
283 .is_some_and(|solid| solid.is_sheet_metal)
284 }
285
286 /// A feature's type token by id (empty string if the id is not in the history).
287 fn feature_type_of(&self, id: &str) -> String {
288 self.history
289 .index_of(id)
290 .and_then(|index| self.history.feature_type(index))
291 .unwrap_or_default()
292 }
293
294 /// The `{ id, type }` provenance JSON for an object by NAME (or `null` if it has
295 /// no known producer). Delegates to [`creating_feature`](Self::creating_feature)
296 /// so a face/edge reports its ORIGIN (first-writer) and a solid its producer
297 /// (last-writer) — one resolver, so the Info tab and the context bar agree.
298 fn creating_feature_value(&self, name: &str) -> Value {
299 match self.creating_feature(name) {
300 Some((id, ty)) => serde_json::json!({ "id": id, "type": ty }),
301 None => Value::Null,
302 }
303 }
304
305 /// The full Properties-panel info for an object by NAME: its resolved kind,
306 /// the right measurements, and provenance. Units are millimetres.
307 ///
308 /// - **Solid** — `{ ok, name, kind:"solid", volume, surfaceArea,
309 /// edgeLengthTotal, density, weight, creatingFeature }`, where
310 /// `weight = density · volume` and `density` comes from the object's
311 /// metadata (default [`DEFAULT_DENSITY`]).
312 /// - **Face** — `{ ok, name, kind:"face", solid, surfaceType, area,
313 /// edgeLengthTotal, creatingFeature }` (`edgeLengthTotal` = its boundary
314 /// edges; `surfaceType` = the carrier-surface classification, e.g.
315 /// `"Plane"`/`"Cylinder"`/`"Cone"`/`"Sphere"`/`"Torus"`/`"NURBS"`).
316 /// - **Edge** — `{ ok, name, kind:"edge", solid, length, creatingFeature }`.
317 ///
318 /// `{ ok:false, name, message }` for an empty/unknown name, a non-resident
319 /// solid, or a kernel measurement failure.
320 ///
321 /// The real solid/face/edge MEASUREMENT is routed to the [`HistoryRunner`] (so
322 /// the warm-registry runner answers it, never the potentially-cold main side)
323 /// and CACHED here keyed by name — fired once per selection, served from the
324 /// cache every subsequent frame. For the synchronous
325 /// [`InlineRunner`](crate::runner::InlineRunner) the submit → `pump_queries`
326 /// resolves same-call, so this returns the merged JSON immediately and stays
327 /// byte-identical to the pre-seam in-process result; a background
328 /// [`ThreadRunner`](crate::runner::ThreadRunner) returns a `pending` placeholder
329 /// for the frame(s) until its reply lands (drained by `pump_queries`). The cache
330 /// is invalidated on any geometry change (`apply_run_output`) or metadata edit
331 /// (`set_metadata_attribute`).
332 ///
333 /// [`HistoryRunner`]: crate::runner::HistoryRunner
334 pub fn object_info_json(&mut self, name: &str) -> String {
335 let Some((kind, owner)) = self.resolve_object(name) else {
336 // A construction datum/plane carries no resident geometry (no volume /
337 // area / length), so it never resolves as a solid/face/edge. Return a
338 // graceful minimal record — name + kind + creating feature — rather than
339 // erroring, so the Properties Info tab renders for a selected datum and
340 // its name flows into the (name-keyed) Metadata tab.
341 if let Some((feature_id, feature_type)) = self.datum_feature_for_name(name) {
342 let kind_label = if feature_type == "P" { "plane" } else { "datum" };
343 return serde_json::json!({
344 "ok": true,
345 "name": name,
346 "kind": kind_label,
347 "creatingFeature": { "id": feature_id, "type": feature_type },
348 })
349 .to_string();
350 }
351 return serde_json::json!({
352 "ok": false, "name": name, "message": "unknown object",
353 })
354 .to_string();
355 };
356 // A committed-sketch SHEET is a scene solid with NO kernel handle, so the
357 // handle-based measurement path can't serve it. Measure straight off its
358 // synthesized display (planar-mesh triangle areas / edge polylines) and
359 // report `kind:"sketch"` — no volume. Handle-less ⇒ synchronous, no query.
360 if let Some(solid) = self.scene.solid(&owner) {
361 if solid.is_sketch {
362 return self.sketch_info_json(name, kind, solid);
363 }
364 }
365
366 // Real geometry: serve from the info cache, else fire a measurement query at
367 // the runner (deduped: never submit a second query for a name already in
368 // flight), pump, and return the resolved JSON — or a pending placeholder.
369 if let Some(cached) = self.info_cache.get(name) {
370 return cached.clone();
371 }
372 if !self.pending_query.values().any(|pending| pending == name) {
373 self.next_query_id += 1;
374 let id = self.next_query_id;
375 let measure_kind = match kind {
376 ObjectKind::Solid => MeasureKind::Solid,
377 ObjectKind::Face => MeasureKind::Face,
378 ObjectKind::Edge => MeasureKind::Edge,
379 };
380 let density = self.metadata.density(name);
381 self.pending_query.insert(id, name.to_string());
382 self.runner.submit_query(MeasureQuery {
383 id,
384 kind: measure_kind,
385 owner,
386 entity: name.to_string(),
387 density,
388 });
389 }
390 self.pump_queries();
391 match self.info_cache.get(name) {
392 Some(resolved) => resolved.clone(),
393 None => serde_json::json!({ "ok": false, "name": name, "pending": true }).to_string(),
394 }
395 }
396
397 /// Drain every completed measurement reply from the runner and fold it into the
398 /// name-keyed info cache — the query counterpart of [`EngineState::pump`]. Called
399 /// once per frame from `pump` AND synchronously from
400 /// [`Self::object_info_json`] (so the Inline runner resolves same-call). Each
401 /// reply is MERGED with the main-injected `name` + `creatingFeature` (from the
402 /// eager provenance) into the final object-info JSON.
403 pub fn pump_queries(&mut self) {
404 while let Some(reply) = self.runner.poll_query() {
405 let Some(name) = self.pending_query.remove(&reply.id) else {
406 // A reply whose request was invalidated (a rerun cleared the pending
407 // set) — drop it; the re-selection re-queries against fresh geometry.
408 continue;
409 };
410 let fragment: Value = serde_json::from_str(&reply.result).unwrap_or(Value::Null);
411 let merged = self.merge_info(&name, &fragment);
412 self.info_cache.insert(name, merged);
413 }
414 }
415
416 /// Merge a runner measurement FRAGMENT with the main-side `name` +
417 /// `creatingFeature` into the final object-info JSON, preserving the exact field
418 /// ORDER the pre-seam `object_info_json` emitted (so the output is
419 /// byte-identical): `ok`, then `name`, then the fragment's remaining fields in
420 /// order (`kind`, the measurements — or `message` on error), then
421 /// `creatingFeature` (only when `ok`, since an error record carries none).
422 fn merge_info(&self, name: &str, fragment: &Value) -> String {
423 let object = fragment.as_object();
424 let ok = object
425 .and_then(|map| map.get("ok"))
426 .and_then(Value::as_bool)
427 .unwrap_or(false);
428 let mut merged = serde_json::Map::new();
429 merged.insert("ok".to_string(), Value::Bool(ok));
430 merged.insert("name".to_string(), Value::String(name.to_string()));
431 if let Some(map) = object {
432 for (key, value) in map {
433 if key == "ok" {
434 continue;
435 }
436 merged.insert(key.clone(), value.clone());
437 }
438 }
439 if ok {
440 merged.insert(
441 "creatingFeature".to_string(),
442 // Resolve by ENTITY NAME (not `owner`): a face/edge reports its
443 // ORIGIN, a solid its producer — the same correction the context bar
444 // gets, so the Info tab's `creatingFeature` agrees.
445 self.creating_feature_value(name),
446 );
447 }
448 Value::Object(merged).to_string()
449 }
450
451 /// Properties info for a committed-sketch SHEET object (the sheet solid, its
452 /// planar face, or a boundary edge), measured off the synthesized display — a
453 /// sketch carries no resident kernel geometry. `kind:"sketch"` for the whole
454 /// sheet (area + total edge length, NO volume); `"face"` for its planar face;
455 /// `"edge"` for a boundary edge. Provenance is the sketch feature itself.
456 fn sketch_info_json(
457 &self,
458 name: &str,
459 kind: ObjectKind,
460 solid: &crate::scene::SolidDisplay,
461 ) -> String {
462 let creating = serde_json::json!({
463 "id": solid.name,
464 "type": self.feature_type_of(&solid.name),
465 });
466 let edge_total: f64 = solid.edges.iter().map(|e| polyline_length(&e.polyline)).sum();
467 match kind {
468 ObjectKind::Solid => serde_json::json!({
469 "ok": true,
470 "name": name,
471 "kind": "sketch",
472 "area": sheet_mesh_area(solid),
473 "edgeLengthTotal": edge_total,
474 "creatingFeature": creating,
475 })
476 .to_string(),
477 ObjectKind::Face => serde_json::json!({
478 "ok": true,
479 "name": name,
480 "kind": "face",
481 "solid": solid.name,
482 // A committed sketch's sheet face is planar by construction.
483 "surfaceType": "Plane",
484 "area": sheet_mesh_area(solid),
485 "edgeLengthTotal": edge_total,
486 "creatingFeature": creating,
487 })
488 .to_string(),
489 ObjectKind::Edge => {
490 let length = solid
491 .edges
492 .iter()
493 .find(|e| e.name == name)
494 .map(|e| polyline_length(&e.polyline))
495 .unwrap_or(0.0);
496 serde_json::json!({
497 "ok": true,
498 "name": name,
499 "kind": "edge",
500 "solid": solid.name,
501 "length": length,
502 "creatingFeature": creating,
503 })
504 .to_string()
505 }
506 }
507 }
508}
509
510/// Total surface area (mm²) of a synthesized sheet's planar display mesh — the
511/// sum of its triangle areas.
512fn sheet_mesh_area(solid: &crate::scene::SolidDisplay) -> f64 {
513 let p = &solid.mesh.positions;
514 solid
515 .mesh
516 .indices
517 .chunks_exact(3)
518 .map(|t| {
519 let a = p[t[0] as usize];
520 let b = p[t[1] as usize];
521 let c = p[t[2] as usize];
522 let ab = [
523 (b[0] - a[0]) as f64,
524 (b[1] - a[1]) as f64,
525 (b[2] - a[2]) as f64,
526 ];
527 let ac = [
528 (c[0] - a[0]) as f64,
529 (c[1] - a[1]) as f64,
530 (c[2] - a[2]) as f64,
531 ];
532 let cross = [
533 ab[1] * ac[2] - ab[2] * ac[1],
534 ab[2] * ac[0] - ab[0] * ac[2],
535 ab[0] * ac[1] - ab[1] * ac[0],
536 ];
537 (cross[0] * cross[0] + cross[1] * cross[1] + cross[2] * cross[2]).sqrt() * 0.5
538 })
539 .sum()
540}
541
542/// Arc length (mm) of a sampled edge polyline — the sum of its segment lengths.
543fn polyline_length(polyline: &[[f32; 3]]) -> f64 {
544 polyline
545 .windows(2)
546 .map(|w| {
547 let d = [
548 (w[1][0] - w[0][0]) as f64,
549 (w[1][1] - w[0][1]) as f64,
550 (w[1][2] - w[0][2]) as f64,
551 ];
552 (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
553 })
554 .sum()
555}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560
561 fn cube_history(name: &str, side: f64) -> String {
562 serde_json::json!({
563 "expressions": "",
564 "configurator": {},
565 "features": [{
566 "type": "P.CU",
567 "inputParams": {
568 "id": name,
569 "sizeX": side, "sizeY": side, "sizeZ": side,
570 "transform": {
571 "position": [0.0, 0.0, 0.0],
572 "rotationEuler": [0.0, 0.0, 0.0],
573 "scale": [1.0, 1.0, 1.0]
574 },
575 "boolean": { "targets": [], "operation": "NONE" }
576 },
577 "persistentData": {}
578 }]
579 })
580 .to_string()
581 }
582
583 /// The same 3-feature seed the app boots with: a 20 mm cube `Box`, a cylinder
584 /// `Pin`, and `Cut` = SUBTRACT(Box, [Pin]). The SUBTRACT result reuses the
585 /// target's name, so the final solid is `Box`, produced by the `Cut` feature.
586 fn seed_history() -> String {
587 serde_json::json!({
588 "expressions": "",
589 "configurator": {},
590 "features": [
591 {
592 "type": "P.CU",
593 "inputParams": {
594 "id": "Box",
595 "sizeX": 20.0, "sizeY": 20.0, "sizeZ": 20.0,
596 "transform": {
597 "position": [0.0, 0.0, 0.0],
598 "rotationEuler": [0.0, 0.0, 0.0],
599 "scale": [1.0, 1.0, 1.0]
600 },
601 "boolean": { "targets": [], "operation": "NONE" }
602 },
603 "persistentData": {}
604 },
605 {
606 "type": "P.CY",
607 "inputParams": {
608 "id": "Pin",
609 "radius": 6.0, "height": 30.0,
610 "transform": {
611 "position": [10.0, -5.0, 10.0],
612 "rotationEuler": [0.0, 0.0, 0.0],
613 "scale": [1.0, 1.0, 1.0]
614 },
615 "boolean": { "targets": [], "operation": "NONE" }
616 },
617 "persistentData": {}
618 },
619 {
620 "type": "B",
621 "inputParams": {
622 "id": "Cut",
623 "targetSolid": "Box",
624 "boolean": { "operation": "SUBTRACT", "targets": ["Pin"] }
625 },
626 "persistentData": {}
627 }
628 ]
629 })
630 .to_string()
631 }
632
633 /// First named entity of a kind on the resident `Box` (cube entities are all
634 /// named; any one works since a cube's edges/faces are congruent).
635 fn first_named_face(engine: &EngineState) -> String {
636 engine
637 .scene
638 .solid("Box")
639 .unwrap()
640 .faces
641 .iter()
642 .find(|face| !face.name.is_empty())
643 .expect("a named face")
644 .name
645 .clone()
646 }
647
648 fn first_named_edge(engine: &EngineState) -> String {
649 engine
650 .scene
651 .solid("Box")
652 .unwrap()
653 .edges
654 .iter()
655 .find(|edge| !edge.name.is_empty())
656 .expect("a named edge")
657 .name
658 .clone()
659 }
660
661 #[test]
662 fn metadata_set_get_remove_and_density() {
663 let mut store = MetadataStore::new();
664 assert!(store.is_empty());
665 // Default density is unit density.
666 assert_eq!(store.density("Box"), DEFAULT_DENSITY);
667
668 store.set_attribute("Box", "material", "steel");
669 store.set_attribute("Box", "density", "7.85");
670 assert_eq!(store.attribute("Box", "material"), Some("steel"));
671 assert_eq!(store.density("Box"), 7.85);
672 // Record JSON carries both attributes.
673 let record: Value = serde_json::from_str(&store.record_json("Box")).unwrap();
674 assert_eq!(record["material"], "steel");
675 assert_eq!(record["density"], "7.85");
676
677 // Empty name / key are ignored (no phantom records).
678 store.set_attribute("", "k", "v");
679 store.set_attribute("Box", "", "v");
680 assert_eq!(store.all().len(), 1);
681
682 // Remove one attribute; the record survives.
683 assert!(store.remove_attribute("Box", "material"));
684 assert_eq!(store.attribute("Box", "material"), None);
685 assert!(!store.is_empty());
686 // Removing an absent attribute reports false.
687 assert!(!store.remove_attribute("Box", "material"));
688 // Removing the last attribute drops the whole record.
689 assert!(store.remove_attribute("Box", "density"));
690 assert!(store.is_empty());
691 }
692
693 #[test]
694 fn metadata_round_trips_through_save_and_load() {
695 let mut engine = EngineState::new();
696 engine.set_history_json(&cube_history("Box", 10.0)).unwrap();
697 engine.set_metadata_attribute("Box", "material", "aluminium");
698 engine.set_metadata_attribute("Box", "density", "2.7");
699
700 // The engine metadata getter reflects the writes.
701 let all: Value = serde_json::from_str(&engine.metadata_json()).unwrap();
702 assert_eq!(all["Box"]["material"], "aluminium");
703
704 // Persist → the document carries a top-level `metadata` field.
705 let saved = engine.history_request_json();
706 let document: Value = serde_json::from_str(&saved).unwrap();
707 assert_eq!(document["metadata"]["Box"]["density"], "2.7");
708
709 // Load into a FRESH engine → the store is restored.
710 let mut reopened = EngineState::new();
711 reopened.set_history_json(&saved).unwrap();
712 assert_eq!(reopened.object_metadata_json("Box"), engine.object_metadata_json("Box"));
713 assert_eq!(reopened.metadata.density("Box"), 2.7);
714
715 // Loading a document with NO metadata clears the store wholesale.
716 reopened.set_history_json(&cube_history("Box", 10.0)).unwrap();
717 assert!(reopened.metadata.is_empty());
718 assert_eq!(reopened.object_metadata_json("Box"), "{}");
719 }
720
721 #[test]
722 fn unannotated_model_persists_without_metadata_field() {
723 let mut engine = EngineState::new();
724 engine.set_history_json(&cube_history("Box", 10.0)).unwrap();
725 let document: Value = serde_json::from_str(&engine.history_request_json()).unwrap();
726 assert!(document.get("metadata").is_none());
727 }
728
729 #[test]
730 fn edge_length_of_cube_edge_equals_side() {
731 let mut engine = EngineState::new();
732 engine.set_history_json(&cube_history("Box", 10.0)).unwrap();
733 let edge = first_named_edge(&engine);
734 let info: Value = serde_json::from_str(&engine.object_info_json(&edge)).unwrap();
735 assert_eq!(info["ok"], true);
736 assert_eq!(info["kind"], "edge");
737 assert_eq!(info["solid"], "Box");
738 assert!(
739 (info["length"].as_f64().unwrap() - 10.0).abs() < 1e-6,
740 "edge length {} != side 10",
741 info["length"]
742 );
743 }
744
745 #[test]
746 fn face_area_and_boundary_of_cube_face() {
747 let mut engine = EngineState::new();
748 engine.set_history_json(&cube_history("Box", 10.0)).unwrap();
749 let face = first_named_face(&engine);
750 let info: Value = serde_json::from_str(&engine.object_info_json(&face)).unwrap();
751 assert_eq!(info["ok"], true);
752 assert_eq!(info["kind"], "face");
753 // A cube face rides a planar carrier.
754 assert_eq!(info["surfaceType"], "Plane");
755 // A 10 mm cube face: area 100, boundary = 4 edges · 10 = 40.
756 assert!((info["area"].as_f64().unwrap() - 100.0).abs() < 1e-6);
757 assert!((info["edgeLengthTotal"].as_f64().unwrap() - 40.0).abs() < 1e-6);
758 }
759
760 #[test]
761 fn surface_type_of_cylinder_faces_through_the_full_path() {
762 // The headline case, driven through the ENTIRE UI path (runner →
763 // face_measurements_native → NurbsSurface::analytic → kind_label): a
764 // real cylinder primitive's side rides a cylindrical carrier and its two
765 // caps ride planar ones — never the "NURBS" freeform fallback.
766 let history = serde_json::json!({
767 "expressions": "",
768 "configurator": {},
769 "features": [{
770 "type": "P.CY",
771 "inputParams": {
772 "id": "Pin",
773 "radius": 5.0, "height": 12.0,
774 "transform": {
775 "position": [0.0, 0.0, 0.0],
776 "rotationEuler": [0.0, 0.0, 0.0],
777 "scale": [1.0, 1.0, 1.0]
778 },
779 "boolean": { "targets": [], "operation": "NONE" }
780 },
781 "persistentData": {}
782 }]
783 })
784 .to_string();
785 let mut engine = EngineState::new();
786 engine.set_history_json(&history).unwrap();
787
788 let names: Vec<String> = engine
789 .scene
790 .solid("Pin")
791 .unwrap()
792 .faces
793 .iter()
794 .filter(|face| !face.name.is_empty())
795 .map(|face| face.name.clone())
796 .collect();
797 let mut cylinders = 0usize;
798 let mut planes = 0usize;
799 for name in names {
800 let info: Value = serde_json::from_str(&engine.object_info_json(&name)).unwrap();
801 assert_eq!(info["kind"], "face");
802 match info["surfaceType"].as_str().unwrap() {
803 "Cylinder" => cylinders += 1,
804 "Plane" => planes += 1,
805 other => panic!("unexpected surface type {other:?} for face {name}"),
806 }
807 }
808 assert!(cylinders >= 1, "the cylindrical side face reads Cylinder");
809 assert_eq!(planes, 2, "the two end caps read Plane");
810 }
811
812 #[test]
813 fn solid_measurements_and_weight_with_density() {
814 let mut engine = EngineState::new();
815 engine.set_history_json(&cube_history("Box", 10.0)).unwrap();
816
817 // Default density → weight == volume.
818 let info: Value = serde_json::from_str(&engine.object_info_json("Box")).unwrap();
819 assert_eq!(info["ok"], true);
820 assert_eq!(info["kind"], "solid");
821 assert!((info["volume"].as_f64().unwrap() - 1000.0).abs() < 1e-6);
822 assert!((info["surfaceArea"].as_f64().unwrap() - 600.0).abs() < 1e-6);
823 assert!((info["edgeLengthTotal"].as_f64().unwrap() - 120.0).abs() < 1e-6);
824 assert!((info["density"].as_f64().unwrap() - 1.0).abs() < 1e-12);
825 assert!((info["weight"].as_f64().unwrap() - 1000.0).abs() < 1e-6);
826
827 // Density 2 → weight == 2 · volume.
828 engine.set_metadata_attribute("Box", "density", "2");
829 let heavy: Value = serde_json::from_str(&engine.object_info_json("Box")).unwrap();
830 assert!((heavy["density"].as_f64().unwrap() - 2.0).abs() < 1e-12);
831 assert!((heavy["weight"].as_f64().unwrap() - 2000.0).abs() < 1e-6);
832 }
833
834 #[test]
835 fn creating_feature_of_seed_box_and_cut() {
836 let mut engine = EngineState::new();
837 engine.set_history_json(&seed_history()).unwrap();
838
839 // The full seed leaves one solid, `Box`, produced by the `Cut` boolean.
840 let info: Value = serde_json::from_str(&engine.object_info_json("Box")).unwrap();
841 assert_eq!(info["creatingFeature"]["id"], "Cut");
842 assert_eq!(info["creatingFeature"]["type"], "B");
843 // The standalone provenance accessor agrees.
844 assert_eq!(
845 engine.creating_feature("Box"),
846 Some(("Cut".to_string(), "B".to_string()))
847 );
848
849 // Rolled back to step 0, `Box` is the plain cube produced by its own P.CU.
850 engine.roll_to(0);
851 let seed: Value = serde_json::from_str(&engine.object_info_json("Box")).unwrap();
852 assert_eq!(seed["creatingFeature"]["id"], "Box");
853 assert_eq!(seed["creatingFeature"]["type"], "P.CU");
854 }
855
856 #[test]
857 fn creating_feature_of_face_is_its_origin_not_solid_last_producer() {
858 // The seed leaves one solid `Box`, produced by the `Cut` boolean, but its
859 // faces/edges must resolve to where they were BORN — the "Edit owning
860 // feature" fix. (The SOLID still resolves to `Cut`; see
861 // `creating_feature_of_seed_box_and_cut`.)
862 let mut engine = EngineState::new();
863 engine.set_history_json(&seed_history()).unwrap();
864
865 // A surviving ORIGINAL box side face originates from `Box` (P.CU) — NOT the
866 // `Cut` boolean that last produced the solid (the bug being fixed).
867 assert_eq!(
868 engine.creating_feature("Box_NX"),
869 Some(("Box".to_string(), "P.CU".to_string())),
870 );
871 // The Info tab's `creatingFeature` agrees (same single resolver).
872 let face_info: Value = serde_json::from_str(&engine.object_info_json("Box_NX")).unwrap();
873 assert_eq!(face_info["creatingFeature"]["id"], "Box");
874 assert_eq!(face_info["creatingFeature"]["type"], "P.CU");
875
876 // The BORE-wall face is the SUBTRACT tool's own face `Pin_S`, so under the
877 // first-writer rule it keeps the cylinder's origin, `Pin` (P.CY) — observed,
878 // not guessed. (If the boolean instead RENAMED the bore wall, this would be
879 // the boolean feature; the rule is the spec, this is what it produced here.)
880 assert_eq!(
881 engine.creating_feature("Pin_S"),
882 Some(("Pin".to_string(), "P.CY".to_string())),
883 );
884
885 // An edge BORN at the boolean (a new box↔bore intersection curve) has NO
886 // earlier writer, so it correctly originates from the `Cut` boolean itself.
887 assert_eq!(
888 engine.creating_feature("Box_NY|Pin_S"),
889 Some(("Cut".to_string(), "B".to_string())),
890 );
891 // A plain box edge still originates from `Box`.
892 assert_eq!(
893 engine.creating_feature("Box_NX|Box_NY[0]"),
894 Some(("Box".to_string(), "P.CU".to_string())),
895 );
896 }
897
898 #[test]
899 fn unknown_object_reports_not_ok() {
900 let mut engine = EngineState::new();
901 engine.set_history_json(&cube_history("Box", 10.0)).unwrap();
902 let info: Value = serde_json::from_str(&engine.object_info_json("Nope")).unwrap();
903 assert_eq!(info["ok"], false);
904 assert!(engine.creating_feature("Nope").is_none());
905 }
906}