Skip to main content

teamcraft/services/
mass_properties.rs

1//! Mass properties of assemblies: the material library, the mass settings of
2//! revisions and files, and the roll-up of mass, centre of gravity and
3//! inertia tensor over the multi-level BOM (see `massprops` and
4//! `docs/src/research/mass-properties.md`).
5//!
6//! * **Frames:** every geometry file is taken as exported in the common
7//!   assembly / vehicle frame – no placements are applied. A file shows
8//!   `instances` units of its item; a BOM line contributes
9//!   `quantity / instances` times the file's body (copies at the same place
10//!   when that is not 1, which is flagged).
11//! * **Sources per revision**, first that applies: the measured mass (with
12//!   optional CG and inertia; it covers the whole sub-tree), the sum of the
13//!   BOM children, the geometry file × density (override, else the material
14//!   library entry of the revision's material), the geometry file with the
15//!   entered mass (uniform density), the entered mass alone (counts in the
16//!   total mass but has no position), nothing (missing).
17//! * **Computation:** the mesh integrals come from the isolated preview
18//!   process and are cached per content hash (`Previews::mass_geometry`);
19//!   this module only scales and adds them (`report` is pure).
20//! * **Read rights:** the structure is loaded like the BOM page
21//!   (`bom::load_structure`); restricted lines are left out and counted.
22
23use 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
45/// Highest density accepted (kg/m³; osmium is 22 590).
46pub const MAX_DENSITY: f64 = 100_000.0;
47/// Geometry files computed for one roll-up at most (each may need a
48/// conversion in the preview process).
49pub const MAX_GEOMETRY_FILES: usize = 2000;
50/// Formats whose 3D preview can serve as geometry, in the order the
51/// automatic choice prefers them (exported meshes first, then STEP, then the
52/// lightweight representations stored in native CAD files).
53pub 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];
69/// Entered and computed mass differing by more than this share are flagged.
70const MASS_MISMATCH: f64 = 0.05;
71
72// ---------------------------------------------------------------------------
73// Material library
74// ---------------------------------------------------------------------------
75
76#[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
88/// The material library, by name (every signed-in user may read it).
89pub 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
107/// Validates a density in kg/m³.
108pub 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/// The editable fields of a material.
119#[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
136/// Adds a material to the library (administrators).
137///
138/// # Invariants
139///
140/// * **Permission:** admin role (403 otherwise); name 1–200 characters,
141///   density above 0 and at most [`MAX_DENSITY`] (400); a name already in the
142///   library (case-insensitive) is a 409.
143/// * **Locks:** none beyond the unique name index.
144/// * **Audit:** `material.create`.
145/// * **Notifications / webhooks:** none.
146/// * **Atomic:** one transaction.
147pub 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
186/// Changes name, density and notes of a material (administrators).
187///
188/// # Invariants
189///
190/// * **Permission:** admin role (403 otherwise); validation as
191///   [`create_material`]; an unknown material is "not found".
192/// * **Locks:** the material row (`FOR UPDATE`).
193/// * **Audit:** `material.update` with the old and new density.
194/// * **Notifications / webhooks:** none.
195/// * **Atomic:** one transaction.
196pub 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
243/// Removes a material from the library (administrators). Revisions keep
244/// their material text; their mass is then reported as missing a density.
245///
246/// # Invariants
247///
248/// * **Permission:** admin role (403 otherwise); an unknown material is
249///   "not found".
250/// * **Locks:** the material row (`FOR UPDATE`).
251/// * **Audit:** `material.delete`.
252/// * **Notifications / webhooks:** none.
253/// * **Atomic:** one transaction.
254pub 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// ---------------------------------------------------------------------------
283// Mass settings of revisions and files
284// ---------------------------------------------------------------------------
285
286/// The stored mass settings of a revision (defaults when none are stored).
287#[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/// A file of a revision that can serve as geometry, with its settings.
302#[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    /// Stored unit; `None` = the format's default (see [`GeometryFile::unit`]).
310    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
347/// The files of a revision as `(id, name)` with the file names their
348/// current versions reference, for the root of an assembly.
349async 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
368/// The geometry file of a revision (see [`mass_geometry`]): the chosen one;
369/// else the best file of the only model; else *ambiguous*. The root of the
370/// file references is looked up only when the files are ambiguous.
371async 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/// The mass settings of a revision the actor may read, its candidate
397/// geometry files and the file that is used.
398#[derive(Clone, Debug, Serialize)]
399pub struct RevisionMass {
400    pub settings: MassSettings,
401    pub files: Vec<GeometryFile>,
402    /// The file the mass is computed from (`None` when ambiguous).
403    pub geometry_file_id: Option<Uuid>,
404    /// How it was decided, with the candidates by model.
405    pub geometry: GeometryInfo,
406    /// The library entry of the revision's material, if any.
407    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/// New mass settings of a revision (and of its geometry file).
458#[derive(Clone, Debug, Default)]
459pub struct MassSettingsUpdate {
460    pub density_kg_m3: Option<f64>,
461    /// `None` = pick automatically.
462    pub geometry_file_id: Option<Uuid>,
463    /// Unit of the geometry file; `None` = the format's default.
464    pub length_unit: Option<String>,
465    /// Units of the item the geometry file shows (1 if not given).
466    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            // the moments of a real body satisfy the triangle inequality
509            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
546/// Stores the mass settings of a revision: density override, geometry file
547/// with its unit and instance count, measured values.
548///
549/// # Invariants
550///
551/// * **Permission:** the revision is visible ("not found" otherwise) and
552///   editable by the actor (`revisions::ensure_editable`: in work, checked
553///   out by the actor). The geometry file must be a 3D file of this revision
554///   (400). Values are validated (400).
555/// * **Locks:** the revision row (`revisions::lock`).
556/// * **Audit:** `revision.mass` with the new values.
557/// * **Notifications / webhooks:** none.
558/// * **Atomic:** one transaction.
559pub 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    // the file whose unit and instance count are set: the chosen one, else
573    // the one picked automatically
574    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    // an ambiguous revision has no file whose unit could be set yet
594    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
659/// Chooses the geometry file of a revision (`None` = back to automatic),
660/// keeping every other mass setting, like the one-click choice on the page.
661/// Same rules as [`update_settings`].
662pub 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 = &current.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    // the file keeps the unit and instance count stored for it (for
677    // automatic: those of the file that is picked automatically)
678    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
703/// Copies the mass settings of `from` to the new revision `to` (*revise*):
704/// the geometry file and the file settings are matched by file name.
705/// Returns whether settings were copied.
706pub(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// ---------------------------------------------------------------------------
746// Roll-up
747// ---------------------------------------------------------------------------
748
749/// Where the numbers of a line come from.
750#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
751#[serde(rename_all = "snake_case")]
752pub enum Source {
753    /// Measured mass (with or without CG / inertia).
754    Measured,
755    /// Geometry × density.
756    Computed,
757    /// Geometry with the entered mass spread uniformly.
758    EnteredOnGeometry,
759    /// The entered mass only: counts in the mass, has no position.
760    Entered,
761    /// The sum of the BOM children.
762    Assembly,
763    /// Covered by the measured mass of an assembly above.
764    Covered,
765    /// No mass at all.
766    Missing,
767    /// Files of several different parts and none chosen: nothing is added.
768    GeometryAmbiguous,
769    /// A line the reader may not see.
770    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/// What the roll-up knows about one revision.
790#[derive(Clone, Debug, Default)]
791pub(crate) struct RevisionInput {
792    pub material: String,
793    pub entered_mass_kg: Option<f64>,
794    pub settings: MassSettings,
795    /// The geometry file and its integrals (or why there are none).
796    pub geometry: Option<(GeometryFile, Result<Geometry, String>)>,
797    /// How the geometry file was decided (set for parts that need one).
798    pub geometry_info: Option<GeometryInfo>,
799    /// Density and where it comes from (`override`, the material name).
800    pub density: Option<(f64, String)>,
801}
802
803/// One unit of a revision, rolled up.
804#[derive(Clone, Debug, Default)]
805struct Unit {
806    /// Placed mass (with CG and inertia).
807    placed: Body,
808    /// Mass without position (entered masses, measured without CG).
809    unplaced: f64,
810    /// Lines below without any mass.
811    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    /// `quantity / instances`: copies of the source body this line adds.
824    pub factor: f64,
825    pub source: Source,
826    pub source_label: &'static str,
827    /// File name, density and material, or the measurement note.
828    pub detail: String,
829    pub unit_mass_kg: Option<f64>,
830    pub total_mass_kg: Option<f64>,
831    /// CG of the placed mass of this line (m).
832    pub cg_m: Option<V3>,
833    pub warnings: Vec<String>,
834    /// The geometry file and how it was chosen; the candidates when ambiguous.
835    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    /// Source of the root itself (measured, assembly, computed …).
845    pub root_source: Source,
846    pub root_detail: String,
847    /// The geometry file of the root and how it was chosen (a part).
848    pub root_geometry: Option<GeometryInfo>,
849    /// The point the second inertia tensor is taken about (m).
850    pub reference_m: V3,
851    /// Placed plus unplaced mass.
852    pub mass_kg: f64,
853    pub placed_mass_kg: f64,
854    pub unplaced_mass_kg: f64,
855    /// CG of the placed mass (m); `None` without placed mass.
856    pub cg_m: Option<V3>,
857    /// About the CG, tensor form `[xx, yy, zz, xy, yz, xz]` (kg·m²).
858    pub inertia_cg: Option<Sym>,
859    /// About [`MassReport::reference_m`].
860    pub inertia_ref: Option<Sym>,
861    /// Principal moments about the CG, largest first.
862    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    /// Every line has a placed mass, nothing restricted, no warnings.
868    pub complete: bool,
869    /// Warnings about the whole result.
870    pub warnings: Vec<String>,
871    /// Lines with warnings.
872    pub lines_with_warnings: usize,
873}
874
875/// Builds the report from the expanded tree and what is known of every
876/// revision (pure).
877pub(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    /// One unit of `rev` with its BOM `children`; pushes the children's
964    /// lines. Returns the unit, its source, a detail text and warnings.
965    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        // 1. measured: wins, covers the sub-tree
975        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        // 2. an assembly: the sum of its children
1016        if has_children {
1017            // its own geometry and entered mass would count the children twice
1018            let unit = self.children(children, covered);
1019            return (unit, Source::Assembly, String::new(), warnings);
1020        }
1021        // 3. geometry
1022        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            // files of several parts: no guess; an entered mass still counts
1026            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                        // the file shows `instances` units: one unit is 1/instances of it
1055                        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        // 4. the entered mass alone
1110        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    /// The lines below one revision; returns the sum of one unit of it.
1144    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            // copies of what the source shows (a file with all instances,
1221            // a measured or summed unit)
1222            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
1283/// The mass properties of the structure below a revision the actor may
1284/// read, under the revision rule; the inertia is also given about
1285/// `reference` (m). Geometry is computed in the preview process (cached).
1286pub 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        // geometry only where it is used: parts without a measured mass
1348        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
1411/// The integrals of a geometry file (preview process, cached per content).
1412async 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
1422/// Parses `x,y,z` (m); empty = the origin.
1423pub 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
1443// ---------------------------------------------------------------------------
1444// Exports
1445// ---------------------------------------------------------------------------
1446
1447fn 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
1459/// The report as CSV: one row per line, the totals first as comment-free
1460/// rows with `level` 0 (`TOTAL`, `INERTIA_CG`, `INERTIA_REF`).
1461pub 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
1543/// The revisions a report read (for the read log).
1544pub 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    /// A box geometry in mm from `lo` with edges `e`.
1609    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        // 100 mm aluminium cube at the origin, 200×50×20 mm steel bar at x = 300 mm
1666        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        // inertia about the origin = Σ about the origin
1685        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        // point mass: a warning, no inertia about its own CG
1729        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        // a: geometry, unknown material, no entered mass → missing density
1751        let mut ia = part(boxed([0.0; 3], [10.0; 3]), None, 1);
1752        ia.material = "Unobtainium".into();
1753        inputs.insert(a, ia);
1754        // b: entered mass only (bolts)
1755        inputs.insert(
1756            b,
1757            RevisionInput {
1758                entered_mass_kg: Some(0.01),
1759                ..RevisionInput::default()
1760            },
1761        );
1762        // c: four fins in one file, quantity 4: no stacking
1763        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        // c: 1 g per file, a unit is a quarter, four units = the file
1777        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        // 5 kg cube of 0.1 m: m a² / 6
1806        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}