Skip to main content

cadmpeg_codec_step/
codec.rs

1// SPDX-License-Identifier: Apache-2.0
2//! STEP codec backend and encoder.
3
4use std::collections::BTreeMap;
5
6use cadmpeg_core::{CodecError, ContainerEntry, ContainerSummary};
7use cadmpeg_ir::codec::{
8    CodecBackend, Confidence, DecodeOptions, DecodeResult, EncodeInput, Encoder, ExportPlan,
9};
10use cadmpeg_ir::FidelityResolution;
11
12use crate::archive;
13use crate::export::write_step;
14use crate::options::{StepSchema, StepWriteOptions};
15use crate::parse;
16use crate::reader;
17
18/// STEP encoder with per-export header options.
19#[derive(Debug, Clone, Default)]
20pub struct StepCodec {
21    /// Header metadata and deterministic writer options.
22    pub options: StepWriteOptions,
23}
24
25impl Encoder for StepCodec {
26    fn id(&self) -> &'static str {
27        "step"
28    }
29
30    fn plan<'a>(&self, input: EncodeInput<'a>) -> Result<ExportPlan<'a>, CodecError> {
31        let mut bytes = Vec::new();
32        let mut report =
33            write_step(input.ir, &mut bytes, &self.options).map_err(CodecError::from)?;
34        // `write_step` takes no fidelity sidecar, so the report it returns
35        // states the only resolution it can see. Whether the caller supplied
36        // one is known here, and only here.
37        report.fidelity = if input.fidelity.is_some() {
38            FidelityResolution::NotConsumed
39        } else {
40            FidelityResolution::NotProvided
41        };
42        Ok(ExportPlan::buffered(report, bytes))
43    }
44}
45
46impl CodecBackend for StepCodec {
47    fn id(&self) -> &'static str {
48        "step"
49    }
50
51    fn detect(&self, prefix: &[u8]) -> Confidence {
52        if starts_with_step_magic(prefix) {
53            Confidence::High
54        } else if archive::has_root_marker(prefix) || is_part28_xml(prefix) {
55            Confidence::Medium
56        } else {
57            Confidence::No
58        }
59    }
60
61    /// Deep semantic analysis of a STEP exchange, exposed as container inspect.
62    ///
63    /// Runs the semantic decode path to populate `unknown_entities` and related
64    /// attributes. Not a cheap syntactic census; see
65    /// `docs/formats/step-inspect.md`.
66    fn inspect_impl(
67        &self,
68        ctx: &cadmpeg_core::decode::DecodeContext<'_>,
69        root: cadmpeg_core::decode::View<'_>,
70    ) -> Result<ContainerSummary, CodecError> {
71        let bytes = root.window();
72        if archive::has_zip_magic(bytes) {
73            return inspect_zip(ctx, root);
74        }
75        refuse_alternate_encoding(bytes)?;
76        if self.detect(bytes) == Confidence::No {
77            return Err(CodecError::WrongFormat("missing ISO-10303-21 magic".into()));
78        }
79        let (mut exchange, diagnostics) = parse::parse_with_context(bytes, ctx)?;
80        let (decoded, opaque_offsets) =
81            reader::analyze_exchange(bytes, &mut exchange, &diagnostics, Some(ctx))?;
82        let mut entries = vec![ContainerEntry {
83            name: "HEADER".into(),
84            role: "metadata".into(),
85            compression: "none".into(),
86            compressed_size: 0,
87            uncompressed_size: 0,
88            attributes: BTreeMap::default(),
89        }];
90        if !exchange.anchors.is_empty() {
91            let mut attributes = std::collections::BTreeMap::new();
92            attributes.insert("anchor_count".into(), exchange.anchors.len().to_string());
93            entries.push(ContainerEntry {
94                name: "ANCHOR".into(),
95                role: "in_file_anchors".into(),
96                compression: "none".into(),
97                compressed_size: 0,
98                uncompressed_size: 0,
99                attributes,
100            });
101        }
102        if !exchange.references.is_empty() {
103            let mut attributes = std::collections::BTreeMap::new();
104            attributes.insert(
105                "external_count".into(),
106                exchange.references.len().to_string(),
107            );
108            attributes.insert(
109                "external_uris".into(),
110                exchange
111                    .references
112                    .iter()
113                    .map(|entry| entry.uri.as_str())
114                    .collect::<Vec<_>>()
115                    .join(","),
116            );
117            entries.push(ContainerEntry {
118                name: "REFERENCE".into(),
119                role: "external_references".into(),
120                compression: "none".into(),
121                compressed_size: 0,
122                uncompressed_size: 0,
123                attributes,
124            });
125        }
126        for (index, section) in exchange.data.iter().enumerate() {
127            let mut counts = std::collections::BTreeMap::<String, usize>::new();
128            for id in &section.records {
129                if !opaque_offsets.contains(&exchange.records[id].span.start) {
130                    continue;
131                }
132                for partial in &exchange.records[id].partials {
133                    *counts.entry(partial.name.clone()).or_default() += 1;
134                }
135            }
136            let unknown = counts
137                .iter()
138                .map(|(name, count)| format!("{name}:{count}"))
139                .collect::<Vec<_>>()
140                .join(",");
141            let mut attributes = std::collections::BTreeMap::new();
142            attributes.insert("entity_count".into(), section.records.len().to_string());
143            attributes.insert("unknown_entities".into(), unknown);
144            entries.push(ContainerEntry {
145                name: format!("DATA[{index}]"),
146                role: "entity_records".into(),
147                compression: "none".into(),
148                compressed_size: 0,
149                uncompressed_size: 0,
150                attributes,
151            });
152        }
153        let external_dependencies = decoded
154            .report()
155            .notes
156            .iter()
157            .filter(|note| {
158                note.starts_with("external document ") || note.starts_with("external source ")
159            })
160            .cloned()
161            .collect::<Vec<_>>();
162        if !external_dependencies.is_empty() {
163            let mut attributes = std::collections::BTreeMap::new();
164            attributes.insert(
165                "dependency_count".into(),
166                external_dependencies.len().to_string(),
167            );
168            attributes.insert("dependencies".into(), external_dependencies.join(","));
169            entries.push(ContainerEntry {
170                name: "EXTERNAL_DEPENDENCIES".into(),
171                role: "external_references".into(),
172                compression: "none".into(),
173                compressed_size: 0,
174                uncompressed_size: 0,
175                attributes,
176            });
177        }
178        for (index, _) in exchange.signatures.iter().enumerate() {
179            entries.push(ContainerEntry {
180                name: if index == 0 {
181                    "SIGNATURE".into()
182                } else {
183                    format!("SIGNATURE[{index}]")
184                },
185                role: "signature".into(),
186                compression: "none".into(),
187                compressed_size: 0,
188                uncompressed_size: 0,
189                attributes: BTreeMap::default(),
190            });
191        }
192        let identifiers = reader::schema_identifiers(&exchange);
193        let schema = if identifiers.is_empty() {
194            "unspecified".into()
195        } else {
196            identifiers.join(",")
197        };
198        let edition = identifiers
199            .first()
200            .and_then(|identifier| StepSchema::ap242_edition(identifier))
201            .unwrap_or("edition unspecified");
202        let mut notes = vec![format!("schema {schema}; {edition}")];
203        notes.extend(diagnostics.into_iter().map(|diagnostic| diagnostic.message));
204        Ok(ContainerSummary {
205            format: "step".into(),
206            container_kind: "iso-10303-21-clear-text".into(),
207            entries,
208            notes,
209        })
210    }
211
212    fn decode_impl(
213        &self,
214        ctx: &cadmpeg_core::decode::DecodeContext<'_>,
215        root: cadmpeg_core::decode::View<'_>,
216    ) -> Result<DecodeResult, CodecError> {
217        let bytes = root.window();
218        if archive::has_zip_magic(bytes) {
219            return decode_zip(ctx, root);
220        }
221        refuse_alternate_encoding(bytes)?;
222        if self.detect(bytes) == Confidence::No {
223            return Err(CodecError::WrongFormat("missing ISO-10303-21 magic".into()));
224        }
225        reader::decode(
226            bytes,
227            DecodeOptions {
228                container_only: ctx.container_only(),
229                policy: *ctx.policy(),
230            },
231            ctx,
232        )
233    }
234}
235
236fn starts_with_step_magic(bytes: &[u8]) -> bool {
237    let mut at = 0;
238    loop {
239        while bytes.get(at).is_some_and(u8::is_ascii_whitespace) {
240            at += 1;
241        }
242        if bytes.get(at..at + 2) != Some(b"/*") {
243            break;
244        }
245        let Some(relative_end) = bytes[at + 2..]
246            .windows(2)
247            .position(|window| window == b"*/")
248        else {
249            return false;
250        };
251        at += relative_end + 4;
252    }
253    let magic = b"ISO-10303-21;";
254    bytes
255        .get(at..at + magic.len())
256        .is_some_and(|candidate| candidate.eq_ignore_ascii_case(magic))
257}
258
259fn inspect_zip(
260    ctx: &cadmpeg_core::decode::DecodeContext<'_>,
261    root: cadmpeg_core::decode::View<'_>,
262) -> Result<ContainerSummary, CodecError> {
263    let (archive, root_view) = archive::open_root(ctx, root)?;
264    let root_summary = StepCodec::default().inspect_impl(ctx, root_view)?;
265    let resource_notes = archive::root_reference_notes(&archive, root_view.window())?;
266    let entry_count = archive.entries().len();
267    let root_entry = archive
268        .entry(archive::ROOT_NAME)
269        .expect("validated STEP ZIP root");
270    let root_data_offset = root_entry.data_start;
271    let logical_entries = root_summary
272        .entries
273        .iter()
274        .map(|entry| entry.name.as_str())
275        .collect::<Vec<_>>()
276        .join(",");
277    let mut entries = archive.container_entries(archive::classify_entry);
278    if let Some(root_entry) = entries
279        .iter_mut()
280        .find(|entry| entry.name == archive::ROOT_NAME)
281    {
282        root_entry
283            .attributes
284            .insert("logical_sections".into(), logical_entries);
285    }
286    let mut notes = vec![
287        format!("root {}", archive::ROOT_NAME),
288        format!("archive entries={entry_count}; root data offset={root_data_offset}"),
289    ];
290    notes.extend(root_summary.notes);
291    notes.extend(resource_notes);
292    Ok(ContainerSummary {
293        format: "step".into(),
294        container_kind: "iso-10303-21-zip".into(),
295        entries,
296        notes,
297    })
298}
299
300fn decode_zip(
301    ctx: &cadmpeg_core::decode::DecodeContext<'_>,
302    root: cadmpeg_core::decode::View<'_>,
303) -> Result<DecodeResult, CodecError> {
304    let (archive, root_view) = archive::open_root(ctx, root)?;
305    let resource_notes = archive::root_reference_notes(&archive, root_view.window())?;
306    let entry_count = archive.entries().len();
307    let root_entry = archive
308        .entry(archive::ROOT_NAME)
309        .expect("validated STEP ZIP root");
310    let root_data_offset = root_entry.data_start;
311    let mut result = reader::decode(
312        root_view.window(),
313        DecodeOptions {
314            container_only: ctx.container_only(),
315            policy: *ctx.policy(),
316        },
317        ctx,
318    )?;
319    if let Some(source) = &mut result.ir_mut().source {
320        source
321            .attributes
322            .insert("container_kind".into(), "iso-10303-21-zip".into());
323        source
324            .attributes
325            .insert("archive_root".into(), archive::ROOT_NAME.into());
326        source
327            .attributes
328            .insert("archive_entries".into(), entry_count.to_string());
329        source.attributes.insert(
330            "archive_root_data_offset".into(),
331            root_data_offset.to_string(),
332        );
333    }
334    result.report_mut().notes.push(format!(
335        "container root {}; archive entries={entry_count}",
336        archive::ROOT_NAME
337    ));
338    result.report_mut().notes.extend(resource_notes);
339    Ok(result)
340}
341
342fn refuse_alternate_encoding(bytes: &[u8]) -> Result<(), CodecError> {
343    if bytes.starts_with(b"\x89HDF\r\n\x1a\n") {
344        return Err(CodecError::NotImplemented(
345            "STEP Part 26 binary/HDF5 encoding".into(),
346        ));
347    }
348    if is_part28_xml(bytes) {
349        return Err(CodecError::NotImplemented(
350            "STEP Part 28 XML encoding".into(),
351        ));
352    }
353    let lower = bytes
354        .iter()
355        .take(4096)
356        .map(u8::to_ascii_lowercase)
357        .collect::<Vec<_>>();
358    if lower.starts_with(b"<?xml")
359        && (lower
360            .windows(21)
361            .any(|window| window == b"business_object_model")
362            || lower.windows(14).any(|window| window == b"ap242_bo_model"))
363    {
364        return Err(CodecError::NotImplemented(
365            "AP242 BO-Model XML sidecar".into(),
366        ));
367    }
368    Ok(())
369}
370
371fn is_part28_xml(bytes: &[u8]) -> bool {
372    let lower = bytes
373        .iter()
374        .take(4096)
375        .map(u8::to_ascii_lowercase)
376        .collect::<Vec<_>>();
377    lower.starts_with(b"<?xml")
378        && (lower.windows(12).any(|window| window == b"iso_10303_28")
379            || lower
380                .windows(21)
381                .any(|window| window == b"iso:std:iso:10303:-28"))
382}