Skip to main content

brep_render/engine_state/
part_mass.rs

1//! MASS PROPERTIES OF A PART — computed from the part's geometry and the
2//! density the PART carries, never typed in and never stored per occurrence.
3//!
4//! # One source
5//!
6//! Material and density are attributes of the PART definition: the
7//! `partAttributes` record on the part's own document (see `bom.rs`), edited
8//! in the part's Properties dialog or the BOM's part columns. Everything that
9//! shows a mass — the BOM row, the Info window, the BOM export, the MCP
10//! readers — resolves it here, so they cannot disagree:
11//!
12//! * a solid's density is its own metadata `density` when one is set (a
13//!   multi-material part's per-body override), else its PART's `Density`,
14//!   else the density of the part's `Material` when that names a material
15//!   this build knows (`material_density`), else none;
16//! * a part's volume is the volume of the solids ONE placement of it
17//!   produces (every placement is the same rigid copy), from the existing
18//!   measurement cache (`object_info_json`), so nothing re-executes the history
19//!   per frame and a value is simply pending until the runner answers;
20//! * a part's mass is `Σ volume × density`. A part with NO geometry (a
21//!   purchased or library part with nothing to measure) may carry a stored
22//!   `Mass` instead: that is the only time a mass is typed, and it is still a
23//!   part attribute.
24//!
25//! Units: density in g/cm³ (what material tables quote), volume in mm³ (the
26//! kernel's length unit), mass in kg.
27//!
28//! # Old documents
29//!
30//! A document saved before this rule may hold `Mass` / `Material` / `Density`
31//! on its OCCURRENCES (the ACOMP's `bom` record, or the `occurrenceAttributes`
32//! key an older batch path wrote). `migrate_occurrence_part_attributes`
33//! lifts them onto the part as the document opens, and says so once.
34
35use super::*;
36use serde_json::Value;
37
38/// The part attribute keys that describe the PART's mass properties. None of
39/// them may be stored on an occurrence.
40pub const PART_ONLY_KEYS: [&str; 4] = ["Mass", "Material", "Density", "Volume"];
41
42/// Derived — computed from geometry, never stored.
43pub const VOLUME_KEY: &str = "Volume";
44/// Computed for a part with geometry; a stored override only without.
45pub const MASS_KEY: &str = "Mass";
46/// The part's density in g/cm³.
47pub const DENSITY_KEY: &str = "Density";
48/// The part's material name; resolves a density when no `Density` is set.
49pub const MATERIAL_KEY: &str = "Material";
50
51pub const DENSITY_UNIT: &str = "g/cm³";
52pub const MASS_UNIT: &str = "kg";
53pub const VOLUME_UNIT: &str = "mm³";
54
55/// mm³ × g/cm³ → kg: 1 mm³ = 1e-3 cm³, 1 g = 1e-3 kg.
56const MM3_G_CM3_TO_KG: f64 = 1e-6;
57
58/// Built-in densities (g/cm³) for common material names, most specific
59/// first: the first key found as a whole word (or word run) of the
60/// material's name wins, so `Stainless Steel 304` is stainless and
61/// `6061-T6` is aluminium.
62const MATERIAL_DENSITIES: &[(&str, f64)] = &[
63    ("stainless", 7.93),
64    ("cast iron", 7.20),
65    ("tool steel", 7.80),
66    ("steel", 7.85),
67    ("iron", 7.87),
68    ("aluminium", 2.70),
69    ("aluminum", 2.70),
70    ("6061", 2.70),
71    ("6063", 2.69),
72    ("7075", 2.81),
73    ("2024", 2.78),
74    ("brass", 8.50),
75    ("bronze", 8.80),
76    ("copper", 8.96),
77    ("titanium", 4.43),
78    ("magnesium", 1.74),
79    ("zinc", 7.14),
80    ("nickel", 8.90),
81    ("lead", 11.34),
82    ("abs", 1.04),
83    ("pla", 1.24),
84    ("petg", 1.27),
85    ("nylon", 1.14),
86    ("polycarbonate", 1.20),
87    ("acetal", 1.41),
88    ("delrin", 1.41),
89    ("pom", 1.41),
90    ("hdpe", 0.95),
91    ("polypropylene", 0.90),
92    ("pvc", 1.38),
93    ("acrylic", 1.18),
94    ("pmma", 1.18),
95    ("ptfe", 2.20),
96    ("glass", 2.50),
97    ("oak", 0.75),
98    ("pine", 0.50),
99    ("plywood", 0.60),
100    ("wood", 0.60),
101];
102
103/// The built-in density (g/cm³) for a material NAME, or `None` when the name
104/// matches nothing this build knows (then the part needs an explicit
105/// `Density`).
106pub fn material_density(material: &str) -> Option<f64> {
107    let words: Vec<String> = material
108        .to_lowercase()
109        .split(|c: char| !c.is_alphanumeric())
110        .filter(|word| !word.is_empty())
111        .map(str::to_string)
112        .collect();
113    if words.is_empty() {
114        return None;
115    }
116    let padded = format!(" {} ", words.join(" "));
117    MATERIAL_DENSITIES
118        .iter()
119        .find(|(key, _)| padded.contains(&format!(" {key} ")))
120        .map(|&(_, density)| density)
121}
122
123/// A mass in kg as a reader wants it: kilograms from 1 kg up, grams below.
124pub fn format_mass_kg(kg: f64) -> String {
125    if kg.abs() >= 1.0 {
126        format!("{kg:.3} kg")
127    } else {
128        format!("{:.3} g", kg * 1000.0)
129    }
130}
131
132/// A volume in mm³ as a reader wants it: cm³ from 1 cm³ up, mm³ below.
133pub fn format_volume_mm3(mm3: f64) -> String {
134    if mm3.abs() >= 1000.0 {
135        format!("{:.3} cm³", mm3 / 1000.0)
136    } else {
137        format!("{mm3:.3} mm³")
138    }
139}
140
141/// The display text of a DERIVED part field (`Mass`, `Volume`) with its
142/// unit; `None` for any other field or a non-number.
143pub fn format_derived(field: &str, value: &Value) -> Option<String> {
144    let number = value.as_f64()?;
145    match field {
146        MASS_KEY => Some(format_mass_kg(number)),
147        VOLUME_KEY => Some(format_volume_mm3(number)),
148        _ => None,
149    }
150}
151
152/// Where a resolved density came from.
153#[derive(Clone, Copy, Debug, PartialEq, Eq)]
154pub enum DensitySource {
155    /// The solid's own metadata `density` (a per-body override).
156    Solid,
157    /// The part's `Density` attribute.
158    Part,
159    /// The built-in density of the part's `Material`.
160    Material,
161}
162
163impl DensitySource {
164    pub fn as_str(self) -> &'static str {
165        match self {
166            DensitySource::Solid => "solid",
167            DensitySource::Part => "part",
168            DensitySource::Material => "material",
169        }
170    }
171}
172
173/// A positive finite number from a JSON number or numeric string.
174fn positive_number(value: &Value) -> Option<f64> {
175    let number = match value {
176        Value::Number(number) => number.as_f64(),
177        Value::String(text) => text.trim().parse::<f64>().ok(),
178        _ => None,
179    }?;
180    (number.is_finite() && number > 0.0).then_some(number)
181}
182
183/// A part's density from its attribute record: its `Density`, else its
184/// `Material`'s built-in density.
185pub fn part_density(attributes: &Value) -> Option<(f64, DensitySource)> {
186    if let Some(density) = attributes.get(DENSITY_KEY).and_then(positive_number) {
187        return Some((density, DensitySource::Part));
188    }
189    attributes
190        .get(MATERIAL_KEY)
191        .and_then(Value::as_str)
192        .and_then(material_density)
193        .map(|density| (density, DensitySource::Material))
194}
195
196/// What a mass reading is.
197#[derive(Clone, Debug, PartialEq)]
198pub enum MassStatus {
199    /// Volume × density over the part's geometry.
200    Computed,
201    /// A part with no geometry: its stored `Mass`.
202    Override,
203    /// A measurement is still in flight.
204    Pending,
205    /// The geometry is measured but some solid has no density.
206    NoDensity,
207    /// No geometry and no stored mass.
208    NoGeometry,
209    /// The kernel could not measure a solid.
210    Failed(String),
211}
212
213impl MassStatus {
214    pub fn as_str(&self) -> &'static str {
215        match self {
216            MassStatus::Computed => "computed",
217            MassStatus::Override => "override",
218            MassStatus::Pending => "pending",
219            MassStatus::NoDensity => "no-density",
220            MassStatus::NoGeometry => "no-geometry",
221            MassStatus::Failed(_) => "failed",
222        }
223    }
224
225    /// The sentence a reader sees when there is no number.
226    pub fn note(&self) -> Option<String> {
227        match self {
228            MassStatus::Computed | MassStatus::Override => None,
229            MassStatus::Pending => Some("measuring".into()),
230            MassStatus::NoDensity => {
231                Some("no density: set the part's Material or Density".into())
232            }
233            MassStatus::NoGeometry => {
234                Some("no geometry: set the part's Mass".into())
235            }
236            MassStatus::Failed(message) => Some(format!("not measurable: {message}")),
237        }
238    }
239}
240
241/// One part's (or one document's) mass properties.
242#[derive(Clone, Debug, PartialEq)]
243pub struct MassReport {
244    /// Σ solid volume (mm³); `None` with no geometry or while pending.
245    pub volume_mm3: Option<f64>,
246    /// Mass (kg); `None` when it cannot be given — `status` says why.
247    pub mass_kg: Option<f64>,
248    /// The density (g/cm³) when every solid resolves the SAME one (`None`
249    /// for a mixed-density part or when none resolves).
250    pub density_g_cm3: Option<f64>,
251    /// Where that density came from.
252    pub density_source: Option<DensitySource>,
253    /// How many solids the reading covers.
254    pub solid_count: usize,
255    pub status: MassStatus,
256}
257
258impl MassReport {
259    /// The reading as JSON, units in the key names:
260    /// `{volumeMm3, massKg, densityGcm3, densitySource, massStatus, massNote}`.
261    pub fn to_json(&self) -> Value {
262        serde_json::json!({
263            "volumeMm3": self.volume_mm3,
264            "massKg": self.mass_kg,
265            "densityGcm3": self.density_g_cm3,
266            "densitySource": self.density_source.map(DensitySource::as_str),
267            "solidCount": self.solid_count,
268            "massStatus": self.status.as_str(),
269            "massNote": self.status.note(),
270        })
271    }
272}
273
274impl EngineState {
275    /// The part attribute record that owns `solid`: the innermost part along
276    /// its component chain (`ACOMP1:ACOMP2:Body` is the part ACOMP2 places
277    /// inside the part ACOMP1 places), or the open document's own record for
278    /// a solid with no chain. `None` when the chain cannot be resolved.
279    pub(crate) fn owning_part_attributes(&self, solid: &str) -> Option<Value> {
280        let (chain, _local) = brep_kernel::split_component_namespace(solid);
281        let Some((&head, rest)) = chain.split_first() else {
282            return Some(self.document_part_attributes());
283        };
284        let part_name = self
285            .assembly_components
286            .iter()
287            .find(|component| component.id == head)
288            .map(|component| component.part_name.clone())
289            .or_else(|| {
290                let index = self.history.index_of(head)?;
291                self.history
292                    .feature_params(index)?
293                    .get("partName")?
294                    .as_str()
295                    .map(str::to_string)
296            })?;
297        let mut document = self
298            .history
299            .parts_library()
300            .get(&part_name)?
301            .get("document")?;
302        for segment in rest {
303            let inner = document
304                .get("features")?
305                .as_array()?
306                .iter()
307                .find(|feature| feature["inputParams"]["id"].as_str() == Some(segment))?;
308            let inner_part = inner["inputParams"]["partName"].as_str()?;
309            document = document
310                .get("partsLibrary")?
311                .get(inner_part)?
312                .get("document")?;
313        }
314        Some(
315            document
316                .get(PART_ATTRIBUTES)
317                .filter(|value| value.is_object())
318                .cloned()
319                .unwrap_or_else(|| Value::Object(serde_json::Map::new())),
320        )
321    }
322
323    /// The density (g/cm³) a solid's mass is computed with, and where it came
324    /// from: its own metadata `density`, else its part's (see the module).
325    pub fn solid_density(&self, solid: &str) -> Option<(f64, DensitySource)> {
326        if let Some(density) = self
327            .metadata
328            .attribute(solid, "density")
329            .and_then(|text| text.trim().parse::<f64>().ok())
330            .filter(|density| density.is_finite() && *density > 0.0)
331        {
332            return Some((density, DensitySource::Solid));
333        }
334        part_density(&self.owning_part_attributes(solid)?)
335    }
336
337    /// Measurable scene solids (sketch sheets have no volume).
338    fn measurable_solids<'a>(&'a self, filter: impl Fn(&str) -> bool + 'a) -> Vec<String> {
339        self.scene
340            .solids()
341            .iter()
342            .filter(|solid| !solid.is_sketch && filter(&solid.name))
343            .map(|solid| solid.name.clone())
344            .collect()
345    }
346
347    /// The solids ONE placement of `part_name` produces at the top level: its
348    /// first occurrence's (every occurrence is a rigid copy of the same part).
349    fn part_solids(&self, part_name: &str) -> Vec<String> {
350        let Some(first) = self
351            .assembly_components
352            .iter()
353            .find(|component| component.part_name == part_name)
354            .map(|component| component.id.clone())
355        else {
356            return Vec::new();
357        };
358        self.chain_solids(&first)
359    }
360
361    /// The solids under one occurrence CHAIN (`ACOMP9` or `ACOMP9:ACOMP1`).
362    fn chain_solids(&self, chain: &str) -> Vec<String> {
363        let prefix = format!("{chain}:");
364        self.measurable_solids(|name| name.starts_with(&prefix))
365    }
366
367    /// The mass properties of what one occurrence chain places — a NESTED
368    /// BOM line (`ACOMP9:ACOMP1` is the part ACOMP1 places inside the
369    /// sub-assembly ACOMP9 places), each solid with its own part's density.
370    /// Asks for missing measurements.
371    pub fn chain_mass(&mut self, chain: &str) -> MassReport {
372        let solids = self.chain_solids(chain);
373        self.measure_solids(&solids);
374        self.chain_mass_cached(chain)
375    }
376
377    /// [`Self::chain_mass`] from what is already measured.
378    pub fn chain_mass_cached(&self, chain: &str) -> MassReport {
379        let solids = self.chain_solids(chain);
380        // The chain's own part record (a solid name under it resolves to it).
381        let attributes = self
382            .owning_part_attributes(&format!("{chain}:_"))
383            .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
384        self.mass_over(&solids, &attributes)
385    }
386
387    /// Ask for every placed part's measurements; `true` while any is still in
388    /// flight (an export waits on this rather than writing blanks).
389    pub fn bom_mass_pending(&mut self) -> bool {
390        let parts: Vec<String> =
391            self.assembly_components.iter().map(|c| c.part_name.clone()).collect();
392        self.measure_parts(parts.iter().map(String::as_str));
393        parts
394            .iter()
395            .any(|part| self.part_mass_cached(part).status == MassStatus::Pending)
396    }
397
398    /// Whether the part has geometry to measure (then its mass is computed and
399    /// a typed `Mass` is refused).
400    pub fn part_has_geometry(&self, part_name: &str) -> bool {
401        !self.part_solids(part_name).is_empty()
402    }
403
404    /// Whether the open document has geometry to measure.
405    pub fn document_has_geometry(&self) -> bool {
406        !self.measurable_solids(|_| true).is_empty()
407    }
408
409    /// A solid's volume (mm³) off the measurement cache, WITHOUT asking:
410    /// `Ok(None)` while no answer is cached (see [`Self::measure_solids`]).
411    fn solid_volume_cached(&self, solid: &str) -> Result<Option<f64>, String> {
412        let Some(text) = self.info_cache.get(solid) else {
413            return Ok(None);
414        };
415        let info: Value = serde_json::from_str(text).unwrap_or(Value::Null);
416        if info.get("ok").and_then(Value::as_bool) != Some(true) {
417            return Err(info
418                .get("message")
419                .and_then(Value::as_str)
420                .unwrap_or("no measurement")
421                .to_string());
422        }
423        info.get("volume")
424            .and_then(Value::as_f64)
425            .map(Some)
426            .ok_or_else(|| "no volume".to_string())
427    }
428
429    /// Ask the runner to measure every solid not measured yet (one query per
430    /// solid, deduplicated by the measurement lane itself). An Inline runner
431    /// answers here; a threaded one on a later frame.
432    fn measure_solids(&mut self, solids: &[String]) {
433        for solid in solids {
434            if !self.info_cache.contains_key(solid) {
435                let _ = self.object_info_raw_json(solid);
436            }
437        }
438    }
439
440    /// Ask for the measurements the named parts' masses need — what a panel
441    /// calls before it reads [`Self::part_bom_record_cached`] for each row.
442    pub fn measure_parts<'a>(&mut self, parts: impl IntoIterator<Item = &'a str>) {
443        for part in parts {
444            let solids = self.part_solids(part);
445            self.measure_solids(&solids);
446        }
447    }
448
449    /// Mass properties over `solids`, falling back to `attributes`' stored
450    /// `Mass` when there are none.
451    fn mass_over(&self, solids: &[String], attributes: &Value) -> MassReport {
452        if solids.is_empty() {
453            let stored = attributes.get(MASS_KEY).and_then(positive_number);
454            return MassReport {
455                volume_mm3: None,
456                mass_kg: stored,
457                density_g_cm3: None,
458                density_source: None,
459                solid_count: 0,
460                status: if stored.is_some() {
461                    MassStatus::Override
462                } else {
463                    MassStatus::NoGeometry
464                },
465            };
466        }
467        let mut volume = 0.0;
468        let mut mass = 0.0;
469        let mut pending = false;
470        let mut failure: Option<String> = None;
471        let mut missing_density = false;
472        let mut densities: Vec<(f64, DensitySource)> = Vec::new();
473        for solid in solids {
474            match self.solid_volume_cached(solid) {
475                Ok(Some(v)) => {
476                    volume += v;
477                    match self.solid_density(solid) {
478                        Some((density, source)) => {
479                            mass += v * density * MM3_G_CM3_TO_KG;
480                            densities.push((density, source));
481                        }
482                        None => missing_density = true,
483                    }
484                }
485                Ok(None) => pending = true,
486                Err(message) => {
487                    failure.get_or_insert(format!("{solid}: {message}"));
488                }
489            }
490        }
491        let uniform = densities
492            .first()
493            .copied()
494            .filter(|(first, _)| densities.iter().all(|(d, _)| (d - first).abs() <= 1e-12));
495        let status = if let Some(message) = failure {
496            MassStatus::Failed(message)
497        } else if pending {
498            MassStatus::Pending
499        } else if missing_density {
500            MassStatus::NoDensity
501        } else {
502            MassStatus::Computed
503        };
504        MassReport {
505            volume_mm3: matches!(status, MassStatus::Computed | MassStatus::NoDensity)
506                .then_some(volume),
507            mass_kg: (status == MassStatus::Computed).then_some(mass),
508            density_g_cm3: uniform.map(|(density, _)| density),
509            density_source: uniform.map(|(_, source)| source),
510            solid_count: solids.len(),
511            status,
512        }
513    }
514
515    /// A parts-library part's mass properties (see the module), asking for
516    /// any measurement still missing.
517    pub fn part_mass(&mut self, part_name: &str) -> MassReport {
518        let solids = self.part_solids(part_name);
519        self.measure_solids(&solids);
520        self.part_mass_cached(part_name)
521    }
522
523    /// [`Self::part_mass`] from what is already measured (never asks; a
524    /// solid not measured yet reads as pending).
525    pub fn part_mass_cached(&self, part_name: &str) -> MassReport {
526        let solids = self.part_solids(part_name);
527        let attributes = self.part_attributes(part_name);
528        let mut report = self.mass_over(&solids, &attributes);
529        if solids.is_empty() {
530            // No geometry: still say what density the part names.
531            if let Some((density, source)) = part_density(&attributes) {
532                report.density_g_cm3 = Some(density);
533                report.density_source = Some(source);
534            }
535        }
536        report
537    }
538
539    /// The OPEN document's own mass properties: every measurable solid it
540    /// shows (a part's bodies; an assembly's placed components, each with its
541    /// own part's density).
542    pub fn document_mass(&mut self) -> MassReport {
543        let solids = self.measurable_solids(|_| true);
544        self.measure_solids(&solids);
545        self.document_mass_cached()
546    }
547
548    /// [`Self::document_mass`] from what is already measured.
549    pub fn document_mass_cached(&self) -> MassReport {
550        let solids = self.measurable_solids(|_| true);
551        let attributes = self.document_part_attributes();
552        let mut report = self.mass_over(&solids, &attributes);
553        if solids.is_empty() {
554            if let Some((density, source)) = part_density(&attributes) {
555                report.density_g_cm3 = Some(density);
556                report.density_source = Some(source);
557            }
558        }
559        report
560    }
561
562    /// A part's BOM record: its stored attributes with the DERIVED mass
563    /// properties folded in — `Volume` (mm³) and `Mass` (kg) as computed, and
564    /// `Density` as resolved when the part names only a `Material`. A `Mass`
565    /// stored on a part with geometry is not what the BOM shows: the computed
566    /// value replaces it, or the key is absent when there is none yet.
567    pub fn part_bom_record(&mut self, part_name: &str) -> Value {
568        let report = self.part_mass(part_name);
569        fold_mass(self.part_attributes(part_name), &report)
570    }
571
572    /// [`Self::part_bom_record`] from what is already measured, plus the
573    /// reading itself (a panel row shows the note when there is no number).
574    pub fn part_bom_record_cached(&self, part_name: &str) -> (Value, MassReport) {
575        let report = self.part_mass_cached(part_name);
576        (fold_mass(self.part_attributes(part_name), &report), report)
577    }
578
579    /// The open document's own record with its derived mass properties — the
580    /// same fold as [`Self::part_bom_record`].
581    pub fn document_bom_record(&mut self) -> Value {
582        let attributes = self.document_part_attributes();
583        let report = self.document_mass();
584        fold_mass(attributes, &report)
585    }
586
587    /// Refuse a write a part attribute may not take: `Volume` is derived, and
588    /// `Mass` is computed whenever there is geometry to compute it from.
589    pub(crate) fn check_part_attribute_write(
590        &self,
591        key: &str,
592        value: &Value,
593        has_geometry: bool,
594    ) -> Result<(), String> {
595        let clearing =
596            value.is_null() || matches!(value, Value::String(text) if text.is_empty());
597        if key == VOLUME_KEY && !clearing {
598            return Err("Volume is computed from the part's geometry and cannot be set".into());
599        }
600        if key == MASS_KEY && !clearing && has_geometry {
601            return Err(
602                "Mass is computed from the part's geometry and density: set the part's Material or Density instead (a Mass can be typed only for a part with no geometry)"
603                    .into(),
604            );
605        }
606        Ok(())
607    }
608}
609
610/// Fold a mass reading into an attribute record (see
611/// [`EngineState::part_bom_record`]).
612fn fold_mass(mut attributes: Value, report: &MassReport) -> Value {
613    let Some(record) = attributes.as_object_mut() else {
614        return attributes;
615    };
616    record.remove(VOLUME_KEY);
617    if report.status != MassStatus::Override {
618        record.remove(MASS_KEY);
619    }
620    if let Some(volume) = report.volume_mm3 {
621        record.insert(VOLUME_KEY.into(), Value::from(volume));
622    }
623    if let Some(mass) = report.mass_kg {
624        record.insert(MASS_KEY.into(), Value::from(mass));
625    }
626    if !record.contains_key(DENSITY_KEY) {
627        if let (Some(density), Some(DensitySource::Material)) =
628            (report.density_g_cm3, report.density_source)
629        {
630            record.insert(DENSITY_KEY.into(), Value::from(density));
631        }
632    }
633    attributes
634}
635
636/// Lift part-only attributes (`Mass`, `Material`, `Density`) that an older
637/// document stored on its OCCURRENCES onto the PART, in place, before the
638/// document is loaded. Returns the notices to show (one per part whose
639/// occurrences disagreed or whose part already said otherwise, plus one
640/// summary of what moved).
641///
642/// * Both occurrence records are swept: the BOM's `bom` record and the
643///   `occurrenceAttributes` key an older batch path wrote (its other keys are
644///   folded into `bom`, which wins on a clash).
645/// * Equal values move to the part when the part does not already carry the
646///   key; the occurrences lose them either way.
647/// * Disagreeing occurrences: the FIRST occurrence's value goes to the part
648///   (unless the part already carries one), and the disagreement is reported
649///   once for that part.
650/// * Every embedded part document is swept too (a sub-assembly's own
651///   occurrences), and a changed entry is re-stamped as an attribute edit is.
652pub fn migrate_occurrence_part_attributes(document: &mut Value) -> Vec<String> {
653    let mut notices = Vec::new();
654    migrate_level(document, "", &mut notices);
655    notices
656}
657
658fn migrate_level(document: &mut Value, path: &str, notices: &mut Vec<String>) {
659    // (part, key) -> [(occurrence id, value)] in history order.
660    let mut found: Vec<(String, String, Vec<(String, Value)>)> = Vec::new();
661    if let Some(features) = document.get_mut("features").and_then(Value::as_array_mut) {
662        for feature in features {
663            let is_acomp = feature
664                .get("type")
665                .and_then(Value::as_str)
666                .is_some_and(super::components::is_acomp_feature_type);
667            if !is_acomp {
668                continue;
669            }
670            let Some(params) = feature.get_mut("inputParams").and_then(Value::as_object_mut) else {
671                continue;
672            };
673            let id = params.get("id").and_then(Value::as_str).unwrap_or("").to_string();
674            let part = params.get("partName").and_then(Value::as_str).unwrap_or("").to_string();
675            // Fold the legacy key into the real record (the real one wins).
676            if let Some(Value::Object(legacy)) = params.remove("occurrenceAttributes") {
677                let record = params
678                    .entry(super::bom::OCCURRENCE_ATTRIBUTES.to_string())
679                    .or_insert_with(|| Value::Object(serde_json::Map::new()));
680                if !record.is_object() {
681                    *record = Value::Object(serde_json::Map::new());
682                }
683                let record = record.as_object_mut().expect("normalized");
684                for (key, value) in legacy {
685                    record.entry(key).or_insert(value);
686                }
687            }
688            let Some(record) = params
689                .get_mut(super::bom::OCCURRENCE_ATTRIBUTES)
690                .and_then(Value::as_object_mut)
691            else {
692                continue;
693            };
694            for key in ["Mass", "Material", "Density", "Volume"] {
695                let Some(value) = record.remove(key) else { continue };
696                let blank = value.is_null() || matches!(&value, Value::String(t) if t.trim().is_empty());
697                if blank || key == VOLUME_KEY || part.is_empty() {
698                    continue;
699                }
700                match found.iter_mut().find(|(p, k, _)| *p == part && k == key) {
701                    Some((_, _, values)) => values.push((id.clone(), value)),
702                    None => found.push((part.clone(), key.to_string(), vec![(id.clone(), value)])),
703                }
704            }
705            if record.is_empty() {
706                params.remove(super::bom::OCCURRENCE_ATTRIBUTES);
707            }
708        }
709    }
710
711    let mut moved: Vec<String> = Vec::new();
712    let mut touched: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
713    for (part, key, values) in &found {
714        let label = if path.is_empty() { part.clone() } else { format!("{path}/{part}") };
715        let Some(entry) = document
716            .get_mut("partsLibrary")
717            .and_then(|library| library.get_mut(part))
718            .filter(|entry| entry.get("document").is_some_and(Value::is_object))
719        else {
720            notices.push(format!(
721                "BOM: {key} on {} occurrence(s) of '{label}' was dropped: the part is not in the parts library",
722                values.len()
723            ));
724            continue;
725        };
726        let first = &values[0].1;
727        let disagree = values.iter().any(|(_, value)| value != first);
728        let part_document = entry.get_mut("document").expect("filtered");
729        let existing = part_document
730            .get(PART_ATTRIBUTES)
731            .and_then(|record| record.get(key))
732            .cloned();
733        let shown = |value: &Value| match value {
734            Value::String(text) => format!("'{text}'"),
735            other => other.to_string(),
736        };
737        match existing {
738            Some(existing) if &existing != first || disagree => {
739                notices.push(format!(
740                    "BOM: '{label}' occurrences carried {key} {} — the part keeps its own {key} {}",
741                    values.iter().map(|(id, value)| format!("{id}={}", shown(value))).collect::<Vec<_>>().join(", "),
742                    shown(&existing),
743                ));
744            }
745            Some(_) => {}
746            None => {
747                let object = part_document.as_object_mut().expect("filtered");
748                let record = object
749                    .entry(PART_ATTRIBUTES.to_string())
750                    .or_insert_with(|| Value::Object(serde_json::Map::new()));
751                if !record.is_object() {
752                    *record = Value::Object(serde_json::Map::new());
753                }
754                record
755                    .as_object_mut()
756                    .expect("normalized")
757                    .insert(key.clone(), first.clone());
758                touched.insert(part.clone());
759                if disagree {
760                    notices.push(format!(
761                        "BOM: '{label}' occurrences disagreed on {key} ({}) — the part takes {} from {}",
762                        values.iter().map(|(id, value)| format!("{id}={}", shown(value))).collect::<Vec<_>>().join(", "),
763                        shown(first),
764                        values[0].0,
765                    ));
766                } else {
767                    moved.push(format!("{key} {} → part '{label}'", shown(first)));
768                }
769            }
770        }
771    }
772
773    // Recurse into every embedded part document, then re-stamp what changed.
774    if let Some(library) = document.get_mut("partsLibrary").and_then(Value::as_object_mut) {
775        for (name, entry) in library.iter_mut() {
776            let Some(part_document) = entry.get_mut("document").filter(|d| d.is_object()) else {
777                continue;
778            };
779            let before = notices.len();
780            let label = if path.is_empty() { name.clone() } else { format!("{path}/{name}") };
781            let changed_inside = migrate_level_changed(part_document, &label, notices);
782            if changed_inside || notices.len() != before {
783                touched.insert(name.clone());
784            }
785        }
786        for name in &touched {
787            if let Some(entry) = library.get_mut(name) {
788                let text = entry["document"].to_string();
789                entry["sourceSignature"] = Value::String(super::document_signature(&text));
790            }
791        }
792    }
793
794    if !moved.is_empty() {
795        notices.push(format!(
796            "BOM: mass properties moved from occurrences to their parts: {} (save to keep)",
797            moved.join("; ")
798        ));
799    }
800}
801
802/// [`migrate_level`] on a nested document, reporting whether it changed.
803fn migrate_level_changed(document: &mut Value, path: &str, notices: &mut Vec<String>) -> bool {
804    let before = document.clone();
805    migrate_level(document, path, notices);
806    *document != before
807}
808