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;