ifc_lite_export/
usd.rs

1// SPDX-License-Identifier: MPL-2.0
2//! OpenUSD (**USDA** ASCII) exporter — a real `.usda` stage, not the USD-flavored
3//! JSON that IFCX emits. Produces a **Z-up** scene graph of `Xform` prims mirroring
4//! the IFC spatial hierarchy (Project → Site → Building → Storey → element); each
5//! geometry-bearing element carries `UsdGeomMesh` prims with `UsdPreviewSurface`
6//! materials and IFC metadata as namespaced custom attributes. Opens in usdview /
7//! Blender / Omniverse; targets `usdchecker` validity.
8//!
9//! Geometry source = `ifc_lite_processing::process_geometry` (Z-up, **metres** — the
10//! router bakes the length-unit scale at mesh time). So the stage is authored
11//! `upAxis = "Z"`, `metersPerUnit = 1` with NO basis/scale conversion (unlike glTF).
12//!
13//! Precision: each `MeshData` stores object-local f32 `positions` relative to a per-mesh
14//! f64 `origin` (the RTC/placement offset). We author `positions` verbatim as local
15//! `point3f[]` and carry `origin` as a **double3 `xformOp:translate`** on each Mesh
16//! prim — so a georeferenced / national-grid model keeps full f64 placement precision
17//! with small (object-scale) f32 vertices, at any model extent. Element Xforms are pure
18//! identity grouping.
19//!
20//! Emission (`emit`) and lexical helpers (`fmt`) live in submodules; this file is the
21//! orchestration + the emittable-mesh gate.
22
23mod emit;
24mod fmt;
25mod instance;
26
27use std::collections::{HashMap, HashSet};
28
29use ifc_lite_processing::{InstanceRecord, MeshData};
30
31use crate::ifc5::{project_name, spatial_children};
32use crate::model::{build_export_model, EntityRow};
33use emit::{emit_material, emit_prim, write_header, Ctx};
34use fmt::{color_key, Namer};
35
36/// Deepest spatial nesting the recursive emitter follows before it stops recursing
37/// (the subtree is then caught by the leftover / Unassigned pass). IFC spatial trees
38/// are shallow; this only guards against a pathological / cyclic aggregation graph
39/// blowing the stack.
40const MAX_DEPTH: usize = 64;
41
42/// Options for USD (USDA) export.
43pub struct UsdOptions {
44    /// Written to `customLayerData.author` (a bare `author` layer metadatum is
45    /// unregistered and would fail `usdchecker`).
46    pub author: String,
47    /// Deduplicate repeated mapped geometry via referenced prototypes (file-size win).
48    /// Default `true`; set `false` for the plain all-baked path (e.g. maximal
49    /// tool compatibility).
50    pub instancing: bool,
51}
52
53impl Default for UsdOptions {
54    fn default() -> Self {
55        Self { author: "ifc-lite".to_string(), instancing: true }
56    }
57}
58
59/// Export the model in `content` (raw IFC/STEP bytes) as a `.usda` (ASCII USD) string.
60pub fn export_usd(content: &[u8], opts: &UsdOptions) -> String {
61    // Instancing-on when requested and every instance is safe; else the plain baked path.
62    let (result, use_instancing) = instance::gather(content, opts.instancing);
63
64    // Emittable meshes: mirror the glTF `mesh_visible` sanity gate (drop the instanced
65    // type-library class, require matched non-empty triangulated buffers) and add the
66    // USD-specific guards — all-finite coordinates and in-range indices — so a
67    // degenerate mesh can never emit non-finite `point3f`/`normal3f` (an usda parse
68    // error) or an out-of-range `faceVertexIndices` (an usdchecker error).
69    let visible: Vec<&MeshData> = result.meshes.iter().filter(|m| mesh_emittable(m)).collect();
70
71    // Group full meshes by express id (an element may produce several submeshes), keeping a
72    // first-appearance ORDER so the Unassigned bucket and output stay deterministic.
73    let mut meshes_by_id: HashMap<u32, Vec<&MeshData>> = HashMap::new();
74    let mut mesh_order: Vec<u32> = Vec::new();
75    {
76        let mut seen: HashSet<u32> = HashSet::new();
77        for m in &visible {
78            if seen.insert(m.express_id) {
79                mesh_order.push(m.express_id);
80            }
81            meshes_by_id.entry(m.express_id).or_default().push(m);
82        }
83    }
84
85    // De-baked instance occurrences (empty unless instancing is active + safe).
86    let instances: &[InstanceRecord] = if use_instancing { &result.instances } else { &[] };
87    let instances_by_id: HashMap<u32, &InstanceRecord> =
88        instances.iter().map(|r| (r.express_id, r)).collect();
89
90    // One prototype per referenced template (sorted); its origin composes each occurrence's
91    // local→world transform. Same mesh backs the prototype geometry AND its origin.
92    let mut template_origin: HashMap<u32, [f64; 3]> = HashMap::new();
93    let mut proto_ids: Vec<u32> = Vec::new();
94    {
95        let mut seen: HashSet<u32> = HashSet::new();
96        for r in instances {
97            let tid = r.template_express_id;
98            if seen.insert(tid) {
99                proto_ids.push(tid);
100                if let Some(m) = meshes_by_id.get(&tid).and_then(|v| v.first()) {
101                    template_origin.insert(tid, m.origin);
102                }
103            }
104        }
105    }
106    proto_ids.sort_unstable();
107
108    // Ordered union of every geometry-bearing id (full meshes + instances) for the info
109    // fallback and the leftover/Unassigned join — nothing with geometry is dropped.
110    let mut all_ids: Vec<u32> = mesh_order.clone();
111    {
112        let mut seen: HashSet<u32> = mesh_order.iter().copied().collect();
113        for r in instances {
114            if seen.insert(r.express_id) {
115                all_ids.push(r.express_id);
116            }
117        }
118    }
119
120    // Attribute rows (GlobalId / class / Name / psets / quantities), keyed by id.
121    let model = build_export_model(content);
122    let by_id: HashMap<u32, &EntityRow> =
123        model.entities.iter().map(|e| (e.express_id, e)).collect();
124
125    // (display-name, ifc-type) per id: rows first, then the IfcProject (not an IfcProduct →
126    // not in the model), then meshed/instanced ids lacking a row (fall back to the mesh's or
127    // instance record's own metadata so a geometry-only element still gets a typed prim).
128    let mut info: HashMap<u32, (String, String)> = HashMap::new();
129    for e in &model.entities {
130        info.insert(e.express_id, (e.name.clone().unwrap_or_default(), e.ifc_type.clone()));
131    }
132    let (children, project) = spatial_children(content);
133    if let Some(pid) = project {
134        info.entry(pid)
135            .or_insert_with(|| (project_name(content, pid), "IfcProject".to_string()));
136    }
137    for id in &mesh_order {
138        if !info.contains_key(id) {
139            if let Some(m) = meshes_by_id.get(id).and_then(|v| v.first()) {
140                info.insert(*id, (m.name.clone().unwrap_or_default(), m.ifc_type.clone()));
141            }
142        }
143    }
144    for r in instances {
145        info.entry(r.express_id)
146            .or_insert_with(|| (r.name.clone().unwrap_or_default(), r.ifc_type.clone()));
147    }
148
149    // Materials: distinct rounded RGBA keys over meshes AND instance occurrences (each
150    // occurrence colour needs a `/World/Looks` prim or its `material:binding` dangles).
151    let mut mat_color: HashMap<(i32, i32, i32, i32), [f32; 4]> = HashMap::new();
152    for m in &visible {
153        mat_color.entry(color_key(m.color)).or_insert(m.color);
154    }
155    for r in instances {
156        mat_color.entry(color_key(r.color)).or_insert(r.color);
157    }
158    let mut mat_keys: Vec<(i32, i32, i32, i32)> = mat_color.keys().copied().collect();
159    mat_keys.sort_unstable();
160
161    let ctx = Ctx {
162        children: &children,
163        info: &info,
164        by_id: &by_id,
165        meshes_by_id: &meshes_by_id,
166        instances_by_id: &instances_by_id,
167        template_origin: &template_origin,
168    };
169
170    // ── Emit ────────────────────────────────────────────────────────────────
171    let mut out = String::new();
172    write_header(&mut out, opts, content);
173
174    // Root /World Xform (identity — per-mesh placement rides each Mesh's transform).
175    out.push_str("def Xform \"World\"\n{\n");
176
177    // Materials under /World/Looks.
178    let mut world_names = Namer::new();
179    world_names.reserve("Looks");
180    world_names.reserve("Prototypes");
181    if !mat_keys.is_empty() {
182        out.push_str("\n    def Scope \"Looks\"\n    {\n");
183        for key in &mat_keys {
184            emit_material(&mut out, 2, *key, mat_color[key]);
185        }
186        out.push_str("    }\n");
187    }
188
189    // Instancing prototypes (`class Mesh`) referenced by the occurrences below.
190    if !proto_ids.is_empty() {
191        out.push_str("\n    def Scope \"Prototypes\"\n    {\n");
192        for tid in &proto_ids {
193            if let Some(m) = meshes_by_id.get(tid).and_then(|v| v.first()) {
194                instance::emit_prototype(&mut out, 2, &instance::proto_name(*tid), m);
195            }
196        }
197        out.push_str("    }\n");
198    }
199
200    let mut emitted: HashSet<u32> = HashSet::new();
201
202    // Spatial subtree from the project (recursive DFS, first-parent-wins).
203    if let Some(pid) = project {
204        emit_prim(&mut out, &ctx, pid, 1, 0, &mut emitted, &mut world_names);
205    }
206
207    // Leftover geometry-bearing ids the spatial walk never reached (type-product meshes,
208    // instances/elements outside the spatial tree). NEVER silently dropped.
209    let leftover: Vec<u32> =
210        all_ids.iter().copied().filter(|id| !emitted.contains(id)).collect();
211    if !leftover.is_empty() {
212        if project.is_some() {
213            // Park them under a synthetic sibling so the project tree stays clean.
214            world_names.reserve("Unassigned");
215            out.push_str("\n    def Xform \"Unassigned\"\n    {\n");
216            // A leftover id may itself have spatial children; `emit_prim` recurses, so
217            // that subtree lands under Unassigned. Later `leftover` iterations for those
218            // same ids no-op via the `emitted` guard — no drop, no double-emit.
219            let mut un_names = Namer::new();
220            for id in &leftover {
221                emit_prim(&mut out, &ctx, *id, 2, 0, &mut emitted, &mut un_names);
222            }
223            out.push_str("    }\n");
224        } else {
225            // No project at all → emit meshed elements directly under /World.
226            for id in &leftover {
227                emit_prim(&mut out, &ctx, *id, 1, 0, &mut emitted, &mut world_names);
228            }
229        }
230    }
231
232    out.push_str("}\n");
233    out
234}
235
236/// Emittable-mesh gate: the glTF `mesh_visible` geometry sanity (no instanced
237/// type-library duplicates, matched non-empty triangulated buffers) PLUS the
238/// USD-specific guards that keep the stage parseable/checkable: all-finite
239/// coordinates, a finite per-mesh origin, and every index within the vertex range.
240pub(super) fn mesh_emittable(m: &MeshData) -> bool {
241    if m.geometry_class == 2 {
242        return false;
243    }
244    // Triangulated: index buffer is whole triangles (so faceVertexCounts of all-3s
245    // sums to the index count — else usdchecker rejects the mesh).
246    if m.indices.len() < 3 || !m.indices.len().is_multiple_of(3) {
247        return false;
248    }
249    if m.positions.len() < 9 || !m.positions.len().is_multiple_of(3) {
250        return false;
251    }
252    if m.normals.len() != m.positions.len() {
253        return false;
254    }
255    if !m.positions.iter().all(|v| v.is_finite()) || !m.normals.iter().all(|v| v.is_finite()) {
256        return false;
257    }
258    // The per-mesh origin becomes a double3 translate; a non-finite one would
259    // silently mislocate the mesh (fmt maps it to 0), so gate it out.
260    if !m.origin.iter().all(|v| v.is_finite()) {
261        return false;
262    }
263    let vcount = (m.positions.len() / 3) as u32;
264    m.indices.iter().all(|&i| i < vcount)
265}
266
267#[cfg(test)]
268mod tests;