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