Skip to main content

teamcraft/mesh/creo/
asm.rs

1//! Creo assemblies (`.asm.N`): the components and their placements, read
2//! from section `NeuAsmSld` (`docs/src/research/creo-assembly-preview.md`).
3//!
4//! An assembly has no geometry of its own: `services::assemblies` finds each
5//! component file by name (`<name>.prt` / `<name>.asm`; TeamCraft stores Creo
6//! files without the iteration suffix) and puts the parts' previews
7//! together. Only the direct components are listed; a sub-assembly is read
8//! from its own file.
9
10use std::io::{self, Read};
11use std::path::Path;
12
13use anyhow::{anyhow, bail, Result};
14
15use super::{container, tagged};
16use crate::nxasm::{Component, Kind};
17
18/// The section with the component list.
19pub const COMPONENTS: &str = "NeuAsmSld";
20/// Largest assembly file read (the samples have 0.1–0.4 MiB).
21const MAX_FILE: u64 = 64 * 1024 * 1024;
22/// Components read from one assembly at most.
23const MAX_COMPONENTS: usize = 100_000;
24/// Longest model name taken (Creo allows 31, newer versions 80 characters).
25const MAX_NAME: usize = 255;
26
27/// The direct components of a Creo assembly (bytes of the whole file),
28/// placed in the assembly's frame, in millimetres.
29pub fn components(data: &[u8]) -> Result<Vec<Component>> {
30    if !container::is_creo(data) {
31        bail!("not a Creo file (the file does not start with '#UGC:')");
32    }
33    let toc = container::toc(data)?;
34    let section = container::section(data, &toc, COMPONENTS)?
35        .ok_or_else(|| anyhow!("The Creo file has no component list ({COMPONENTS})."))?;
36    let tree = tagged::parse(&section)?;
37    let mm = super::unit_mm(data, &toc);
38    let root = tree.root();
39    let top = tree.child(root, "neut_assem").unwrap_or(root);
40    let Some(members) = tree.child(top, "members") else {
41        return Ok(Vec::new());
42    };
43    let mut out = Vec::new();
44    for &m in tree.list(members).iter().take(MAX_COMPONENTS) {
45        let Some(name) = tree.str_at(m, "name").filter(|n| is_model_name(n)) else {
46            continue;
47        };
48        // 2: a part, 1: an assembly (skeleton assemblies too)
49        let ext = match tree.int_at(m, "type") {
50            Some(2) => "prt",
51            Some(1) => "asm",
52            _ => continue,
53        };
54        let vector = |field: &str| -> Option<[f64; 3]> {
55            let v = tree.dbls(tree.child(m, field)?);
56            <[f64; 3]>::try_from(v.as_slice())
57                .ok()
58                .filter(|v| v.iter().all(|x| x.is_finite()))
59        };
60        let (Some(e1), Some(e2), Some(e3), Some(origin)) =
61            (vector("e1"), vector("e2"), vector("e3"), vector("origin"))
62        else {
63            continue;
64        };
65        out.push(Component {
66            name: format!("{name}.{ext}"),
67            // e1–e3 are the images of the component's axes: p lands at
68            // origin + p.x·e1 + p.y·e2 + p.z·e3, the row convention
69            rotation: [e1, e2, e3],
70            translation: origin.map(|x| x * mm),
71            layer: None,
72            occurrence: None,
73            document: None,
74        });
75    }
76    Ok(out)
77}
78
79/// A model name as Creo writes it: no path, no control characters.
80fn is_model_name(name: &str) -> bool {
81    !name.is_empty()
82        && name.len() <= MAX_NAME
83        && !name
84            .chars()
85            .any(|c| c.is_control() || matches!(c, '/' | '\\' | ':'))
86}
87
88/// [`components`] of a file: an assembly when it lists any.
89pub fn kind_of(path: &Path) -> io::Result<Kind> {
90    let mut data = Vec::new();
91    std::fs::File::open(path)?
92        .take(MAX_FILE)
93        .read_to_end(&mut data)?;
94    Ok(match components(&data) {
95        Ok(c) if !c.is_empty() => Kind::Assembly(c),
96        _ => Kind::Other,
97    })
98}
99
100/// A member of [`sample`]: model name, sub-assembly (else part), axes
101/// `e1`–`e3` and origin in the assembly's unit.
102pub type SampleMember<'a> = (&'a str, bool, [[f64; 3]; 3], [f64; 3]);
103
104/// A Creo assembly reduced to what [`components`] reads: the container with
105/// a `NeuAsmSld` section for the members and a `UnitSystemDef` section
106/// for the length unit (`"mm"`, `"in"`, …). For tests and the fuzz corpus.
107#[doc(hidden)]
108pub fn sample(members: &[SampleMember], unit: &str) -> Vec<u8> {
109    let mut w = tagged::Writer::new("neut_assem");
110    w.begin("assem_top")
111        .int("model_class", 0)
112        .end()
113        .begin_array("members", members.len());
114    for (k, (name, assembly, axes, origin)) in members.iter().enumerate() {
115        let [e1, e2, e3] = axes;
116        w.element("members")
117            .int("id", 40 + k as i64)
118            .int("type", if *assembly { 1 } else { 2 })
119            .string("name", name)
120            .string("family_name", name)
121            .dbls("e1", e1)
122            .dbls("e2", e2)
123            .dbls("e3", e3)
124            .dbls("origin", origin)
125            .end();
126    }
127    if !members.is_empty() {
128        w.end();
129    }
130    let structure = w.finish();
131    let units = unit_section(unit);
132    sample_file(
133        "ASSEMBLY",
134        &[
135            (super::UNIT_SYSTEM, units.as_slice()),
136            (COMPONENTS, structure.as_slice()),
137        ],
138    )
139}
140
141/// A `UnitSystemDef` section naming `unit` as the length unit, laid out like
142/// Creo's: the names in order of first use, the length unit after `Offset`.
143#[doc(hidden)]
144pub fn unit_section(unit: &str) -> Vec<u8> {
145    let mut w = tagged::Writer::new("nd_unitsystemdef_l03");
146    w.int("format", 0).strings(
147        "string_xar",
148        &[
149            "nd_unitsystemdef_l03::UnitSystemDef_L03",
150            "Unit_Length",
151            "Length",
152            "Name",
153            "length",
154            "System",
155            "Scale",
156            "Offset",
157            unit,
158            "Expression",
159        ],
160    );
161    w.finish()
162}
163
164/// A Creo container (`#UGC:2 <kind> …`, table of contents, uncompressed
165/// sections) around `sections`. For tests and the fuzz corpus.
166#[doc(hidden)]
167pub fn sample_file(kind: &str, sections: &[(&str, &[u8])]) -> Vec<u8> {
168    const ENTRY: usize = 81;
169    let header = format!("#UGC:2 {kind} 1966 1560 800 1 1 15 3800 2020281 0 \\\n");
170    let toc_head = format!("#UGC_TOC 1 {} {ENTRY}\n", sections.len());
171    let mut offset = header.len() + toc_head.len() + ENTRY * sections.len();
172    let mut table = Vec::new();
173    let mut bodies = Vec::new();
174    for (name, body) in sections {
175        let line = format!("#{name}\n");
176        let size = line.len() + body.len();
177        let mut rec = format!("{name} {offset:x} {size:x} {:x} 1966 9 -1", body.len());
178        rec.truncate(ENTRY - 1);
179        while rec.len() < ENTRY - 1 {
180            rec.push(' ');
181        }
182        rec.push('\n');
183        table.extend(rec.into_bytes());
184        bodies.extend(line.into_bytes());
185        bodies.extend(*body);
186        offset += size;
187    }
188    let mut out = header.into_bytes();
189    out.extend(toc_head.into_bytes());
190    out.extend(table);
191    out.extend(bodies);
192    out
193}
194
195#[cfg(test)]
196#[allow(clippy::indexing_slicing)]
197mod tests {
198    use super::*;
199
200    const IDENTITY: [[f64; 3]; 3] = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
201    /// A quarter turn about z: x along y, y along -x.
202    const QUARTER: [[f64; 3]; 3] = [[0.0, 1.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 0.0, 1.0]];
203
204    #[test]
205    fn members_become_components_with_their_files_and_placements() {
206        let data = sample(
207            &[
208                ("MS_TIP", false, IDENTITY, [0.0; 3]),
209                ("MS_MID", false, QUARTER, [34.5, 0.0, -2.0]),
210                ("BODY_SUB", true, IDENTITY, [0.0, 10.0, 0.0]),
211            ],
212            "mm",
213        );
214        let c = components(&data).unwrap();
215        let names: Vec<_> = c.iter().map(|c| c.name.as_str()).collect();
216        assert_eq!(names, ["MS_TIP.prt", "MS_MID.prt", "BODY_SUB.asm"]);
217        // the component's x axis lands on the assembly's y axis
218        assert_eq!(c[1].place([1.0, 0.0, 0.0]), [34.5, 1.0, -2.0]);
219        assert_eq!(c[1].place([0.0, 2.0, 0.0]), [32.5, 0.0, -2.0]);
220        assert_eq!(c[2].translation, [0.0, 10.0, 0.0]);
221    }
222
223    #[test]
224    fn placements_are_converted_to_millimetres() {
225        let data = sample(&[("BOLT", false, IDENTITY, [1.0, 2.0, 0.5])], "in");
226        let c = components(&data).unwrap();
227        assert_eq!(c[0].translation, [25.4, 50.8, 12.7]);
228        let toc = container::toc(&data).unwrap();
229        assert_eq!(super::super::unit_mm(&data, &toc), 25.4);
230        // no unit section: millimetres
231        let bare = sample_file("ASSEMBLY", &[]);
232        assert_eq!(super::super::unit_mm(&bare, &[]), 1.0);
233    }
234
235    #[test]
236    fn unusable_members_are_skipped() {
237        let mut w = tagged::Writer::new("neut_assem");
238        w.begin_array("members", 4);
239        // a datum (type 3), a path as name, missing axes, one good part
240        for (ty, name, axes) in [
241            (3, "ASM_DEF_CSYS", true),
242            (2, "C:\\x", true),
243            (2, "NO_AXES", false),
244            (2, "GOOD", true),
245        ] {
246            w.element("members").int("type", ty).string("name", name);
247            if axes {
248                w.dbls("e1", &[1.0, 0.0, 0.0])
249                    .dbls("e2", &[0.0, 1.0, 0.0])
250                    .dbls("e3", &[0.0, 0.0, 1.0]);
251            }
252            w.dbls("origin", &[0.0; 3]).end();
253        }
254        w.end();
255        let data = sample_file("ASSEMBLY", &[(COMPONENTS, w.finish().as_slice())]);
256        let c = components(&data).unwrap();
257        assert_eq!(c.len(), 1);
258        assert_eq!(c[0].name, "GOOD.prt");
259    }
260
261    #[test]
262    fn other_files_and_damage_are_errors_not_panics() {
263        assert!(components(b"solid stl").is_err());
264        assert!(components(&[]).is_err());
265        // a part has no component list
266        assert!(components(&sample_file("PART", &[])).is_err());
267        let data = sample(
268            &[
269                ("A", false, IDENTITY, [1.0; 3]),
270                ("B", true, QUARTER, [2.0; 3]),
271            ],
272            "in",
273        );
274        for cut in 0..data.len() {
275            let _ = components(&data[..cut]);
276        }
277        let mut damaged = data.clone();
278        for i in (0..damaged.len()).step_by(7) {
279            damaged[i] ^= 0x5a;
280            let _ = components(&damaged);
281        }
282    }
283
284    #[test]
285    fn kind_of_reads_the_file() {
286        let dir = std::env::temp_dir().join(format!("creo-asm-{}", std::process::id()));
287        std::fs::create_dir_all(&dir).unwrap();
288        let asm = dir.join("frame.asm");
289        std::fs::write(&asm, sample(&[("A", false, IDENTITY, [0.0; 3])], "mm")).unwrap();
290        assert!(matches!(kind_of(&asm).unwrap(), Kind::Assembly(c) if c.len() == 1));
291        let empty = dir.join("empty.asm");
292        std::fs::write(&empty, sample(&[], "mm")).unwrap();
293        assert!(matches!(kind_of(&empty).unwrap(), Kind::Other));
294        std::fs::remove_dir_all(&dir).unwrap();
295    }
296}