ifc_lite_export/
step.rs

1// SPDX-License-Identifier: MPL-2.0
2//! **STEP / IFC** (ISO-10303-21) exporter — re-serialize the parsed model back to a
3//! valid `.ifc` text.
4//!
5//! Phase 2 **P1**: faithful base re-serialization (original entity lines, regenerated
6//! header) + subset export via a forward-`#`-reference closure (so a filtered export
7//! never dangles a reference). Entity-type **schema conversion** (IFC2X3↔4↔4X3) and
8//! **mutation application** (MutablePropertyView edits bridged from TS) are the P2/P3
9//! follow-ons; the structure here is the seam they plug into.
10
11use std::collections::{BTreeMap, HashMap, HashSet};
12
13use ifc_lite_core::EntityScanner;
14
15pub use crate::step_api::{
16    AttrMutation, CopyOnWriteMutation, PropMutation, StepOptions, StepStats,
17};
18
19use crate::schema_detect::detect_schema;
20use crate::step_text::{
21    apply_attr_mutations_counted, escape, merge_edits, refs_in_line_counted, renumber,
22};
23
24/// Export the parsed model in `content` as a STEP/IFC string.
25///
26/// Errs when a source entity's (possibly renamed) type has no representation
27/// at all in an explicit `opts.schema` target and is not an `IfcRoot`
28/// subtype either — see [`crate::schema_unrepresented`] (#5116).
29pub fn export_step(content: &[u8], opts: &StepOptions) -> std::io::Result<String> {
30    export_step_with_stats(content, opts).map(|(s, _)| s)
31}
32
33/// Like [`export_step`] but also returns coverage stats.
34pub fn export_step_with_stats(content: &[u8], opts: &StepOptions) -> std::io::Result<(String, StepStats)> {
35    // Presized for a full re-export, where the output is within a small factor
36    // of the source. Not for a subset: 200 MB filtered to a few records would
37    // reserve 200 MB, and on wasm that linear memory never comes back.
38    let mut buf = match opts.included {
39        None => Vec::with_capacity(content.len()),
40        Some(_) => Vec::new(),
41    };
42    // `emit`, not `export_step_to_writer`: a `Vec` needs no buffering, and
43    // wrapping one memcpys the whole output through a 1 MiB window for nothing.
44    // A Vec's `Write` impl cannot fail for I/O reasons, so an `Err` here is
45    // always a schema-conversion failure (`emit` also propagates those through
46    // this same `io::Result`, see `schema_unrepresented::UnrepresentedEntityError`'s
47    // `From` impl) — genuinely fallible now, unlike the `.expect` this replaced.
48    let stats = emit(content, opts, &mut buf)?;
49    // Every byte came from the source by way of `from_utf8_lossy`, or from a
50    // `format!`, so this validates rather than converts.
51    let out = String::from_utf8(buf).expect("the writer emits UTF-8");
52    Ok((out, stats))
53}
54
55/// [`export_step_with_stats`], writing as it goes instead of returning the file.
56/// The two cannot drift: that one is this one writing into a `Vec`.
57///
58/// The gigabyte of `String` that doubled its way there is gone. What replaces
59/// it is not free: the entity index measures ~84 bytes a record, so 370 MB on
60/// 4.4 M records and ~1.1 GB on 16.8 M, before a byte is written. On a very
61/// large model it outweighs the output it stopped holding, and it is not
62/// removable -- source order and the reference closure both need it.
63///
64/// `out` is buffered here, so a bare `File` is fine. A record costs two `write`
65/// calls, which unbuffered is two syscalls: measured 15.0 s against 4.3 s on
66/// 4.4 M records. A caller who already buffers pays one memcpy through 1 MiB.
67// The grouped-property-mutation Vec type is explicit by design; aliasing it
68// would hide the (entity, pset) -> [(key, value)] grouping structure.
69#[allow(clippy::type_complexity)]
70pub fn export_step_to_writer<W: std::io::Write>(
71    content: &[u8],
72    opts: &StepOptions,
73    w: &mut W,
74) -> std::io::Result<StepStats> {
75    use std::io::Write as _;
76    let mut buffered = std::io::BufWriter::with_capacity(1 << 20, w);
77    let stats = emit(content, opts, &mut buffered)?;
78    // Flushed here for the error, not for the bytes: `BufWriter::drop` does
79    // flush, it just has nowhere to report a failure and swallows it.
80    buffered.flush()?;
81    Ok(stats)
82}
83
84/// Write one record this exporter SYNTHESIZES (a property set, its properties,
85/// the relationship that attaches it), through the IFC2X3 required-slot fills
86/// when the output is IFC2X3.
87///
88/// These records never reach `convert_step_line` — they are built here, after
89/// the emit loop — so before #4714 they went out with `$` in `OwnerHistory`,
90/// which IFC2X3 requires, even when the file had an owner history to point
91/// them at. Source records are filled inside the converter; these are filled
92/// here, through the same object, so both land in the same counters.
93fn write_synthesized<W: std::io::Write>(
94    out: &mut W,
95    line: String,
96    targets_ifc2x3: bool,
97    slot_fill: &mut crate::schema_ifc2x3_slots::Ifc2x3SlotFill,
98) -> std::io::Result<()> {
99    let line = if targets_ifc2x3 { slot_fill.apply(line) } else { line };
100    out.write_all(line.as_bytes())?;
101    out.write_all(b"\n")
102}
103
104#[allow(clippy::type_complexity)]
105fn emit<W: std::io::Write>(
106    content: &[u8],
107    opts: &StepOptions,
108    out: &mut W,
109) -> std::io::Result<StepStats> {
110    // 1. Index every entity line (preserve source order).
111    let mut order: Vec<u32> = Vec::new();
112    let mut line_of: HashMap<u32, (usize, usize)> = HashMap::new();
113    let mut max_id = 0u32;
114    let mut owner_histories: Vec<u32> = Vec::new();
115    let mut scanner = EntityScanner::new(content);
116    while let Some((id, type_name, start, end)) = scanner.next_entity() {
117        max_id = max_id.max(id);
118        if type_name.eq_ignore_ascii_case("IFCOWNERHISTORY") {
119            owner_histories.push(id);
120        }
121        if line_of.insert(id, (start, end)).is_none() {
122            order.push(id);
123        }
124    }
125
126    // 2. Resolve the included set + forward reference closure.
127    let mut refused_refs = 0usize;
128    let mut attribute_edits_refused = 0usize;
129    let included: HashSet<u32> = match &opts.included {
130        None => order.iter().copied().collect(),
131        Some(roots) => {
132            let mut keep: HashSet<u32> = HashSet::new();
133            let mut stack: Vec<u32> = roots.clone();
134            let mut refs = Vec::new();
135            while let Some(id) = stack.pop() {
136                if !keep.insert(id) {
137                    continue;
138                }
139                if let Some(&(s, e)) = line_of.get(&id) {
140                    refs.clear();
141                    refs_in_line_counted(&content[s..e], &mut refs, &mut refused_refs);
142                    for &r in &refs {
143                        if !keep.contains(&r) {
144                            stack.push(r);
145                        }
146                    }
147                }
148            }
149            keep
150        }
151    };
152
153    // Read the source HEADER once, here, so the writer can carry its
154    // provenance forward instead of blanking it (see `step_header`).
155    let source_header = crate::source_header::parse_source_header(content);
156    let source_schema = detect_schema(content);
157    let schema = opts.schema.clone().unwrap_or_else(|| source_schema.clone());
158    // Only convert entity types/attributes when an explicit target differs from source.
159    let converting = opts.schema.is_some()
160        && crate::schema_convert::needs_conversion(&source_schema, &schema);
161    // The slots IFC2X3 requires a value in: `OwnerHistory` from the first owner
162    // history this export writes (#4686), the rest from the generated
163    // required-slot table (#4714).
164    let mut slot_fill = crate::schema_ifc2x3_slots::Ifc2x3SlotFill::new(
165        owner_histories.into_iter().find(|id| included.contains(id)),
166    );
167    // IFC4-required `$` slots (#5307) and enum members the target lacks (#5365).
168    let mut checks = crate::schema_enum::ConversionChecks::new();
169    // The synthesized property sets below are filled whenever the OUTPUT is
170    // IFC2X3, not only when a conversion runs: an IFC2X3 source needs no
171    // conversion, and the records this exporter writes for it still have to be
172    // valid IFC2X3.
173    let targets_ifc2x3 = crate::schema_convert::targets_ifc2x3(&schema);
174
175    // Root-attribute edits, resolved per (entity, attribute) as they are read.
176    // A list plus a last-wins rule made "the value at this index" a derived
177    // fact that every reader re-derived its own way, and two of them got it
178    // wrong. Keyed by index there is nothing left to derive.
179    let mut muts_by_id: HashMap<u32, BTreeMap<usize, String>> = HashMap::new();
180    for m in &opts.attribute_mutations {
181        muts_by_id.entry(m.express_id).or_default().insert(m.index, m.value.clone());
182    }
183
184    // Copy-on-write, resolved before the emit loop so the copies and the
185    // repointed referrers both go through it. Its own module: the pass has four
186    // rules now and every one of them is a file that came out wrong without it.
187    let resolved = crate::step_cow::resolve(
188        &opts.copy_on_write,
189        content,
190        &line_of,
191        &included,
192        &muts_by_id,
193        max_id.checked_add(1),
194    );
195    let next_id = resolved.next_id;
196    let copies_refused = resolved.refused;
197    let copies = resolved.copies;
198    let repointed = resolved.repointed;
199
200    // 3. Emit header + filtered entities (source order) + footer.
201    // The FILE_SCHEMA token, by the TypeScript twin's rule: keep the source's
202    // exact identifier unless this export converts, else declare the target
203    // family's file identifier (IFC4X3 is written as IFC4X3_ADD2, #5351).
204    let declared = source_header
205        .as_ref()
206        .and_then(|h| h.schema_identifiers.first())
207        .filter(|s| !s.is_empty());
208    let header_schema = match declared {
209        Some(token) if !converting => token.as_str(),
210        _ => crate::file_schema::file_schema_identifier(&schema),
211    };
212    crate::step_header::write_header(out, opts, source_header.as_ref(), header_schema)?;
213
214    let mut written = 0usize;
215    for id in &order {
216        if included.contains(id) {
217            if let Some(&(s, e)) = line_of.get(id) {
218                let raw = String::from_utf8_lossy(&content[s..e]);
219                // Apply root-attribute edits first (original-schema positions), then convert.
220                let edited = match merge_edits(muts_by_id.get(id), repointed.get(id)) {
221                    Some(edits) => {
222                        apply_attr_mutations_counted(&raw, &edits, &mut attribute_edits_refused)
223                    }
224                    None => raw.into_owned(),
225                };
226                if converting {
227                    let converted = crate::schema_convert::convert_step_line(
228                        &edited,
229                        &source_schema,
230                        &schema,
231                        *id,
232                        &mut slot_fill,
233                        Some(&mut checks),
234                    )?;
235                    out.write_all(converted.as_bytes())?;
236                } else {
237                    out.write_all(edited.as_bytes())?;
238                }
239                out.write_all(b"\n")?;
240                written += 1;
241            }
242        }
243    }
244
245    // The copies, emitted rather than appended: counted in `written` and put
246    // through `convert_step_line` like every other record.
247    for (copy_id, source_id, edits) in &copies {
248        if let Some(&(s0, e0)) = line_of.get(source_id) {
249            let raw = String::from_utf8_lossy(&content[s0..e0]);
250            // The caller's edits to the record belong to the copy too: the
251            // copy is this element's version of that record, not a snapshot
252            // taken before the caller touched it. Repointings do not, which is
253            // why they are resolved into their own map.
254            let mut muts = muts_by_id.get(source_id).cloned().unwrap_or_default();
255            muts.extend(edits.iter().map(|(i, v)| (*i, v.clone())));
256            let edited = apply_attr_mutations_counted(&raw, &muts, &mut attribute_edits_refused);
257            let renumbered = renumber(&edited, *copy_id);
258            if converting {
259                let converted = crate::schema_convert::convert_step_line(
260                    &renumbered,
261                    &source_schema,
262                    &schema,
263                    *copy_id,
264                    &mut slot_fill,
265                    Some(&mut checks),
266                )?;
267                out.write_all(converted.as_bytes())?;
268            } else {
269                out.write_all(renumbered.as_bytes())?;
270            }
271            out.write_all(b"\n")?;
272            written += 1;
273        }
274    }
275
276    // 4. Synthesize new property sets from property mutations (fresh ids past max_id).
277    if !opts.property_mutations.is_empty() {
278        // Group props by (entity, pset) preserving first-seen order.
279        let mut groups: Vec<((u32, String), Vec<(&str, &str)>)> = Vec::new();
280        let mut index_of: HashMap<(u32, String), usize> = HashMap::new();
281        for m in &opts.property_mutations {
282            // Only attach to entities actually present in the export.
283            if !included.contains(&m.express_id) {
284                continue;
285            }
286            let key = (m.express_id, m.pset_name.clone());
287            let idx = *index_of.entry(key.clone()).or_insert_with(|| {
288                groups.push((key.clone(), Vec::new()));
289                groups.len() - 1
290            });
291            groups[idx].1.push((m.prop_name.as_str(), m.value.as_str()));
292        }
293
294        // Same exhaustion, same answer: inventing ids on a full file would
295        // duplicate real records.
296        let Some(mut next) = next_id else {
297            out.write_all(b"ENDSEC;\nEND-ISO-10303-21;\n")?;
298            return Ok(StepStats {
299                total: order.len(),
300                written,
301                copies_refused,
302                refused_refs,
303                attribute_edits_refused,
304                owner_history_unfilled: slot_fill.owner_history_unfilled(),
305                required_slots_unfilled: slot_fill.required_slots_unfilled(),
306                ifc4_required_slots_unfilled: checks.ifc4_slots.required_slots_unfilled(),
307                enum_values_lost: checks.enums.lost(),
308                enum_values_refused: checks.enums.refused(),
309            });
310        };
311        for ((express_id, pset_name), props) in &groups {
312            // One property set costs one id per property plus one for the set
313            // and one for the relationship. Checking that a single id is left
314            // is not enough: a group that starts near the ceiling used to run
315            // off it part way through and wrap, emitting ids that already
316            // belong to real records. A group that does not fit is skipped
317            // whole, so nothing half-written reaches the file.
318            let needed = u32::try_from(props.len()).ok().and_then(|n| n.checked_add(2));
319            match needed.and_then(|n| u32::MAX.checked_sub(n).map(|limit| next <= limit)) {
320                Some(true) => {}
321                _ => continue,
322            }
323            let mut prop_refs: Vec<u32> = Vec::with_capacity(props.len());
324            for (pname, value) in props {
325                let line = format!(
326                    "#{next}=IFCPROPERTYSINGLEVALUE('{}',$,{},$);",
327                    escape(pname),
328                    value
329                );
330                write_synthesized(out, line, targets_ifc2x3, &mut slot_fill)?;
331                prop_refs.push(next);
332                next += 1;
333                written += 1;
334            }
335            let psid = next;
336            next += 1;
337            let refs_str = prop_refs.iter().map(|r| format!("#{r}")).collect::<Vec<_>>().join(",");
338            let line = format!(
339                "#{psid}=IFCPROPERTYSET('{}',$,'{}',$,({}));",
340                crate::schema_convert::placeholder_guid(psid),
341                escape(pset_name),
342                refs_str
343            );
344            write_synthesized(out, line, targets_ifc2x3, &mut slot_fill)?;
345            written += 1;
346            let rid = next;
347            next += 1;
348            let line = format!(
349                "#{rid}=IFCRELDEFINESBYPROPERTIES('{}',$,$,$,(#{express_id}),#{psid});",
350                crate::schema_convert::placeholder_guid(rid),
351            );
352            write_synthesized(out, line, targets_ifc2x3, &mut slot_fill)?;
353            written += 1;
354        }
355    }
356
357    out.write_all(b"ENDSEC;\nEND-ISO-10303-21;\n")?;
358
359    Ok(StepStats {
360        total: order.len(),
361        written,
362        copies_refused,
363        refused_refs,
364        attribute_edits_refused,
365        owner_history_unfilled: slot_fill.owner_history_unfilled(),
366        required_slots_unfilled: slot_fill.required_slots_unfilled(),
367        ifc4_required_slots_unfilled: checks.ifc4_slots.required_slots_unfilled(),
368                enum_values_lost: checks.enums.lost(),
369                enum_values_refused: checks.enums.refused(),
370    })
371}
372
373#[cfg(test)]
374#[path = "step_tests.rs"]
375mod tests;
376
377#[cfg(test)]
378#[path = "step_roundtrip_tests.rs"]
379mod roundtrip_tests;