Skip to main content

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 and the cached `IfcMappedItem` path. A boolean result reports
105    /// the `IfcBooleanResult` id, which is a real entity. GPU-instanced
106    /// occurrences used to be `None` here too; they now carry the id through
107    /// [`RawInstanceOccurrence::geometry_item_id`] and the IFNS wire format.
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub geometry_item_id: Option<u32>,
110    /// The `IfcMaterial` whose layer this mesh is a slice of.
111    ///
112    /// ALWAYS a material, and never a representation item. Set only on the
113    /// material-layer path (`router/layers.rs`), and DISJOINT from
114    /// [`Self::geometry_item_id`] — never both, so a consumer that ignores the
115    /// distinction still cannot read one as the other (#3199).
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub material_id: Option<u32>,
118    /// Optional IFC property set values keyed by IFC property names.
119    /// Primarily attached for IfcSpace/IfcZone so downstream tools can build room attribute UIs.
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub properties: Option<BTreeMap<String, String>>,
122    /// Per-vertex texture coordinates (u, v pairs, 1:1 with `positions`),
123    /// present only for textured meshes (issue #961).
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub uvs: Option<Vec<f32>>,
126    /// Decoded surface texture, present only for textured meshes (#961).
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub texture: Option<MeshTextureData>,
129    /// Provenance of the geometry for the viewer's Model/Types switch (#957):
130    /// 0 = ordinary occurrence, 1 = orphan type-product RepresentationMap (no
131    /// occurrence instantiates it), 2 = instanced type-product map (the type
132    /// library shape; its occurrences already draw the real geometry), 3 =
133    /// occurrence geometry of a material-layer wall (`GEOM_CLASS_LAYER_SLICE`).
134    /// Serde-default so existing JSON payloads and disk caches stay readable;
135    /// skipped when 0 so ordinary meshes serialize byte-identically.
136    #[serde(default, skip_serializing_if = "geometry_class_is_occurrence")]
137    pub geometry_class: u8,
138    /// Per-mesh local origin (world/RTC frame, f64). `positions` are stored
139    /// RELATIVE to this — the world position of a vertex is `origin + position` —
140    /// so building/georef-scale placement never collapses adjacent vertices to
141    /// bit-identical f32. The renderer applies it as a per-mesh translation
142    /// (camera-relative). `[0, 0, 0]` ⇒ positions are absolute (legacy/local).
143    /// Serde-default + skip-when-zero so existing payloads/caches stay readable
144    /// and local meshes serialize byte-identically.
145    #[serde(default, skip_serializing_if = "origin_is_zero")]
146    pub origin: [f64; 3],
147    /// GPU-instancing metadata (rep-identity + per-occurrence world transform),
148    /// attached only when `IFC_LITE_INSTANCING` is on and the element is a clean
149    /// single-item mapped instance. Purely in-memory for the native streaming
150    /// path — `#[serde(skip)]` because instancing is recomputed fresh each load
151    /// and never round-trips through the JSON/disk cache.
152    #[serde(skip)]
153    pub instance: Option<InstanceMeta>,
154    /// Local (pre-placement, object-space) AABB (issue #1474) — see
155    /// `ifc_lite_geometry::Mesh::local_bounds`. Purely in-memory, like
156    /// `instance` — `#[serde(skip)]`, recomputed fresh each load.
157    #[serde(skip)]
158    pub local_bounds: Option<[f32; 6]>,
159    /// The resolved `IfcLocalPlacement` chain applied to this mesh (issue
160    /// #1474), row-major — see `ifc_lite_geometry::Mesh::local_to_world`.
161    /// Purely in-memory, like `instance` — `#[serde(skip)]`.
162    #[serde(skip)]
163    pub local_to_world: Option<[f64; 16]>,
164}
165
166fn geometry_class_is_occurrence(class: &u8) -> bool {
167    *class == 0
168}
169
170fn origin_is_zero(origin: &[f64; 3]) -> bool {
171    origin[0] == 0.0 && origin[1] == 0.0 && origin[2] == 0.0
172}
173
174impl MeshData {
175    /// Create a new MeshData from geometry components.
176    pub fn new(
177        express_id: u32,
178        ifc_type: String,
179        positions: Vec<f32>,
180        normals: Vec<f32>,
181        indices: Vec<u32>,
182        color: [f32; 4],
183    ) -> Self {
184        Self {
185            express_id,
186            ifc_type,
187            global_id: None,
188            name: None,
189            presentation_layer: None,
190            positions,
191            normals,
192            indices,
193            color,
194            material_name: None,
195            geometry_item_id: None,
196            material_id: None,
197            properties: None,
198            uvs: None,
199            texture: None,
200            geometry_class: 0,
201            origin: [0.0; 3],
202            instance: None,
203            local_bounds: None,
204            local_to_world: None,
205        }
206    }
207
208    /// Attach GPU-instancing metadata (see the `instance` field).
209    pub fn with_instance(mut self, instance: Option<InstanceMeta>) -> Self {
210        self.instance = instance;
211        self
212    }
213
214    /// Set the local (pre-placement, object-space) AABB (see `local_bounds`).
215    pub fn with_local_bounds(mut self, local_bounds: Option<[f32; 6]>) -> Self {
216        self.local_bounds = local_bounds;
217        self
218    }
219
220    /// Set the resolved placement transform (see `local_to_world`).
221    pub fn with_local_to_world(mut self, local_to_world: Option<[f64; 16]>) -> Self {
222        self.local_to_world = local_to_world;
223        self
224    }
225
226    /// Tag the geometry's provenance for the Model/Types view switch (#957).
227    pub fn with_geometry_class(mut self, geometry_class: u8) -> Self {
228        self.geometry_class = geometry_class;
229        self
230    }
231
232    /// Set the per-mesh local origin (positions are relative to it).
233    pub fn with_origin(mut self, origin: [f64; 3]) -> Self {
234        self.origin = origin;
235        self
236    }
237
238    /// Set element-level IFC metadata.
239    pub fn with_element_metadata(
240        mut self,
241        global_id: Option<String>,
242        name: Option<String>,
243        presentation_layer: Option<String>,
244    ) -> Self {
245        self.global_id = global_id;
246        self.name = name;
247        self.presentation_layer = presentation_layer;
248        self
249    }
250
251    /// Set material name and the source id, routed to whichever of the two
252    /// disjoint fields the id actually IS.
253    ///
254    /// Takes the discriminator rather than the destination field so a caller
255    /// cannot put a material id in `geometry_item_id` by picking the wrong
256    /// setter — the confusion #3199 removes is exactly that, and a two-setter
257    /// API would leave it one typo away.
258    ///
259    /// **A source id of `0` becomes `None`, on BOTH fields.** STEP instance
260    /// names start at `#1`, so `0` is never an entity; every producer that
261    /// hands one here is passing its own "no reference" sentinel through.
262    /// `material_layer_index.rs` is the live case: `IfcMaterialLayer.Material`
263    /// is OPTIONAL and the layer's `material_id` is `get_ref(0).unwrap_or(0)`,
264    /// so an air gap or ventilated cavity arrives as `0`. Storing that would
265    /// make `material_id` say "a slice of `IfcMaterial #0`", and a host that
266    /// followed it — the one thing this field exists for — would land on
267    /// nothing. That is the defect #3199 fixes, one field over, so the filter
268    /// lives HERE rather than at each producer, where the next producer would
269    /// have to remember it.
270    pub fn with_style_metadata(
271        mut self,
272        material_name: Option<String>,
273        source_id: Option<u32>,
274        id_is_material: bool,
275    ) -> Self {
276        self.material_name = material_name;
277        self.geometry_item_id = Self::style_geometry_item_id(source_id, id_is_material);
278        // The two fields are disjoint BY CONSTRUCTION: the same source id is a
279        // material id exactly when it is not a geometry item id, so the material
280        // half is the same rule with the discriminator negated. Spelling it as
281        // two independent branches is what lets one drift from the other.
282        self.material_id = Self::style_geometry_item_id(source_id, !id_is_material);
283        self
284    }
285
286    /// The `geometry_item_id` a producer's `source_id` resolves to, given the
287    /// #3199 discriminator: `None` when the id is a material's, and `None` for
288    /// the `0` sentinel (see [`Self::with_style_metadata`], which owns the why).
289    /// Passing the NEGATED discriminator gives the `material_id` half.
290    ///
291    /// Both rules live HERE because both are invisible when violated. `element.rs`
292    /// computes the same id for a don't-bake `RawInstanceOccurrence`, which has no
293    /// `MeshData` to run `with_style_metadata` over, so it calls this rather than
294    /// restating the pair.
295    pub fn style_geometry_item_id(source_id: Option<u32>, id_is_material: bool) -> Option<u32> {
296        if id_is_material {
297            None
298        } else {
299            source_id.filter(|&id| id != 0)
300        }
301    }
302
303    /// Attach optional IFC property set values.
304    pub fn with_properties(mut self, properties: Option<BTreeMap<String, String>>) -> Self {
305        self.properties = properties;
306        self
307    }
308
309    /// Get the number of vertices.
310    pub fn vertex_count(&self) -> usize {
311        self.positions.len() / 3
312    }
313
314    /// Get the number of triangles.
315    pub fn triangle_count(&self) -> usize {
316        self.indices.len() / 3
317    }
318
319    /// Check if the mesh is empty.
320    pub fn is_empty(&self) -> bool {
321        self.positions.is_empty() || self.indices.is_empty()
322    }
323}
324
325/// #1623 Phase 2 "don't-bake": a non-template occurrence of a shared
326/// `IfcRepresentationMap` that skipped the per-occurrence vertex materialize. The
327/// router emits an instance-only placeholder (empty geometry carrying
328/// `InstanceMeta`); [`crate::element::emit_sub_meshes`] turns it into one of these,
329/// and the streaming finalize resolves it against the template MeshData into an
330/// [`InstanceRecord`]. Purely in-memory (recomputed each load), never serialized.
331#[derive(Debug, Clone)]
332pub struct RawInstanceOccurrence {
333    /// This occurrence's IFC element id.
334    pub express_id: u32,
335    /// IFC type name (e.g. "IfcFlowFitting").
336    pub ifc_type: String,
337    /// IFC GlobalId when available.
338    pub global_id: Option<String>,
339    /// IFC Name when available.
340    pub name: Option<String>,
341    /// IFC presentation layer assignment name when available.
342    pub presentation_layer: Option<String>,
343    /// This occurrence's resolved RGBA colour.
344    pub color: [f32; 4],
345    /// Shared-template key = the `IfcRepresentationMap` express id. Matches the
346    /// template MeshData's `instance.rep_identity`.
347    pub rep_identity: u128,
348    /// PRE-RTC composed world transform (row-major) `transform · local · canonical`
349    /// — the same composition `collate_refs` computes for a baked occurrence, but
350    /// captured WITHOUT materializing vertices. The finalize reduces it to the
351    /// post-RTC frame and derives the template-relative `InstanceRecord.transform`.
352    pub world_transform: [f64; 16],
353    /// The `IfcRepresentationItem` this occurrence's geometry comes from — the
354    /// same id a materialized sub-mesh would carry in
355    /// [`MeshData::geometry_item_id`], read off the sub-mesh that was NOT
356    /// baked. `None` when the collection's ids are material ids rather than
357    /// item ids (#3199) or when the producer had none.
358    pub geometry_item_id: Option<u32>,
359}
360
361/// #1623 Phase 2: one resolved occurrence of a shared template geometry, emitted in
362/// [`crate::ProcessingResult::instances`] instead of a full materialized mesh when
363/// `StreamingOptions.enable_instancing` is set. The consumer uploads the template
364/// MeshData (`template_express_id`, still in `meshes`) once and draws this occurrence
365/// by applying `transform` to the template's baked world geometry. Purely in-memory,
366/// like [`MeshData::instance`] — recomputed fresh each load, never round-trips a cache.
367#[derive(Debug, Clone)]
368pub struct InstanceRecord {
369    /// This occurrence's IFC element id.
370    pub express_id: u32,
371    /// IFC type name (e.g. "IfcFlowFitting").
372    pub ifc_type: String,
373    /// IFC GlobalId when available.
374    pub global_id: Option<String>,
375    /// IFC Name when available.
376    pub name: Option<String>,
377    /// IFC presentation layer assignment name when available.
378    pub presentation_layer: Option<String>,
379    /// This occurrence's RGBA colour (may differ from the template occurrence's).
380    pub color: [f32; 4],
381    /// `express_id` of the template `MeshData` this occurrence instantiates — the
382    /// consumer's link from record to the geometry it draws (JS-safe u32).
383    pub template_express_id: u32,
384    /// Representation-identity of the shared geometry (`IfcRepresentationMap` id).
385    pub rep_identity: u128,
386    /// Row-major, TEMPLATE-RELATIVE mat4: applied to the template's baked world
387    /// geometry (`template.origin + positions`) it yields this occurrence's world
388    /// geometry (`rel_k = post_rtc(M_k) · post_rtc(M_ref)⁻¹`).
389    pub transform: [f32; 16],
390    /// The `IfcRepresentationItem` this occurrence's geometry comes from, so a
391    /// host can drill from a rendered instanced piece back into the source.
392    /// Same meaning and same disjointness rule as [`MeshData::geometry_item_id`]
393    /// — always a representation item, never an `IfcMaterial`.
394    pub geometry_item_id: Option<u32>,
395}