Skip to main content

ifc_lite_geometry/processors/
texture.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 (issue #961).
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` (external URL) is intentionally out of scope here — it
15//! needs an async fetch resolver outside the geometry pipeline; tracked as a
16//! follow-up.
17
18use ifc_lite_core::{DecodedEntity, EntityDecoder, EntityScanner, IfcType};
19use rustc_hash::FxHashMap;
20
21/// A decoded RGBA8 image ready for GPU upload.
22#[derive(Debug, Clone)]
23pub struct MeshTexture {
24    /// `width * height * 4` bytes, row-major, top-down, straight alpha.
25    pub rgba: Vec<u8>,
26    pub width: u32,
27    pub height: u32,
28    /// `IfcSurfaceTexture.RepeatS/RepeatT` → sampler wrap (repeat vs clamp).
29    pub repeat_s: bool,
30    pub repeat_t: bool,
31}
32
33/// A fully resolved `IfcIndexedTriangleTextureMap` for one face set.
34#[derive(Debug, Clone)]
35pub struct ResolvedTextureMap {
36    pub texture: MeshTexture,
37    /// `IfcTextureVertexList.TexCoordsList` as `[u, v]` (0-based storage).
38    pub tex_coords: Vec<[f32; 2]>,
39    /// `TexCoordIndex`: per-triangle 1-based indices into `tex_coords`,
40    /// parallel to the face set's `CoordIndex`.
41    pub tex_coord_index: Vec<[u32; 3]>,
42}
43
44// NOTE: `IfcSurfaceTexture.TextureTransform` (IfcCartesianTransformationOperator2D)
45// is intentionally NOT applied. The authored `IfcTextureVertexList` coordinates
46// already map the image as intended (the buildingSMART annex-E reference renders
47// them ~1:1); applying the operator's Scale (e.g. 48 in the blob fixture)
48// over-tiles the texture into noise. If a future file genuinely needs a UV
49// rotation/offset we can revisit, but no test fixture requires it.
50
51/// Decode a STEP binary literal as surfaced by the decoder (the parser strips
52/// the surrounding double quotes; the first hex character is the count of
53/// unused leading bits — `0` for the byte-aligned data every IFC texture
54/// fixture uses). Strips that leading character and hex-decodes the remainder
55/// pairwise. Returns the raw bytes (e.g. a complete PNG file for a blob, or one
56/// pixel's colour components for a pixel literal).
57pub fn decode_step_binary(s: &str) -> Vec<u8> {
58    let s = s.trim().trim_matches('"');
59    if s.len() < 3 {
60        return Vec::new();
61    }
62    let hex = s.as_bytes();
63    let mut out = Vec::with_capacity(hex.len() / 2);
64    // Skip index 0 (the unused-bits indicator); decode the rest in pairs.
65    let mut i = 1;
66    while i + 1 < hex.len() {
67        match (hex_val(hex[i]), hex_val(hex[i + 1])) {
68            (Some(h), Some(l)) => out.push((h << 4) | l),
69            _ => break,
70        }
71        i += 2;
72    }
73    out
74}
75
76#[inline]
77fn hex_val(b: u8) -> Option<u8> {
78    match b {
79        b'0'..=b'9' => Some(b - b'0'),
80        b'a'..=b'f' => Some(b - b'a' + 10),
81        b'A'..=b'F' => Some(b - b'A' + 10),
82        _ => None,
83    }
84}
85
86/// Upper bound on a decoded texture's width/height. 16384² RGBA ≈ 1 GiB — a
87/// hostile/garbage image header claiming larger dimensions is rejected BEFORE
88/// any pixel buffer is allocated, so a crafted file can't drive an OOM. Matches
89/// the `IfcPixelTexture` bound.
90const MAX_TEX_DIM: u32 = 16384;
91
92/// Decode a PNG byte buffer to RGBA8. Returns `(rgba, width, height)`.
93fn decode_png(bytes: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
94    // png 0.18 requires the reader to be `BufRead + Seek`. `&[u8]` is `BufRead`
95    // but not `Seek`, so wrap it in a `Cursor`, which satisfies both.
96    let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
97    // EXPAND: palette → RGB, sub-8-bit grayscale → 8-bit, tRNS → alpha.
98    // STRIP_16: 16-bit channels → 8-bit. Leaves Rgb/Rgba/Grayscale/GA at 8-bit.
99    decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
100    let mut reader = decoder.read_info().ok()?;
101    // Reject an oversized header before `output_buffer_size()` allocates.
102    let png_info = reader.info();
103    if png_info.width == 0
104        || png_info.height == 0
105        || png_info.width > MAX_TEX_DIM
106        || png_info.height > MAX_TEX_DIM
107    {
108        return None;
109    }
110    // png 0.18 returns Option here (None on size overflow); propagate as a decode failure.
111    let mut buf = vec![0u8; reader.output_buffer_size()?];
112    let info = reader.next_frame(&mut buf).ok()?;
113    let (w, h) = (info.width, info.height);
114    let px = (w as usize) * (h as usize);
115    let src = &buf[..info.buffer_size()];
116    let mut rgba = Vec::with_capacity(px * 4);
117    match info.color_type {
118        png::ColorType::Rgba => rgba.extend_from_slice(&src[..px * 4]),
119        png::ColorType::Rgb => {
120            for c in src.chunks_exact(3) {
121                rgba.extend_from_slice(&[c[0], c[1], c[2], 255]);
122            }
123        }
124        png::ColorType::Grayscale => {
125            for &g in src.iter() {
126                rgba.extend_from_slice(&[g, g, g, 255]);
127            }
128        }
129        png::ColorType::GrayscaleAlpha => {
130            for c in src.chunks_exact(2) {
131                rgba.extend_from_slice(&[c[0], c[0], c[0], c[1]]);
132            }
133        }
134        // EXPAND should have removed Indexed; bail if a decoder ever leaves it.
135        png::ColorType::Indexed => return None,
136    }
137    if rgba.len() != px * 4 {
138        return None;
139    }
140    Some((rgba, w, h))
141}
142
143/// Decode a JPEG byte buffer to RGBA8. Returns `(rgba, width, height)`.
144fn decode_jpeg(bytes: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
145    let mut decoder = jpeg_decoder::Decoder::new(bytes);
146    // Read just the headers first so an oversized image is rejected before the
147    // full `decode()` allocates its pixel buffer.
148    decoder.read_info().ok()?;
149    let info = decoder.info()?;
150    if info.width == 0
151        || info.height == 0
152        || info.width as u32 > MAX_TEX_DIM
153        || info.height as u32 > MAX_TEX_DIM
154    {
155        return None;
156    }
157    let pixels = decoder.decode().ok()?;
158    let (w, h) = (info.width as usize, info.height as usize);
159    let px = w * h;
160    let mut rgba = Vec::with_capacity(px * 4);
161    match info.pixel_format {
162        jpeg_decoder::PixelFormat::RGB24 => {
163            for c in pixels.chunks_exact(3) {
164                rgba.extend_from_slice(&[c[0], c[1], c[2], 255]);
165            }
166        }
167        jpeg_decoder::PixelFormat::L8 => {
168            for &g in pixels.iter() {
169                rgba.extend_from_slice(&[g, g, g, 255]);
170            }
171        }
172        // L16 / CMYK32 are rare for IFC textures; bail to the white fallback.
173        _ => return None,
174    }
175    if rgba.len() != px * 4 {
176        return None;
177    }
178    Some((rgba, w as u32, h as u32))
179}
180
181/// Decode raster image bytes to RGBA8 by sniffing the magic bytes (PNG or
182/// JPEG), so any `RasterFormat` string spelling resolves correctly.
183fn decode_raster_image(bytes: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
184    const PNG_MAGIC: [u8; 8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
185    if bytes.len() >= 8 && bytes[..8] == PNG_MAGIC {
186        return decode_png(bytes);
187    }
188    // JPEG: starts with FF D8 FF.
189    if bytes.len() >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF {
190        return decode_jpeg(bytes);
191    }
192    None
193}
194
195/// Decode `IfcBlobTexture` → RGBA8. Attributes (IFC4):
196/// RepeatS(0), RepeatT(1), Mode(2), TextureTransform(3), Parameter(4),
197/// RasterFormat(5), RasterCode(6).
198fn decode_blob_texture(entity: &DecodedEntity) -> Option<MeshTexture> {
199    let raster_code = entity.get(6).and_then(|a| a.as_string())?;
200    let bytes = decode_step_binary(raster_code);
201    if bytes.len() < 8 {
202        return None;
203    }
204    // Dispatch on the image's magic bytes (PNG or JPEG) rather than trusting the
205    // RasterFormat string spelling ('PNG' / 'JPG' / 'JPEG' all occur).
206    let (rgba, width, height) = decode_raster_image(&bytes)?;
207    Some(MeshTexture {
208        rgba,
209        width,
210        height,
211        repeat_s: read_bool(entity, 0).unwrap_or(true),
212        repeat_t: read_bool(entity, 1).unwrap_or(true),
213    })
214}
215
216/// Decode `IfcPixelTexture` → RGBA8. Attributes (IFC4):
217/// RepeatS(0), RepeatT(1), Mode(2), TextureTransform(3), Parameter(4),
218/// Width(5), Height(6), ColourComponents(7), Pixel(8 = list of BINARY).
219fn decode_pixel_texture(entity: &DecodedEntity) -> Option<MeshTexture> {
220    // Validate the signed values BEFORE casting — a malformed `-1` would become
221    // u32::MAX and try to reserve absurd memory. Bound the dimensions
222    // (16384² RGBA ≈ 1 GiB) so a hostile/garbage file is rejected cleanly.
223    let width = entity.get(5).and_then(|a| a.as_int())?;
224    let height = entity.get(6).and_then(|a| a.as_int())?;
225    let components = entity.get(7).and_then(|a| a.as_int())?;
226    let max_dim = MAX_TEX_DIM as i64;
227    if width <= 0
228        || height <= 0
229        || width > max_dim
230        || height > max_dim
231        || !(1..=4).contains(&components)
232    {
233        return None;
234    }
235    let width = width as u32;
236    let height = height as u32;
237    let components = components as usize;
238    let pixels = entity.get(8).and_then(|a| a.as_list())?;
239    let expected = (width as usize) * (height as usize);
240    let mut rgba = Vec::with_capacity(expected * 4);
241    for px in pixels.iter() {
242        let s = px.as_string()?;
243        let comp = decode_step_binary(s);
244        if comp.len() < components {
245            return None;
246        }
247        // Expand 1..=4 colour components to RGBA8.
248        let (r, g, b, a) = match components {
249            1 => (comp[0], comp[0], comp[0], 255),
250            2 => (comp[0], comp[0], comp[0], comp[1]),
251            3 => (comp[0], comp[1], comp[2], 255),
252            _ => (comp[0], comp[1], comp[2], comp[3]),
253        };
254        rgba.extend_from_slice(&[r, g, b, a]);
255    }
256    if rgba.len() != expected * 4 {
257        return None;
258    }
259    Some(MeshTexture {
260        rgba,
261        width,
262        height,
263        repeat_s: read_bool(entity, 0).unwrap_or(true),
264        repeat_t: read_bool(entity, 1).unwrap_or(true),
265    })
266}
267
268fn read_bool(entity: &DecodedEntity, idx: usize) -> Option<bool> {
269    entity.get(idx).and_then(|a| a.as_enum()).map(|v| v == "T")
270}
271
272/// Resolve an `IfcSurfaceTexture` subtype reference to a decoded image.
273fn resolve_surface_texture(texture_id: u32, decoder: &mut EntityDecoder) -> Option<MeshTexture> {
274    let entity = decoder.decode_by_id(texture_id).ok()?;
275    match entity.ifc_type {
276        IfcType::IfcBlobTexture => decode_blob_texture(&entity),
277        IfcType::IfcPixelTexture => decode_pixel_texture(&entity),
278        // IfcImageTexture (URL) deferred — needs async fetch outside the kernel.
279        _ => None,
280    }
281}
282
283/// Resolve a single `IfcIndexedTriangleTextureMap` entity into a
284/// [`ResolvedTextureMap`] keyed by the face set it maps to.
285/// Attributes: Maps(0 = list of IfcSurfaceTexture), MappedTo(1 = face set),
286/// TexCoords(2 = IfcTextureVertexList), TexCoordIndex(3 = list of 3 ints).
287fn resolve_triangle_texture_map(
288    entity: &DecodedEntity,
289    decoder: &mut EntityDecoder,
290) -> Option<(u32, ResolvedTextureMap)> {
291    let face_set_id = entity.get_ref(1)?;
292
293    // Maps[0] → surface texture.
294    let maps = entity.get(0)?.as_list()?;
295    let texture_id = maps.iter().find_map(|m| m.as_entity_ref())?;
296    let texture = resolve_surface_texture(texture_id, decoder)?;
297
298    // TexCoords → IfcTextureVertexList.TexCoordsList (attr 0). Use `map` +
299    // `collect::<Option<_>>` (NOT filter_map): a malformed entry must reject the
300    // whole map, not silently drop a row. Dropping one shifts every later row
301    // left, and `tex_coord_index[n]` must stay parallel to triangle `n` in
302    // build_flat_shaded_mesh_with_uvs — a compressed list scrambles all UVs.
303    let tvl_id = entity.get_ref(2)?;
304    let tvl = decoder.decode_by_id(tvl_id).ok()?;
305    let coord_list = tvl.get(0)?.as_list()?;
306    let tex_coords: Vec<[f32; 2]> = coord_list
307        .iter()
308        .map(|c| {
309            let uv = c.as_list()?;
310            let u = uv.first().and_then(|v| v.as_float())? as f32;
311            let v = uv.get(1).and_then(|v| v.as_float())? as f32;
312            Some([u, v])
313        })
314        .collect::<Option<Vec<_>>>()?;
315    if tex_coords.is_empty() {
316        return None;
317    }
318
319    // TexCoordIndex (attr 3) → per-triangle [i, j, k]. Same all-or-nothing rule
320    // so the index stays 1:1 with the triangle list.
321    let index_attr = entity.get(3)?.as_list()?;
322    let tex_coord_index: Vec<[u32; 3]> = index_attr
323        .iter()
324        .map(|tri| {
325            let t = tri.as_list()?;
326            let a = t.first().and_then(|v| v.as_int())? as u32;
327            let b = t.get(1).and_then(|v| v.as_int())? as u32;
328            let c = t.get(2).and_then(|v| v.as_int())? as u32;
329            Some([a, b, c])
330        })
331        .collect::<Option<Vec<_>>>()?;
332    if tex_coord_index.is_empty() {
333        return None;
334    }
335
336    Some((
337        face_set_id,
338        ResolvedTextureMap {
339            texture,
340            tex_coords,
341            tex_coord_index,
342        },
343    ))
344}
345
346/// Scan the model for `IfcIndexedTriangleTextureMap` entities and build an index
347/// keyed by the face set id each one maps to (issue #961). Cheap substring
348/// bail-out keeps untextured files (the overwhelming majority) off the scan.
349pub fn build_texture_index(
350    content: &[u8],
351    decoder: &mut EntityDecoder,
352) -> FxHashMap<u32, ResolvedTextureMap> {
353    let mut index = FxHashMap::default();
354    if !content
355        .windows(b"IFCINDEXEDTRIANGLETEXTUREMAP".len())
356        .any(|window| window == b"IFCINDEXEDTRIANGLETEXTUREMAP")
357    {
358        return index;
359    }
360    let mut scanner = EntityScanner::new(content);
361    while let Some((id, type_name, start, end)) = scanner.next_entity() {
362        if type_name != "IFCINDEXEDTRIANGLETEXTUREMAP" {
363            continue;
364        }
365        if let Ok(entity) = decoder.decode_at_with_id(id, start, end) {
366            if let Some((face_set_id, resolved)) = resolve_triangle_texture_map(&entity, decoder) {
367                index.entry(face_set_id).or_insert(resolved);
368            }
369        }
370    }
371    index
372}