1#![deny(
25 clippy::unwrap_used,
26 clippy::expect_used,
27 clippy::indexing_slicing,
28 clippy::panic,
29 clippy::unreachable
30)]
31
32use std::collections::{HashMap, HashSet};
33
34use crate::bomtable::{field_label, BomRow, BomTable, Column, MAX_ROWS};
35
36const MAX_DEPTH: usize = 64;
38
39const MAX_NESTING: usize = 32;
42
43const MAX_OCCURRENCES: usize = 1_000_000;
46
47pub fn is_step(filename: &str) -> bool {
49 filename
50 .rsplit_once('.')
51 .is_some_and(|(_, ext)| matches!(ext.to_ascii_lowercase().as_str(), "step" | "stp" | "p21"))
52}
53
54#[derive(Debug, PartialEq)]
56enum Param {
57 Str(String),
58 Ref(u64),
59 List(Vec<Param>),
60 Other,
62}
63
64struct Product {
65 id: String,
66 name: String,
67 description: String,
68}
69
70impl Product {
71 fn shown(&self) -> &str {
72 if self.id.trim().is_empty() {
73 self.name.trim()
74 } else {
75 self.id.trim()
76 }
77 }
78}
79
80pub fn read(bytes: &[u8]) -> Result<BomTable, String> {
82 let text = String::from_utf8_lossy(bytes);
83 if !text
84 .trim_start_matches('\u{feff}')
85 .trim_start()
86 .starts_with("ISO-10303-21")
87 {
88 return Err("Not a STEP file: it does not start with ISO-10303-21.".into());
89 }
90 let mut products: HashMap<u64, Product> = HashMap::new();
91 let mut formations: HashMap<u64, u64> = HashMap::new();
93 let mut definitions: HashMap<u64, u64> = HashMap::new();
94 let mut usages: Vec<(u64, u64)> = Vec::new();
96 for_each_instance(&text, |id, keyword, body| {
97 let is = |name: &str| keyword.eq_ignore_ascii_case(name);
98 if is("PRODUCT") {
99 let p = params(body);
100 products.insert(
101 id,
102 Product {
103 id: string(&p, 0),
104 name: string(&p, 1),
105 description: string(&p, 2),
106 },
107 );
108 } else if is("PRODUCT_DEFINITION_FORMATION")
109 || is("PRODUCT_DEFINITION_FORMATION_WITH_SPECIFIED_SOURCE")
110 {
111 if let Some(product) = reference(¶ms(body), 2) {
112 formations.insert(id, product);
113 }
114 } else if is("PRODUCT_DEFINITION") || is("PRODUCT_DEFINITION_WITH_ASSOCIATED_DOCUMENTS") {
115 if let Some(formation) = reference(¶ms(body), 2) {
116 definitions.insert(id, formation);
117 }
118 } else if is("NEXT_ASSEMBLY_USAGE_OCCURRENCE") {
119 let p = params(body);
120 if let (Some(parent), Some(child)) = (reference(&p, 3), reference(&p, 4)) {
121 usages.push((parent, child));
122 }
123 }
124 });
125
126 let product_of = |definition: u64| {
127 definitions
128 .get(&definition)
129 .and_then(|f| formations.get(f))
130 .filter(|p| products.contains_key(p))
131 .copied()
132 };
133 let mut children: HashMap<u64, Vec<(u64, usize)>> = HashMap::new();
135 let mut parents: Vec<u64> = Vec::new();
136 let mut used = HashSet::new();
137 let mut unresolved = 0;
138 for (parent, child) in usages {
139 let (Some(p), Some(c)) = (product_of(parent), product_of(child)) else {
140 unresolved += 1;
141 continue;
142 };
143 let list = children.entry(p).or_insert_with(|| {
144 parents.push(p);
145 Vec::new()
146 });
147 match list.iter_mut().find(|(x, _)| *x == c) {
148 Some(entry) => entry.1 += 1,
149 None => list.push((c, 1)),
150 }
151 used.insert(c);
152 }
153 if children.is_empty() {
154 return Err(
155 "No assembly structure found: the STEP file has no NEXT_ASSEMBLY_USAGE_OCCURRENCE – \
156 it holds a single part or was exported without its product structure."
157 .into(),
158 );
159 }
160 let roots: Vec<u64> = parents
161 .iter()
162 .filter(|p| !used.contains(*p))
163 .copied()
164 .collect();
165 if roots.is_empty() {
166 return Err("The product structure of the STEP file is circular.".into());
167 }
168
169 let mut walk = Walk {
170 products: &products,
171 children: &children,
172 expanded: HashSet::new(),
173 path: Vec::new(),
174 rows: Vec::new(),
175 warnings: Vec::new(),
176 };
177 if unresolved > 0 {
178 walk.warnings.push(format!(
179 "{unresolved} assembly occurrences point to unknown product definitions and are \
180 ignored."
181 ));
182 }
183 let root = if let [root] = roots[..] {
184 let mut row = walk.row(root, 0, 1.0);
185 row.line = 1;
186 walk.expand(root, 0)?;
187 Some(row)
188 } else {
189 walk.warnings.push(format!(
190 "The STEP file holds {} top-level assemblies: each becomes a component of the new \
191 assembly.",
192 roots.len()
193 ));
194 for &root in &roots {
195 let row = walk.row(root, 1, 1.0);
196 walk.rows.push(row);
197 walk.expand(root, 1)?;
198 }
199 None
200 };
201 let columns = [
202 ("PRODUCT id", "number"),
203 ("PRODUCT name", "name"),
204 ("PRODUCT description", "description"),
205 ("NEXT_ASSEMBLY_USAGE_OCCURRENCE", "quantity"),
206 ]
207 .into_iter()
208 .map(|(header, field)| Column {
209 header: header.into(),
210 field,
211 label: field_label(field),
212 })
213 .collect();
214 Ok(BomTable {
215 root,
216 rows: walk.rows,
217 columns,
218 extra_columns: Vec::new(),
219 warnings: walk.warnings,
220 })
221}
222
223pub fn external_instance_counts(bytes: &[u8]) -> Option<HashMap<String, u32>> {
243 let text = String::from_utf8_lossy(bytes);
244 if !text
245 .trim_start_matches('\u{feff}')
246 .trim_start()
247 .starts_with("ISO-10303-21")
248 {
249 return None;
250 }
251 let mut doc_names: HashMap<u64, String> = HashMap::new();
253 let mut doc_ids: HashMap<u64, String> = HashMap::new();
254 let mut doc_links: Vec<(u64, u64)> = Vec::new();
256 let mut formations: HashMap<u64, u64> = HashMap::new();
257 let mut definitions: HashMap<u64, u64> = HashMap::new();
258 let mut usages: Vec<(u64, u64)> = Vec::new();
259 let file_name = |s: &str| -> Option<String> {
260 let last = s.rsplit(['\\', '/']).next().unwrap_or(s).trim();
261 (last.contains('.') && !last.starts_with('.')).then(|| last.to_string())
262 };
263 for_each_instance(&text, |id, keyword, body| {
264 let is = |name: &str| keyword.eq_ignore_ascii_case(name);
265 if is("DOCUMENT_FILE") {
266 let p = params(body);
267 if let Some(n) = file_name(&string(&p, 0)).or_else(|| file_name(&string(&p, 1))) {
268 doc_ids.insert(id, n);
269 }
270 } else if is("APPLIED_EXTERNAL_IDENTIFICATION_ASSIGNMENT") {
271 let p = params(body);
272 if let (Some(n), Some(Param::List(items))) = (file_name(&string(&p, 0)), p.get(3)) {
273 for item in items {
274 if let Param::Ref(doc) = item {
275 doc_names.entry(*doc).or_insert_with(|| n.clone());
276 }
277 }
278 }
279 } else if is("APPLIED_DOCUMENT_REFERENCE") {
280 let p = params(body);
281 if let (Some(doc), Some(Param::List(items))) = (reference(&p, 0), p.get(2)) {
282 for item in items {
283 if let Param::Ref(target) = item {
284 doc_links.push((doc, *target));
285 }
286 }
287 }
288 } else if is("DOCUMENT_PRODUCT_EQUIVALENCE") || is("DOCUMENT_PRODUCT_ASSOCIATION") {
289 let p = params(body);
290 if let (Some(doc), Some(target)) = (reference(&p, 2), reference(&p, 3)) {
291 doc_links.push((doc, target));
292 }
293 } else if is("PRODUCT_DEFINITION_FORMATION")
294 || is("PRODUCT_DEFINITION_FORMATION_WITH_SPECIFIED_SOURCE")
295 {
296 if let Some(product) = reference(¶ms(body), 2) {
297 formations.insert(id, product);
298 }
299 } else if is("PRODUCT_DEFINITION") || is("PRODUCT_DEFINITION_WITH_ASSOCIATED_DOCUMENTS") {
300 let p = params(body);
301 if let Some(formation) = reference(&p, 2) {
302 definitions.insert(id, formation);
303 }
304 if let Some(Param::List(docs)) = p.get(4) {
305 for d in docs {
306 if let Param::Ref(doc) = d {
307 doc_links.push((*doc, id));
308 }
309 }
310 }
311 } else if is("NEXT_ASSEMBLY_USAGE_OCCURRENCE") {
312 let p = params(body);
313 if let (Some(parent), Some(child)) = (reference(&p, 3), reference(&p, 4)) {
314 usages.push((parent, child));
315 }
316 }
317 });
318 for (doc, name) in doc_ids {
320 doc_names.insert(doc, name);
321 }
322 if usages.is_empty() || doc_names.is_empty() {
323 return None;
324 }
325 let mut by_target: HashMap<u64, &str> = HashMap::new();
328 for (doc, target) in &doc_links {
329 if let Some(name) = doc_names.get(doc) {
330 by_target.entry(*target).or_insert(name.as_str());
331 }
332 }
333 let file_of = |definition: u64| -> Option<&str> {
334 let formation = definitions.get(&definition).copied();
335 let product = formation.and_then(|f| formations.get(&f).copied());
336 [Some(definition), formation, product]
337 .into_iter()
338 .flatten()
339 .find_map(|t| by_target.get(&t).copied())
340 };
341 let mut children: HashMap<u64, Vec<u64>> = HashMap::new();
342 let mut used = HashSet::new();
343 let mut parents = Vec::new();
344 for (parent, child) in usages {
345 children
346 .entry(parent)
347 .or_insert_with(|| {
348 parents.push(parent);
349 Vec::new()
350 })
351 .push(child);
352 used.insert(child);
353 }
354 let mut counts: HashMap<String, u32> = HashMap::new();
355 let mut stack: Vec<(u64, usize)> = parents
358 .iter()
359 .filter(|p| !used.contains(*p))
360 .map(|&p| (p, 0))
361 .collect();
362 let mut steps = 0usize;
363 while let Some((parent, depth)) = stack.pop() {
364 steps += 1;
365 if depth >= MAX_DEPTH || steps > MAX_OCCURRENCES {
366 break;
367 }
368 for &child in children.get(&parent).into_iter().flatten() {
369 match file_of(child) {
370 Some(name) => {
371 let n = counts.entry(name.to_string()).or_default();
372 *n = n.saturating_add(1);
373 }
374 None => stack.push((child, depth + 1)),
375 }
376 }
377 }
378 (!counts.is_empty()).then_some(counts)
379}
380
381struct Walk<'a> {
383 products: &'a HashMap<u64, Product>,
384 children: &'a HashMap<u64, Vec<(u64, usize)>>,
385 expanded: HashSet<u64>,
386 path: Vec<u64>,
387 rows: Vec<BomRow>,
388 warnings: Vec<String>,
389}
390
391impl Walk<'_> {
392 fn row(&self, product: u64, level: usize, quantity: f64) -> BomRow {
394 let p = &self.products[&product];
395 let number = p.id.trim().to_string();
396 let name = match p.name.trim() {
397 "" => number.clone(),
398 name => name.to_string(),
399 };
400 let description = match p.description.trim() {
401 d if d == name || d == number => String::new(),
402 d => d.to_string(),
403 };
404 BomRow {
405 line: self.rows.len() + 2,
406 level,
407 find_number: None,
408 number,
409 name,
410 description,
411 quantity,
412 unit: String::new(),
413 material: String::new(),
414 mass_kg: None,
415 structure: String::new(),
416 extra: Vec::new(),
417 }
418 }
419
420 fn expand(&mut self, product: u64, level: usize) -> Result<(), String> {
423 if !self.expanded.insert(product) {
424 return Ok(());
425 }
426 let Some(children) = self.children.get(&product) else {
427 return Ok(());
428 };
429 if level >= MAX_DEPTH {
430 self.warnings.push(format!(
431 "The structure is deeper than {MAX_DEPTH} levels – the components of {} are \
432 skipped.",
433 self.products[&product].shown()
434 ));
435 return Ok(());
436 }
437 self.path.push(product);
438 for &(child, count) in children {
439 if self.path.contains(&child) {
440 self.warnings.push(format!(
441 "{} would contain itself – the occurrence is skipped.",
442 self.products[&child].shown()
443 ));
444 continue;
445 }
446 if self.rows.len() >= MAX_ROWS {
447 return Err(format!("The assembly has more than {MAX_ROWS} BOM rows."));
448 }
449 let row = self.row(child, level + 1, count as f64);
450 self.rows.push(row);
451 self.expand(child, level + 1)?;
452 }
453 self.path.pop();
454 Ok(())
455 }
456}
457
458fn string(params: &[Param], index: usize) -> String {
460 match params.get(index) {
461 Some(Param::Str(s)) => s.clone(),
462 _ => String::new(),
463 }
464}
465
466fn reference(params: &[Param], index: usize) -> Option<u64> {
467 match params.get(index) {
468 Some(Param::Ref(r)) => Some(*r),
469 _ => None,
470 }
471}
472
473fn for_each_instance(text: &str, mut f: impl FnMut(u64, &str, &str)) {
476 let b = text.as_bytes();
477 let (mut start, mut i) = (0, 0);
478 let mut in_string = false;
479 while let Some(&c) = b.get(i) {
480 if in_string {
481 if c == b'\'' {
482 if b.get(i + 1) == Some(&b'\'') {
483 i += 1;
484 } else {
485 in_string = false;
486 }
487 }
488 } else if c == b'\'' {
489 in_string = true;
490 } else if c == b'/' && b.get(i + 1) == Some(&b'*') {
491 i = text
492 .get(i + 2..)
493 .and_then(|t| t.find("*/"))
494 .map_or(b.len(), |e| i + 2 + e + 2);
495 continue;
496 } else if c == b';' {
497 if let Some((id, keyword, body)) = text.get(start..i).and_then(instance) {
498 f(id, keyword, body);
499 }
500 start = i + 1;
501 }
502 i += 1;
503 }
504}
505
506fn instance(mut record: &str) -> Option<(u64, &str, &str)> {
509 while let Some(comment) = record.trim_start().strip_prefix("/*") {
511 record = comment.split_once("*/")?.1;
512 }
513 let rest = record.trim_start().strip_prefix('#')?;
514 let digits = rest.find(|c: char| !c.is_ascii_digit())?;
515 let id = rest.get(..digits)?.parse().ok()?;
516 let rest = rest
517 .get(digits..)?
518 .trim_start()
519 .strip_prefix('=')?
520 .trim_start();
521 let end = rest
522 .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
523 .unwrap_or(rest.len());
524 let (keyword, rest) = rest.split_at(end);
525 if keyword.is_empty() {
526 return None; }
528 let body = rest.trim().strip_prefix('(')?.strip_suffix(')')?;
529 Some((id, keyword, body))
530}
531
532fn params(body: &str) -> Vec<Param> {
534 let mut parser = Parser {
535 text: body,
536 bytes: body.as_bytes(),
537 i: 0,
538 depth: 0,
539 };
540 parser.list()
541}
542
543struct Parser<'a> {
544 text: &'a str,
545 bytes: &'a [u8],
546 i: usize,
547 depth: usize,
549}
550
551impl Parser<'_> {
552 fn peek(&self) -> Option<u8> {
553 self.bytes.get(self.i).copied()
554 }
555
556 fn space(&mut self) {
558 loop {
559 match self.peek() {
560 Some(c) if c.is_ascii_whitespace() => self.i += 1,
561 Some(b'/') if self.bytes.get(self.i + 1) == Some(&b'*') => {
562 self.i = self
563 .text
564 .get(self.i + 2..)
565 .unwrap_or_default()
566 .find("*/")
567 .map_or(self.bytes.len(), |e| self.i + 2 + e + 2);
568 }
569 _ => return,
570 }
571 }
572 }
573
574 fn list(&mut self) -> Vec<Param> {
576 let mut out = Vec::new();
577 loop {
578 self.space();
579 match self.peek() {
580 None => return out,
581 Some(b')') => {
582 self.i += 1;
583 return out;
584 }
585 Some(b',') => self.i += 1,
586 Some(b'\'') => out.push(Param::Str(self.string())),
587 Some(b'#') => {
588 self.i += 1;
589 let start = self.i;
590 while self.peek().is_some_and(|c| c.is_ascii_digit()) {
591 self.i += 1;
592 }
593 out.push(
594 match self.text.get(start..self.i).unwrap_or_default().parse() {
595 Ok(r) => Param::Ref(r),
596 Err(_) => Param::Other,
597 },
598 );
599 }
600 Some(b'(') if self.depth >= MAX_NESTING => {
601 self.other();
603 out.push(Param::Other);
604 }
605 Some(b'(') => {
606 self.i += 1;
607 self.depth += 1;
608 let list = self.list();
609 self.depth -= 1;
610 out.push(Param::List(list));
611 }
612 Some(_) => {
613 self.other();
614 out.push(Param::Other);
615 }
616 }
617 }
618 }
619
620 fn other(&mut self) {
622 let mut depth = 0usize;
623 while let Some(c) = self.peek() {
624 match c {
625 b',' | b')' if depth == 0 => return,
626 b'(' => depth += 1,
627 b')' => depth -= 1,
628 b'\'' => {
629 self.string();
630 continue;
631 }
632 _ => {}
633 }
634 self.i += 1;
635 }
636 }
637
638 fn string(&mut self) -> String {
640 self.i += 1;
641 let mut raw = String::new();
642 let mut from = self.i;
643 while let Some(c) = self.peek() {
644 if c == b'\'' {
645 raw.push_str(self.text.get(from..self.i).unwrap_or_default());
646 if self.bytes.get(self.i + 1) == Some(&b'\'') {
647 raw.push('\'');
648 self.i += 2;
649 from = self.i;
650 continue;
651 }
652 self.i += 1;
653 return decode(&raw);
654 }
655 self.i += 1;
656 }
657 raw.push_str(self.text.get(from..).unwrap_or_default());
658 decode(&raw)
659 }
660}
661
662fn decode(raw: &str) -> String {
666 let mut out = String::with_capacity(raw.len());
667 let mut rest = raw;
668 fn from(s: &str, n: usize) -> &str {
671 s.get(n..).unwrap_or_default()
672 }
673 while let Some(p) = rest.find('\\') {
674 out.push_str(rest.get(..p).unwrap_or_default());
675 rest = from(rest, p);
676 if let Some(r) = rest.strip_prefix("\\\\") {
677 out.push('\\');
678 rest = r;
679 } else if let Some((width, r)) = rest
680 .strip_prefix("\\X2\\")
681 .map(|r| (4, r))
682 .or_else(|| rest.strip_prefix("\\X4\\").map(|r| (8, r)))
683 {
684 let (hex, after) = r.split_once("\\X0\\").unwrap_or((r, ""));
685 let units = hex.as_bytes().chunks(width).filter_map(|chunk| {
686 std::str::from_utf8(chunk)
687 .ok()
688 .and_then(|h| u32::from_str_radix(h, 16).ok())
689 });
690 if width == 4 {
691 let units: Vec<u16> = units.map(|u| u as u16).collect();
692 out.extend(char::decode_utf16(units).map(|c| c.unwrap_or('\u{fffd}')));
693 } else {
694 out.extend(units.map(|u| char::from_u32(u).unwrap_or('\u{fffd}')));
695 }
696 rest = after;
697 } else if let Some(v) = rest
698 .strip_prefix("\\X\\")
699 .and_then(|r| r.get(..2))
700 .and_then(|h| u8::from_str_radix(h, 16).ok())
701 {
702 out.push(char::from(v));
703 rest = from(rest, 5);
704 } else if let Some(c) = rest
705 .strip_prefix("\\S\\")
706 .and_then(|r| r.bytes().next())
707 .filter(u8::is_ascii)
708 {
709 out.push(char::from(c + 128));
710 rest = from(rest, 4);
711 } else if let Some(r) = rest.strip_prefix("\\N\\") {
712 out.push('\n');
713 rest = r;
714 } else if let Some(r) = rest.strip_prefix("\\T\\") {
715 out.push('\t');
716 rest = r;
717 } else if let [b'\\', b'P', _, b'\\', ..] = rest.as_bytes() {
718 rest = from(rest, 4);
719 } else {
720 out.push('\\');
721 rest = from(rest, 1);
722 }
723 }
724 out.push_str(rest);
725 out
726}
727
728#[cfg(test)]
729mod tests {
730 use super::*;
731
732 fn step(data: &str) -> Vec<u8> {
734 format!(
735 "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\n\
736 FILE_NAME('pump.step','2026-11-04T12:00:00',(''),(''),'','','');\n\
737 FILE_SCHEMA(('AUTOMOTIVE_DESIGN'));\nENDSEC;\nDATA;\n{data}\nENDSEC;\n\
738 END-ISO-10303-21;\n"
739 )
740 .into_bytes()
741 }
742
743 fn product(n: u32, id: &str, name: &str) -> String {
745 format!(
746 "#{n}1=PRODUCT('{id}','{name}','',(#9));\n\
747 #{n}2=PRODUCT_DEFINITION_FORMATION_WITH_SPECIFIED_SOURCE('','',#{n}1,.NOT_KNOWN.);\n\
748 #{n}3=PRODUCT_DEFINITION('design','',#{n}2,#8);\n"
749 )
750 }
751
752 fn usage(id: u32, parent: u32, child: u32) -> String {
753 format!("#{id}=NEXT_ASSEMBLY_USAGE_OCCURRENCE('{id}','','',#{parent}3,#{child}3,$);\n")
754 }
755
756 fn flat(t: &BomTable) -> Vec<(usize, &str, f64)> {
757 t.rows
758 .iter()
759 .map(|r| (r.level, r.number.as_str(), r.quantity))
760 .collect()
761 }
762
763 #[test]
764 fn assembly_with_sub_assembly_and_repeated_parts() {
765 let data = [
766 product(1, "PUMP-100", "Pump"),
767 product(2, "HSG-1", "Housing"),
768 product(3, "ROT-1", "Rotor assembly"),
769 product(4, "M6x20", "Screw"),
770 product(5, "SHAFT-1", "Shaft"),
771 usage(101, 1, 2),
773 usage(102, 1, 3),
774 usage(103, 1, 4),
775 usage(104, 1, 4),
776 usage(105, 3, 5),
777 usage(106, 3, 4),
778 usage(107, 1, 4),
779 ]
780 .concat();
781 let t = read(&step(&data)).unwrap();
782 let root = t.root.as_ref().unwrap();
783 assert_eq!((root.level, root.number.as_str()), (0, "PUMP-100"));
784 assert_eq!(root.name, "Pump");
785 assert_eq!(
786 flat(&t),
787 vec![
788 (1, "HSG-1", 1.0),
789 (1, "ROT-1", 1.0),
790 (2, "SHAFT-1", 1.0),
791 (2, "M6x20", 1.0),
792 (1, "M6x20", 3.0),
793 ]
794 );
795 assert_eq!(t.rows[1].name, "Rotor assembly");
796 assert_eq!(
797 t.rows.iter().map(|r| r.line).collect::<Vec<_>>(),
798 vec![2, 3, 4, 5, 6]
799 );
800 assert!(t.warnings.is_empty());
801 assert_eq!(t.columns.len(), 4);
802 }
803
804 #[test]
805 fn shared_sub_assembly_is_listed_once() {
806 let data = [
807 product(1, "TOP", "Top"),
808 product(2, "LEFT", "Left"),
809 product(3, "RIGHT", "Right"),
810 product(4, "WHEEL", "Wheel"),
811 product(5, "HUB", "Hub"),
812 usage(101, 1, 2),
813 usage(102, 1, 3),
814 usage(103, 2, 4),
815 usage(104, 3, 4),
816 usage(105, 4, 5),
817 ]
818 .concat();
819 let t = read(&step(&data)).unwrap();
820 assert_eq!(
821 flat(&t),
822 vec![
823 (1, "LEFT", 1.0),
824 (2, "WHEEL", 1.0),
825 (3, "HUB", 1.0),
826 (1, "RIGHT", 1.0),
827 (2, "WHEEL", 1.0),
828 ]
829 );
830 }
831
832 #[test]
833 fn several_top_level_assemblies() {
834 let data = [
835 product(1, "A", "A"),
836 product(2, "B", "B"),
837 product(3, "P", "P"),
838 usage(101, 1, 3),
839 usage(102, 2, 3),
840 ]
841 .concat();
842 let t = read(&step(&data)).unwrap();
843 assert!(t.root.is_none());
844 assert_eq!(
845 flat(&t),
846 vec![(1, "A", 1.0), (2, "P", 1.0), (1, "B", 1.0), (2, "P", 1.0)]
847 );
848 assert!(t.warnings[0].contains("2 top-level assemblies"));
849 }
850
851 #[test]
852 fn single_parts_and_other_files_are_refused() {
853 let part = read(&step(&product(1, "BRACKET", "Bracket"))).unwrap_err();
854 assert!(part.contains("No assembly structure"), "{part}");
855 let fixture = std::fs::read("tests/fixtures/cube.step").unwrap();
856 assert!(read(&fixture).is_err());
857 assert!(read(b"solid cube\nendsolid\n")
858 .unwrap_err()
859 .contains("Not a STEP"));
860 let circular = [
861 product(1, "A", "A"),
862 product(2, "B", "B"),
863 usage(101, 1, 2),
864 usage(102, 2, 1),
865 ]
866 .concat();
867 assert!(read(&step(&circular)).unwrap_err().contains("circular"));
868 }
869
870 #[test]
871 fn strings_comments_and_escapes() {
872 let data = "/* exported; with comments */\n\
873 #11=PRODUCT('M\\X2\\00DC\\X0\\LLER-1','Gr\\S\\|n''s part; \\X\\E4','',(#9));\n\
874 #12=PRODUCT_DEFINITION_FORMATION('','',#11);\n\
875 #13=PRODUCT_DEFINITION('design','' /* note */,#12,#8);\n\
876 #21=PRODUCT('X','Box','A box',(#9));\n\
877 #22=PRODUCT_DEFINITION_FORMATION('','',#21);\n\
878 #23=PRODUCT_DEFINITION('design','',#22,#8);\n\
879 #30=(GEOMETRIC_REPRESENTATION_CONTEXT(3)REPRESENTATION_CONTEXT('',''));\n\
880 #31=NEXT_ASSEMBLY_USAGE_OCCURRENCE('1','','',#23,#13,$);\n";
881 let t = read(&step(data)).unwrap();
882 let root = t.root.unwrap();
883 assert_eq!(
884 (root.number.as_str(), root.description.as_str()),
885 ("X", "A box")
886 );
887 assert_eq!(t.rows[0].number, "MÜLLER-1");
888 assert_eq!(t.rows[0].name, "Grün's part; ä");
889 }
890
891 #[test]
892 fn decoding() {
893 assert_eq!(decode("a\\\\b"), "a\\b");
894 assert_eq!(decode("\\X2\\03B103B2\\X0\\!"), "αβ!");
895 assert_eq!(decode("\\X4\\0001F600\\X0\\"), "😀");
896 assert_eq!(decode("\\PA\\x\\N\\y"), "x\ny");
897 assert_eq!(decode("50\\%"), "50\\%");
898 assert!(is_step("Pump.STEP") && is_step("a.stp") && !is_step("a.stl"));
899 assert_eq!(
900 params("'a',#5,(#6,#7),.T.,LENGTH_MEASURE(2.5),$,'x(y'"),
901 vec![
902 Param::Str("a".into()),
903 Param::Ref(5),
904 Param::List(vec![Param::Ref(6), Param::Ref(7)]),
905 Param::Other,
906 Param::Other,
907 Param::Other,
908 Param::Str("x(y".into()),
909 ]
910 );
911 }
912
913 #[test]
914 fn unresolved_occurrences_are_reported_and_ignored() {
915 let data = [
916 product(1, "TOP", "Top"),
917 product(2, "P", "Part"),
918 usage(101, 1, 2),
919 usage(102, 1, 7), usage(103, 8, 2), ]
922 .concat();
923 let t = read(&step(&data)).unwrap();
924 assert_eq!(flat(&t), vec![(1, "P", 1.0)]);
925 assert_eq!(t.warnings.len(), 1);
926 assert!(
927 t.warnings[0].starts_with("2 assembly occurrences"),
928 "{:?}",
929 t.warnings
930 );
931 }
932
933 #[test]
934 fn byte_order_mark_lower_case_keywords_and_plain_formations() {
935 let data = "#11=product('ASM','Assembly','',(#9));\n\
939 #12=PRODUCT_DEFINITION_FORMATION('','',#11);\n\
940 #13=PRODUCT_DEFINITION_WITH_ASSOCIATED_DOCUMENTS('d','',#12,#8,());\n\
941 #21=PRODUCT('PRT','Part','',(#9));\n\
942 #22=product_definition_formation_with_specified_source('','',#21,.MADE.);\n\
943 #23=product_definition('design','',#22,#8);\n\
944 #31=next_assembly_usage_occurrence('1','','',#13,#23,$);\n";
945 let mut bytes = "\u{feff}\n\n".as_bytes().to_vec();
946 bytes.extend(step(data));
947 let t = read(&bytes).unwrap();
948 assert_eq!(t.root.as_ref().unwrap().number, "ASM");
949 assert_eq!(flat(&t), vec![(1, "PRT", 1.0)]);
950 }
951
952 #[test]
953 fn names_and_descriptions() {
954 let data = "#11=PRODUCT('ASM','','ASM',(#9));\n\
955 #12=PRODUCT_DEFINITION_FORMATION('','',#11);\n\
956 #13=PRODUCT_DEFINITION('design','',#12,#8);\n\
957 #21=PRODUCT(' P-1 ',' Plate ',' Plate ',(#9));\n\
958 #22=PRODUCT_DEFINITION_FORMATION('','',#21);\n\
959 #23=PRODUCT_DEFINITION('design','',#22,#8);\n\
960 #31=NEXT_ASSEMBLY_USAGE_OCCURRENCE('1','','',#13,#23,$);\n";
961 let t = read(&step(data)).unwrap();
962 let root = t.root.unwrap();
963 assert_eq!((root.name.as_str(), root.description.as_str()), ("ASM", ""));
966 let p = &t.rows[0];
967 assert_eq!(
968 (p.number.as_str(), p.name.as_str(), p.description.as_str()),
969 ("P-1", "Plate", "")
970 );
971 assert_eq!(
972 (p.find_number, p.unit.as_str(), p.mass_kg),
973 (None, "", None)
974 );
975 }
976
977 #[test]
978 fn a_component_that_contains_itself_is_skipped() {
979 let data = [
981 product(1, "TOP", "Top"),
982 product(2, "A", "A"),
983 product(3, "B", "B"),
984 usage(101, 1, 2),
985 usage(102, 2, 3),
986 usage(103, 3, 2),
987 ]
988 .concat();
989 let t = read(&step(&data)).unwrap();
990 assert_eq!(flat(&t), vec![(1, "A", 1.0), (2, "B", 1.0)]);
991 assert!(
992 t.warnings
993 .iter()
994 .any(|w| w.contains("A would contain itself")),
995 "{:?}",
996 t.warnings
997 );
998 }
999
1000 #[test]
1001 fn very_deep_structures_are_cut() {
1002 let n = (MAX_DEPTH + 5) as u32;
1004 let mut data = String::new();
1005 for i in 1..=n + 1 {
1006 data.push_str(&product(i, &format!("L{i}"), "Level"));
1007 }
1008 for i in 1..=n {
1009 data.push_str(&usage(100_000 + i, i, i + 1));
1010 }
1011 let t = read(&step(&data)).unwrap();
1012 assert_eq!(t.rows.len(), MAX_DEPTH);
1013 assert_eq!(t.rows.last().unwrap().level, MAX_DEPTH);
1014 assert!(t.warnings.iter().any(|w| w.contains("deeper than")));
1015 }
1016
1017 #[test]
1018 fn more_decoding() {
1019 assert_eq!(decode("Gr\\S\\|n"), "Grün", "\\S\\ adds 128");
1020 assert_eq!(decode("\\X\\C4rger"), "Ärger");
1021 assert_eq!(decode("tab\\T\\end"), "tab\tend");
1022 assert_eq!(decode("\\X2\\00E4"), "ä", "missing \\X0\\ terminator");
1023 assert_eq!(decode("plain"), "plain");
1024 assert_eq!(decode("trailing\\"), "trailing\\");
1025 assert!(is_step("asm.P21") && !is_step("step") && !is_step("a.step.zip"));
1026 }
1027
1028 #[test]
1029 fn deeply_nested_parameters_do_not_overflow_the_stack() {
1030 let n = 200_000;
1031 let body = format!("'a',{}{},#7", "(".repeat(n), ")".repeat(n));
1032 let p = params(&body);
1033 assert_eq!(p.len(), 3);
1034 assert_eq!(p[0], Param::Str("a".into()));
1035 assert_eq!(p[2], Param::Ref(7));
1036 let mut level = &p[1];
1038 let mut depth = 0;
1039 while let Param::List(inner) = level {
1040 depth += 1;
1041 level = &inner[0];
1042 }
1043 assert_eq!((depth, level), (MAX_NESTING, &Param::Other));
1044
1045 let file = format!(
1046 "{}#1=PRODUCT({}{});\n",
1047 product(1, "A", "A"),
1048 "(".repeat(n),
1049 ")".repeat(n)
1050 );
1051 assert!(read(&step(&file)).is_err());
1052 }
1053
1054 #[test]
1057 fn comments_and_stray_slashes_between_parameters() {
1058 assert_eq!(
1059 params("#1, /* the part */ #2,'x'"),
1060 [Param::Ref(1), Param::Ref(2), Param::Str("x".into())]
1061 );
1062 assert_eq!(params("/x,#5"), [Param::Other, Param::Ref(5)]);
1063 assert_eq!(
1065 params("NAME('x)y'),LABEL('a,b'),#7"),
1066 [Param::Other, Param::Other, Param::Ref(7)]
1067 );
1068 }
1069
1070 #[test]
1074 fn sibling_lists_do_not_count_as_nesting() {
1075 let siblings: Vec<String> = (1..=MAX_NESTING * 3).map(|i| format!("(#{i})")).collect();
1076 let parsed = params(&siblings.join(","));
1077 assert_eq!(parsed.len(), MAX_NESTING * 3);
1078 assert!(parsed
1079 .iter()
1080 .all(|p| matches!(p, Param::List(l) if l.len() == 1)));
1081 }
1082
1083 #[test]
1086 fn instances_with_unset_parameters_comments_and_quotes() {
1087 let text = "#1=PRODUCT('a;b','',(#2),*);\n/* a comment; with #9=X(); */\
1088 #2=PRODUCT_CONTEXT('it''s',*,'mechanical');#3=X(1.5/2.);";
1089 let mut seen = Vec::new();
1090 for_each_instance(text, |id, keyword, _| seen.push((id, keyword.to_string())));
1091 assert_eq!(
1092 seen,
1093 [
1094 (1, "PRODUCT".to_string()),
1095 (2, "PRODUCT_CONTEXT".to_string()),
1096 (3, "X".to_string())
1097 ]
1098 );
1099 }
1100
1101 fn external(doc: u32, name: &str, link: &str) -> String {
1103 format!("#{doc}=DOCUMENT_FILE('{name}','',#7,'','','');\n{link}\n")
1104 }
1105
1106 #[test]
1107 fn external_references_count_their_occurrences() {
1108 let mut data =
1111 product(1, "PUMP", "Pump") + &product(2, "B", "Bolt") + &product(3, "N", "Nut");
1112 data += &external(
1113 900,
1114 "C:\\parts\\bolt.stp",
1115 "#901=APPLIED_DOCUMENT_REFERENCE(#900,'',(#23));",
1116 );
1117 data += &external(
1118 910,
1119 "nut.stp",
1120 "#911=DOCUMENT_PRODUCT_EQUIVALENCE('','',#910,#31);",
1121 );
1122 for (i, child) in [2, 2, 2, 2, 3, 3].into_iter().enumerate() {
1123 data += &usage(100 + i as u32, 1, child);
1124 }
1125 let counts = external_instance_counts(&step(&data)).unwrap();
1126 assert_eq!(counts.get("bolt.stp"), Some(&4));
1127 assert_eq!(counts.get("nut.stp"), Some(&2));
1128 assert_eq!(counts.len(), 2);
1129 }
1130
1131 #[test]
1132 fn inline_sub_assemblies_multiply_and_associated_documents_link() {
1133 let mut data = product(1, "PUMP", "Pump") + &product(2, "F", "Frame");
1136 data += "#31=PRODUCT('S','Screw','',(#9));\n\
1137 #32=PRODUCT_DEFINITION_FORMATION('','',#31);\n\
1138 #33=PRODUCT_DEFINITION_WITH_ASSOCIATED_DOCUMENTS('design','',#32,#8,(#920));\n";
1139 data += "#920=DOCUMENT_FILE('','',#7,'','','');\n\
1140 #921=APPLIED_EXTERNAL_IDENTIFICATION_ASSIGNMENT('screw.step',$,#6,(#920));\n";
1141 data += &usage(100, 1, 2);
1142 data += &usage(101, 1, 2);
1143 for i in 0..3 {
1144 data += &usage(110 + i, 2, 3);
1145 }
1146 let counts = external_instance_counts(&step(&data)).unwrap();
1147 assert_eq!(counts, HashMap::from([("screw.step".to_string(), 6)]));
1148 }
1149
1150 #[test]
1151 fn monolithic_files_and_other_bytes_have_no_counts() {
1152 let data = product(1, "PUMP", "Pump") + &product(2, "B", "Bolt") + &usage(100, 1, 2);
1153 assert_eq!(external_instance_counts(&step(&data)), None);
1154 assert_eq!(external_instance_counts(b"\x00bolt.stp\x00"), None);
1155 let doc = product(2, "B", "Bolt")
1157 + &external(
1158 900,
1159 "bolt.stp",
1160 "#901=APPLIED_DOCUMENT_REFERENCE(#900,'',(#23));",
1161 );
1162 assert_eq!(external_instance_counts(&step(&doc)), None);
1163 }
1164}