Skip to main content

cadmpeg_codec_nx/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Read Siemens NX `.prt` files into [`cadmpeg_ir::document::CadIr`].
3//!
4//! The codec recognizes the `SPLMSSTR` container signature, extracts compressed
5//! Parasolid neutral-binary streams from the canonical part payload, and decodes
6//! supported geometry and topology. Detection uses file content because NX and
7//! Creo share the `.prt` extension.
8//!
9//! Support level: [L4](https://github.com/cadmpeg/cadmpeg/blob/main/docs/format-support.md#support-ladder)
10//! for single-body, `RMFastLoad`-selected, and terminal-lineage-resolved body
11//! images; L2 for unresolved multi-partition history.
12//!
13//! # Decode a part
14//!
15//! ```no_run
16//! use std::fs::File;
17//!
18//! use cadmpeg_codec_nx::NxCodec;
19//! use cadmpeg_ir::{Codec, CodecEntry, DecodeOptions};
20//!
21//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
22//! let mut input = File::open("part.prt")?;
23//! let result = NxCodec.decode(&mut input, &DecodeOptions::default())?;
24//!
25//! println!("{} bodies", result.ir.model.bodies.len());
26//! for loss in &result.report.losses {
27//!     println!("{:?}: {}", loss.severity, loss.message);
28//! }
29//! # Ok(())
30//! # }
31//! ```
32//!
33//! [`CodecEntry::inspect`](cadmpeg_ir::CodecEntry::inspect) returns the SPLMSSTR directory and embedded-stream
34//! classifications without decoding entities. `DecodeOptions::container_only`
35//! produces metadata IR and skips entity decode.
36//!
37//! # Model and loss boundaries
38//!
39//! NX stores part geometry in zlib-compressed Parasolid partition, deltas, or
40//! plain streams. The decoder converts Parasolid metre values to millimetres and
41//! emits points; analytic curves and surfaces; NURBS curves and surfaces;
42//! selected trimmed curves; and resolvable body, region, shell, face, loop,
43//! coedge, edge, and vertex topology. Each inflated Parasolid stream is also
44//! retained as an unknown record.
45//!
46//! Read [`cadmpeg_ir::report::DecodeReport`] before using the model as a complete
47//! representation. Deltas streams pair with the preceding equal-schema partition
48//! in validated `UG_PART` segment order and apply
49//! supported non-topology full records and exact-key tombstones using the last
50//! event for each key. Valid partition topology remains authoritative. Unmatched
51//! tombstone relations remain unresolved. Segment body aliases, primary-body
52//! writers, and Boolean tool operands select terminal partition images when the
53//! complete body lineage is unambiguous. Assembly files may contain only
54//! references to external child parts.
55//!
56//! Ordered feature-operation records, body dependencies, Boolean operations,
57//! sketch record lanes, and numeric expressions transfer from the NX object
58//! model. Operation suppression remains unresolved instead of being asserted
59//! active. Embedded JT coordinates and triangle connectivity transfer as canonical
60//! tessellations. Complete design history, assembly occurrence placement, material
61//! and appearance assignment, class-specific entity attribute fields, and `.prt`
62//! writing are not supported.
63//! Part attributes transfer as document attributes. The public submodules
64//! expose the lower-level container, stream, geometry, NURBS, intersection, and
65//! topology decoders. The object-model extraction and attachment tier (record
66//! families, feature semantics, and IR writing) is crate-internal and reached
67//! only through the decode entry point. Applications that need a complete IR
68//! entry point should use [`NxCodec`].
69
70pub mod container;
71pub mod decode;
72pub mod deltas;
73pub mod geometry;
74pub mod intersection;
75mod jt;
76mod jt_topology;
77pub(crate) mod native;
78pub mod nurbs;
79pub mod om;
80pub mod om_tokens;
81pub mod parasolid;
82pub mod topology;
83
84use std::collections::BTreeMap;
85
86use cadmpeg_ir::codec::{
87    Codec, CodecError, Confidence, ContainerEntry, ContainerSummary, DecodeResult,
88};
89use cadmpeg_ir::decode::{DecodeContext, View};
90
91/// Decoder and inspector for Siemens NX `.prt` files.
92#[derive(Debug, Default, Clone, Copy)]
93pub struct NxCodec;
94
95impl Codec for NxCodec {
96    fn id(&self) -> &'static str {
97        "nx"
98    }
99
100    fn detect(&self, prefix: &[u8]) -> Confidence {
101        if container::looks_like_nx(prefix) {
102            Confidence::High
103        } else {
104            Confidence::No
105        }
106    }
107
108    fn inspect_impl(
109        &self,
110        ctx: &DecodeContext<'_>,
111        root: View<'_>,
112    ) -> Result<ContainerSummary, CodecError> {
113        let scan = decode::scan(ctx, root)?;
114        Ok(summarize(&scan))
115    }
116
117    fn decode_impl(
118        &self,
119        ctx: &DecodeContext<'_>,
120        root: View<'_>,
121    ) -> Result<DecodeResult, CodecError> {
122        decode::decode(ctx, root)
123    }
124}
125
126/// Build the container summary: one entry per catalogued directory stream, plus
127/// one per embedded Parasolid stream, and the shared container notes.
128fn summarize(scan: &decode::Scan) -> ContainerSummary {
129    let mut entries = Vec::new();
130    let semantic_streams = native::topology_streams(scan);
131
132    for entry in &scan.container.entries {
133        let mut attributes = BTreeMap::new();
134        attributes.insert("region".to_string(), entry.region.label().to_string());
135        let (compressed, uncompressed) = match entry.file_span {
136            Some((off, size)) => {
137                attributes.insert("file_offset".to_string(), off.to_string());
138                (size, size)
139            }
140            None => {
141                attributes.insert("kind".to_string(), "directory".to_string());
142                (0, 0)
143            }
144        };
145        entries.push(ContainerEntry {
146            name: entry.name.clone(),
147            role: "stream".to_string(),
148            compression: "none".to_string(),
149            compressed_size: compressed,
150            uncompressed_size: uncompressed,
151            attributes,
152        });
153    }
154
155    for (si, stream) in scan.streams.iter().enumerate() {
156        let mut attributes = BTreeMap::new();
157        attributes.insert("file_offset".to_string(), stream.file_offset.to_string());
158        attributes.insert("kind".to_string(), stream.kind.label().to_string());
159        if let Some(schema) = &stream.schema {
160            attributes.insert("schema".to_string(), schema.clone());
161        }
162        if stream.kind.is_parasolid() {
163            let graph = topology::Graph::parse(&stream.inflated);
164            for (kind, name) in [
165                (12, "body"),
166                (13, "shell"),
167                (14, "face"),
168                (15, "loop"),
169                (16, "edge"),
170                (17, "fin"),
171                (18, "vertex"),
172                (19, "region"),
173            ] {
174                attributes.insert(
175                    format!("records.{name}"),
176                    graph.of_kind(kind).count().to_string(),
177                );
178            }
179            if stream.kind == parasolid::StreamKind::Partition {
180                let graph = topology::Graph::parse(&semantic_streams[si]);
181                for (kind, name) in [
182                    (12, "body"),
183                    (13, "shell"),
184                    (14, "face"),
185                    (15, "loop"),
186                    (16, "edge"),
187                    (17, "fin"),
188                    (18, "vertex"),
189                    (19, "region"),
190                ] {
191                    attributes.insert(
192                        format!("records.live.{name}"),
193                        graph.of_kind(kind).count().to_string(),
194                    );
195                }
196            } else if stream.kind == parasolid::StreamKind::Deltas {
197                let census = deltas::walk(&stream.inflated);
198                for (family, count) in census.full_counts {
199                    attributes.insert(
200                        format!("records.delta.full.{}", family.to_ascii_lowercase()),
201                        count.to_string(),
202                    );
203                }
204                for (family, count) in census.tombstone_counts {
205                    attributes.insert(
206                        format!("records.delta.tombstone.{}", family.to_ascii_lowercase()),
207                        count.to_string(),
208                    );
209                }
210            }
211        }
212        entries.push(ContainerEntry {
213            name: format!("parasolid#{si}"),
214            role: if stream.kind.is_parasolid() {
215                "parasolid-stream".to_string()
216            } else {
217                "preview".to_string()
218            },
219            compression: "zlib".to_string(),
220            compressed_size: 0,
221            uncompressed_size: stream.inflated.len() as u64,
222            attributes,
223        });
224    }
225
226    ContainerSummary {
227        format: "nx".to_string(),
228        container_kind: "splmsstr".to_string(),
229        entries,
230        notes: decode::summary_notes(scan),
231    }
232}
233
234#[cfg(test)]
235pub(crate) mod test_support;
236#[cfg(test)]
237mod tests;