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 /// Names the content: every mutation takes a fresh process-wide number, and
44 /// a clone (an undo snapshot) carries its content's number with it, so two
45 /// equal stamps always hold equal records.
46 stamp: u64,
47}
48
49static STAMPS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
50
51impl MetadataStore {
52 pub fn new() -> Self {
53 Self::default()
54 }
55
56 /// Whether the store holds no records at all.
57 pub fn is_empty(&self) -> bool {
58 self.entries.is_empty()
59 }
60
61 /// The content stamp: unmoved means unchanged (see the field).
62 pub fn stamp(&self) -> u64 {
63 self.stamp
64 }
65
66 fn touch(&mut self) {
67 self.stamp = STAMPS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
68 }
69
70 /// Set (or overwrite) one attribute of `name`'s record. An empty object name
71 /// or key is ignored (no phantom records).
72 pub fn set_attribute(&mut self, name: &str, key: &str, value: &str) {
73 if name.is_empty() || key.is_empty() {
74 return;
75 }
76 self.touch();
77 self.entries
78 .entry(name.to_string())
79 .or_default()
80 .insert(key.to_string(), value.to_string());
81 }
82
83 /// Remove one attribute of `name`'s record, dropping the record if it becomes
84 /// empty. Returns whether the attribute existed.
85 pub fn remove_attribute(&mut self, name: &str, key: &str) -> bool {
86 let Some(record) = self.entries.get_mut(name) else {
87 return false;
88 };
89 let existed = record.remove(key).is_some();
90 if record.is_empty() {
91 self.entries.remove(name);
92 }
93 if existed {
94 self.touch();
95 }
96 existed
97 }
98
99 /// One attribute's value (`None` if the object or key is unknown).
100 pub fn attribute(&self, name: &str, key: &str) -> Option<&str> {
101 self.entries.get(name)?.get(key).map(String::as_str)
102 }
103
104 /// One object's whole record (empty map if the object has no metadata).
105 pub fn record(&self, name: &str) -> BTreeMap<String, String> {
106 self.entries.get(name).cloned().unwrap_or_default()
107 }
108
109 /// The whole store (all records), read-only.
110 pub fn all(&self) -> &BTreeMap<String, BTreeMap<String, String>> {
111 &self.entries
112 }
113
114 /// The resolved density (mass per mm³) for `name`: its `density` attribute
115 /// parsed as a positive finite number, else [`DEFAULT_DENSITY`].
116 pub fn density(&self, name: &str) -> f64 {
117 self.attribute(name, "density")
118 .and_then(|value| value.trim().parse::<f64>().ok())
119 .filter(|density| density.is_finite() && *density > 0.0)
120 .unwrap_or(DEFAULT_DENSITY)
121 }
122
123 /// Drop every record (a part load replaces the store wholesale).
124 pub fn clear(&mut self) {
125 self.entries.clear();
126 self.touch();
127 }
128
129 /// One object's record as a JSON object `{ key: value, ... }` (`{}` when the
130 /// object has no metadata).
131 pub fn record_json(&self, name: &str) -> String {
132 Value::Object(
133 self.record(name)
134 .into_iter()
135 .map(|(key, value)| (key, Value::String(value)))
136 .collect(),
137 )
138 .to_string()
139 }
140
141 /// The whole store as a JSON value `{ name: { key: value, ... }, ... }` — the
142 /// persisted shape and the whole-store getter.
143 pub fn to_json(&self) -> Value {
144 Value::Object(
145 self.entries
146 .iter()
147 .map(|(name, record)| {
148 let object = record
149 .iter()
150 .map(|(key, value)| (key.clone(), Value::String(value.clone())))
151 .collect();
152 (name.clone(), Value::Object(object))
153 })
154 .collect(),
155 )
156 }
157
158 /// The whole store as a JSON string (the whole-store getter's string form).
159 pub fn to_json_string(&self) -> String {
160 self.to_json().to_string()
161 }
162
163 /// Replace the whole store from a persisted `metadata` value (or `None`, which
164 /// clears it — loading a part with no metadata). Non-string leaf values are
165 /// coerced to their JSON text so a legacy document never fails the load.
166 pub fn load_json(&mut self, value: Option<&Value>) {
167 self.entries.clear();
168 self.touch();
169 let Some(Value::Object(objects)) = value else {
170 return;
171 };
172 for (name, record) in objects {
173 let Value::Object(attributes) = record else {
174 continue;
175 };
176 let map: BTreeMap<String, String> = attributes
177 .iter()
178 .map(|(key, value)| (key.clone(), value_to_string(value)))
179 .collect();
180 if !map.is_empty() {
181 self.entries.insert(name.clone(), map);
182 }
183 }
184 }
185}
186
187/// Coerce a persisted metadata leaf to a string value (strings verbatim, `null`
188/// to empty, everything else to its JSON text).
189fn value_to_string(value: &Value) -> String {
190 match value {
191 Value::String(text) => text.clone(),
192 Value::Null => String::new(),
193 other => other.to_string(),
194 }
195}
196
197/// The kind an object NAME resolves to in the current scene.
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199enum ObjectKind {
200 Solid,
201 Face,
202 Edge,
203}
204
205// --- EngineState: metadata store API (a SEPARATE impl block, appended, so
206// concurrent edits to the primary block don't conflict) -------------------
207impl EngineState {
208 /// One object's metadata record as JSON `{ key: value, ... }` (`{}` if none).
209 pub fn object_metadata_json(&self, name: &str) -> String {
210 self.metadata.record_json(name)
211 }
212
213 /// Set (or overwrite) one metadata attribute of an object by NAME. String
214 /// value; the well-known `density` key drives the object's weight and the
215 /// well-known `color` key drives its shaded colour.
216 pub fn set_metadata_attribute(&mut self, name: &str, key: &str, value: &str) {
217 self.metadata.set_attribute(name, key, value);
218 // A metadata edit (notably `density`) feeds the object-info output but does
219 // NOT trigger a history rerun, so drop this object's cached info so a
220 // re-selection re-measures with the new attribute.
221 self.info_cache.remove(name);
222 // `color` is the one attribute the VIEWPORT reads, and there is no rerun
223 // to carry it through — push it to the display right here so recolouring
224 // a body is immediate.
225 if key == COLOR_METADATA_KEY {
226 self.sync_colors_from_metadata();
227 }
228 }
229
230 /// Remove one metadata attribute of an object. Returns whether it existed.
231 pub fn remove_metadata_attribute(&mut self, name: &str, key: &str) -> bool {
232 let existed = self.metadata.remove_attribute(name, key);
233 // Same rerun-less invalidation as `set_metadata_attribute`.
234 self.info_cache.remove(name);
235 if existed && key == COLOR_METADATA_KEY {
236 self.sync_colors_from_metadata();
237 }
238 existed
239 }
240
241 /// Re-derive the display scene's colours from the metadata store — the ONE
242 /// call that connects the durable `color` attribute to the renderer.
243 ///
244 /// Every colour the viewport shows comes from here: a STEP import's stamped
245 /// body/face colours, a colour typed or picked in the Info window, a colour
246 /// restored from a saved document. The store is the authority; the display's
247 /// `color_override` fields are a derived cache of it, rebuilt after every
248 /// history apply (`EngineState::finish_apply`), on every document load, on
249 /// a metadata edit, and whenever the display setting flips.
250 ///
251 /// [`crate::style::RenderSettings::override_model_colors`] is honoured HERE
252 /// rather than in the renderer, and it is deliberately a READ of the store,
253 /// never a write: ticking the box resolves every colour to `None` so the
254 /// viewport falls back to `faceColorMode`, while the stored attributes stay
255 /// exactly as they were. Unticking restores them from the same records.
256 ///
257 /// Returns whether anything changed, and marks the engine dirty only then —
258 /// the no-op case must stay free, since this runs after every single run.
259 pub fn sync_colors_from_metadata(&mut self) -> bool {
260 // Disjoint field borrows: the closure reads `self.metadata` while
261 // `self.scene` is borrowed mutably.
262 let ignore = self.settings.override_model_colors;
263 let metadata = &self.metadata;
264 let changed = self.scene.apply_metadata_colors(|name| {
265 if ignore {
266 return None;
267 }
268 metadata
269 .attribute(name, COLOR_METADATA_KEY)
270 .and_then(crate::style::parse_css_hex)
271 // A derived BOARD body has a colour of its own and no producing
272 // feature to have coloured it, so without this fallback the
273 // pass would resolve it to `None` and the board would draw in
274 // the name-hashed default — green substrate and copper traces
275 // are the whole point of drawing it. A stored attribute still
276 // wins, so a user may recolour the board like anything else.
277 .or_else(|| crate::engine_state::board_default_color(name))
278 });
279 if changed {
280 self.dirty = true;
281 }
282 changed
283 }
284
285 /// The whole metadata store as JSON `{ name: { key: value } }`.
286 pub fn metadata_json(&self) -> String {
287 self.metadata.to_json_string()
288 }
289
290 /// [`Self::metadata_json`] as a JSON value.
291 pub fn metadata_value(&self) -> Value {
292 self.metadata.to_json()
293 }
294}
295
296// --- EngineState: measurements + provenance (SEPARATE impl block) ------------
297impl EngineState {
298 /// Resolve an object NAME to `(kind, owning solid name)` via the display
299 /// scene: a solid resolves to itself; a face/edge resolves to its owning
300 /// solid. `None` for an empty or unknown name (vertices carry no kernel name).
301 fn resolve_object(&self, name: &str) -> Option<(ObjectKind, String)> {
302 if name.is_empty() {
303 return None;
304 }
305 if self.scene.solid(name).is_some() {
306 return Some((ObjectKind::Solid, name.to_string()));
307 }
308 for solid in self.scene.solids() {
309 if solid.faces.iter().any(|face| face.name == name) {
310 return Some((ObjectKind::Face, solid.name.clone()));
311 }
312 }
313 for solid in self.scene.solids() {
314 if solid.edges.iter().any(|edge| edge.name == name) {
315 return Some((ObjectKind::Edge, solid.name.clone()));
316 }
317 }
318 None
319 }
320
321 /// The feature that an object ORIGINATES from, as `(feature id, feature type)`.
322 /// For a FACE/EDGE this is the feature that first gave it its name (its true
323 /// origin, from the eager `entity_origin` first-writer map) — NOT the owning
324 /// solid's last producer — so "Edit owning feature" rolls back to where the
325 /// entity was born. For a SOLID it's the solid's producer (last writer, the
326 /// eager `provenance` map). `None` if the name is unknown or has no known
327 /// producer. Reads only the eager maps the last run shipped, so it never re-runs
328 /// the history — the freeze side-door `context_bar` hit every selected frame is
329 /// now O(1).
330 pub fn creating_feature(&self, name: &str) -> Option<(String, String)> {
331 let Some((kind, owner)) = self.resolve_object(name) else {
332 // A datum / construction PLANE (or one plane of a datum) is not a
333 // resident solid/face/edge — it's a named frame. Its producer is the
334 // D/P feature that emitted the frame (same fallback object_info_json
335 // uses), so "Edit owning feature" resolves for a lone plane pick too.
336 return self.datum_feature_for_name(name);
337 };
338 let id = match kind {
339 // A face/edge resolves to its ORIGIN. Fall back to the owning solid's
340 // producer when the name is somehow absent from `entity_origin` (keeps
341 // the "Edit owning feature" button from vanishing).
342 ObjectKind::Face | ObjectKind::Edge => self
343 .entity_origin
344 .get(name)
345 .cloned()
346 .or_else(|| self.provenance.get(&owner).cloned())?,
347 // A solid keeps its existing last-writer producer semantics.
348 ObjectKind::Solid => self.provenance.get(&owner)?.clone(),
349 };
350 Some((id.clone(), self.feature_type_of(&id)))
351 }
352
353 /// Whether the object `name` (a solid, or a face/edge owned by a solid) sits
354 /// on a SHEET-METAL body — its owning display solid carries the sheet-metal
355 /// marker the pipeline stamped from the resident handle's `SheetTree`. Reads
356 /// only the display scene, so it is O(1) and thread-safe (no `SheetTree`
357 /// thread-local touched on the UI thread — [`crate::scene::SolidDisplay::
358 /// is_sheet_metal`]). The gate for the sheet-metal edit features (SM Flange /
359 /// Fillet / Chamfer). `false` for an unknown name or a synthesized sketch
360 /// sheet (no resident handle, no tree).
361 pub fn is_sheet_metal_object(&self, name: &str) -> bool {
362 self.resolve_object(name)
363 .and_then(|(_, owner)| self.scene.solid(&owner))
364 .is_some_and(|solid| solid.is_sheet_metal)
365 }
366
367 /// A feature's type token by id (empty string if the id is not in the history).
368 fn feature_type_of(&self, id: &str) -> String {
369 self.history
370 .index_of(id)
371 .and_then(|index| self.history.feature_type(index))
372 .unwrap_or_default()
373 }
374
375 /// The `{ id, type }` provenance JSON for an object by NAME (or `null` if it has
376 /// no known producer). Delegates to [`creating_feature`](Self::creating_feature)
377 /// so a face/edge reports its ORIGIN (first-writer) and a solid its producer
378 /// (last-writer) — one resolver, so the Info tab and the context bar agree.
379 fn creating_feature_value(&self, name: &str) -> Value {
380 match self.creating_feature(name) {
381 Some((id, ty)) => serde_json::json!({ "id": id, "type": ty }),
382 None => Value::Null,
383 }
384 }
385
386 /// The full Properties-panel info for an object by NAME: its resolved kind,
387 /// the right measurements, and provenance. Units are millimetres.
388 ///
389 /// - **Solid** — `{ ok, name, kind:"solid", volume, surfaceArea,
390 /// edgeLengthTotal, density, weight, creatingFeature }`, where
391 /// `weight = density · volume` and `density` comes from the object's
392 /// metadata (default [`DEFAULT_DENSITY`]).
393 /// - **Face** — `{ ok, name, kind:"face", solid, surfaceType, area,
394 /// edgeLengthTotal, creatingFeature }` (`edgeLengthTotal` = its boundary
395 /// edges; `surfaceType` = the carrier-surface classification, e.g.
396 /// `"Plane"`/`"Cylinder"`/`"Cone"`/`"Sphere"`/`"Torus"`/`"NURBS"`), plus
397 /// the carrier's dimensions when it has them: `radius` + `diameter`
398 /// (cylinder, sphere), `halfAngleDeg` (cone), `majorRadius` +
399 /// `minorRadius` (torus).
400 /// - **Edge** — `{ ok, name, kind:"edge", solid, length, creatingFeature }`,
401 /// plus `curveType` (`"Circle"`/`"Arc"`), `radius`, `diameter` and an
402 /// arc's `sweepDeg` when the edge lies on a circle.
403 ///
404 /// `{ ok:false, name, message }` for an empty/unknown name, a non-resident
405 /// solid, or a kernel measurement failure.
406 ///
407 /// The real solid/face/edge MEASUREMENT is routed to the [`HistoryRunner`] (so
408 /// the warm-registry runner answers it, never the potentially-cold main side)
409 /// and CACHED here keyed by name — fired once per selection, served from the
410 /// cache every subsequent frame. For the synchronous
411 /// [`InlineRunner`](crate::runner::InlineRunner) the submit → `pump_queries`
412 /// resolves same-call, so this returns the merged JSON immediately and stays
413 /// byte-identical to the pre-seam in-process result; a background
414 /// [`ThreadRunner`](crate::runner::ThreadRunner) returns a `pending` placeholder
415 /// for the frame(s) until its reply lands (drained by `pump_queries`). The cache
416 /// is invalidated on any geometry change (`apply_run_output`) or metadata edit
417 /// (`set_metadata_attribute`).
418 ///
419 /// [`HistoryRunner`]: crate::runner::HistoryRunner
420 pub fn object_info_json(&mut self, name: &str) -> String {
421 let raw = self.object_info_raw_json(name);
422 self.with_part_mass(name, raw)
423 }
424
425 /// A SOLID's info carries the mass the part's density gives it (see
426 /// `engine_state::part_mass`): `density` (g/cm³, `null` when none
427 /// resolves), `densitySource` (`solid` | `part` | `material`), `mass` (kg)
428 /// and, with no number, `massNote` saying why. The runner's unit-density
429 /// `weight` is not reported: the BOM and this window read ONE density.
430 fn with_part_mass(&self, name: &str, raw: String) -> String {
431 let Ok(mut info) = serde_json::from_str::<Value>(&raw) else {
432 return raw;
433 };
434 if info.get("ok").and_then(Value::as_bool) != Some(true)
435 || info.get("kind").and_then(Value::as_str) != Some("solid")
436 {
437 return raw;
438 }
439 let volume = info.get("volume").and_then(Value::as_f64);
440 let resolved = self.solid_density(name);
441 if let Some(object) = info.as_object_mut() {
442 object.remove("weight");
443 object.insert(
444 "density".into(),
445 resolved.map(|(density, _)| Value::from(density)).unwrap_or(Value::Null),
446 );
447 object.insert(
448 "densitySource".into(),
449 resolved
450 .map(|(_, source)| Value::from(source.as_str()))
451 .unwrap_or(Value::Null),
452 );
453 let mass = volume.zip(resolved).map(|(v, (d, _))| v * d * 1e-6);
454 object.insert("mass".into(), mass.map(Value::from).unwrap_or(Value::Null));
455 if mass.is_none() {
456 object.insert(
457 "massNote".into(),
458 Value::from("no density: set the part's Material or Density"),
459 );
460 }
461 }
462 info.to_string()
463 }
464
465 /// [`Self::object_info_json`] without the part-density fold: the runner's
466 /// measurement as cached.
467 pub(crate) fn object_info_raw_json(&mut self, name: &str) -> String {
468 let Some((kind, owner)) = self.resolve_object(name) else {
469 // A construction datum/plane carries no resident geometry (no volume /
470 // area / length), so it never resolves as a solid/face/edge. Return a
471 // graceful minimal record — name + kind + creating feature — rather than
472 // erroring, so the Properties Info tab renders for a selected datum and
473 // its name flows into the (name-keyed) Metadata tab.
474 if let Some((feature_id, feature_type)) = self.datum_feature_for_name(name) {
475 let kind_label = if feature_type == "P" { "plane" } else { "datum" };
476 return serde_json::json!({
477 "ok": true,
478 "name": name,
479 "kind": kind_label,
480 "creatingFeature": { "id": feature_id, "type": feature_type },
481 })
482 .to_string();
483 }
484 return serde_json::json!({
485 "ok": false, "name": name, "message": "unknown object",
486 })
487 .to_string();
488 };
489 // A committed-sketch SHEET is a scene solid with NO kernel handle, so the
490 // handle-based measurement path can't serve it. Measure straight off its
491 // synthesized display (planar-mesh triangle areas / edge polylines) and
492 // report `kind:"sketch"` — no volume. Handle-less ⇒ synchronous, no query.
493 if let Some(solid) = self.scene.solid(&owner) {
494 if solid.is_sketch {
495 return self.sketch_info_json(name, kind, solid);
496 }
497 }
498
499 // Real geometry: serve from the info cache, else fire a measurement query at
500 // the runner (deduped: never submit a second query for a name already in
501 // flight), pump, and return the resolved JSON — or a pending placeholder.
502 if let Some(cached) = self.info_cache.get(name) {
503 return cached.clone();
504 }
505 if !self.pending_query.values().any(|pending| pending == name) {
506 self.next_query_id += 1;
507 let id = self.next_query_id;
508 let measure_kind = match kind {
509 ObjectKind::Solid => MeasureKind::Solid,
510 ObjectKind::Face => MeasureKind::Face,
511 ObjectKind::Edge => MeasureKind::Edge,
512 };
513 let density = self.metadata.density(name);
514 self.pending_query.insert(id, name.to_string());
515 self.runner.submit_query(MeasureQuery {
516 id,
517 kind: measure_kind,
518 owner,
519 entity: name.to_string(),
520 density,
521 });
522 }
523 self.pump_queries();
524 match self.info_cache.get(name) {
525 Some(resolved) => resolved.clone(),
526 None => serde_json::json!({ "ok": false, "name": name, "pending": true }).to_string(),
527 }
528 }
529
530 /// Drain every completed measurement reply from the runner and fold it into the
531 /// name-keyed info cache — the query counterpart of [`EngineState::pump`]. Called
532 /// once per frame from `pump` AND synchronously from
533 /// [`Self::object_info_json`] (so the Inline runner resolves same-call). Each
534 /// reply is MERGED with the main-injected `name` + `creatingFeature` (from the
535 /// eager provenance) into the final object-info JSON.
536 pub fn pump_queries(&mut self) {
537 while let Some(reply) = self.runner.poll_query() {
538 let Some(name) = self.pending_query.remove(&reply.id) else {
539 // A reply whose request was invalidated (a rerun cleared the pending
540 // set) — drop it; the re-selection re-queries against fresh geometry.
541 continue;
542 };
543 let fragment: Value = serde_json::from_str(&reply.result).unwrap_or(Value::Null);
544 let merged = self.merge_info(&name, &fragment);
545 self.info_cache.insert(name, merged);
546 }
547 }
548
549 /// Merge a runner measurement FRAGMENT with the main-side `name` +
550 /// `creatingFeature` into the final object-info JSON, preserving the exact field
551 /// ORDER the pre-seam `object_info_json` emitted (so the output is
552 /// byte-identical): `ok`, then `name`, then the fragment's remaining fields in
553 /// order (`kind`, the measurements — or `message` on error), then
554 /// `creatingFeature` (only when `ok`, since an error record carries none).
555 fn merge_info(&self, name: &str, fragment: &Value) -> String {
556 let object = fragment.as_object();
557 let ok = object
558 .and_then(|map| map.get("ok"))
559 .and_then(Value::as_bool)
560 .unwrap_or(false);
561 let mut merged = serde_json::Map::new();
562 merged.insert("ok".to_string(), Value::Bool(ok));
563 merged.insert("name".to_string(), Value::String(name.to_string()));
564 if let Some(map) = object {
565 for (key, value) in map {
566 if key == "ok" {
567 continue;
568 }
569 merged.insert(key.clone(), value.clone());
570 }
571 }
572 if ok {
573 merged.insert(
574 "creatingFeature".to_string(),
575 // Resolve by ENTITY NAME (not `owner`): a face/edge reports its
576 // ORIGIN, a solid its producer — the same correction the context bar
577 // gets, so the Info tab's `creatingFeature` agrees.
578 self.creating_feature_value(name),
579 );
580 }
581 Value::Object(merged).to_string()
582 }
583
584 /// Properties info for a committed-sketch SHEET object (the sheet solid, its
585 /// planar face, or a boundary edge), measured off the synthesized display — a
586 /// sketch carries no resident kernel geometry. `kind:"sketch"` for the whole
587 /// sheet (area + total edge length, NO volume); `"face"` for its planar face;
588 /// `"edge"` for a boundary edge. Provenance is the sketch feature itself.
589 fn sketch_info_json(
590 &self,
591 name: &str,
592 kind: ObjectKind,
593 solid: &crate::scene::SolidDisplay,
594 ) -> String {
595 let creating = serde_json::json!({
596 "id": solid.name,
597 "type": self.feature_type_of(&solid.name),
598 });
599 let edge_total: f64 = solid.edges.iter().map(|e| polyline_length(&e.polyline)).sum();
600 match kind {
601 ObjectKind::Solid => serde_json::json!({
602 "ok": true,
603 "name": name,
604 "kind": "sketch",
605 "area": sheet_mesh_area(solid),
606 "edgeLengthTotal": edge_total,
607 "creatingFeature": creating,
608 })
609 .to_string(),
610 ObjectKind::Face => serde_json::json!({
611 "ok": true,
612 "name": name,
613 "kind": "face",
614 "solid": solid.name,
615 // A committed sketch's sheet face is planar by construction.
616 "surfaceType": "Plane",
617 "area": sheet_mesh_area(solid),
618 "edgeLengthTotal": edge_total,
619 "creatingFeature": creating,
620 })
621 .to_string(),
622 ObjectKind::Edge => {
623 let length = solid
624 .edges
625 .iter()
626 .find(|e| e.name == name)
627 .map(|e| polyline_length(&e.polyline))
628 .unwrap_or(0.0);
629 serde_json::json!({
630 "ok": true,
631 "name": name,
632 "kind": "edge",
633 "solid": solid.name,
634 "length": length,
635 "creatingFeature": creating,
636 })
637 .to_string()
638 }
639 }
640 }
641}
642
643/// Total surface area (mm²) of a synthesized sheet's planar display mesh — the
644/// sum of its triangle areas.
645fn sheet_mesh_area(solid: &crate::scene::SolidDisplay) -> f64 {
646 let p = &solid.mesh.positions;
647 solid
648 .mesh
649 .indices
650 .chunks_exact(3)
651 .map(|t| {
652 let a = p[t[0] as usize];
653 let b = p[t[1] as usize];
654 let c = p[t[2] as usize];
655 let ab = [
656 (b[0] - a[0]) as f64,
657 (b[1] - a[1]) as f64,
658 (b[2] - a[2]) as f64,
659 ];
660 let ac = [
661 (c[0] - a[0]) as f64,
662 (c[1] - a[1]) as f64,
663 (c[2] - a[2]) as f64,
664 ];
665 let cross = [
666 ab[1] * ac[2] - ab[2] * ac[1],
667 ab[2] * ac[0] - ab[0] * ac[2],
668 ab[0] * ac[1] - ab[1] * ac[0],
669 ];
670 (cross[0] * cross[0] + cross[1] * cross[1] + cross[2] * cross[2]).sqrt() * 0.5
671 })
672 .sum()
673}
674
675/// Arc length (mm) of a sampled edge polyline — the sum of its segment lengths.
676fn polyline_length(polyline: &[[f32; 3]]) -> f64 {
677 polyline
678 .windows(2)
679 .map(|w| {
680 let d = [
681 (w[1][0] - w[0][0]) as f64,
682 (w[1][1] - w[0][1]) as f64,
683 (w[1][2] - w[0][2]) as f64,
684 ];
685 (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
686 })
687 .sum()
688}
689