ifc_lite_processing/style/surface.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//! Canonical `IfcSurfaceStyle` → colour extraction, shared by the processing
6//! (server/CLI) and wasm (viewer) geometry pipelines so they cannot drift on
7//! surface-style rendering semantics — the coordinate/colour analogue of the
8//! single default-colour table (#913).
9
10use ifc_lite_core::{AttributeValue, DecodedEntity, EntityDecoder, IfcType};
11
12/// Read an `IfcColourRgb` as linear `[r, g, b, 1.0]` (alpha is filled by the
13/// caller from the rendering's transparency). Missing components default to 0.8
14/// (mid-grey), matching the historical behaviour on both pipelines.
15fn read_colour_rgb(color_id: u32, decoder: &mut EntityDecoder) -> Option<[f32; 4]> {
16 let color = decoder.decode_by_id(color_id).ok()?;
17 if color.ifc_type != IfcType::IfcColourRgb {
18 return None;
19 }
20 // IfcColourRgb: Name(0), Red(1), Green(2), Blue(3) — same in IFC2x3 and IFC4.
21 let r = color.get_float(1).unwrap_or(0.8) as f32;
22 let g = color.get_float(2).unwrap_or(0.8) as f32;
23 let b = color.get_float(3).unwrap_or(0.8) as f32;
24 Some([r, g, b, 1.0])
25}
26
27/// Colours of a single `IfcSurfaceStyleRendering` / `IfcSurfaceStyleShading`.
28///
29/// Returns `(apparent, shading)`:
30/// - `apparent` is `SurfaceColour` (attr 0), modulated by `DiffuseColour`
31/// (attr 2) **only** when the author supplied it as an
32/// `IfcNormalisedRatioMeasure` factor. `SurfaceColour` is the apparent surface
33/// colour per the IFC spec and how web-ifc / IfcOpenShell / BlenderBIM read
34/// the chain.
35/// - `shading` is populated only when a *distinct* `DiffuseColour` `IfcColourRgb`
36/// is authored, for downstream consumers that want the diffuse override (the
37/// GLB exporter's "Shading" source).
38///
39/// A `DiffuseColour = IfcColourRgb(0, 0, 0)` therefore does NOT black out the
40/// surface — that is the spec's "no diffuse reflection contribution", and the
41/// regression #859/#871 fixed (otherwise every `IfcSignal` / `IfcReferent` on
42/// the railway fixture rendered opaque black).
43fn rendering_colours(
44 rendering_id: u32,
45 decoder: &mut EntityDecoder,
46) -> Option<([f32; 4], Option<[f32; 4]>)> {
47 let rendering = decoder.decode_by_id(rendering_id).ok()?;
48 match rendering.ifc_type {
49 IfcType::IfcSurfaceStyleRendering | IfcType::IfcSurfaceStyleShading => {
50 // Attr 0: SurfaceColour, Attr 1: Transparency (0=opaque, 1=transparent),
51 // Attr 2: DiffuseColour — SELECT(IfcColourRgb, IfcNormalisedRatioMeasure),
52 // only present on IfcSurfaceStyleRendering.
53 let color_ref = rendering.get_ref(0)?;
54 let [sr, sg, sb, _] = read_colour_rgb(color_ref, decoder)?;
55
56 let transparency = rendering.get_float(1).unwrap_or(0.0);
57 let alpha = (1.0 - transparency as f32).clamp(0.0, 1.0);
58 let surface_rgba = [sr, sg, sb, alpha];
59
60 let mut apparent = surface_rgba;
61 let mut shading: Option<[f32; 4]> = None;
62
63 if rendering.ifc_type == IfcType::IfcSurfaceStyleRendering {
64 if let Some(diffuse_id) = rendering.get_ref(2) {
65 if let Some([dr, dg, db, _]) = read_colour_rgb(diffuse_id, decoder) {
66 let diffuse_rgba = [dr, dg, db, alpha];
67 // Surface the diffuse override only when it actually
68 // differs — it is NOT the apparent colour.
69 if diffuse_rgba != surface_rgba {
70 shading = Some(diffuse_rgba);
71 }
72 }
73 } else if let Some(factor) = rendering.get_float(2) {
74 let f = (factor as f32).clamp(0.0, 1.0);
75 apparent = [sr * f, sg * f, sb * f, alpha];
76 }
77 }
78
79 Some((apparent, shading))
80 }
81 _ => None,
82 }
83}
84
85/// Extract the apparent surface colour (and an optional distinct shading colour)
86/// from an `IfcSurfaceStyle`, walking its `Styles` list (attr 2) and returning
87/// the first rendering's `(apparent, shading)` pair.
88///
89/// This is the **single source of truth** for surface-style colour, consumed by
90/// both the server geometry processor and the browser pre-pass, so the two
91/// cannot disagree on `SurfaceColour` vs `DiffuseColour` precedence.
92pub fn extract_surface_style_colors(
93 surface_style_id: u32,
94 decoder: &mut EntityDecoder,
95) -> Option<([f32; 4], Option<[f32; 4]>)> {
96 let style = decoder.decode_by_id(surface_style_id).ok()?;
97 if style.ifc_type != IfcType::IfcSurfaceStyle {
98 return None;
99 }
100 // IfcSurfaceStyle: Name(0), Side(1), Styles(2: list of surface-style elements).
101 for element_id in style.get_refs(2)? {
102 if let Some(pair) = rendering_colours(element_id, decoder) {
103 return Some(pair);
104 }
105 }
106 None
107}
108
109// ---------------------------------------------------------------------------
110// Specular finish (#5582): `IfcSurfaceStyleRendering.SpecularColour` /
111// `.SpecularHighlight` / `.ReflectanceMethod` → a metallic/roughness pair.
112//
113// `IfcSurfaceStyleRendering` (SUBTYPE OF `IfcSurfaceStyleShading`) flattens to
114// 9 attributes: 0 SurfaceColour, 1 Transparency (both on `IfcSurfaceStyleShading`),
115// then 2 DiffuseColour, 3 TransmissionColour, 4 DiffuseTransmissionColour,
116// 5 ReflectionColour, 6 SpecularColour, 7 SpecularHighlight, 8 ReflectanceMethod
117// (`IFC4_ADD2_TC1.exp`). Verified against `AC20-FZK-Haus.ifc`'s 'Glas'
118// (`SpecularColour = IFCNORMALISEDRATIOMEASURE(1.)`) and 'Kiefer, glänzend'
119// (`SpecularColour = IFCNORMALISEDRATIOMEASURE(0.75)`), both with a null
120// `SpecularHighlight` and `ReflectanceMethod = .NOTDEFINED.` — the common case
121// for BIM-authoring exporters, which populate the scalar factor and nothing
122// else.
123//
124// Roughness is passed through AS AUTHORED, clamped only to the valid `[0, 1]`
125// range — never floored here. A roughness near 0 is numerically unstable in
126// the renderer's Cook-Torrance term (GGX `alpha = roughness^2` approaches 0),
127// but the renderer already owns exactly one clamp for that
128// (`MIN_SPECULAR_ROUGHNESS = 0.045`, `shaders/specular.wgsl.ts`); flooring
129// here too would be a second, independently-tunable copy of the same guard
130// (and did briefly disagree with it, review of #5582).
131// ---------------------------------------------------------------------------
132
133/// Metallic/roughness evidence read from one `IfcSurfaceStyleRendering`.
134/// Either field is `None` when the file carries no evidence for it; the
135/// renderer's `packMeshMaterial` then falls back to its own default
136/// (`DEFAULT_MATERIAL_METALLIC` / `DEFAULT_MATERIAL_ROUGHNESS`).
137#[derive(Debug, Clone, Copy, PartialEq, Default)]
138pub struct SpecularMaterial {
139 pub metallic: Option<f32>,
140 pub roughness: Option<f32>,
141}
142
143impl SpecularMaterial {
144 fn is_empty(&self) -> bool {
145 self.metallic.is_none() && self.roughness.is_none()
146 }
147}
148
149/// `ReflectanceMethod` values the IFC spec models as a conductor: a metal or
150/// mirror surface reflects via a coloured Fresnel term with no diffuse
151/// transmission, the physical signature `metallic = 1.0` encodes in the
152/// glTF/PBR metallic-roughness model. Every other enumerator (`BLINN`,
153/// `FLAT`, `GLASS`, `MATT`, `PHONG`, `PLASTIC`, `STRAUSS`, `NOTDEFINED`) is a
154/// dielectric shading model, so it carries no metal evidence either way.
155fn reflectance_method_is_metal(method: &str) -> bool {
156 matches!(method, "METAL" | "MIRROR")
157}
158
159/// A `SpecularColour` authored as an actual `IfcColourRgb` (rather than the
160/// far more common `IfcNormalisedRatioMeasure` scalar factor) tints the
161/// specular reflection. Only a conductor's Fresnel reflectance is
162/// wavelength-dependent — a dielectric's specular reflectance is always
163/// achromatic regardless of its base colour, which is exactly the split the
164/// glTF metallic-roughness model itself relies on. A saturated tint is
165/// therefore treated as authored evidence of a metal even when
166/// `ReflectanceMethod` was left `NOTDEFINED` (the common case: most
167/// exporters never populate the enum).
168fn specular_colour_is_tinted(rgb: [f32; 3]) -> bool {
169 const SATURATION_TINT_THRESHOLD: f32 = 0.08;
170 let max = rgb[0].max(rgb[1]).max(rgb[2]);
171 let min = rgb[0].min(rgb[1]).min(rgb[2]);
172 (max - min) > SATURATION_TINT_THRESHOLD
173}
174
175/// Convert a Blinn-Phong specular exponent (`IfcSpecularExponent`, an
176/// unbounded REAL, typically 1..1000+) to a GGX roughness via the standard
177/// Phong-to-GGX approximation `roughness = sqrt(2 / (n + 2))` (Brian Karis,
178/// "Physically Based Shading on Mobile", 2014). A tighter highlight (higher
179/// exponent) maps to a lower roughness.
180fn phong_exponent_to_roughness(exponent: f32) -> f32 {
181 (2.0 / (exponent.max(0.0) + 2.0)).sqrt().clamp(0.0, 1.0)
182}
183
184/// `SpecularColour` (attr 6), a `SELECT(IfcColourRgb, IfcNormalisedRatioMeasure)`.
185enum SpecularColour {
186 /// A scalar reflectance factor in `[0, 1]`.
187 Factor(f32),
188 /// An authored tint (linear, unclamped — only the saturation and
189 /// luminance are read, so out-of-range components don't need clamping).
190 Colour([f32; 3]),
191}
192
193fn read_specular_colour(rendering: &DecodedEntity, decoder: &mut EntityDecoder) -> Option<SpecularColour> {
194 if let Some(color_id) = rendering.get_ref(6) {
195 let [r, g, b, _] = read_colour_rgb(color_id, decoder)?;
196 // A non-finite component (a malformed or overflowed STEP real) is no
197 // evidence: it would reach the tint test and the luminance fallback,
198 // where `f32::clamp` passes `NaN` through unchanged (#5582 review).
199 return [r, g, b]
200 .iter()
201 .all(|c| c.is_finite())
202 .then_some(SpecularColour::Colour([r, g, b]));
203 }
204 rendering
205 .get_float(6)
206 .map(|f| f as f32)
207 // Same non-finite guard as `read_specular_highlight`: `f32::clamp`
208 // returns `NaN` unchanged rather than saturating it, so a malformed
209 // factor would otherwise survive as `Factor(NaN)` (#5582 review).
210 .filter(|f| f.is_finite())
211 .map(|f| SpecularColour::Factor(f.clamp(0.0, 1.0)))
212}
213
214/// `SpecularHighlight` (attr 7), a `SELECT(IfcSpecularExponent, IfcSpecularRoughness)`.
215/// Both select members are plain `REAL`s, so the STEP encoding carries the
216/// choice only as a type tag (`IFCSPECULAREXPONENT(20.)` vs
217/// `IFCSPECULARROUGHNESS(0.1)`), which the tokenizer preserves as a
218/// `List([String(type_name), Float(value)])` (`AttributeValue::from_token`).
219/// Returns `(is_roughness, value)`; an untagged bare real (non-conformant,
220/// never seen in practice) is treated as an exponent, since that is the
221/// historically dominant Blinn-Phong wording of "specular highlight size".
222///
223/// A non-finite authored value (`NaN`/`±inf`, from a malformed file) carries
224/// no usable evidence, so the whole attribute is treated as unauthored
225/// (`None`) rather than feeding a garbage number into the roughness/exponent
226/// conversion below — `exponent.max(0.0)` alone does not reliably reject it
227/// (review of #5582).
228fn read_specular_highlight(rendering: &DecodedEntity) -> Option<(bool, f32)> {
229 let attr = rendering.get(7)?;
230 let (is_roughness, value) = if let AttributeValue::List(items) = attr {
231 let (Some(AttributeValue::String(name)), Some(value_attr)) = (items.first(), items.get(1))
232 else {
233 return None;
234 };
235 (name.eq_ignore_ascii_case("IFCSPECULARROUGHNESS"), value_attr.as_float()? as f32)
236 } else {
237 (false, attr.as_float()? as f32)
238 };
239 value.is_finite().then_some((is_roughness, value))
240}
241
242/// Resolve the metallic/roughness pair for one `IfcSurfaceStyleRendering`.
243///
244/// - `metallic` is `1.0` when `ReflectanceMethod` names a conductor (`METAL`,
245/// `MIRROR`), or when `SpecularColour` is an authored tint rather than a
246/// scalar factor; otherwise `None` (defer to the renderer's dielectric
247/// default).
248/// - `roughness` prefers `SpecularHighlight` when present (an
249/// `IfcSpecularRoughness` factor is used directly; an `IfcSpecularExponent`
250/// is converted via [`phong_exponent_to_roughness`]); otherwise it falls
251/// back to `1 - SpecularColour`, reading `SpecularColour`'s luminance when
252/// it is a tint rather than a factor. `None` when neither attribute is
253/// authored.
254fn rendering_specular_material(
255 rendering: &DecodedEntity,
256 decoder: &mut EntityDecoder,
257) -> Option<SpecularMaterial> {
258 if rendering.ifc_type != IfcType::IfcSurfaceStyleRendering {
259 // IfcSurfaceStyleShading carries none of these attributes.
260 return None;
261 }
262
263 let reflectance_is_metal = rendering
264 .get(8)
265 .and_then(AttributeValue::as_enum)
266 .map(reflectance_method_is_metal)
267 .unwrap_or(false);
268
269 let specular_colour = read_specular_colour(rendering, decoder);
270 let tinted_metal = matches!(
271 specular_colour,
272 Some(SpecularColour::Colour(rgb)) if specular_colour_is_tinted(rgb)
273 );
274 let metallic = (reflectance_is_metal || tinted_metal).then_some(1.0);
275
276 let highlight_roughness = read_specular_highlight(rendering).map(|(is_roughness, value)| {
277 if is_roughness {
278 // IfcSpecularRoughness (WR1: 0..=1) as authored — no floor here,
279 // see the module doc above.
280 value.clamp(0.0, 1.0)
281 } else {
282 phong_exponent_to_roughness(value)
283 }
284 });
285 let factor_roughness = match specular_colour {
286 Some(SpecularColour::Factor(f)) => Some((1.0 - f).clamp(0.0, 1.0)),
287 Some(SpecularColour::Colour([r, g, b])) => {
288 let luma = (0.2126 * r + 0.7152 * g + 0.0722 * b).clamp(0.0, 1.0);
289 Some((1.0 - luma).clamp(0.0, 1.0))
290 }
291 None => None,
292 };
293
294 let material = SpecularMaterial {
295 metallic,
296 roughness: highlight_roughness.or(factor_roughness),
297 };
298 (!material.is_empty()).then_some(material)
299}
300
301/// Extract the metallic/roughness pair from an `IfcSurfaceStyle`'s first
302/// `IfcSurfaceStyleRendering`, walking its `Styles` list (attr 2) exactly as
303/// [`extract_surface_style_colors`] does, so the two never disagree about
304/// which rendering "wins" for a multi-style `IfcSurfaceStyle`.
305///
306/// Lives here, in the canonical styling module, rather than as a parallel
307/// path (see `AGENTS.md` "Colour and coordinate resolution is canonical
308/// Rust"): both the server (`process_geometry`) and the viewer
309/// (`process_geometry_batch`) reach this through the shared
310/// `GeometryStyleInfo` the prepass builds (`prepass.rs`), so they cannot
311/// drift on the mapping.
312pub fn extract_surface_style_specular(
313 surface_style_id: u32,
314 decoder: &mut EntityDecoder,
315) -> Option<SpecularMaterial> {
316 let style = decoder.decode_by_id(surface_style_id).ok()?;
317 if style.ifc_type != IfcType::IfcSurfaceStyle {
318 return None;
319 }
320 for element_id in style.get_refs(2)? {
321 let Ok(rendering) = decoder.decode_by_id(element_id) else {
322 continue;
323 };
324 if let Some(material) = rendering_specular_material(&rendering, decoder) {
325 return Some(material);
326 }
327 }
328 None
329}
330
331#[cfg(test)]
332#[path = "surface_tests.rs"]
333mod tests;