1use super::*;
36use serde_json::Value;
37
38pub const PART_ONLY_KEYS: [&str; 4] = ["Mass", "Material", "Density", "Volume"];
41
42pub const VOLUME_KEY: &str = "Volume";
44pub const MASS_KEY: &str = "Mass";
46pub const DENSITY_KEY: &str = "Density";
48pub 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
55const MM3_G_CM3_TO_KG: f64 = 1e-6;
57
58const 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
103pub 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
123pub 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
132pub 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
141pub 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
154pub enum DensitySource {
155 Solid,
157 Part,
159 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
173fn 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
183pub 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#[derive(Clone, Debug, PartialEq)]
198pub enum MassStatus {
199 Computed,
201 Override,
203 Pending,
205 NoDensity,
207 NoGeometry,
209 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 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#[derive(Clone, Debug, PartialEq)]
243pub struct MassReport {
244 pub volume_mm3: Option<f64>,
246 pub mass_kg: Option<f64>,
248 pub density_g_cm3: Option<f64>,
251 pub density_source: Option<DensitySource>,
253 pub solid_count: usize,
255 pub status: MassStatus,
256}
257
258impl MassReport {
259 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 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 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 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 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 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 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 pub fn chain_mass_cached(&self, chain: &str) -> MassReport {
379 let solids = self.chain_solids(chain);
380 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 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 pub fn part_has_geometry(&self, part_name: &str) -> bool {
401 !self.part_solids(part_name).is_empty()
402 }
403
404 pub fn document_has_geometry(&self) -> bool {
406 !self.measurable_solids(|_| true).is_empty()
407 }
408
409 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 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 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 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 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 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 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 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 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 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 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 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 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
610fn 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
636pub 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 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 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 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
802fn 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