1use std::collections::{HashMap, HashSet};
24
25use serde::Serialize;
26use serde_json::json;
27use sqlx::{PgConnection, PgPool};
28use uuid::Uuid;
29
30use crate::{
31 auth::Actor,
32 domain::{self, RevisionRule, Role},
33 error::{AppError, AppResult},
34 massprops::{self, Body, Geometry, LengthUnit, Sym, V3},
35 preview::PreviewFormat,
36 services::{
37 audit,
38 bom::{self, BomNode, ResolvedRevision},
39 mass_geometry::{self, GeometryInfo, Hints, Resolution},
40 revisions,
41 },
42 util, AppState,
43};
44
45pub const MAX_DENSITY: f64 = 100_000.0;
47pub const MAX_GEOMETRY_FILES: usize = 2000;
50pub const GEOMETRY_FORMATS: [&str; 14] = [
54 "stl",
55 "3mf",
56 "obj",
57 "ply",
58 "off",
59 "amf",
60 "gltf",
61 "glb",
62 "3ds",
63 "step",
64 "nx-part",
65 "solidworks-part",
66 "jt",
67 "rhino",
68];
69const MASS_MISMATCH: f64 = 0.05;
71
72#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
77pub struct Material {
78 pub id: Uuid,
79 pub name: String,
80 pub density_kg_m3: f64,
81 pub notes: String,
82 pub created_at: chrono::DateTime<chrono::Utc>,
83 pub updated_at: chrono::DateTime<chrono::Utc>,
84}
85
86const MATERIAL_COLUMNS: &str = "id, name, density_kg_m3, notes, created_at, updated_at";
87
88pub async fn materials(pool: &PgPool) -> AppResult<Vec<Material>> {
90 Ok(sqlx::query_as::<_, Material>(&format!(
91 "SELECT {MATERIAL_COLUMNS} FROM materials ORDER BY lower(name)"
92 ))
93 .fetch_all(pool)
94 .await?)
95}
96
97pub async fn material(pool: &PgPool, id: Uuid) -> AppResult<Material> {
98 sqlx::query_as::<_, Material>(&format!(
99 "SELECT {MATERIAL_COLUMNS} FROM materials WHERE id = $1"
100 ))
101 .bind(id)
102 .fetch_optional(pool)
103 .await?
104 .ok_or_else(|| AppError::not_found("Material not found."))
105}
106
107pub fn validate_density(value: f64) -> AppResult<f64> {
109 if value.is_finite() && value > 0.0 && value <= MAX_DENSITY {
110 Ok(value)
111 } else {
112 Err(AppError::BadRequest(format!(
113 "Density must be a number above 0 and at most {MAX_DENSITY} kg/m³."
114 )))
115 }
116}
117
118#[derive(Clone, Debug, Default)]
120pub struct MaterialInput {
121 pub name: String,
122 pub density_kg_m3: f64,
123 pub notes: String,
124}
125
126impl MaterialInput {
127 fn validated(&self) -> AppResult<MaterialInput> {
128 Ok(MaterialInput {
129 name: domain::require_text("Name", &self.name, 200)?,
130 density_kg_m3: validate_density(self.density_kg_m3)?,
131 notes: domain::limit_text("Notes", &self.notes, 2000)?,
132 })
133 }
134}
135
136pub async fn create_material(
148 pool: &PgPool,
149 actor: &Actor,
150 input: MaterialInput,
151) -> AppResult<Material> {
152 actor.require(Role::Admin)?;
153 let input = input.validated()?;
154 let mut tx = pool.begin().await?;
155 let m = sqlx::query_as::<_, Material>(&format!(
156 "INSERT INTO materials (id, name, density_kg_m3, notes, created_by)
157 VALUES ($1, $2, $3, $4, $5) RETURNING {MATERIAL_COLUMNS}"
158 ))
159 .bind(Uuid::new_v4())
160 .bind(&input.name)
161 .bind(input.density_kg_m3)
162 .bind(&input.notes)
163 .bind(actor.id)
164 .fetch_one(&mut *tx)
165 .await
166 .map_err(|e| {
167 AppError::unique_violation(
168 e,
169 format!("The material '{}' is already in the library.", input.name),
170 )
171 })?;
172 audit::record(
173 &mut tx,
174 Some(actor),
175 "material.create",
176 "material",
177 Some(m.id),
178 format!("Added material {} ({} kg/m³)", m.name, m.density_kg_m3),
179 json!({ "name": m.name, "density_kg_m3": m.density_kg_m3 }),
180 )
181 .await?;
182 tx.commit().await?;
183 Ok(m)
184}
185
186pub async fn update_material(
197 pool: &PgPool,
198 actor: &Actor,
199 id: Uuid,
200 input: MaterialInput,
201) -> AppResult<Material> {
202 actor.require(Role::Admin)?;
203 let input = input.validated()?;
204 let mut tx = pool.begin().await?;
205 let old = sqlx::query_as::<_, Material>(&format!(
206 "SELECT {MATERIAL_COLUMNS} FROM materials WHERE id = $1 FOR UPDATE"
207 ))
208 .bind(id)
209 .fetch_optional(&mut *tx)
210 .await?
211 .ok_or_else(|| AppError::not_found("Material not found."))?;
212 let m = sqlx::query_as::<_, Material>(&format!(
213 "UPDATE materials SET name = $2, density_kg_m3 = $3, notes = $4, updated_at = now()
214 WHERE id = $1 RETURNING {MATERIAL_COLUMNS}"
215 ))
216 .bind(id)
217 .bind(&input.name)
218 .bind(input.density_kg_m3)
219 .bind(&input.notes)
220 .fetch_one(&mut *tx)
221 .await
222 .map_err(|e| {
223 AppError::unique_violation(
224 e,
225 format!("The material '{}' is already in the library.", input.name),
226 )
227 })?;
228 audit::record(
229 &mut tx,
230 Some(actor),
231 "material.update",
232 "material",
233 Some(id),
234 format!("Updated material {}", m.name),
235 json!({ "name": m.name, "old_name": old.name,
236 "density_kg_m3": m.density_kg_m3, "old_density_kg_m3": old.density_kg_m3 }),
237 )
238 .await?;
239 tx.commit().await?;
240 Ok(m)
241}
242
243pub async fn delete_material(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<Material> {
255 actor.require(Role::Admin)?;
256 let mut tx = pool.begin().await?;
257 let m = sqlx::query_as::<_, Material>(&format!(
258 "SELECT {MATERIAL_COLUMNS} FROM materials WHERE id = $1 FOR UPDATE"
259 ))
260 .bind(id)
261 .fetch_optional(&mut *tx)
262 .await?
263 .ok_or_else(|| AppError::not_found("Material not found."))?;
264 sqlx::query("DELETE FROM materials WHERE id = $1")
265 .bind(id)
266 .execute(&mut *tx)
267 .await?;
268 audit::record(
269 &mut tx,
270 Some(actor),
271 "material.delete",
272 "material",
273 Some(id),
274 format!("Removed material {}", m.name),
275 json!({ "name": m.name, "density_kg_m3": m.density_kg_m3 }),
276 )
277 .await?;
278 tx.commit().await?;
279 Ok(m)
280}
281
282#[derive(Clone, Debug, Default, Serialize, sqlx::FromRow)]
288pub struct MassSettings {
289 pub revision_id: Uuid,
290 pub density_kg_m3: Option<f64>,
291 pub geometry_file_id: Option<Uuid>,
292 pub measured_mass_kg: Option<f64>,
293 pub measured_cg_m: Option<Vec<f64>>,
294 pub measured_inertia_kg_m2: Option<Vec<f64>>,
295 pub measured_note: String,
296}
297
298const SETTINGS_COLUMNS: &str = "revision_id, density_kg_m3, geometry_file_id, measured_mass_kg,
299 measured_cg_m, measured_inertia_kg_m2, measured_note";
300
301#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
303pub struct GeometryFile {
304 pub id: Uuid,
305 pub revision_id: Uuid,
306 pub filename: String,
307 pub sha256: String,
308 pub format: String,
309 pub length_unit: Option<String>,
311 pub instances: i32,
312}
313
314impl GeometryFile {
315 pub fn unit(&self) -> LengthUnit {
316 self.length_unit
317 .as_deref()
318 .and_then(LengthUnit::parse)
319 .unwrap_or_else(|| LengthUnit::default_for(&self.format))
320 }
321}
322
323async fn settings_of(pool: &PgPool, ids: &[Uuid]) -> AppResult<HashMap<Uuid, MassSettings>> {
324 let rows = sqlx::query_as::<_, MassSettings>(&format!(
325 "SELECT {SETTINGS_COLUMNS} FROM revision_mass WHERE revision_id = ANY($1)"
326 ))
327 .bind(ids)
328 .fetch_all(pool)
329 .await?;
330 Ok(rows.into_iter().map(|s| (s.revision_id, s)).collect())
331}
332
333async fn geometry_files_of(pool: &PgPool, ids: &[Uuid]) -> AppResult<Vec<GeometryFile>> {
334 Ok(sqlx::query_as::<_, GeometryFile>(
335 "SELECT f.id, f.revision_id, f.filename, f.sha256, f.format, fm.length_unit,
336 COALESCE(fm.instances, 1) AS instances
337 FROM files f LEFT JOIN file_mass_settings fm ON fm.file_id = f.id
338 WHERE f.revision_id = ANY($1) AND f.format = ANY($2)
339 ORDER BY f.filename",
340 )
341 .bind(ids)
342 .bind(GEOMETRY_FORMATS.to_vec())
343 .fetch_all(pool)
344 .await?)
345}
346
347async fn reference_roots_of(pool: &PgPool, revision_id: Uuid) -> AppResult<HashSet<Uuid>> {
350 let files: Vec<(Uuid, String)> =
351 sqlx::query_as("SELECT id, filename FROM files WHERE revision_id = $1")
352 .bind(revision_id)
353 .fetch_all(pool)
354 .await?;
355 let references: Vec<(Uuid, String)> = sqlx::query_as(
356 "SELECT f.id, fr.name
357 FROM files f
358 JOIN file_versions fv ON fv.file_id = f.id AND fv.version = f.current_version
359 JOIN file_references fr ON fr.file_version_id = fv.id
360 WHERE f.revision_id = $1",
361 )
362 .bind(revision_id)
363 .fetch_all(pool)
364 .await?;
365 Ok(mass_geometry::reference_roots(&files, &references))
366}
367
368async fn resolve_geometry<'a>(
372 pool: &PgPool,
373 files: &'a [GeometryFile],
374 chosen: Option<Uuid>,
375 preferred: &str,
376 number: &str,
377 name: &str,
378) -> AppResult<Resolution<'a>> {
379 let hints = Hints {
380 item_number: number,
381 item_name: name,
382 roots: None,
383 };
384 let first = mass_geometry::resolve(files, chosen, preferred, &hints);
385 if !first.is_ambiguous() {
386 return Ok(first);
387 }
388 let roots = reference_roots_of(pool, files[0].revision_id).await?;
389 let hints = Hints {
390 roots: Some(&roots),
391 ..hints
392 };
393 Ok(mass_geometry::resolve(files, chosen, preferred, &hints))
394}
395
396#[derive(Clone, Debug, Serialize)]
399pub struct RevisionMass {
400 pub settings: MassSettings,
401 pub files: Vec<GeometryFile>,
402 pub geometry_file_id: Option<Uuid>,
404 pub geometry: GeometryInfo,
406 pub material: Option<Material>,
408}
409
410pub async fn revision_settings(
411 pool: &PgPool,
412 actor: &Actor,
413 revision_id: Uuid,
414) -> AppResult<RevisionMass> {
415 let rev = revisions::get(pool, actor, revision_id).await?;
416 let settings = settings_of(pool, &[rev.id])
417 .await?
418 .remove(&rev.id)
419 .unwrap_or(MassSettings {
420 revision_id: rev.id,
421 ..MassSettings::default()
422 });
423 let files = geometry_files_of(pool, &[rev.id]).await?;
424 let resolution = resolve_geometry(
425 pool,
426 &files,
427 settings.geometry_file_id,
428 &actor.preferred_cad,
429 &rev.item_number,
430 &rev.name,
431 )
432 .await?;
433 let geometry_file_id = resolution.file().map(|f| f.id);
434 let geometry = mass_geometry::info(&resolution, true);
435 let material = material_named(pool, &rev.material).await?;
436 Ok(RevisionMass {
437 settings,
438 files,
439 geometry_file_id,
440 geometry,
441 material,
442 })
443}
444
445async fn material_named(pool: &PgPool, name: &str) -> AppResult<Option<Material>> {
446 if name.trim().is_empty() {
447 return Ok(None);
448 }
449 Ok(sqlx::query_as::<_, Material>(&format!(
450 "SELECT {MATERIAL_COLUMNS} FROM materials WHERE lower(name) = lower($1)"
451 ))
452 .bind(name.trim())
453 .fetch_optional(pool)
454 .await?)
455}
456
457#[derive(Clone, Debug, Default)]
459pub struct MassSettingsUpdate {
460 pub density_kg_m3: Option<f64>,
461 pub geometry_file_id: Option<Uuid>,
463 pub length_unit: Option<String>,
465 pub instances: Option<i32>,
467 pub measured_mass_kg: Option<f64>,
468 pub measured_cg_m: Option<[f64; 3]>,
469 pub measured_inertia_kg_m2: Option<[f64; 6]>,
470 pub measured_note: String,
471}
472
473impl MassSettingsUpdate {
474 fn validated(self) -> AppResult<MassSettingsUpdate> {
475 let density = self.density_kg_m3.map(validate_density).transpose()?;
476 let mass = domain::validate_non_negative("Measured mass", self.measured_mass_kg)?;
477 if mass.is_some_and(|m| m > 1e9) {
478 return Err(AppError::BadRequest(
479 "Measured mass must be at most 10⁹ kg.".into(),
480 ));
481 }
482 let finite = |v: &[f64]| v.iter().all(|x| x.is_finite() && x.abs() <= 1e9);
483 if let Some(cg) = &self.measured_cg_m {
484 if mass.is_none() {
485 return Err(AppError::BadRequest(
486 "A measured centre of gravity needs a measured mass.".into(),
487 ));
488 }
489 if !finite(cg) {
490 return Err(AppError::BadRequest(
491 "The centre of gravity must be three finite numbers (m).".into(),
492 ));
493 }
494 }
495 if let Some(inertia) = &self.measured_inertia_kg_m2 {
496 if self.measured_cg_m.is_none() {
497 return Err(AppError::BadRequest(
498 "A measured inertia tensor needs the measured centre of gravity it is taken about."
499 .into(),
500 ));
501 }
502 if !finite(inertia) || inertia[0] < 0.0 || inertia[1] < 0.0 || inertia[2] < 0.0 {
503 return Err(AppError::BadRequest(
504 "The inertia tensor must be six finite numbers with non-negative Ixx, Iyy, Izz (kg·m²)."
505 .into(),
506 ));
507 }
508 let [a, b, c] = [inertia[0], inertia[1], inertia[2]];
510 let slack = 1e-9 * (a + b + c);
511 if a > b + c + slack || b > a + c + slack || c > a + b + slack {
512 return Err(AppError::BadRequest(
513 "These moments of inertia are impossible for a real body (each must be at most the sum of the other two)."
514 .into(),
515 ));
516 }
517 }
518 let unit = match self.length_unit.as_deref().map(str::trim) {
519 None | Some("") => None,
520 Some(u) => Some(
521 LengthUnit::parse(u)
522 .ok_or_else(|| AppError::BadRequest("Length unit must be mm, m or in.".into()))?
523 .as_str()
524 .to_string(),
525 ),
526 };
527 let instances = self.instances.unwrap_or(1);
528 if !(1..=100_000).contains(&instances) {
529 return Err(AppError::BadRequest(
530 "Instances must be a whole number from 1 to 100000.".into(),
531 ));
532 }
533 Ok(MassSettingsUpdate {
534 density_kg_m3: density,
535 geometry_file_id: self.geometry_file_id,
536 length_unit: unit,
537 instances: Some(instances),
538 measured_mass_kg: mass,
539 measured_cg_m: self.measured_cg_m,
540 measured_inertia_kg_m2: self.measured_inertia_kg_m2,
541 measured_note: domain::limit_text("Note", &self.measured_note, 2000)?,
542 })
543 }
544}
545
546pub async fn update_settings(
560 pool: &PgPool,
561 actor: &Actor,
562 revision_id: Uuid,
563 upd: MassSettingsUpdate,
564) -> AppResult<RevisionMass> {
565 let upd = upd.validated()?;
566 let mut tx = pool.begin().await?;
567 let rev = revisions::lock(&mut tx, revision_id).await?;
568 if !actor.can_see_project(rev.project_id) {
569 return Err(AppError::not_found("Revision not found."));
570 }
571 revisions::ensure_editable(&rev, actor)?;
572 let candidates = sqlx::query_as::<_, GeometryFile>(
575 "SELECT f.id, f.revision_id, f.filename, f.sha256, f.format, fm.length_unit,
576 COALESCE(fm.instances, 1) AS instances
577 FROM files f LEFT JOIN file_mass_settings fm ON fm.file_id = f.id
578 WHERE f.revision_id = $1 AND f.format = ANY($2)
579 ORDER BY f.filename
580 FOR UPDATE OF f",
581 )
582 .bind(rev.id)
583 .bind(GEOMETRY_FORMATS.to_vec())
584 .fetch_all(&mut *tx)
585 .await?;
586 if let Some(id) = upd.geometry_file_id {
587 if !candidates.iter().any(|f| f.id == id) {
588 return Err(AppError::BadRequest(
589 "The geometry file must be a 3D file of this revision.".into(),
590 ));
591 }
592 }
593 let file = mass_geometry::resolve(
595 &candidates,
596 upd.geometry_file_id,
597 &actor.preferred_cad,
598 &Hints::default(),
599 )
600 .file()
601 .map(|f| (f.id, f.filename.clone()));
602 sqlx::query(
603 "INSERT INTO revision_mass (revision_id, density_kg_m3, geometry_file_id, measured_mass_kg,
604 measured_cg_m, measured_inertia_kg_m2, measured_note,
605 updated_by, updated_at)
606 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
607 ON CONFLICT (revision_id) DO UPDATE SET
608 density_kg_m3 = EXCLUDED.density_kg_m3, geometry_file_id = EXCLUDED.geometry_file_id,
609 measured_mass_kg = EXCLUDED.measured_mass_kg, measured_cg_m = EXCLUDED.measured_cg_m,
610 measured_inertia_kg_m2 = EXCLUDED.measured_inertia_kg_m2,
611 measured_note = EXCLUDED.measured_note, updated_by = EXCLUDED.updated_by,
612 updated_at = now()",
613 )
614 .bind(rev.id)
615 .bind(upd.density_kg_m3)
616 .bind(upd.geometry_file_id)
617 .bind(upd.measured_mass_kg)
618 .bind(upd.measured_cg_m.map(|v| v.to_vec()))
619 .bind(upd.measured_inertia_kg_m2.map(|v| v.to_vec()))
620 .bind(&upd.measured_note)
621 .bind(actor.id)
622 .execute(&mut *tx)
623 .await?;
624 if let Some((file_id, _)) = &file {
625 sqlx::query(
626 "INSERT INTO file_mass_settings (file_id, length_unit, instances) VALUES ($1, $2, $3)
627 ON CONFLICT (file_id) DO UPDATE SET length_unit = EXCLUDED.length_unit,
628 instances = EXCLUDED.instances",
629 )
630 .bind(file_id)
631 .bind(&upd.length_unit)
632 .bind(upd.instances.unwrap_or(1))
633 .execute(&mut *tx)
634 .await?;
635 }
636 audit::record(
637 &mut tx,
638 Some(actor),
639 "revision.mass",
640 "revision",
641 Some(rev.id),
642 format!("Updated the mass settings of {}", revisions::label(&rev)),
643 json!({
644 "item_id": rev.item_id,
645 "density_kg_m3": upd.density_kg_m3,
646 "geometry_file": file.as_ref().map(|f| &f.1),
647 "length_unit": upd.length_unit,
648 "instances": upd.instances,
649 "measured_mass_kg": upd.measured_mass_kg,
650 "measured_cg_m": upd.measured_cg_m,
651 "measured_inertia_kg_m2": upd.measured_inertia_kg_m2,
652 }),
653 )
654 .await?;
655 tx.commit().await?;
656 revision_settings(pool, actor, revision_id).await
657}
658
659pub async fn choose_geometry(
663 pool: &PgPool,
664 actor: &Actor,
665 revision_id: Uuid,
666 file_id: Option<Uuid>,
667) -> AppResult<RevisionMass> {
668 let current = revision_settings(pool, actor, revision_id).await?;
669 let s = ¤t.settings;
670 let chosen = file_id.and_then(|id| current.files.iter().find(|f| f.id == id));
671 if file_id.is_some() && chosen.is_none() {
672 return Err(AppError::BadRequest(
673 "The geometry file must be a 3D file of this revision.".into(),
674 ));
675 }
676 let file = chosen.or_else(|| {
679 let auto = current.geometry.choice == "automatic";
680 current
681 .files
682 .iter()
683 .find(|f| auto && Some(f.id) == current.geometry_file_id)
684 });
685 update_settings(
686 pool,
687 actor,
688 revision_id,
689 MassSettingsUpdate {
690 density_kg_m3: s.density_kg_m3,
691 geometry_file_id: file_id,
692 length_unit: file.and_then(|f| f.length_unit.clone()),
693 instances: file.map(|f| f.instances),
694 measured_mass_kg: s.measured_mass_kg,
695 measured_cg_m: s.measured_cg_m.as_deref().and_then(v3),
696 measured_inertia_kg_m2: s.measured_inertia_kg_m2.as_deref().and_then(sym),
697 measured_note: s.measured_note.clone(),
698 },
699 )
700 .await
701}
702
703pub(crate) async fn copy_to_revision(
707 conn: &mut PgConnection,
708 from: Uuid,
709 to: Uuid,
710) -> AppResult<bool> {
711 let copied = sqlx::query(
712 "INSERT INTO revision_mass (revision_id, density_kg_m3, geometry_file_id, measured_mass_kg,
713 measured_cg_m, measured_inertia_kg_m2, measured_note,
714 updated_by, updated_at)
715 SELECT $2, m.density_kg_m3, nf.id, m.measured_mass_kg, m.measured_cg_m,
716 m.measured_inertia_kg_m2, m.measured_note, m.updated_by, m.updated_at
717 FROM revision_mass m
718 LEFT JOIN files ofl ON ofl.id = m.geometry_file_id
719 LEFT JOIN LATERAL (
720 SELECT f.id FROM files f WHERE f.revision_id = $2 AND f.filename = ofl.filename
721 ORDER BY f.id LIMIT 1
722 ) nf ON true
723 WHERE m.revision_id = $1",
724 )
725 .bind(from)
726 .bind(to)
727 .execute(&mut *conn)
728 .await?
729 .rows_affected();
730 sqlx::query(
731 "INSERT INTO file_mass_settings (file_id, length_unit, instances)
732 SELECT nf.id, s.length_unit, s.instances
733 FROM file_mass_settings s
734 JOIN files ofl ON ofl.id = s.file_id AND ofl.revision_id = $1
735 JOIN files nf ON nf.revision_id = $2 AND nf.filename = ofl.filename
736 ON CONFLICT (file_id) DO NOTHING",
737 )
738 .bind(from)
739 .bind(to)
740 .execute(&mut *conn)
741 .await?;
742 Ok(copied > 0)
743}
744
745#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
751#[serde(rename_all = "snake_case")]
752pub enum Source {
753 Measured,
755 Computed,
757 EnteredOnGeometry,
759 Entered,
761 Assembly,
763 Covered,
765 Missing,
767 GeometryAmbiguous,
769 Restricted,
771}
772
773impl Source {
774 pub fn label(self) -> &'static str {
775 match self {
776 Source::Measured => "measured",
777 Source::Computed => "computed",
778 Source::EnteredOnGeometry => "entered mass on geometry",
779 Source::Entered => "entered mass (no position)",
780 Source::Assembly => "sum of children",
781 Source::Covered => "covered by measured parent",
782 Source::Missing => "missing",
783 Source::GeometryAmbiguous => "geometry ambiguous",
784 Source::Restricted => "restricted",
785 }
786 }
787}
788
789#[derive(Clone, Debug, Default)]
791pub(crate) struct RevisionInput {
792 pub material: String,
793 pub entered_mass_kg: Option<f64>,
794 pub settings: MassSettings,
795 pub geometry: Option<(GeometryFile, Result<Geometry, String>)>,
797 pub geometry_info: Option<GeometryInfo>,
799 pub density: Option<(f64, String)>,
801}
802
803#[derive(Clone, Debug, Default)]
805struct Unit {
806 placed: Body,
808 unplaced: f64,
810 missing: usize,
812}
813
814#[derive(Clone, Debug, Serialize)]
815pub struct MassLine {
816 pub level: usize,
817 pub find_number: i32,
818 pub item_number: String,
819 pub revision_id: Option<Uuid>,
820 pub revision_code: Option<String>,
821 pub name: String,
822 pub quantity: f64,
823 pub factor: f64,
825 pub source: Source,
826 pub source_label: &'static str,
827 pub detail: String,
829 pub unit_mass_kg: Option<f64>,
830 pub total_mass_kg: Option<f64>,
831 pub cg_m: Option<V3>,
833 pub warnings: Vec<String>,
834 pub geometry: Option<GeometryInfo>,
836}
837
838#[derive(Clone, Debug, Serialize)]
839pub struct MassReport {
840 pub rule: RevisionRule,
841 pub root_revision_id: Uuid,
842 pub root_label: String,
843 pub root_name: String,
844 pub root_source: Source,
846 pub root_detail: String,
847 pub root_geometry: Option<GeometryInfo>,
849 pub reference_m: V3,
851 pub mass_kg: f64,
853 pub placed_mass_kg: f64,
854 pub unplaced_mass_kg: f64,
855 pub cg_m: Option<V3>,
857 pub inertia_cg: Option<Sym>,
859 pub inertia_ref: Option<Sym>,
861 pub principal_moments: Option<V3>,
863 pub lines: Vec<MassLine>,
864 pub line_count: usize,
865 pub restricted_lines: usize,
866 pub missing_lines: usize,
867 pub complete: bool,
869 pub warnings: Vec<String>,
871 pub lines_with_warnings: usize,
873}
874
875pub(crate) fn report(
878 tree: &bom::BomTree,
879 root_label: String,
880 root_name: String,
881 root_has_children: bool,
882 inputs: &HashMap<Uuid, RevisionInput>,
883 reference: V3,
884) -> MassReport {
885 let mut lines = Vec::new();
886 let empty = RevisionInput::default();
887 let root_input = inputs.get(&tree.root.id).unwrap_or(&empty);
888 let mut walker = Walker {
889 inputs,
890 lines: &mut lines,
891 restricted: 0,
892 };
893 let (unit, source, detail, root_warnings) =
894 walker.revision(root_input, &tree.children, root_has_children, false);
895 let restricted = walker.restricted;
896 let total = unit.placed;
897 let has_placed = total.mass > 0.0 && total.is_finite();
898 let missing_lines = lines
899 .iter()
900 .filter(|l| matches!(l.source, Source::Missing | Source::GeometryAmbiguous))
901 .count();
902 let lines_with_warnings = lines.iter().filter(|l| !l.warnings.is_empty()).count();
903 let mut warnings = root_warnings;
904 if restricted > 0 {
905 warnings.push(format!(
906 "{restricted} restricted line{} (items of project groups you are not a member of) left out.",
907 if restricted == 1 { "" } else { "s" }
908 ));
909 }
910 if missing_lines > 0 || unit.missing > 0 {
911 let n = missing_lines.max(unit.missing);
912 warnings.push(format!(
913 "{n} line{} without any mass.",
914 if n == 1 { "" } else { "s" }
915 ));
916 }
917 if unit.unplaced > 0.0 {
918 warnings.push(format!(
919 "{} kg have no position (entered or measured mass without CG): counted in the mass, not in the CG and inertia.",
920 util::format_number(unit.unplaced, 4)
921 ));
922 }
923 if lines_with_warnings > 0 {
924 warnings.push(format!(
925 "{lines_with_warnings} line{} with warnings (see below).",
926 if lines_with_warnings == 1 { "" } else { "s" }
927 ));
928 }
929 let complete = warnings.is_empty();
930 MassReport {
931 rule: tree.rule,
932 root_revision_id: tree.root.id,
933 root_label,
934 root_name,
935 root_source: source,
936 root_detail: detail,
937 root_geometry: root_input.geometry_info.clone(),
938 reference_m: reference,
939 mass_kg: total.mass + unit.unplaced,
940 placed_mass_kg: total.mass,
941 unplaced_mass_kg: unit.unplaced,
942 cg_m: has_placed.then_some(total.cg),
943 inertia_cg: has_placed.then_some(total.inertia),
944 inertia_ref: has_placed.then(|| total.inertia_about(reference)),
945 principal_moments: has_placed.then(|| massprops::principal_moments(total.inertia)),
946 line_count: lines.len(),
947 lines,
948 restricted_lines: restricted,
949 missing_lines,
950 complete,
951 warnings,
952 lines_with_warnings,
953 }
954}
955
956struct Walker<'a> {
957 inputs: &'a HashMap<Uuid, RevisionInput>,
958 lines: &'a mut Vec<MassLine>,
959 restricted: usize,
960}
961
962impl Walker<'_> {
963 fn revision(
966 &mut self,
967 input: &RevisionInput,
968 children: &[BomNode],
969 has_children: bool,
970 covered: bool,
971 ) -> (Unit, Source, String, Vec<String>) {
972 let mut warnings = Vec::new();
973 let s = &input.settings;
974 if let Some(mass) = s.measured_mass_kg {
976 self.children(children, true);
977 let note = if s.measured_note.is_empty() {
978 String::new()
979 } else {
980 s.measured_note.clone()
981 };
982 let cg = s.measured_cg_m.as_deref().and_then(v3);
983 let unit = match cg {
984 Some(cg) => {
985 let inertia = s.measured_inertia_kg_m2.as_deref().and_then(sym);
986 if inertia.is_none() && mass > 0.0 {
987 warnings.push(
988 "measured without inertia tensor: taken as a point mass at the CG"
989 .into(),
990 );
991 }
992 Unit {
993 placed: Body {
994 mass,
995 cg,
996 inertia: inertia.unwrap_or([0.0; 6]),
997 },
998 unplaced: 0.0,
999 missing: 0,
1000 }
1001 }
1002 None => {
1003 if mass > 0.0 {
1004 warnings.push("measured without CG: the mass has no position".into());
1005 }
1006 Unit {
1007 placed: Body::default(),
1008 unplaced: mass,
1009 missing: 0,
1010 }
1011 }
1012 };
1013 return (unit, Source::Measured, note, warnings);
1014 }
1015 if has_children {
1017 let unit = self.children(children, covered);
1019 return (unit, Source::Assembly, String::new(), warnings);
1020 }
1021 let entered = input.entered_mass_kg;
1023 let ambiguous = input.geometry_info.as_ref().filter(|g| g.is_ambiguous());
1024 if let Some(info) = ambiguous {
1025 warnings.push(info.ambiguity_warning());
1027 if entered.is_none() {
1028 return (
1029 Unit {
1030 missing: 1,
1031 ..Unit::default()
1032 },
1033 Source::GeometryAmbiguous,
1034 info.note(),
1035 warnings,
1036 );
1037 }
1038 }
1039 let geometry_note = input
1040 .geometry_info
1041 .as_ref()
1042 .map(GeometryInfo::note)
1043 .filter(|n| !n.is_empty() && ambiguous.is_none())
1044 .map(|n| format!(" – {n}"))
1045 .unwrap_or_default();
1046 if let Some((file, geometry)) = &input.geometry {
1047 let unit_label = file.unit().as_str();
1048 match geometry {
1049 Ok(g) => {
1050 warnings.extend(g.warnings());
1051 let metres = file.unit().metres();
1052 let instances = f64::from(file.instances.max(1));
1053 if let Some((density, from)) = &input.density {
1054 let body = g.body(*density, metres).times(1.0 / instances);
1056 if let Some(entered) = entered.filter(|m| *m > 0.0) {
1057 if (entered - body.mass).abs() > MASS_MISMATCH * body.mass {
1058 warnings.push(format!(
1059 "the entered mass {} kg differs from the computed {} kg",
1060 util::format_number(entered, 4),
1061 util::format_number(body.mass, 4)
1062 ));
1063 }
1064 }
1065 let detail = format!(
1066 "{} ({unit_label}) × {} kg/m³ ({from}){geometry_note}",
1067 file.filename,
1068 util::format_number(*density, 3)
1069 );
1070 return (
1071 Unit {
1072 placed: body,
1073 unplaced: 0.0,
1074 missing: 0,
1075 },
1076 Source::Computed,
1077 detail,
1078 warnings,
1079 );
1080 }
1081 if let Some(mass) = entered.filter(|m| *m > 0.0) {
1082 let body = g.body(1.0, metres).times(1.0 / instances);
1083 let scaled = if body.mass > 0.0 {
1084 body.times(mass / body.mass)
1085 } else {
1086 body
1087 };
1088 let detail = format!(
1089 "{} ({unit_label}), entered mass spread uniformly{geometry_note}",
1090 file.filename
1091 );
1092 warnings.push(no_density(&input.material));
1093 return (
1094 Unit {
1095 placed: scaled,
1096 unplaced: 0.0,
1097 missing: 0,
1098 },
1099 Source::EnteredOnGeometry,
1100 detail,
1101 warnings,
1102 );
1103 }
1104 warnings.push(no_density(&input.material));
1105 }
1106 Err(e) => warnings.push(format!("{}: {e}", file.filename)),
1107 }
1108 }
1109 if let Some(mass) = entered {
1111 if input.geometry.is_none() && mass > 0.0 {
1112 warnings.push(if ambiguous.is_some() {
1113 "no geometry file chosen: the entered mass has no position".into()
1114 } else {
1115 "no 3D file: the entered mass has no position".to_string()
1116 });
1117 }
1118 return (
1119 Unit {
1120 placed: Body::default(),
1121 unplaced: mass,
1122 missing: 0,
1123 },
1124 Source::Entered,
1125 String::new(),
1126 warnings,
1127 );
1128 }
1129 if input.geometry.is_none() {
1130 warnings.push("no mass: no 3D file, no measured or entered mass".into());
1131 }
1132 (
1133 Unit {
1134 missing: 1,
1135 ..Unit::default()
1136 },
1137 Source::Missing,
1138 String::new(),
1139 warnings,
1140 )
1141 }
1142
1143 fn children(&mut self, nodes: &[BomNode], covered: bool) -> Unit {
1145 let empty = RevisionInput::default();
1146 let mut bodies = Vec::new();
1147 let mut unplaced = 0.0;
1148 let mut missing = 0;
1149 for n in nodes {
1150 let base = MassLine {
1151 level: n.level,
1152 find_number: n.find_number,
1153 item_number: n.item_number.clone(),
1154 revision_id: n.revision.as_ref().map(|r| r.id),
1155 revision_code: n.revision.as_ref().map(|r| r.code.clone()),
1156 name: n
1157 .revision
1158 .as_ref()
1159 .map(|r| r.name.clone())
1160 .unwrap_or_default(),
1161 quantity: n.quantity,
1162 factor: n.quantity,
1163 source: Source::Missing,
1164 source_label: Source::Missing.label(),
1165 detail: String::new(),
1166 unit_mass_kg: None,
1167 total_mass_kg: None,
1168 cg_m: None,
1169 warnings: Vec::new(),
1170 geometry: None,
1171 };
1172 if n.restricted {
1173 self.restricted += 1;
1174 self.lines.push(MassLine {
1175 source: Source::Restricted,
1176 source_label: Source::Restricted.label(),
1177 ..base
1178 });
1179 continue;
1180 }
1181 let at = self.lines.len();
1182 self.lines.push(base);
1183 let Some(rev) = &n.revision else {
1184 if let Some(line) = self.lines.get_mut(at) {
1185 line.warnings
1186 .push("no revision matches the revision rule".into());
1187 }
1188 if !covered {
1189 missing += 1;
1190 }
1191 continue;
1192 };
1193 if n.cycle {
1194 if let Some(line) = self.lines.get_mut(at) {
1195 line.warnings.push("the structure loops back here".into());
1196 }
1197 if !covered {
1198 missing += 1;
1199 }
1200 continue;
1201 }
1202 let input = self.inputs.get(&rev.id).unwrap_or(&empty);
1203 let has_children = !n.children.is_empty();
1204 let (unit, source, detail, mut warnings) =
1205 self.revision(input, &n.children, has_children, covered);
1206 if covered {
1207 if let Some(line) = self.lines.get_mut(at) {
1208 line.source = Source::Covered;
1209 line.source_label = Source::Covered.label();
1210 line.detail = detail;
1211 }
1212 continue;
1213 }
1214 let instances = match (&input.geometry, source) {
1215 (Some((f, _)), Source::Computed | Source::EnteredOnGeometry) => {
1216 f64::from(f.instances.max(1))
1217 }
1218 _ => 1.0,
1219 };
1220 let copies = n.quantity / instances;
1223 if unit.placed.mass > 0.0 && (copies - 1.0).abs() > 1e-9 {
1224 warnings.push(format!(
1225 "quantity {} with geometry of {} unit{}: {} copies stacked at the same place – export every instance into the file and set its instance count, or check the CG",
1226 util::format_number(n.quantity, 6),
1227 util::format_number(instances, 0),
1228 if instances == 1.0 { "" } else { "s" },
1229 util::format_number(copies, 6)
1230 ));
1231 }
1232 let line_body = unit.placed.times(n.quantity);
1233 let unit_mass = unit.placed.mass + unit.unplaced;
1234 let factor = n.quantity;
1235 if let Some(line) = self.lines.get_mut(at) {
1236 line.factor = copies;
1237 line.source = source;
1238 line.source_label = source.label();
1239 line.detail = detail;
1240 let massless = matches!(source, Source::Missing | Source::GeometryAmbiguous);
1241 line.geometry = input.geometry_info.clone();
1242 line.unit_mass_kg = (!massless).then_some(unit_mass);
1243 line.total_mass_kg = (!massless).then_some(unit_mass * factor);
1244 line.cg_m = (line_body.mass > 0.0).then_some(line_body.cg);
1245 line.warnings = warnings;
1246 }
1247 if line_body.mass > 0.0 {
1248 bodies.push(line_body);
1249 }
1250 unplaced += unit.unplaced * factor;
1251 missing += unit.missing;
1252 }
1253 Unit {
1254 placed: massprops::combine(bodies.iter()),
1255 unplaced,
1256 missing,
1257 }
1258 }
1259}
1260
1261fn no_density(material: &str) -> String {
1262 if material.trim().is_empty() {
1263 "no density: no material and no density override".into()
1264 } else {
1265 format!("no density: the material '{material}' is not in the material library")
1266 }
1267}
1268
1269fn v3(v: &[f64]) -> Option<V3> {
1270 match v {
1271 &[x, y, z] => Some([x, y, z]),
1272 _ => None,
1273 }
1274}
1275
1276fn sym(v: &[f64]) -> Option<Sym> {
1277 match v {
1278 &[a, b, c, d, e, f] => Some([a, b, c, d, e, f]),
1279 _ => None,
1280 }
1281}
1282
1283pub async fn rollup(
1287 st: &AppState,
1288 actor: &Actor,
1289 root_id: Uuid,
1290 rule: RevisionRule,
1291 reference: V3,
1292) -> AppResult<MassReport> {
1293 if !reference.iter().all(|v| v.is_finite() && v.abs() <= 1e9) {
1294 return Err(AppError::BadRequest(
1295 "The reference point must be three finite numbers (m).".into(),
1296 ));
1297 }
1298 let pool = &st.db;
1299 let root = revisions::get(pool, actor, root_id).await?;
1300 let (lines_by_rev, _) = bom::load_structure(pool, actor, root.id, rule).await?;
1301 let tree = bom::assemble(
1302 rule,
1303 ResolvedRevision::from(&root),
1304 root.item_number.clone(),
1305 &lines_by_rev,
1306 )?;
1307 let mut ids: Vec<Uuid> = bom::tree_revision_ids(&tree);
1308 let mut seen = HashSet::new();
1309 ids.retain(|id| seen.insert(*id));
1310 let has_children: HashSet<Uuid> = lines_by_rev
1311 .iter()
1312 .filter(|(_, lines)| !lines.is_empty())
1313 .map(|(id, _)| *id)
1314 .collect();
1315
1316 let settings = settings_of(pool, &ids).await?;
1317 let base: Vec<(Uuid, String, Option<f64>, String, String)> = sqlx::query_as(
1318 "SELECT r.id, r.material, r.mass_kg, i.number, r.name
1319 FROM revisions r JOIN items i ON i.id = r.item_id WHERE r.id = ANY($1)",
1320 )
1321 .bind(&ids)
1322 .fetch_all(pool)
1323 .await?;
1324 let library: HashMap<String, Material> = materials(pool)
1325 .await?
1326 .into_iter()
1327 .map(|m| (m.name.to_lowercase(), m))
1328 .collect();
1329 let mut files: HashMap<Uuid, Vec<GeometryFile>> = HashMap::new();
1330 for f in geometry_files_of(pool, &ids).await? {
1331 files.entry(f.revision_id).or_default().push(f);
1332 }
1333
1334 let mut inputs: HashMap<Uuid, RevisionInput> = HashMap::new();
1335 let mut cache: HashMap<String, Result<Geometry, String>> = HashMap::new();
1336 for (id, material, entered, number, name) in base {
1337 let settings = settings.get(&id).cloned().unwrap_or(MassSettings {
1338 revision_id: id,
1339 ..MassSettings::default()
1340 });
1341 let density = match settings.density_kg_m3 {
1342 Some(d) => Some((d, "override".to_string())),
1343 None => library
1344 .get(&material.trim().to_lowercase())
1345 .map(|m| (m.density_kg_m3, m.name.clone())),
1346 };
1347 let needs_geometry = settings.measured_mass_kg.is_none() && !has_children.contains(&id);
1349 let mut geometry_info = None;
1350 let geometry = if needs_geometry {
1351 let resolution = match files.get(&id).filter(|f| !f.is_empty()) {
1352 Some(f) => Some(
1353 resolve_geometry(
1354 pool,
1355 f,
1356 settings.geometry_file_id,
1357 &actor.preferred_cad,
1358 &number,
1359 &name,
1360 )
1361 .await?,
1362 ),
1363 None => None,
1364 };
1365 geometry_info = resolution.as_ref().map(|r| mass_geometry::info(r, false));
1366 match resolution.as_ref().and_then(Resolution::file) {
1367 Some(file) => {
1368 let result = match cache.get(&file.sha256) {
1369 Some(r) => r.clone(),
1370 None => {
1371 let r = if cache.len() >= MAX_GEOMETRY_FILES {
1372 Err(format!(
1373 "more than {MAX_GEOMETRY_FILES} geometry files in one roll-up"
1374 ))
1375 } else {
1376 geometry_of(st, file).await
1377 };
1378 cache.insert(file.sha256.clone(), r.clone());
1379 r
1380 }
1381 };
1382 Some((file.clone(), result))
1383 }
1384 None => None,
1385 }
1386 } else {
1387 None
1388 };
1389 inputs.insert(
1390 id,
1391 RevisionInput {
1392 material,
1393 entered_mass_kg: entered,
1394 settings,
1395 geometry,
1396 geometry_info,
1397 density,
1398 },
1399 );
1400 }
1401 Ok(report(
1402 &tree,
1403 revisions::label(&root),
1404 root.name.clone(),
1405 has_children.contains(&root.id),
1406 &inputs,
1407 reference,
1408 ))
1409}
1410
1411async fn geometry_of(st: &AppState, file: &GeometryFile) -> Result<Geometry, String> {
1413 let Some(format) = PreviewFormat::of_format(&file.format) else {
1414 return Err(format!("no 3D reader for the format '{}'", file.format));
1415 };
1416 st.previews
1417 .mass_geometry(&st.vault, &file.sha256, format)
1418 .await
1419 .map_err(|e| e.public_message())
1420}
1421
1422pub fn parse_point(raw: Option<&str>) -> AppResult<V3> {
1424 let raw = raw.map(str::trim).unwrap_or("");
1425 if raw.is_empty() {
1426 return Ok([0.0; 3]);
1427 }
1428 let parts: Vec<&str> = raw
1429 .split([',', ';', ' '])
1430 .filter(|s| !s.is_empty())
1431 .collect();
1432 let bad = || AppError::BadRequest("The reference point must be x,y,z in metres.".into());
1433 let values: Vec<f64> = parts
1434 .iter()
1435 .map(|p| p.parse::<f64>().map_err(|_| bad()))
1436 .collect::<AppResult<_>>()?;
1437 match values.as_slice() {
1438 &[x, y, z] if values.iter().all(|v| v.is_finite() && v.abs() <= 1e9) => Ok([x, y, z]),
1439 _ => Err(bad()),
1440 }
1441}
1442
1443fn num(v: f64) -> String {
1448 util::format_number(v, 9)
1449}
1450
1451fn csv_field(s: &str) -> String {
1452 if s.contains([',', '"', '\n', '\r']) {
1453 format!("\"{}\"", s.replace('"', "\"\""))
1454 } else {
1455 s.to_string()
1456 }
1457}
1458
1459pub fn to_csv(r: &MassReport) -> String {
1462 let mut out = String::from(
1463 "level,find_number,item_number,revision,name,quantity,source,unit_mass_kg,total_mass_kg,cg_x_m,cg_y_m,cg_z_m,detail,warnings,geometry_file,geometry_choice\n",
1464 );
1465 let cg = r.cg_m.unwrap_or([f64::NAN; 3]);
1466 let fmt = |v: f64| if v.is_finite() { num(v) } else { String::new() };
1467 out.push_str(&format!(
1468 "0,,TOTAL,{},{},1,{},{},{},{},{},{},{},{},{},{}\n",
1469 csv_field(&r.root_label),
1470 csv_field(&r.root_name),
1471 r.root_source.label(),
1472 num(r.mass_kg),
1473 num(r.mass_kg),
1474 fmt(cg[0]),
1475 fmt(cg[1]),
1476 fmt(cg[2]),
1477 csv_field(&format!(
1478 "placed {} kg, unplaced {} kg",
1479 num(r.placed_mass_kg),
1480 num(r.unplaced_mass_kg)
1481 )),
1482 csv_field(&r.warnings.join("; ")),
1483 csv_field(
1484 r.root_geometry
1485 .as_ref()
1486 .and_then(|g| g.file.as_deref())
1487 .unwrap_or("")
1488 ),
1489 r.root_geometry.as_ref().map_or("", |g| g.choice)
1490 ));
1491 for (label, tensor, point) in [
1492 ("INERTIA_CG", r.inertia_cg, r.cg_m),
1493 ("INERTIA_REF", r.inertia_ref, Some(r.reference_m)),
1494 ] {
1495 if let (Some(t), Some(p)) = (tensor, point) {
1496 out.push_str(&format!(
1497 "0,,{label},,,,,,,{},{},{},{},,,\n",
1498 num(p[0]),
1499 num(p[1]),
1500 num(p[2]),
1501 csv_field(&format!(
1502 "Ixx={} Iyy={} Izz={} Ixy={} Iyz={} Ixz={} kg·m² (tensor form)",
1503 num(t[0]),
1504 num(t[1]),
1505 num(t[2]),
1506 num(t[3]),
1507 num(t[4]),
1508 num(t[5])
1509 ))
1510 ));
1511 }
1512 }
1513 for l in &r.lines {
1514 let cg = l.cg_m.unwrap_or([f64::NAN; 3]);
1515 out.push_str(&format!(
1516 "{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}\n",
1517 l.level,
1518 l.find_number,
1519 csv_field(&l.item_number),
1520 l.revision_code.as_deref().unwrap_or(""),
1521 csv_field(&l.name),
1522 num(l.quantity),
1523 l.source_label,
1524 l.unit_mass_kg.map(num).unwrap_or_default(),
1525 l.total_mass_kg.map(num).unwrap_or_default(),
1526 fmt(cg[0]),
1527 fmt(cg[1]),
1528 fmt(cg[2]),
1529 csv_field(&l.detail),
1530 csv_field(&l.warnings.join("; ")),
1531 csv_field(
1532 l.geometry
1533 .as_ref()
1534 .and_then(|g| g.file.as_deref())
1535 .unwrap_or("")
1536 ),
1537 l.geometry.as_ref().map_or("", |g| g.choice)
1538 ));
1539 }
1540 out
1541}
1542
1543pub fn report_revision_ids(r: &MassReport) -> Vec<Uuid> {
1545 let mut ids = vec![r.root_revision_id];
1546 ids.extend(r.lines.iter().filter_map(|l| l.revision_id));
1547 ids
1548}
1549
1550#[cfg(test)]
1551mod tests {
1552 use super::*;
1553
1554 fn rev(id: Uuid) -> ResolvedRevision {
1555 ResolvedRevision {
1556 id,
1557 code: "A".into(),
1558 state: "in_work".into(),
1559 name: format!("rev {id}"),
1560 mass_kg: None,
1561 unit_cost: None,
1562 }
1563 }
1564
1565 fn node(rev_id: Option<Uuid>, qty: f64, level: usize, children: Vec<BomNode>) -> BomNode {
1566 BomNode {
1567 line_id: Uuid::new_v4(),
1568 level,
1569 find_number: 10,
1570 quantity: qty,
1571 notes: String::new(),
1572 item_id: Uuid::new_v4(),
1573 item_number: "PRT".into(),
1574 item_type: "part".into(),
1575 unit: "each".into(),
1576 revision: rev_id.map(rev),
1577 unit_mass_kg: 0.0,
1578 total_mass_kg: 0.0,
1579 unit_cost_total: 0.0,
1580 total_cost: 0.0,
1581 mass_complete: true,
1582 cost_complete: true,
1583 cycle: false,
1584 restricted: false,
1585 project_id: None,
1586 children,
1587 }
1588 }
1589
1590 fn tree(root: Uuid, children: Vec<BomNode>) -> bom::BomTree {
1591 bom::BomTree {
1592 rule: RevisionRule::Latest,
1593 root: rev(root),
1594 root_item_number: "ASM".into(),
1595 children,
1596 total_mass_kg: 0.0,
1597 total_cost: 0.0,
1598 mass_complete: true,
1599 cost_complete: true,
1600 line_count: 0,
1601 unresolved_count: 0,
1602 restricted_lines: 0,
1603 rollup_incomplete: false,
1604 queries: 0,
1605 }
1606 }
1607
1608 fn boxed(lo: V3, e: V3) -> Geometry {
1610 let v = e[0] * e[1] * e[2];
1611 Geometry {
1612 version: massprops::VERSION,
1613 volume: v,
1614 centroid: [lo[0] + e[0] / 2.0, lo[1] + e[1] / 2.0, lo[2] + e[2] / 2.0],
1615 inertia: [
1616 v * (e[1] * e[1] + e[2] * e[2]) / 12.0,
1617 v * (e[0] * e[0] + e[2] * e[2]) / 12.0,
1618 v * (e[0] * e[0] + e[1] * e[1]) / 12.0,
1619 0.0,
1620 0.0,
1621 0.0,
1622 ],
1623 area: 0.0,
1624 bounds: [0.0; 6],
1625 triangles: 12,
1626 closed: true,
1627 checked: true,
1628 open_edges: 0,
1629 non_manifold_edges: 0,
1630 misoriented_edges: 0,
1631 degenerate_triangles: 0,
1632 inverted_fixed: false,
1633 }
1634 }
1635
1636 fn file(name: &str, instances: i32) -> GeometryFile {
1637 GeometryFile {
1638 id: Uuid::new_v4(),
1639 revision_id: Uuid::nil(),
1640 filename: name.into(),
1641 sha256: "0".repeat(64),
1642 format: "stl".into(),
1643 length_unit: None,
1644 instances,
1645 }
1646 }
1647
1648 fn part(g: Geometry, density: Option<f64>, instances: i32) -> RevisionInput {
1649 RevisionInput {
1650 material: "Aluminium".into(),
1651 geometry: Some((file("p.stl", instances), Ok(g))),
1652 density: density.map(|d| (d, "Aluminium".into())),
1653 ..RevisionInput::default()
1654 }
1655 }
1656
1657 #[test]
1658 fn cube_and_offset_box_roll_up_by_parallel_axis() {
1659 let (root, a, b) = (Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4());
1660 let t = tree(
1661 root,
1662 vec![node(Some(a), 1.0, 1, vec![]), node(Some(b), 1.0, 1, vec![])],
1663 );
1664 let mut inputs = HashMap::new();
1665 inputs.insert(a, part(boxed([0.0; 3], [100.0; 3]), Some(2700.0), 1));
1667 inputs.insert(
1668 b,
1669 part(
1670 boxed([300.0, 0.0, 0.0], [200.0, 50.0, 20.0]),
1671 Some(7850.0),
1672 1,
1673 ),
1674 );
1675 let r = report(&t, "ASM/A".into(), String::new(), true, &inputs, [0.0; 3]);
1676 let (ma, mb) = (2.7, 7850.0 * 2e-4);
1677 assert!((r.mass_kg - (ma + mb)).abs() < 1e-12);
1678 let cg = r.cg_m.unwrap();
1679 assert!((cg[0] - (ma * 0.05 + mb * 0.4) / (ma + mb)).abs() < 1e-12);
1680 assert!(r.complete, "{:?}", r.warnings);
1681 assert_eq!(r.root_source, Source::Assembly);
1682 assert_eq!(r.lines.len(), 2);
1683 assert!(r.lines.iter().all(|l| l.source == Source::Computed));
1684 let ba = boxed([0.0; 3], [100.0; 3]).body(2700.0, 1e-3);
1686 let bb = boxed([300.0, 0.0, 0.0], [200.0, 50.0, 20.0]).body(7850.0, 1e-3);
1687 let want = ba.inertia_about([0.0; 3]);
1688 let want_b = bb.inertia_about([0.0; 3]);
1689 for k in 0..6 {
1690 assert!((r.inertia_ref.unwrap()[k] - (want[k] + want_b[k])).abs() < 1e-12);
1691 }
1692 let csv = to_csv(&r);
1693 assert!(csv.starts_with("level,"));
1694 assert!(csv.contains("TOTAL") && csv.contains("INERTIA_CG"));
1695 }
1696
1697 #[test]
1698 fn measured_mass_wins_and_covers_the_sub_tree() {
1699 let (root, sub, a) = (Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4());
1700 let t = tree(
1701 root,
1702 vec![node(Some(sub), 1.0, 1, vec![node(Some(a), 1.0, 2, vec![])])],
1703 );
1704 let mut inputs = HashMap::new();
1705 inputs.insert(a, part(boxed([0.0; 3], [100.0; 3]), Some(2700.0), 1));
1706 inputs.insert(
1707 sub,
1708 RevisionInput {
1709 settings: MassSettings {
1710 measured_mass_kg: Some(3.1),
1711 measured_cg_m: Some(vec![0.1, 0.2, 0.3]),
1712 measured_note: "scale 2026-11-01".into(),
1713 ..MassSettings::default()
1714 },
1715 ..RevisionInput::default()
1716 },
1717 );
1718 let r = report(&t, "ASM/A".into(), String::new(), true, &inputs, [0.0; 3]);
1719 assert!((r.mass_kg - 3.1).abs() < 1e-12);
1720 let cg = r.cg_m.unwrap();
1721 assert!(cg
1722 .iter()
1723 .zip([0.1, 0.2, 0.3])
1724 .all(|(a, b)| (a - b).abs() < 1e-12));
1725 assert_eq!(r.lines[0].source, Source::Measured);
1726 assert_eq!(r.lines[0].detail, "scale 2026-11-01");
1727 assert_eq!(r.lines[1].source, Source::Covered);
1728 assert!(r.lines[0].warnings.iter().any(|w| w.contains("point mass")));
1730 assert!(r.inertia_cg.unwrap().iter().all(|v| v.abs() < 1e-12));
1731 }
1732
1733 #[test]
1734 fn missing_density_entered_mass_restricted_and_quantities() {
1735 let root = Uuid::new_v4();
1736 let (a, b, c) = (Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4());
1737 let mut hidden = node(None, 1.0, 1, vec![]);
1738 hidden.restricted = true;
1739 let t = tree(
1740 root,
1741 vec![
1742 node(Some(a), 1.0, 1, vec![]),
1743 node(Some(b), 2.0, 1, vec![]),
1744 node(Some(c), 4.0, 1, vec![]),
1745 hidden,
1746 node(None, 1.0, 1, vec![]),
1747 ],
1748 );
1749 let mut inputs = HashMap::new();
1750 let mut ia = part(boxed([0.0; 3], [10.0; 3]), None, 1);
1752 ia.material = "Unobtainium".into();
1753 inputs.insert(a, ia);
1754 inputs.insert(
1756 b,
1757 RevisionInput {
1758 entered_mass_kg: Some(0.01),
1759 ..RevisionInput::default()
1760 },
1761 );
1762 inputs.insert(c, part(boxed([0.0; 3], [10.0; 3]), Some(1000.0), 4));
1764 let r = report(&t, "ASM/A".into(), String::new(), true, &inputs, [0.0; 3]);
1765 assert!(!r.complete);
1766 let la = &r.lines[0];
1767 assert_eq!(la.source, Source::Missing);
1768 assert!(
1769 la.warnings.iter().any(|w| w.contains("Unobtainium")),
1770 "{la:?}"
1771 );
1772 let lb = &r.lines[1];
1773 assert_eq!(lb.source, Source::Entered);
1774 assert_eq!(lb.total_mass_kg, Some(0.02));
1775 assert!((r.unplaced_mass_kg - 0.02).abs() < 1e-12);
1776 let lc = &r.lines[2];
1778 assert!((lc.total_mass_kg.unwrap() - 1e-3).abs() < 1e-12);
1779 assert!(lc.warnings.is_empty(), "{lc:?}");
1780 assert!((lc.factor - 1.0).abs() < 1e-12);
1781 assert_eq!(r.restricted_lines, 1);
1782 assert_eq!(r.lines[3].source, Source::Restricted);
1783 assert_eq!(r.missing_lines, 2);
1784 assert!(r.warnings.iter().any(|w| w.contains("restricted")));
1785 assert!(r.warnings.iter().any(|w| w.contains("no position")));
1786 assert!((r.mass_kg - (0.02 + 1e-3)).abs() < 1e-12);
1787 }
1788
1789 #[test]
1790 fn entered_mass_on_geometry_and_open_mesh_warnings() {
1791 let (root, a) = (Uuid::new_v4(), Uuid::new_v4());
1792 let t = tree(root, vec![node(Some(a), 1.0, 1, vec![])]);
1793 let mut g = boxed([0.0; 3], [100.0; 3]);
1794 g.open_edges = 3;
1795 g.closed = false;
1796 let mut input = part(g, None, 1);
1797 input.entered_mass_kg = Some(5.0);
1798 let mut inputs = HashMap::new();
1799 inputs.insert(a, input);
1800 let r = report(&t, "ASM/A".into(), String::new(), true, &inputs, [0.0; 3]);
1801 let l = &r.lines[0];
1802 assert_eq!(l.source, Source::EnteredOnGeometry);
1803 assert!((r.mass_kg - 5.0).abs() < 1e-12);
1804 assert_eq!(r.cg_m, Some([0.05, 0.05, 0.05]));
1805 assert!((r.inertia_cg.unwrap()[0] - 5.0 * 0.01 / 6.0).abs() < 1e-12);
1807 assert!(l.warnings.iter().any(|w| w.contains("open mesh")));
1808 }
1809
1810 #[test]
1811 fn a_single_part_root_uses_its_own_geometry() {
1812 let root = Uuid::new_v4();
1813 let t = tree(root, vec![]);
1814 let mut inputs = HashMap::new();
1815 inputs.insert(root, part(boxed([0.0; 3], [100.0; 3]), Some(1000.0), 1));
1816 let r = report(&t, "PRT/A".into(), String::new(), false, &inputs, [0.0; 3]);
1817 assert_eq!(r.root_source, Source::Computed);
1818 assert!((r.mass_kg - 1.0).abs() < 1e-12);
1819 }
1820
1821 #[test]
1822 fn reference_points_parse() {
1823 assert_eq!(parse_point(None).unwrap(), [0.0; 3]);
1824 assert_eq!(parse_point(Some(" 1, -2.5;3 ")).unwrap(), [1.0, -2.5, 3.0]);
1825 assert!(parse_point(Some("1,2")).is_err());
1826 assert!(parse_point(Some("1,2,x")).is_err());
1827 assert!(parse_point(Some("1,2,inf")).is_err());
1828 }
1829
1830 #[test]
1831 fn settings_are_validated() {
1832 let ok = MassSettingsUpdate {
1833 measured_mass_kg: Some(1.0),
1834 measured_cg_m: Some([0.0, 0.0, 1.0]),
1835 measured_inertia_kg_m2: Some([1.0, 1.0, 1.5, 0.0, 0.0, 0.0]),
1836 length_unit: Some("IN".into()),
1837 ..MassSettingsUpdate::default()
1838 };
1839 let v = ok.clone().validated().unwrap();
1840 assert_eq!(v.length_unit.as_deref(), Some("in"));
1841 assert_eq!(v.instances, Some(1));
1842 let bad = [
1843 MassSettingsUpdate {
1844 density_kg_m3: Some(0.0),
1845 ..MassSettingsUpdate::default()
1846 },
1847 MassSettingsUpdate {
1848 measured_cg_m: Some([0.0; 3]),
1849 ..MassSettingsUpdate::default()
1850 },
1851 MassSettingsUpdate {
1852 measured_inertia_kg_m2: Some([1.0, 1.0, 3.0, 0.0, 0.0, 0.0]),
1853 ..ok.clone()
1854 },
1855 MassSettingsUpdate {
1856 length_unit: Some("ft".into()),
1857 ..MassSettingsUpdate::default()
1858 },
1859 MassSettingsUpdate {
1860 instances: Some(0),
1861 ..MassSettingsUpdate::default()
1862 },
1863 MassSettingsUpdate {
1864 measured_mass_kg: Some(f64::NAN),
1865 ..MassSettingsUpdate::default()
1866 },
1867 ];
1868 for b in bad {
1869 assert!(b.clone().validated().is_err(), "{b:?}");
1870 }
1871 }
1872}