1use std::collections::{BTreeMap, HashSet};
13
14use serde::Serialize;
15use uuid::Uuid;
16
17use super::mass_properties::{GeometryFile, GEOMETRY_FORMATS};
18use crate::cadformat;
19
20const MIN_NAME_LEN: usize = 3;
22
23pub 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
50fn 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
59pub 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#[derive(Clone, Copy, Debug, Default)]
83pub struct Hints<'a> {
84 pub item_number: &'a str,
85 pub item_name: &'a str,
86 pub roots: Option<&'a HashSet<Uuid>>,
88}
89
90#[derive(Clone, Debug)]
92pub struct Group<'a> {
93 pub stem: String,
94 pub files: Vec<&'a GeometryFile>,
95}
96
97#[derive(Clone, Debug)]
99pub enum Pick<'a> {
100 Nothing,
102 Chosen(&'a GeometryFile),
104 Automatic {
106 file: &'a GeometryFile,
107 other_formats: usize,
108 },
109 Ambiguous { models: usize, files: usize },
111}
112
113#[derive(Clone, Debug)]
114pub struct Resolution<'a> {
115 pub pick: Pick<'a>,
116 pub groups: Vec<Group<'a>>,
118 pub likely: Option<&'a GeometryFile>,
120}
121
122impl Resolution<'_> {
123 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
140fn 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
154pub 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
168fn 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
193fn 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
204pub 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#[derive(Clone, Debug, Serialize)]
263pub struct CandidateOut {
264 pub file_id: Uuid,
265 pub filename: String,
266 pub format: String,
267 pub likely: bool,
269 pub used: bool,
271}
272
273#[derive(Clone, Debug, Serialize)]
274pub struct GroupOut {
275 pub model: String,
277 pub files: Vec<CandidateOut>,
278}
279
280#[derive(Clone, Debug, Serialize)]
283pub struct GeometryInfo {
284 pub choice: &'static str,
286 pub file: Option<String>,
287 pub file_id: Option<Uuid>,
288 pub other_formats: usize,
290 pub models: usize,
292 pub files: usize,
294 pub likely_file: Option<String>,
295 pub likely_file_id: Option<Uuid>,
296 pub candidates: Vec<GroupOut>,
299}
300
301impl GeometryInfo {
302 pub fn is_ambiguous(&self) -> bool {
303 self.choice == "ambiguous"
304 }
305
306 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 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
346pub 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 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 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 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()), ];
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 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 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 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 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 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 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}