Skip to main content

ifc_lite_geometry/processors/texture/
mod.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//! IFC surface-texture resolution (issues #961, #1781).
6//!
7//! Decodes `IfcBlobTexture` (embedded PNG) and `IfcPixelTexture` (raw pixels)
8//! to RGBA8, and resolves `IfcIndexedTriangleTextureMap` per-triangle texture
9//! coordinates aligned with the tessellated face set. All texture logic lives
10//! in Rust so the server, CLI, SDK and the browser (wasm) path share one
11//! implementation — no Rust/TS drift. The browser layer only uploads the
12//! decoded RGBA to a GPU texture; it performs no IFC or image decoding.
13//!
14//! `IfcImageTexture` (#1781) resolves to an [`ImageTextureRef`] — the
15//! `URLReference` plus repeat flags — NOT decoded pixels: the image bytes live
16//! outside the model (typically a sibling file inside the `.ifcZIP` container),
17//! and the real-world files reference multi-megapixel JPEGs shared by dozens of
18//! face sets, so shipping decoded RGBA through every worker/mesh would multiply
19//! hundreds of MB. The host layer (browser: `createImageBitmap` on the zip
20//! sibling; native consumers: the file next to the .ifc) resolves the reference
21//! ONCE per `texture_id` and shares the GPU upload.
22
23use ifc_lite_core::{DecodedEntity, EntityDecoder, EntityScanner, IfcType};
24
25mod raster;
26mod raster_header;
27pub use raster::{decode_step_binary, embedded_raster_dimensions, MAX_TEX_DIM as MAX_TEXTURE_DIMENSION};
28use raster::{decode_raster_image, MAX_TEX_DIM};
29use rustc_hash::FxHashMap;
30use std::sync::Arc;
31
32/// A decoded RGBA8 image ready for GPU upload.
33#[derive(Debug, Clone)]
34pub struct MeshTexture {
35    /// `width * height * 4` bytes, row-major, top-down, straight alpha.
36    /// `Arc`-shared so every mesh/attachment referencing this texture reuses
37    /// ONE pixel allocation (#1781 — real files share a multi-megapixel image
38    /// across dozens of face sets).
39    pub rgba: std::sync::Arc<Vec<u8>>,
40    pub width: u32,
41    pub height: u32,
42    /// `IfcSurfaceTexture.RepeatS/RepeatT` → sampler wrap (repeat vs clamp).
43    pub repeat_s: bool,
44    pub repeat_t: bool,
45}
46
47/// Where a resolved surface texture's pixels come from.
48#[derive(Debug, Clone)]
49pub enum TextureSource {
50    /// Decoded RGBA8 (`IfcBlobTexture` / `IfcPixelTexture`), shared via `Arc`
51    /// across every face set that maps the same texture entity.
52    Decoded(Arc<MeshTexture>),
53    /// `IfcImageTexture` (#1781): an external image reference the host layer
54    /// resolves (e.g. a sibling file inside the `.ifcZIP` container).
55    Image(ImageTextureRef),
56}
57
58/// An unresolved `IfcImageTexture` reference (#1781).
59#[derive(Debug, Clone)]
60pub struct ImageTextureRef {
61    /// `IfcImageTexture.URLReference` verbatim (usually a relative filename).
62    pub url: String,
63    pub repeat_s: bool,
64    pub repeat_t: bool,
65}
66
67/// A surface texture attached to an output mesh: the stable dedup key plus the
68/// pixel source. `texture_id` is the `IfcSurfaceTexture` express id — every
69/// mesh sampling the same image carries the same id, so consumers create one
70/// GPU texture per id instead of one per mesh.
71#[derive(Debug, Clone)]
72pub struct TextureAttachment {
73    pub texture_id: u32,
74    pub source: TextureSource,
75}
76
77/// A fully resolved `IfcIndexedTriangleTextureMap` for one face set.
78#[derive(Debug, Clone)]
79pub struct ResolvedTextureMap {
80    /// Express id of the source `IfcSurfaceTexture` (dedup key).
81    pub texture_id: u32,
82    pub texture: TextureSource,
83    /// `IfcTextureVertexList.TexCoordsList` as `[u, v]` (0-based storage).
84    pub tex_coords: Vec<[f32; 2]>,
85    /// `TexCoordIndex`: per-triangle 1-based indices into `tex_coords`,
86    /// parallel to the face set's `CoordIndex`. `None` when the attribute is
87    /// `$` — the spec default, meaning texture vertices pair 1:1 with the face
88    /// set's `Coordinates`, so its `CoordIndex` doubles as the UV index (the
89    /// SketchUp IFC Manager export path authors exactly this shape, #1781).
90    pub tex_coord_index: Option<Vec<[u32; 3]>>,
91}
92
93impl ResolvedTextureMap {
94    /// The attachment consumers stamp on meshes produced from this map.
95    pub fn attachment(&self) -> TextureAttachment {
96        TextureAttachment {
97            texture_id: self.texture_id,
98            source: self.texture.clone(),
99        }
100    }
101}
102
103// NOTE: `IfcSurfaceTexture.TextureTransform` (IfcCartesianTransformationOperator2D)
104// is intentionally NOT applied. The authored `IfcTextureVertexList` coordinates
105// already map the image as intended (the buildingSMART annex-E reference renders
106// them ~1:1); applying the operator's Scale (e.g. 48 in the blob fixture)
107// over-tiles the texture into noise. If a future file genuinely needs a UV
108// rotation/offset we can revisit, but no test fixture requires it.
109
110/// Decode `IfcBlobTexture` → RGBA8. Attributes (IFC4):
111/// RepeatS(0), RepeatT(1), Mode(2), TextureTransform(3), Parameter(4),
112/// RasterFormat(5), RasterCode(6).
113fn decode_blob_texture(entity: &DecodedEntity) -> Option<MeshTexture> {
114    let raster_code = entity.get(6).and_then(|a| a.as_string())?;
115    let bytes = decode_step_binary(raster_code);
116    if bytes.len() < 8 {
117        return None;
118    }
119    // Dispatch on the image's magic bytes (PNG or JPEG) rather than trusting the
120    // RasterFormat string spelling ('PNG' / 'JPG' / 'JPEG' all occur).
121    let (rgba, width, height) = decode_raster_image(&bytes)?;
122    Some(MeshTexture {
123        rgba: Arc::new(rgba),
124        width,
125        height,
126        repeat_s: read_bool(entity, 0).unwrap_or(true),
127        repeat_t: read_bool(entity, 1).unwrap_or(true),
128    })
129}
130
131/// Decode `IfcPixelTexture` → RGBA8. Attributes (IFC4):
132/// RepeatS(0), RepeatT(1), Mode(2), TextureTransform(3), Parameter(4),
133/// Width(5), Height(6), ColourComponents(7), Pixel(8 = list of BINARY).
134fn decode_pixel_texture(entity: &DecodedEntity) -> Option<MeshTexture> {
135    // Validate the signed values BEFORE casting — a malformed `-1` would become
136    // u32::MAX and try to reserve absurd memory. Bound the dimensions
137    // (16384² RGBA ≈ 1 GiB) so a hostile/garbage file is rejected cleanly.
138    let width = entity.get(5).and_then(|a| a.as_int())?;
139    let height = entity.get(6).and_then(|a| a.as_int())?;
140    let components = entity.get(7).and_then(|a| a.as_int())?;
141    let max_dim = MAX_TEX_DIM as i64;
142    if width <= 0
143        || height <= 0
144        || width > max_dim
145        || height > max_dim
146        || !(1..=4).contains(&components)
147    {
148        return None;
149    }
150    let width = width as u32;
151    let height = height as u32;
152    let components = components as usize;
153    let pixels = entity.get(8).and_then(|a| a.as_list())?;
154    let expected = (width as usize) * (height as usize);
155    // Reject a cardinality mismatch BEFORE decoding: a hostile file declaring
156    // tiny dimensions but carrying a huge Pixel list would otherwise decode
157    // (and allocate) the whole list just to fail the length check at the end.
158    if pixels.len() != expected {
159        return None;
160    }
161    let mut rgba = Vec::with_capacity(expected * 4);
162    for px in pixels.iter() {
163        let s = px.as_string()?;
164        let comp = decode_step_binary(s);
165        if comp.len() < components {
166            return None;
167        }
168        // Expand 1..=4 colour components to RGBA8.
169        let (r, g, b, a) = match components {
170            1 => (comp[0], comp[0], comp[0], 255),
171            2 => (comp[0], comp[0], comp[0], comp[1]),
172            3 => (comp[0], comp[1], comp[2], 255),
173            _ => (comp[0], comp[1], comp[2], comp[3]),
174        };
175        rgba.extend_from_slice(&[r, g, b, a]);
176    }
177    if rgba.len() != expected * 4 {
178        return None;
179    }
180    Some(MeshTexture {
181        rgba: Arc::new(rgba),
182        width,
183        height,
184        repeat_s: read_bool(entity, 0).unwrap_or(true),
185        repeat_t: read_bool(entity, 1).unwrap_or(true),
186    })
187}
188
189fn read_bool(entity: &DecodedEntity, idx: usize) -> Option<bool> {
190    entity.get(idx).and_then(|a| a.as_enum()).map(|v| v == "T")
191}
192
193/// Read `IfcImageTexture` → an [`ImageTextureRef`] (#1781). Attributes (IFC4):
194/// RepeatS(0), RepeatT(1), Mode(2), TextureTransform(3), Parameter(4),
195/// URLReference(5). The URL is carried verbatim for the host layer to resolve;
196/// `TextureTransform` is intentionally ignored like the other subtypes (see the
197/// NOTE above `decode_step_binary`).
198fn read_image_texture(entity: &DecodedEntity) -> Option<ImageTextureRef> {
199    let url = entity.get(5).and_then(|a| a.as_string())?.trim().to_string();
200    if url.is_empty() {
201        return None;
202    }
203    Some(ImageTextureRef {
204        url,
205        repeat_s: read_bool(entity, 0).unwrap_or(true),
206        repeat_t: read_bool(entity, 1).unwrap_or(true),
207    })
208}
209
210/// Resolve an `IfcSurfaceTexture` subtype reference to a pixel source. Decoded
211/// results are cached per `build_texture_index` run: real files map ONE texture
212/// entity from dozens of `IfcIndexedTriangleTextureMap`s (one per face set), so
213/// without the cache the same image would decode once per face set.
214fn resolve_surface_texture(
215    texture_id: u32,
216    decoder: &mut EntityDecoder,
217    cache: &mut FxHashMap<u32, Option<TextureSource>>,
218) -> Option<TextureSource> {
219    if let Some(cached) = cache.get(&texture_id) {
220        return cached.clone();
221    }
222    let resolved = decoder.decode_by_id(texture_id).ok().and_then(|entity| {
223        match entity.ifc_type {
224            IfcType::IfcBlobTexture => decode_blob_texture(&entity)
225                .map(|t| TextureSource::Decoded(Arc::new(t))),
226            IfcType::IfcPixelTexture => decode_pixel_texture(&entity)
227                .map(|t| TextureSource::Decoded(Arc::new(t))),
228            IfcType::IfcImageTexture => read_image_texture(&entity).map(TextureSource::Image),
229            _ => None,
230        }
231    });
232    cache.insert(texture_id, resolved.clone());
233    resolved
234}
235
236/// Resolve a single `IfcIndexedTriangleTextureMap` entity into a
237/// [`ResolvedTextureMap`] keyed by the face set it maps to.
238/// Attributes: Maps(0 = list of IfcSurfaceTexture), MappedTo(1 = face set),
239/// TexCoords(2 = IfcTextureVertexList), TexCoordIndex(3 = list of 3 ints).
240fn resolve_triangle_texture_map(
241    entity: &DecodedEntity,
242    decoder: &mut EntityDecoder,
243    texture_cache: &mut FxHashMap<u32, Option<TextureSource>>,
244) -> Option<(u32, ResolvedTextureMap)> {
245    let face_set_id = entity.get_ref(1)?;
246
247    // Maps[0] → surface texture.
248    let maps = entity.get(0)?.as_list()?;
249    let texture_id = maps.iter().find_map(|m| m.as_entity_ref())?;
250    let texture = resolve_surface_texture(texture_id, decoder, texture_cache)?;
251
252    // TexCoords → IfcTextureVertexList.TexCoordsList (attr 0). Use `map` +
253    // `collect::<Option<_>>` (NOT filter_map): a malformed entry must reject the
254    // whole map, not silently drop a row. Dropping one shifts every later row
255    // left, and `tex_coord_index[n]` must stay parallel to triangle `n` in
256    // build_flat_shaded_mesh_with_uvs — a compressed list scrambles all UVs.
257    let tvl_id = entity.get_ref(2)?;
258    let tvl = decoder.decode_by_id(tvl_id).ok()?;
259    let coord_list = tvl.get(0)?.as_list()?;
260    let tex_coords: Vec<[f32; 2]> = coord_list
261        .iter()
262        .map(|c| {
263            let uv = c.as_list()?;
264            let u = uv.first().and_then(|v| v.as_float())? as f32;
265            let v = uv.get(1).and_then(|v| v.as_float())? as f32;
266            Some([u, v])
267        })
268        .collect::<Option<Vec<_>>>()?;
269    if tex_coords.is_empty() {
270        return None;
271    }
272
273    // TexCoordIndex (attr 3) → per-triangle [i, j, k]. Same all-or-nothing rule
274    // so the index stays 1:1 with the triangle list. `$` (null) is VALID per
275    // spec — texture vertices then pair 1:1 with the face set's Coordinates and
276    // its CoordIndex doubles as the UV index (`None` here; the mesher derives
277    // the per-triangle index from the face set itself, #1781).
278    let tex_coord_index: Option<Vec<[u32; 3]>> = match entity.get(3) {
279        Some(attr) if !attr.is_null() => {
280            let index_attr = attr.as_list()?;
281            let idx: Vec<[u32; 3]> = index_attr
282                .iter()
283                .map(|tri| {
284                    let t = tri.as_list()?;
285                    let a = t.first().and_then(|v| v.as_int())? as u32;
286                    let b = t.get(1).and_then(|v| v.as_int())? as u32;
287                    let c = t.get(2).and_then(|v| v.as_int())? as u32;
288                    Some([a, b, c])
289                })
290                .collect::<Option<Vec<_>>>()?;
291            if idx.is_empty() {
292                return None;
293            }
294            Some(idx)
295        }
296        _ => None,
297    };
298
299    Some((
300        face_set_id,
301        ResolvedTextureMap {
302            texture_id,
303            texture,
304            tex_coords,
305            tex_coord_index,
306        },
307    ))
308}
309
310/// Scan the model for `IfcIndexedTriangleTextureMap` entities and build an index
311/// keyed by the face set id each one maps to (issue #961). Cheap substring
312/// bail-out keeps untextured files (the overwhelming majority) off the scan.
313pub fn build_texture_index(
314    content: &[u8],
315    decoder: &mut EntityDecoder,
316) -> FxHashMap<u32, ResolvedTextureMap> {
317    let mut index = FxHashMap::default();
318    if ifc_lite_core::find_keyword(content, b"IFCINDEXEDTRIANGLETEXTUREMAP").is_none() {
319        return index;
320    }
321    let mut texture_cache: FxHashMap<u32, Option<TextureSource>> = FxHashMap::default();
322    let mut scanner = EntityScanner::new(content);
323    while let Some((id, type_name, start, end)) = scanner.next_entity() {
324        if !ifc_lite_core::keyword_eq(type_name, "IFCINDEXEDTRIANGLETEXTUREMAP") {
325            continue;
326        }
327        if let Ok(entity) = decoder.decode_at_with_id(id, start, end) {
328            if let Some((face_set_id, resolved)) =
329                resolve_triangle_texture_map(&entity, decoder, &mut texture_cache)
330            {
331                index.entry(face_set_id).or_insert(resolved);
332            }
333        }
334    }
335    index
336}