ifc_lite_processing/types/mesh.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Mesh data types for serialization.
6
7use ifc_lite_geometry::InstanceMeta;
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10
11/// A surface texture attached to a mesh (issues #961, #1781). Exactly one of
12/// `rgba` / `url` is set:
13/// - `rgba`: decoded in Rust (`IfcBlobTexture` PNG / `IfcPixelTexture` raw);
14/// the browser only uploads it to a GPU texture — no image logic in TS.
15/// - `url`: an `IfcImageTexture` reference (#1781) the HOST layer resolves —
16/// typically a sibling image file inside the `.ifcZIP` container. Real files
17/// share one multi-megapixel image across dozens of face sets, so the
18/// pipeline ships the reference, never per-mesh pixels.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct MeshTextureData {
21 /// Express id of the source `IfcSurfaceTexture` — the stable dedup key:
22 /// every mesh sampling the same image carries the same id, so consumers
23 /// create ONE GPU texture per id, not one per mesh. 0 in legacy payloads.
24 #[serde(default)]
25 pub texture_id: u32,
26 /// `width * height * 4` bytes, row-major, top-down, straight alpha.
27 /// `Arc`-shared across meshes; `None` for an external image reference.
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub rgba: Option<std::sync::Arc<Vec<u8>>>,
30 #[serde(default)]
31 pub width: u32,
32 #[serde(default)]
33 pub height: u32,
34 /// `IfcImageTexture.URLReference` verbatim (#1781); `None` for decoded.
35 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub url: Option<String>,
37 /// Sampler wrap from `IfcSurfaceTexture.RepeatS/RepeatT`.
38 pub repeat_s: bool,
39 pub repeat_t: bool,
40}
41
42impl MeshTextureData {
43 /// Build from the geometry crate's per-face-set attachment.
44 pub fn from_attachment(att: &ifc_lite_geometry::TextureAttachment) -> Self {
45 match &att.source {
46 ifc_lite_geometry::TextureSource::Decoded(tex) => Self {
47 texture_id: att.texture_id,
48 rgba: Some(tex.rgba.clone()),
49 width: tex.width,
50 height: tex.height,
51 url: None,
52 repeat_s: tex.repeat_s,
53 repeat_t: tex.repeat_t,
54 },
55 ifc_lite_geometry::TextureSource::Image(img) => Self {
56 texture_id: att.texture_id,
57 rgba: None,
58 width: 0,
59 height: 0,
60 url: Some(img.url.clone()),
61 repeat_s: img.repeat_s,
62 repeat_t: img.repeat_t,
63 },
64 }
65 }
66}
67
68/// Individual mesh data with geometry and metadata.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct MeshData {
71 /// Express ID of the IFC element.
72 pub express_id: u32,
73 /// IFC type name (e.g., "IfcWall").
74 pub ifc_type: String,
75 /// IFC GlobalId (Root attribute #0) when available.
76 #[serde(skip_serializing_if = "Option::is_none")]
77 pub global_id: Option<String>,
78 /// IFC Name (Root/Object attribute #2) when available.
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub name: Option<String>,
81 /// IFC presentation layer assignment name when available.
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub presentation_layer: Option<String>,
84 /// Vertex positions (x, y, z triplets).
85 pub positions: Vec<f32>,
86 /// Vertex normals (x, y, z triplets).
87 pub normals: Vec<f32>,
88 /// Triangle indices.
89 pub indices: Vec<u32>,
90 /// RGBA color [r, g, b, a] in 0-1 range.
91 pub color: [f32; 4],
92 /// Optional material/style name resolved from per-item IFC styling.
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub material_name: Option<String>,
95 /// The `IfcRepresentationItem` this mesh was tessellated from.
96 ///
97 /// ALWAYS a representation item, and never a material — a host can follow
98 /// it to source and land on the entity that produced the geometry. Before
99 /// #3199 this field also carried the `IfcMaterial` id for material-layered
100 /// walls and slabs, so following it landed on the wrong entity with nothing
101 /// to warn the caller.
102 ///
103 /// `None` where the identity is genuinely merged away: the single-mesh
104 /// fallback, the cached `IfcMappedItem` path, and GPU-instanced
105 /// occurrences. A boolean result reports the `IfcBooleanResult` id, which
106 /// is a real entity.
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub geometry_item_id: Option<u32>,
109 /// The `IfcMaterial` whose layer this mesh is a slice of.
110 ///
111 /// ALWAYS a material, and never a representation item. Set only on the
112 /// material-layer path (`router/layers.rs`), and DISJOINT from
113 /// [`Self::geometry_item_id`] — never both, so a consumer that ignores the
114 /// distinction still cannot read one as the other (#3199).
115 #[serde(skip_serializing_if = "Option::is_none")]
116 pub material_id: Option<u32>,
117 /// Optional IFC property set values keyed by IFC property names.
118 /// Primarily attached for IfcSpace/IfcZone so downstream tools can build room attribute UIs.
119 #[serde(skip_serializing_if = "Option::is_none")]
120 pub properties: Option<BTreeMap<String, String>>,
121 /// Per-vertex texture coordinates (u, v pairs, 1:1 with `positions`),
122 /// present only for textured meshes (issue #961).
123 #[serde(skip_serializing_if = "Option::is_none")]
124 pub uvs: Option<Vec<f32>>,
125 /// Decoded surface texture, present only for textured meshes (#961).
126 #[serde(skip_serializing_if = "Option::is_none")]
127 pub texture: Option<MeshTextureData>,
128 /// Provenance of the geometry for the viewer's Model/Types switch (#957):
129 /// 0 = ordinary occurrence, 1 = orphan type-product RepresentationMap (no
130 /// occurrence instantiates it), 2 = instanced type-product map (the type
131 /// library shape; its occurrences already draw the real geometry).
132 /// Serde-default so existing JSON payloads and disk caches stay readable;
133 /// skipped when 0 so ordinary meshes serialize byte-identically.
134 #[serde(default, skip_serializing_if = "geometry_class_is_occurrence")]
135 pub geometry_class: u8,
136 /// Per-mesh local origin (world/RTC frame, f64). `positions` are stored
137 /// RELATIVE to this — the world position of a vertex is `origin + position` —
138 /// so building/georef-scale placement never collapses adjacent vertices to
139 /// bit-identical f32. The renderer applies it as a per-mesh translation
140 /// (camera-relative). `[0, 0, 0]` ⇒ positions are absolute (legacy/local).
141 /// Serde-default + skip-when-zero so existing payloads/caches stay readable
142 /// and local meshes serialize byte-identically.
143 #[serde(default, skip_serializing_if = "origin_is_zero")]
144 pub origin: [f64; 3],
145 /// GPU-instancing metadata (rep-identity + per-occurrence world transform),
146 /// attached only when `IFC_LITE_INSTANCING` is on and the element is a clean
147 /// single-item mapped instance. Purely in-memory for the native streaming
148 /// path — `#[serde(skip)]` because instancing is recomputed fresh each load
149 /// and never round-trips through the JSON/disk cache.
150 #[serde(skip)]
151 pub instance: Option<InstanceMeta>,
152 /// Local (pre-placement, object-space) AABB (issue #1474) — see
153 /// `ifc_lite_geometry::Mesh::local_bounds`. Purely in-memory, like
154 /// `instance` — `#[serde(skip)]`, recomputed fresh each load.
155 #[serde(skip)]
156 pub local_bounds: Option<[f32; 6]>,
157 /// The resolved `IfcLocalPlacement` chain applied to this mesh (issue
158 /// #1474), row-major — see `ifc_lite_geometry::Mesh::local_to_world`.
159 /// Purely in-memory, like `instance` — `#[serde(skip)]`.
160 #[serde(skip)]
161 pub local_to_world: Option<[f64; 16]>,
162}
163
164fn geometry_class_is_occurrence(class: &u8) -> bool {
165 *class == 0
166}
167
168fn origin_is_zero(origin: &[f64; 3]) -> bool {
169 origin[0] == 0.0 && origin[1] == 0.0 && origin[2] == 0.0
170}
171
172impl MeshData {
173 /// Create a new MeshData from geometry components.
174 pub fn new(
175 express_id: u32,
176 ifc_type: String,
177 positions: Vec<f32>,
178 normals: Vec<f32>,
179 indices: Vec<u32>,
180 color: [f32; 4],
181 ) -> Self {
182 Self {
183 express_id,
184 ifc_type,
185 global_id: None,
186 name: None,
187 presentation_layer: None,
188 positions,
189 normals,
190 indices,
191 color,
192 material_name: None,
193 geometry_item_id: None,
194 material_id: None,
195 properties: None,
196 uvs: None,
197 texture: None,
198 geometry_class: 0,
199 origin: [0.0; 3],
200 instance: None,
201 local_bounds: None,
202 local_to_world: None,
203 }
204 }
205
206 /// Attach GPU-instancing metadata (see the `instance` field).
207 pub fn with_instance(mut self, instance: Option<InstanceMeta>) -> Self {
208 self.instance = instance;
209 self
210 }
211
212 /// Set the local (pre-placement, object-space) AABB (see `local_bounds`).
213 pub fn with_local_bounds(mut self, local_bounds: Option<[f32; 6]>) -> Self {
214 self.local_bounds = local_bounds;
215 self
216 }
217
218 /// Set the resolved placement transform (see `local_to_world`).
219 pub fn with_local_to_world(mut self, local_to_world: Option<[f64; 16]>) -> Self {
220 self.local_to_world = local_to_world;
221 self
222 }
223
224 /// Tag the geometry's provenance for the Model/Types view switch (#957).
225 pub fn with_geometry_class(mut self, geometry_class: u8) -> Self {
226 self.geometry_class = geometry_class;
227 self
228 }
229
230 /// Set the per-mesh local origin (positions are relative to it).
231 pub fn with_origin(mut self, origin: [f64; 3]) -> Self {
232 self.origin = origin;
233 self
234 }
235
236 /// Set element-level IFC metadata.
237 pub fn with_element_metadata(
238 mut self,
239 global_id: Option<String>,
240 name: Option<String>,
241 presentation_layer: Option<String>,
242 ) -> Self {
243 self.global_id = global_id;
244 self.name = name;
245 self.presentation_layer = presentation_layer;
246 self
247 }
248
249 /// Set material name and the source id, routed to whichever of the two
250 /// disjoint fields the id actually IS.
251 ///
252 /// Takes the discriminator rather than the destination field so a caller
253 /// cannot put a material id in `geometry_item_id` by picking the wrong
254 /// setter — the confusion #3199 removes is exactly that, and a two-setter
255 /// API would leave it one typo away.
256 ///
257 /// **A source id of `0` becomes `None`, on BOTH fields.** STEP instance
258 /// names start at `#1`, so `0` is never an entity; every producer that
259 /// hands one here is passing its own "no reference" sentinel through.
260 /// `material_layer_index.rs` is the live case: `IfcMaterialLayer.Material`
261 /// is OPTIONAL and the layer's `material_id` is `get_ref(0).unwrap_or(0)`,
262 /// so an air gap or ventilated cavity arrives as `0`. Storing that would
263 /// make `material_id` say "a slice of `IfcMaterial #0`", and a host that
264 /// followed it — the one thing this field exists for — would land on
265 /// nothing. That is the defect #3199 fixes, one field over, so the filter
266 /// lives HERE rather than at each producer, where the next producer would
267 /// have to remember it.
268 pub fn with_style_metadata(
269 mut self,
270 material_name: Option<String>,
271 source_id: Option<u32>,
272 id_is_material: bool,
273 ) -> Self {
274 self.material_name = material_name;
275 let source_id = source_id.filter(|&id| id != 0);
276 if id_is_material {
277 self.material_id = source_id;
278 self.geometry_item_id = None;
279 } else {
280 self.geometry_item_id = source_id;
281 self.material_id = None;
282 }
283 self
284 }
285
286 /// Attach optional IFC property set values.
287 pub fn with_properties(mut self, properties: Option<BTreeMap<String, String>>) -> Self {
288 self.properties = properties;
289 self
290 }
291
292 /// Get the number of vertices.
293 pub fn vertex_count(&self) -> usize {
294 self.positions.len() / 3
295 }
296
297 /// Get the number of triangles.
298 pub fn triangle_count(&self) -> usize {
299 self.indices.len() / 3
300 }
301
302 /// Check if the mesh is empty.
303 pub fn is_empty(&self) -> bool {
304 self.positions.is_empty() || self.indices.is_empty()
305 }
306}
307
308/// #1623 Phase 2 "don't-bake": a non-template occurrence of a shared
309/// `IfcRepresentationMap` that skipped the per-occurrence vertex materialize. The
310/// router emits an instance-only placeholder (empty geometry carrying
311/// `InstanceMeta`); [`crate::element::emit_sub_meshes`] turns it into one of these,
312/// and the streaming finalize resolves it against the template MeshData into an
313/// [`InstanceRecord`]. Purely in-memory (recomputed each load), never serialized.
314#[derive(Debug, Clone)]
315pub struct RawInstanceOccurrence {
316 /// This occurrence's IFC element id.
317 pub express_id: u32,
318 /// IFC type name (e.g. "IfcFlowFitting").
319 pub ifc_type: String,
320 /// IFC GlobalId when available.
321 pub global_id: Option<String>,
322 /// IFC Name when available.
323 pub name: Option<String>,
324 /// IFC presentation layer assignment name when available.
325 pub presentation_layer: Option<String>,
326 /// This occurrence's resolved RGBA colour.
327 pub color: [f32; 4],
328 /// Shared-template key = the `IfcRepresentationMap` express id. Matches the
329 /// template MeshData's `instance.rep_identity`.
330 pub rep_identity: u128,
331 /// PRE-RTC composed world transform (row-major) `transform · local · canonical`
332 /// — the same composition `collate_refs` computes for a baked occurrence, but
333 /// captured WITHOUT materializing vertices. The finalize reduces it to the
334 /// post-RTC frame and derives the template-relative `InstanceRecord.transform`.
335 pub world_transform: [f64; 16],
336}
337
338/// #1623 Phase 2: one resolved occurrence of a shared template geometry, emitted in
339/// [`crate::ProcessingResult::instances`] instead of a full materialized mesh when
340/// `StreamingOptions.enable_instancing` is set. The consumer uploads the template
341/// MeshData (`template_express_id`, still in `meshes`) once and draws this occurrence
342/// by applying `transform` to the template's baked world geometry. Purely in-memory,
343/// like [`MeshData::instance`] — recomputed fresh each load, never round-trips a cache.
344#[derive(Debug, Clone)]
345pub struct InstanceRecord {
346 /// This occurrence's IFC element id.
347 pub express_id: u32,
348 /// IFC type name (e.g. "IfcFlowFitting").
349 pub ifc_type: String,
350 /// IFC GlobalId when available.
351 pub global_id: Option<String>,
352 /// IFC Name when available.
353 pub name: Option<String>,
354 /// IFC presentation layer assignment name when available.
355 pub presentation_layer: Option<String>,
356 /// This occurrence's RGBA colour (may differ from the template occurrence's).
357 pub color: [f32; 4],
358 /// `express_id` of the template `MeshData` this occurrence instantiates — the
359 /// consumer's link from record to the geometry it draws (JS-safe u32).
360 pub template_express_id: u32,
361 /// Representation-identity of the shared geometry (`IfcRepresentationMap` id).
362 pub rep_identity: u128,
363 /// Row-major, TEMPLATE-RELATIVE mat4: applied to the template's baked world
364 /// geometry (`template.origin + positions`) it yields this occurrence's world
365 /// geometry (`rel_k = post_rtc(M_k) · post_rtc(M_ref)⁻¹`).
366 pub transform: [f32; 16],
367}