ifc_lite_export/obj.rs
1// SPDX-License-Identifier: MPL-2.0
2//! Wavefront **OBJ** exporter — triangulated render geometry as a single `.obj` text.
3//!
4//! Source = `ifc_lite_processing::process_geometry` (the same per-element `MeshData`
5//! the viewer renders, produced by the one unified Rust pipeline). Per-mesh `origin`
6//! is folded into world positions so building / georef-scale placements export without
7//! f32 collapse. Vertices and normals are emitted once per mesh with a running global
8//! index; each element becomes an OBJ `o`/`g` group (`IfcWall_1234`) so downstream DCC
9//! tools keep per-element traceability.
10//!
11//! Instanced type-library meshes (`geometry_class == 2`) are skipped — their geometry is
12//! already drawn by the real occurrences, so emitting them would duplicate shapes at the
13//! type origin (the Model/Types orphan-gate footgun).
14
15use std::fmt::Write as _;
16
17use ifc_lite_processing::{process_geometry, MeshData};
18
19use crate::frame::{yup_f32, yup_f64};
20
21/// Options for OBJ export.
22pub struct ObjOptions {
23 /// Emit per-vertex normals (`vn` + `f a//na`). Most DCC tools expect them.
24 pub include_normals: bool,
25 /// Restrict to these express ids (isolation allowlist). `None` ⇒ no filter,
26 /// every mesh is a candidate; `Some(empty)` ⇒ the filter is ACTIVE and
27 /// matched nothing, so every mesh is excluded. Do not collapse the two —
28 /// an active isolation that matches zero elements must export nothing, not
29 /// silently fall back to the whole model (matches the null-vs-empty
30 /// convention used by `GltfOptions::isolated` and by
31 /// `packages/export/src/reference-collector.ts` on the TS side).
32 pub isolated: Option<Vec<u32>>,
33 /// Exclude these express ids (hidden in the viewer).
34 pub hidden: Vec<u32>,
35}
36
37impl Default for ObjOptions {
38 fn default() -> Self {
39 Self { include_normals: true, isolated: None, hidden: Vec::new() }
40 }
41}
42
43/// Coverage stats for an OBJ export.
44pub struct ObjStats {
45 /// Meshes written.
46 pub meshes: usize,
47 /// Vertices written.
48 pub vertices: usize,
49 /// Triangles written.
50 pub triangles: usize,
51}
52
53/// True when `mesh` should be written given the isolation/hidden filters.
54fn mesh_visible(mesh: &MeshData, isolated: &Option<Vec<u32>>, hidden: &[u32]) -> bool {
55 // Instanced type-library shapes duplicate real occurrence geometry — never export.
56 if mesh.geometry_class == 2 {
57 return false;
58 }
59 if hidden.contains(&mesh.express_id) {
60 return false;
61 }
62 // `Some(ids)` = isolation is ACTIVE — an empty allowlist must exclude every
63 // mesh here, not read the same as "no filter" (`None`). See `ObjOptions::isolated`.
64 if let Some(ids) = isolated {
65 if !ids.contains(&mesh.express_id) {
66 return false;
67 }
68 }
69 if mesh.indices.is_empty() || mesh.positions.len() < 9 {
70 return false;
71 }
72 // OBJ's `v`/`vn` tokens have no lexical form for a non-finite number (unlike
73 // `mesh_input::scrub_nonfinite`'s target formats, nothing here even reads
74 // "nan"/"inf" back as a number), and this exporter folds `mesh.origin` into
75 // every position before writing it, so a non-finite origin would poison every
76 // otherwise-good vertex in the mesh. `process_geometry` can hand back such a
77 // value only for a derived quantity like the mid-vertex normal of a
78 // zero-area face (see `usd::fmt::fmt_f32`'s comment on the same source).
79 // Gate the whole mesh out rather than write a token no reader accepts —
80 // mirrors `usd::mesh_emittable`, the sibling from-bytes exporter over the
81 // same `process_geometry` output.
82 if !mesh.origin.iter().all(|v| v.is_finite()) || !mesh.positions.iter().all(|v| v.is_finite()) {
83 return false;
84 }
85 if mesh.normals.len() == mesh.positions.len() && !mesh.normals.iter().all(|v| v.is_finite()) {
86 return false;
87 }
88 true
89}
90
91/// Export the render geometry in `content` (raw IFC/STEP bytes) as a Wavefront OBJ string.
92pub fn export_obj(content: &[u8], opts: &ObjOptions) -> String {
93 export_obj_with_stats(content, opts).0
94}
95
96/// Like [`export_obj`] but also returns coverage stats.
97pub fn export_obj_with_stats(content: &[u8], opts: &ObjOptions) -> (String, ObjStats) {
98 write_obj(&process_geometry(content).meshes, opts)
99}
100
101/// The OBJ text for already-meshed geometry.
102fn write_obj(meshes: &[MeshData], opts: &ObjOptions) -> (String, ObjStats) {
103 let mut out = String::new();
104 let _ = writeln!(out, "# ifc-lite OBJ export");
105 let _ = writeln!(out, "# units: metres (renderer Y-up frame, origin-folded world coords)");
106
107 let mut vert_base: usize = 0; // 0-based count of vertices written so far
108 // `vn` lines written so far: a mesh without normals writes none, so this is
109 // not `vert_base`.
110 let mut norm_base: usize = 0;
111 let mut stats = ObjStats { meshes: 0, vertices: 0, triangles: 0 };
112
113 for mesh in meshes {
114 if !mesh_visible(mesh, &opts.isolated, &opts.hidden) {
115 continue;
116 }
117 let nverts = mesh.positions.len() / 3;
118 let has_normals = opts.include_normals && mesh.normals.len() == mesh.positions.len();
119
120 let group = format!("{}_{}", mesh.ifc_type, mesh.express_id);
121 let _ = writeln!(out, "o {group}");
122 let _ = writeln!(out, "g {group}");
123
124 // Vertices — fold the per-mesh f64 origin so georef-scale placements survive,
125 // then convert the producer-native IFC Z-up world point to WebGL Y-up
126 // (`(x,y,z) -> (x,z,-y)`) so OBJ matches the header's declared frame and the
127 // GLB exporter (`process_geometry` itself is Z-up; the swap is normally done
128 // at the wasm FFI, which this path never crosses).
129 let [ox, oy, oz] = mesh.origin;
130 for i in 0..nverts {
131 let wx = mesh.positions[i * 3] as f64 + ox;
132 let wy = mesh.positions[i * 3 + 1] as f64 + oy;
133 let wz = mesh.positions[i * 3 + 2] as f64 + oz;
134 let [x, y, z] = yup_f64([wx, wy, wz]);
135 let _ = writeln!(out, "v {x:.6} {y:.6} {z:.6}");
136 }
137 if has_normals {
138 for i in 0..nverts {
139 let [nx, ny, nz] = yup_f32([
140 mesh.normals[i * 3],
141 mesh.normals[i * 3 + 1],
142 mesh.normals[i * 3 + 2],
143 ]);
144 let _ = writeln!(out, "vn {nx:.6} {ny:.6} {nz:.6}");
145 }
146 }
147
148 // Faces — OBJ indices are 1-based and global; offset by vert_base. Winding is
149 // preserved: (x,y,z) -> (x,z,-y) has determinant +1, so this frame
150 // rotation does not change handedness.
151 for tri in mesh.indices.chunks_exact(3) {
152 let [a, b, c] = [0, 1, 2].map(|k| vert_base + tri[k] as usize + 1);
153 if has_normals {
154 let [na, nb, nc] = [0, 1, 2].map(|k| norm_base + tri[k] as usize + 1);
155 let _ = writeln!(out, "f {a}//{na} {b}//{nb} {c}//{nc}");
156 } else {
157 let _ = writeln!(out, "f {a} {b} {c}");
158 }
159 stats.triangles += 1;
160 }
161
162 vert_base += nverts;
163 if has_normals {
164 norm_base += nverts;
165 }
166 stats.vertices += nverts;
167 stats.meshes += 1;
168 }
169
170 (out, stats)
171}
172
173#[cfg(test)]
174#[path = "obj_tests.rs"]
175mod tests;