Skip to main content

teamcraft/services/
mass_geometry.rs

1//! Which geometry file a revision's mass is computed from (pure rules).
2//!
3//! The 3D files of a revision are grouped by *model*: files whose
4//! normalised stem is the same (`box.prt`, `Box.SLDPRT`, `box_rev-B.stp`)
5//! are one model in several formats. One model is picked automatically
6//! (format rank, the user's preferred CAD program breaks ties between native
7//! formats); files of several models are *ambiguous* and add nothing until a
8//! file is chosen, because the mass of one arbitrary part is worse than none.
9//! A candidate that is probably the whole assembly (the root of the file
10//! references of the revision, or named like the item) is suggested.
11
12use std::collections::{BTreeMap, HashSet};
13
14use serde::Serialize;
15use uuid::Uuid;
16
17use super::mass_properties::{GeometryFile, GEOMETRY_FORMATS};
18use crate::cadformat;
19
20/// The shortest item number or name that counts as a name match.
21const MIN_NAME_LEN: usize = 3;
22
23/// File name without extension, lower case, `_`, `-` and spaces folded to
24/// single spaces and a trailing revision (`rev-D`, `_rev_B`, `revB`)
25/// removed. Creo version extensions (`.prt.5`) are dropped as well.
26pub fn normalise_stem(filename: &str) -> String {
27    let mut stem = filename;
28    if let Some((rest, ext)) = stem.rsplit_once('.') {
29        stem = rest;
30        if !ext.is_empty() && ext.bytes().all(|b| b.is_ascii_digit()) {
31            if let Some((again, _)) = stem.rsplit_once('.') {
32                stem = again;
33            }
34        }
35    }
36    let lower = stem.to_lowercase();
37    let mut words: Vec<&str> = lower
38        .split(|c: char| c == '_' || c == '-' || c.is_whitespace())
39        .filter(|w| !w.is_empty())
40        .collect();
41    let n = words.len();
42    if n >= 3 && words[n - 2] == "rev" && words[n - 1].len() <= 2 {
43        words.truncate(n - 2);
44    } else if n >= 2 && words[n - 1].starts_with("rev") && words[n - 1].len() <= 5 {
45        words.truncate(n - 1);
46    }
47    words.join(" ")
48}
49
50/// Folds an item number or name like a stem (no extension handling).
51fn fold(text: &str) -> String {
52    text.to_lowercase()
53        .split(|c: char| c == '_' || c == '-' || c.is_whitespace())
54        .filter(|w| !w.is_empty())
55        .collect::<Vec<_>>()
56        .join(" ")
57}
58
59/// The files of a revision that look like the root of an assembly: they
60/// reference other files of the revision (by name) and no other file of the
61/// revision references them. `files` are `(id, file name)`, `references`
62/// are `(referencing file id, referenced name)`.
63pub fn reference_roots(files: &[(Uuid, String)], references: &[(Uuid, String)]) -> HashSet<Uuid> {
64    let by_name: BTreeMap<String, Uuid> = files
65        .iter()
66        .map(|(id, name)| (name.to_lowercase(), *id))
67        .collect();
68    let mut referencing: HashSet<Uuid> = HashSet::new();
69    let mut referenced: HashSet<Uuid> = HashSet::new();
70    for (from, name) in references {
71        if let Some(to) = by_name.get(&name.to_lowercase()) {
72            if to != from {
73                referencing.insert(*from);
74                referenced.insert(*to);
75            }
76        }
77    }
78    referencing.difference(&referenced).copied().collect()
79}
80
81/// What the choice of a revision's geometry file knows besides the files.
82#[derive(Clone, Copy, Debug, Default)]
83pub struct Hints<'a> {
84    pub item_number: &'a str,
85    pub item_name: &'a str,
86    /// Files that are the root of the file references of the revision.
87    pub roots: Option<&'a HashSet<Uuid>>,
88}
89
90/// Several files of one model, best first.
91#[derive(Clone, Debug)]
92pub struct Group<'a> {
93    pub stem: String,
94    pub files: Vec<&'a GeometryFile>,
95}
96
97/// The result of the choice.
98#[derive(Clone, Debug)]
99pub enum Pick<'a> {
100    /// No 3D file.
101    Nothing,
102    /// Chosen by hand.
103    Chosen(&'a GeometryFile),
104    /// One model: its best file; `other_formats` files of the same model.
105    Automatic {
106        file: &'a GeometryFile,
107        other_formats: usize,
108    },
109    /// Files of several models, none chosen.
110    Ambiguous { models: usize, files: usize },
111}
112
113#[derive(Clone, Debug)]
114pub struct Resolution<'a> {
115    pub pick: Pick<'a>,
116    /// Models, the likely whole assembly first, then by name.
117    pub groups: Vec<Group<'a>>,
118    /// The file suggested as the whole assembly (only when ambiguous).
119    pub likely: Option<&'a GeometryFile>,
120}
121
122impl Resolution<'_> {
123    /// The file the mass is computed from.
124    pub fn file(&self) -> Option<&GeometryFile> {
125        match &self.pick {
126            Pick::Chosen(f) | Pick::Automatic { file: f, .. } => Some(f),
127            Pick::Nothing | Pick::Ambiguous { .. } => None,
128        }
129    }
130
131    pub fn is_ambiguous(&self) -> bool {
132        matches!(self.pick, Pick::Ambiguous { .. })
133    }
134}
135
136fn is_native(format: &str) -> bool {
137    cadformat::family(format).is_some()
138}
139
140/// Sort key inside a model: mesh and neutral STEP before native formats, a
141/// native format of the preferred CAD program before the other native ones,
142/// then the format rank, then the name.
143fn order(f: &GeometryFile, preferred: &str) -> (u8, usize, String) {
144    let preferred_native =
145        !preferred.is_empty() && cadformat::family(&f.format).is_some_and(|s| s.id == preferred);
146    let class = u8::from(is_native(&f.format) && !preferred_native);
147    let rank = GEOMETRY_FORMATS
148        .iter()
149        .position(|g| *g == f.format)
150        .unwrap_or(GEOMETRY_FORMATS.len());
151    (class, rank, f.filename.clone())
152}
153
154/// Groups the candidates by model, each group best file first.
155pub fn groups<'a>(files: &'a [GeometryFile], preferred: &str) -> Vec<Group<'a>> {
156    let mut map: BTreeMap<String, Vec<&GeometryFile>> = BTreeMap::new();
157    for f in files {
158        map.entry(normalise_stem(&f.filename)).or_default().push(f);
159    }
160    map.into_iter()
161        .map(|(stem, mut files)| {
162            files.sort_by_cached_key(|f| order(f, preferred));
163            Group { stem, files }
164        })
165        .collect()
166}
167
168/// The group that is probably the whole assembly: the only one holding the
169/// root of the file references, else the only one named like the item.
170fn likely_group(groups: &[Group<'_>], hints: &Hints<'_>) -> Option<usize> {
171    let number = fold(hints.item_number);
172    let name = fold(hints.item_name);
173    let named = |stem: &str| {
174        [&number, &name]
175            .iter()
176            .any(|n| n.len() >= MIN_NAME_LEN && stem.contains(n.as_str()))
177    };
178    let rooted = |g: &Group<'_>| {
179        hints
180            .roots
181            .is_some_and(|roots| g.files.iter().any(|f| roots.contains(&f.id)))
182    };
183    let only = |hits: Vec<usize>| (hits.len() == 1).then(|| hits[0]);
184    only((0..groups.len()).filter(|i| rooted(&groups[*i])).collect()).or_else(|| {
185        only(
186            (0..groups.len())
187                .filter(|i| named(&groups[*i].stem))
188                .collect(),
189        )
190    })
191}
192
193/// The file that stands for a whole group: a neutral exchange file (STEP,
194/// JT, STL …) if there is one, else the best native file.
195fn representative<'a>(group: &Group<'a>) -> &'a GeometryFile {
196    group
197        .files
198        .iter()
199        .find(|f| !is_native(&f.format))
200        .copied()
201        .unwrap_or(group.files[0])
202}
203
204/// Chooses the geometry file of a revision from its candidates.
205pub fn resolve<'a>(
206    files: &'a [GeometryFile],
207    chosen: Option<Uuid>,
208    preferred: &str,
209    hints: &Hints<'_>,
210) -> Resolution<'a> {
211    let mut groups = groups(files, preferred);
212    let nothing = |pick| Resolution {
213        pick,
214        groups: Vec::new(),
215        likely: None,
216    };
217    if files.is_empty() {
218        return nothing(Pick::Nothing);
219    }
220    let chosen_file = chosen.and_then(|id| files.iter().find(|f| f.id == id));
221    let likely_idx = likely_group(&groups, hints);
222    if groups.len() > 1 {
223        if let Some(i) = likely_idx {
224            let g = groups.remove(i);
225            groups.insert(0, g);
226        }
227    }
228    if let Some(file) = chosen_file {
229        return Resolution {
230            pick: Pick::Chosen(file),
231            groups,
232            likely: None,
233        };
234    }
235    if groups.len() == 1 {
236        let g = &groups[0];
237        let file = g.files[0];
238        return Resolution {
239            pick: Pick::Automatic {
240                file,
241                other_formats: g.files.len() - 1,
242            },
243            groups,
244            likely: None,
245        };
246    }
247    let likely = likely_idx.map(|_| representative(&groups[0]));
248    Resolution {
249        pick: Pick::Ambiguous {
250            models: groups.len(),
251            files: files.len(),
252        },
253        groups,
254        likely,
255    }
256}
257
258// ---------------------------------------------------------------------------
259// What is shown and exported
260// ---------------------------------------------------------------------------
261
262#[derive(Clone, Debug, Serialize)]
263pub struct CandidateOut {
264    pub file_id: Uuid,
265    pub filename: String,
266    pub format: String,
267    /// The suggested whole-assembly file.
268    pub likely: bool,
269    /// The file the mass is computed from.
270    pub used: bool,
271}
272
273#[derive(Clone, Debug, Serialize)]
274pub struct GroupOut {
275    /// The normalised name shared by the files.
276    pub model: String,
277    pub files: Vec<CandidateOut>,
278}
279
280/// How the geometry file of a revision was decided, for the page, the
281/// exports, REST and MCP.
282#[derive(Clone, Debug, Serialize)]
283pub struct GeometryInfo {
284    /// `none`, `chosen` (by hand), `automatic` or `ambiguous`.
285    pub choice: &'static str,
286    pub file: Option<String>,
287    pub file_id: Option<Uuid>,
288    /// Further formats of the same model (automatic choice).
289    pub other_formats: usize,
290    /// Different models among the candidates.
291    pub models: usize,
292    /// Candidate files.
293    pub files: usize,
294    pub likely_file: Option<String>,
295    pub likely_file_id: Option<Uuid>,
296    /// The candidates by model (likely whole assembly first); empty for a
297    /// single model unless `with_groups` was asked for.
298    pub candidates: Vec<GroupOut>,
299}
300
301impl GeometryInfo {
302    pub fn is_ambiguous(&self) -> bool {
303        self.choice == "ambiguous"
304    }
305
306    /// The warning of an ambiguous choice.
307    pub fn ambiguity_warning(&self) -> String {
308        let mut text = format!(
309            "{} files of {} different parts – choose the geometry file in Mass settings, or split the parts into BOM lines (one item per part)",
310            self.files, self.models
311        );
312        if let Some(f) = &self.likely_file {
313            text.push_str(&format!("; likely the whole assembly: {f}"));
314        }
315        text
316    }
317
318    /// A short note for the detail column.
319    pub fn note(&self) -> String {
320        match self.choice {
321            "automatic" => {
322                let n = self.other_formats;
323                if n == 0 {
324                    "chosen automatically".to_string()
325                } else {
326                    format!(
327                        "chosen automatically; {n} other format{} of the same model",
328                        if n == 1 { "" } else { "s" }
329                    )
330                }
331            }
332            "ambiguous" => {
333                let names: Vec<&str> = self
334                    .candidates
335                    .iter()
336                    .flat_map(|g| &g.files)
337                    .map(|c| c.filename.as_str())
338                    .collect();
339                format!("candidates: {}", names.join("; "))
340            }
341            _ => String::new(),
342        }
343    }
344}
345
346/// Describes a resolution; the candidates are listed when ambiguous or when
347/// `with_groups` is set.
348pub fn info(r: &Resolution<'_>, with_groups: bool) -> GeometryInfo {
349    let used = r.file().map(|f| f.id);
350    let (choice, other_formats, models, files) = match &r.pick {
351        Pick::Nothing => ("none", 0, 0, 0),
352        Pick::Chosen(_) => (
353            "chosen",
354            0,
355            r.groups.len(),
356            r.groups.iter().map(|g| g.files.len()).sum(),
357        ),
358        Pick::Automatic { other_formats, .. } => {
359            ("automatic", *other_formats, 1, other_formats + 1)
360        }
361        Pick::Ambiguous { models, files } => ("ambiguous", 0, *models, *files),
362    };
363    let candidates = if with_groups || r.is_ambiguous() {
364        r.groups
365            .iter()
366            .map(|g| GroupOut {
367                model: g.stem.clone(),
368                files: g
369                    .files
370                    .iter()
371                    .map(|f| CandidateOut {
372                        file_id: f.id,
373                        filename: f.filename.clone(),
374                        format: f.format.clone(),
375                        likely: r.likely.is_some_and(|l| l.id == f.id),
376                        used: used == Some(f.id),
377                    })
378                    .collect(),
379            })
380            .collect()
381    } else {
382        Vec::new()
383    };
384    GeometryInfo {
385        choice,
386        file: r.file().map(|f| f.filename.clone()),
387        file_id: used,
388        other_formats,
389        models,
390        files,
391        likely_file: r.likely.map(|f| f.filename.clone()),
392        likely_file_id: r.likely.map(|f| f.id),
393        candidates,
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    fn file(name: &str, format: &str) -> GeometryFile {
402        GeometryFile {
403            id: Uuid::new_v4(),
404            revision_id: Uuid::nil(),
405            filename: name.into(),
406            sha256: name.into(),
407            format: format.into(),
408            length_unit: None,
409            instances: 1,
410        }
411    }
412
413    fn picked(files: &[GeometryFile], chosen: Option<Uuid>, hints: &Hints<'_>) -> String {
414        let r = resolve(files, chosen, "", hints);
415        match &r.pick {
416            Pick::Nothing => "nothing".into(),
417            Pick::Chosen(f) => format!("chosen {}", f.filename),
418            Pick::Automatic {
419                file,
420                other_formats,
421            } => format!("auto {} +{other_formats}", file.filename),
422            Pick::Ambiguous { models, files } => format!("ambiguous {models}/{files}"),
423        }
424    }
425
426    #[test]
427    fn stems_fold_case_separators_extensions_and_revisions() {
428        for (name, stem) in [
429            ("box.prt", "box"),
430            ("Box.SLDPRT", "box"),
431            ("box.stp", "box"),
432            ("nist_mtc_crada_box_rev-D.SLDPRT", "nist mtc crada box"),
433            ("NIST mtc crada box.prt", "nist mtc crada box"),
434            ("cover_rev_B.SAT", "cover"),
435            ("cover rev-A.stl", "cover"),
436            ("cover-revB.stl", "cover"),
437            ("part.prt.5", "part"),
438            ("rev.stl", "rev"),
439            ("Review Rig.stl", "review rig"),
440            ("noextension", "noextension"),
441        ] {
442            assert_eq!(normalise_stem(name), stem, "{name}");
443        }
444    }
445
446    #[test]
447    fn one_model_in_several_formats_is_picked_by_rank() {
448        let files = vec![
449            file("part.SLDPRT", "solidworks-part"),
450            file("part.prt", "nx-part"),
451            file("part.stl", "stl"),
452        ];
453        let hints = Hints::default();
454        assert_eq!(picked(&files, None, &hints), "auto part.stl +2");
455        let r = resolve(&files, None, "", &hints);
456        let i = info(&r, false);
457        assert_eq!(i.choice, "automatic");
458        assert_eq!(i.other_formats, 2);
459        assert!(i.note().contains("2 other formats of the same model"));
460        // a single file: no other formats
461        assert_eq!(picked(&files[..1], None, &hints), "auto part.SLDPRT +0");
462    }
463
464    #[test]
465    fn the_preferred_cad_program_breaks_ties_between_native_formats() {
466        let files = vec![
467            file("part.prt", "nx-part"),
468            file("part.SLDPRT", "solidworks-part"),
469        ];
470        let hints = Hints::default();
471        let first = |preferred: &str| {
472            resolve(&files, None, preferred, &hints)
473                .file()
474                .map(|f| f.filename.clone())
475        };
476        assert_eq!(first("").as_deref(), Some("part.prt"));
477        assert_eq!(first("solidworks").as_deref(), Some("part.SLDPRT"));
478        // a mesh still wins over a preferred native file
479        let mut with_mesh = files.clone();
480        with_mesh.push(file("part.stl", "stl"));
481        assert_eq!(
482            resolve(&with_mesh, None, "solidworks", &hints)
483                .file()
484                .map(|f| f.filename.clone())
485                .as_deref(),
486            Some("part.stl")
487        );
488    }
489
490    #[test]
491    fn files_of_several_parts_are_ambiguous_until_one_is_chosen() {
492        let files = vec![
493            file("fin.stl", "stl"),
494            file("body.stl", "stl"),
495            file("body.prt", "nx-part"),
496        ];
497        let hints = Hints::default();
498        assert_eq!(picked(&files, None, &hints), "ambiguous 2/3");
499        let chosen = files[0].id;
500        assert_eq!(picked(&files, Some(chosen), &hints), "chosen fin.stl");
501        // a stale choice (file gone) is ignored
502        assert_eq!(
503            picked(&files, Some(Uuid::new_v4()), &hints),
504            "ambiguous 2/3"
505        );
506        assert_eq!(picked(&[], None, &hints), "nothing");
507        let r = resolve(&files, None, "", &hints);
508        let i = info(&r, false);
509        assert!(i.is_ambiguous() && i.file.is_none() && i.likely_file.is_none());
510        assert_eq!(i.candidates.len(), 2);
511        assert!(i
512            .ambiguity_warning()
513            .starts_with("3 files of 2 different parts"));
514    }
515
516    #[test]
517    fn the_root_of_the_file_references_is_the_likely_assembly() {
518        let files = vec![
519            file("asm.prt", "nx-part"),
520            file("asm.jt", "jt"),
521            file("a.prt", "nx-part"),
522            file("b.prt", "nx-part"),
523        ];
524        let ids: Vec<(Uuid, String)> = files.iter().map(|f| (f.id, f.filename.clone())).collect();
525        let refs = vec![
526            (files[0].id, "A.PRT".to_string()),
527            (files[0].id, "b.prt".to_string()),
528            (files[0].id, "elsewhere.prt".to_string()),
529            (files[2].id, "a.prt".to_string()), // itself
530        ];
531        let roots = reference_roots(&ids, &refs);
532        assert_eq!(roots, HashSet::from([files[0].id]));
533        let hints = Hints {
534            roots: Some(&roots),
535            ..Hints::default()
536        };
537        let r = resolve(&files, None, "", &hints);
538        // the neutral file of the assembly's model stands for it
539        assert_eq!(r.likely.map(|f| f.filename.as_str()), Some("asm.jt"));
540        assert_eq!(r.groups[0].stem, "asm");
541        let i = info(&r, false);
542        assert!(i.candidates[0].files.iter().any(|c| c.likely));
543        assert!(i
544            .ambiguity_warning()
545            .contains("likely the whole assembly: asm.jt"));
546        // two roots: no suggestion
547        let two = HashSet::from([files[0].id, files[2].id]);
548        let hints = Hints {
549            roots: Some(&two),
550            ..Hints::default()
551        };
552        assert!(resolve(&files, None, "", &hints).likely.is_none());
553    }
554
555    #[test]
556    fn a_file_named_like_the_item_is_the_likely_assembly() {
557        let files = vec![file("Rover Frame.step", "step"), file("wheel.stl", "stl")];
558        let by_name = Hints {
559            item_number: "ASM-1",
560            item_name: "Rover frame",
561            roots: None,
562        };
563        assert_eq!(
564            resolve(&files, None, "", &by_name)
565                .likely
566                .map(|f| f.filename.as_str()),
567            Some("Rover Frame.step")
568        );
569        // too short to count
570        let short = Hints {
571            item_number: "A1",
572            item_name: "",
573            roots: None,
574        };
575        assert!(resolve(&files, None, "", &short).likely.is_none());
576    }
577
578    /// The file names of the NIST MTC assembly fixture (`tests/fixtures/cad`)
579    /// attached to one revision, as the issue describes it.
580    fn nist() -> Vec<GeometryFile> {
581        let nx = "nx-part";
582        let sw = "solidworks-part";
583        vec![
584            file("90591A141 HEX NUT.prt", nx),
585            file("91274A118 SHCS.prt", nx),
586            file("91274A141 SHCS.prt", nx),
587            file("91304A112 FHSCS.prt", nx),
588            file("NIST mtc crada assembly.jt", "jt"),
589            file("NIST mtc crada assembly.prt", nx),
590            file("NIST mtc crada box.prt", nx),
591            file("NIST mtc crada cover.prt", nx),
592            file("NIST mtc crada plate.prt", nx),
593            file("NIST mtc crada polycarb.prt", nx),
594            file("90591A141_ZINC PLATED STEEL HEX NUT.SLDPRT", sw),
595            file("nist_mtc_crada_box_rev-D.SLDPRT", sw),
596            file("nist_mtc_crada_cover_rev-B.SLDPRT", sw),
597            file("nist_mtc_crada_plate_rev-A.SLDPRT", sw),
598            file("nist_mtc_crada_polycarb-window.SLDPRT", sw),
599        ]
600    }
601
602    #[test]
603    fn the_nist_assembly_in_one_revision_is_ambiguous_with_the_jt_suggested() {
604        let files = nist();
605        let hints = Hints {
606            item_number: "PRT-000001",
607            item_name: "NIST mtc crada assembly",
608            roots: None,
609        };
610        let r = resolve(&files, None, "", &hints);
611        assert!(r.is_ambiguous());
612        assert!(r.file().is_none());
613        assert_eq!(
614            r.likely.map(|f| f.filename.as_str()),
615            Some("NIST mtc crada assembly.jt")
616        );
617        // the NX and SOLIDWORKS parts of one part are one model
618        let box_group = r
619            .groups
620            .iter()
621            .find(|g| g.stem == "nist mtc crada box")
622            .unwrap();
623        assert_eq!(box_group.files.len(), 2);
624        assert_eq!(box_group.files[0].format, "nx-part");
625        assert_eq!(r.groups[0].stem, "nist mtc crada assembly");
626        // the hex nut is no longer picked behind the user's back
627        assert_ne!(
628            resolve(&files, None, "", &Hints::default())
629                .file()
630                .map(|f| f.filename.as_str()),
631            Some("90591A141 HEX NUT.prt")
632        );
633    }
634}