ifc_lite_geometry/router/textured.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//! The router's surface-texture channel (#961, #1781): texture-aware
6//! representation-map tessellation (orphan type geometry) and the textured
7//! occurrence sub-mesh entry point. Split from `processing.rs` so the main
8//! element pipeline stays within the module-size house rule; the texture
9//! index itself is built in `crate::processors::texture`.
10
11use ifc_lite_core::{DecodedEntity, EntityDecoder, IfcType};
12
13use super::frame_parts::{single_frame_source, SourceParts};
14use super::GeometryRouter;
15use crate::{Error, Mesh, Result, SubMeshCollection};
16
17impl GeometryRouter {
18 /// Texture-aware [`Self::process_element_with_submeshes`] (#1781): a face
19 /// set listed in `texture_index` becomes its own textured sub-mesh carrying
20 /// per-vertex UVs + the texture attachment. The void path never passes an
21 /// index — a CSG cut rebuilds vertices, which would orphan the UVs, so a
22 /// voided textured element renders with its style colour instead.
23 pub fn process_element_with_submeshes_textured(
24 &self,
25 element: &DecodedEntity,
26 decoder: &mut EntityDecoder,
27 texture_index: &rustc_hash::FxHashMap<u32, crate::processors::texture::ResolvedTextureMap>,
28 ) -> Result<SubMeshCollection> {
29 let textures = if texture_index.is_empty() {
30 None
31 } else {
32 Some(texture_index)
33 };
34 self.process_element_with_submeshes_impl(
35 element,
36 decoder,
37 true,
38 textures,
39 super::processing::SourceHygiene::Watertight,
40 )
41 }
42
43 /// Tessellate an `IfcRepresentationMap`'s `MappedRepresentation` and bake
44 /// its `MappingOrigin` placement (issue #957).
45 ///
46 /// Used to render geometry that hangs off an `IfcTypeProduct` (e.g.
47 /// `IfcBoilerType`) through its `RepresentationMaps` when no occurrence
48 /// instantiates it — the buildingSMART annex-E "tessellated shape with
49 /// style" samples ship exactly this shape (geometry on the type, declared
50 /// via `IfcRelDeclares`, with no product instance).
51 ///
52 /// Unlike [`Self::process_mapped_item_cached`], this applies `MappingOrigin`
53 /// (`IfcRepresentationMap` attr 0) rather than a `MappingTarget`: there is
54 /// no occurrence placement and no `IfcMappedItem` to carry one, so the
55 /// MappingOrigin axis placement is the only transform. It is the caller's
56 /// responsibility to only invoke this for orphan representation maps so
57 /// normally-instanced typed products aren't double-rendered.
58 ///
59 /// Errors when the map's items cannot share one f64 frame (#6446); use
60 /// [`Self::process_representation_map_with_texture`] to keep such a map
61 /// at full precision.
62 pub fn process_representation_map(
63 &self,
64 rep_map: &DecodedEntity,
65 decoder: &mut EntityDecoder,
66 ) -> Result<Mesh> {
67 let empty = rustc_hash::FxHashMap::default();
68 let parts = self.process_representation_map_with_texture(rep_map, decoder, &empty)?;
69 let mut mesh = SourceParts::default();
70 for (part, _uvs, _texture) in parts {
71 mesh.merge(&part);
72 }
73 single_frame_source("IfcRepresentationMap", rep_map.id, mesh.into_parts())
74 }
75
76 /// Texture-aware variant of [`Self::process_representation_map`] (issue
77 /// #961). Returns one render part per output mesh: each textured
78 /// `IfcTriangulatedFaceSet` item becomes its OWN part carrying its UVs +
79 /// decoded image (so a representation with several differently-textured
80 /// items renders each with the correct image), and all untextured items are
81 /// merged into a single part with empty UVs / no texture, except that
82 /// untextured items in frames at least 1 km apart stay separate untextured
83 /// parts (#6446). The MappingOrigin placement is baked into every part.
84 pub fn process_representation_map_with_texture(
85 &self,
86 rep_map: &DecodedEntity,
87 decoder: &mut EntityDecoder,
88 texture_index: &rustc_hash::FxHashMap<u32, crate::processors::texture::ResolvedTextureMap>,
89 ) -> Result<
90 Vec<(
91 Mesh,
92 Vec<f32>,
93 Option<crate::processors::texture::TextureAttachment>,
94 )>,
95 > {
96 // attr 1: MappedRepresentation (IfcShapeRepresentation)
97 let mapped_rep_attr = rep_map.get(1).ok_or_else(|| {
98 Error::geometry("RepresentationMap missing MappedRepresentation".to_string())
99 })?;
100 let mapped_rep = decoder
101 .resolve_ref(mapped_rep_attr)?
102 .ok_or_else(|| Error::geometry("Failed to resolve MappedRepresentation".to_string()))?;
103
104 // attr 3: Items
105 let items_attr = mapped_rep
106 .get(3)
107 .ok_or_else(|| Error::geometry("Representation missing Items".to_string()))?;
108 let items = decoder.resolve_ref_list(items_attr)?;
109
110 // Whether a dropped item here is a real content gap, or just this router
111 // declining to mesh a 2D representation it was never meant to mesh.
112 //
113 // The occurrence path filters representations with `is_body_representation`
114 // before it ever reaches an item (`processing.rs`, "Skip 'Axis', 'Curve2D',
115 // 'FootPrint'"). The TYPE path does not: `plan_type_geometry` selects
116 // RepresentationMaps by reference/instantiation only and never looks at the
117 // identifier, so a Revit/ArchiCAD `IfcDoorType` carrying a 'FootPrint' or
118 // 'Annotation' map hands us `IfcGeometricCurveSet` / `IfcPolyline` /
119 // `IfcAnnotationFillArea` — none of which have a processor, all of which are
120 // CORRECTLY absent from a 3D view. Counting those made a clean model warn
121 // "N representation items dropped ... elements are missing or incomplete",
122 // which is exactly the false positive that would teach users to ignore it.
123 //
124 // Only the counting is gated, not the walk: a non-body map still runs
125 // through the loop (merging nothing) so geometry output is byte-identical.
126 // The scope also makes the count per SOURCE rather than per call — this
127 // map may already have been walked through `mapped_item.rs` — see
128 // `GeometryRouter::enter_unsupported_source`.
129 let _drop_scope = self.enter_unsupported_source(rep_map.id, &mapped_rep);
130
131 let mut untextured = SourceParts::default();
132 // One entry per textured item — keeps each item with its own image.
133 let mut textured: Vec<(
134 Mesh,
135 Vec<f32>,
136 crate::processors::texture::TextureAttachment,
137 )> = Vec::new();
138 for item in items {
139 // A nested IfcMappedItem inside a type's own representation: process
140 // it (applies its MappingTarget) rather than dropping its geometry.
141 if item.ifc_type == IfcType::IfcMappedItem {
142 match self.process_mapped_item_parts(&item, decoder) {
143 // already scaled inside the cached path
144 Ok(sub_parts) => sub_parts.iter().for_each(|part| untextured.merge(part)),
145 Err(_e) => {
146 self.record_unsupported_item(item.ifc_type.clone());
147 crate::diag::diag_debug!(
148 { item_id = item.id, error = %_e,
149 "skipping unsupported nested IfcMappedItem in representation map" }
150 else {
151 #[cfg(debug_assertions)]
152 eprintln!(
153 "[ifc-lite] Skipping unsupported nested IfcMappedItem #{} in representation map: {}",
154 item.id, _e
155 );
156 }
157 );
158 }
159 }
160 continue;
161 }
162
163 // Textured tessellated face set → its own part with per-vertex UVs (#961);
164 // a terrain TIN is a face set that only appends `Flags` (#5942).
165 if matches!(item.ifc_type, IfcType::IfcTriangulatedFaceSet | IfcType::IfcTriangulatedIrregularNetwork) {
166 if let Some(map) = texture_index.get(&item.id) {
167 let proc = crate::processors::TriangulatedFaceSetProcessor::new();
168 if let Ok((mut sub_mesh, sub_uvs)) =
169 proc.process_with_texture(&item, decoder, map)
170 {
171 self.scale_mesh(&mut sub_mesh); // UVs are unaffected by scale
172 textured.push((sub_mesh, sub_uvs, map.attachment()));
173 continue;
174 }
175 }
176 }
177
178 match self.processors.get(&item.ifc_type, self.schema) {
179 Some(processor) => match processor.process(
180 &item,
181 decoder,
182 self.schema,
183 self.tessellation_quality,
184 ) {
185 Ok(mut sub_mesh) => {
186 sub_mesh.validate_indices();
187 self.scale_mesh(&mut sub_mesh);
188 untextured.merge(&sub_mesh);
189 }
190 Err(_e) => {
191 self.record_unsupported_item(item.ifc_type.clone());
192 crate::diag::diag_debug!(
193 { item_id = item.id, ifc_type = ?item.ifc_type,
194 error = %_e, "skipping unsupported representation-map item" }
195 else {
196 #[cfg(debug_assertions)]
197 eprintln!(
198 "[ifc-lite] Skipping unsupported representation-map item #{} ({:?}): {}",
199 item.id, item.ifc_type, _e
200 );
201 }
202 );
203 }
204 },
205 None => {
206 self.record_unsupported_item(item.ifc_type.clone());
207 crate::diag::diag_debug!(
208 { item_id = item.id, ifc_type = ?item.ifc_type,
209 "skipping unsupported representation-map item (no processor)" }
210 else {
211 #[cfg(debug_assertions)]
212 eprintln!(
213 "[ifc-lite] Skipping unsupported representation-map item #{} ({:?}): no processor",
214 item.id, item.ifc_type
215 );
216 }
217 );
218 }
219 }
220 }
221
222 // attr 0: MappingOrigin (IfcAxis2Placement3D) — the only 3D transform;
223 // UVs are 2D and unaffected. Parse once, bake into every part.
224 let origin_transform: Option<nalgebra::Matrix4<f64>> = match rep_map.get(0) {
225 Some(origin_attr) if !origin_attr.is_null() => {
226 match decoder.resolve_ref(origin_attr)? {
227 Some(origin) if origin.ifc_type == IfcType::IfcAxis2Placement3D => {
228 let mut t = self.parse_axis2_placement_3d(&origin, decoder)?;
229 self.scale_transform(&mut t);
230 Some(t)
231 }
232 _ => None,
233 }
234 }
235 _ => None,
236 };
237
238 let mut out: Vec<(
239 Mesh,
240 Vec<f32>,
241 Option<crate::processors::texture::TextureAttachment>,
242 )> = Vec::new();
243 for (mut mesh, uvs, texture) in textured {
244 if let Some(t) = &origin_transform {
245 self.transform_mesh_local(&mut mesh, t);
246 }
247 // Same sliver hygiene as the other mesh-output chokepoints. This is
248 // the type-geometry (RepresentationMap) channel and the only one
249 // carrying a parallel per-vertex UV array; clean_degenerate edits
250 // only indices (vertices/UVs untouched), so the UVs stay in sync.
251 mesh.clean_degenerate();
252 out.push((mesh, uvs, Some(texture)));
253 }
254 for mut untextured in untextured.into_parts().into_iter().filter(|mesh| !mesh.is_empty()) {
255 if let Some(t) = &origin_transform {
256 self.transform_mesh_local(&mut untextured, t);
257 }
258 untextured.clean_degenerate();
259 out.push((untextured, Vec::new(), None));
260 }
261
262 Ok(out)
263 }
264}