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;