ifc_lite_processing/style/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//! Canonical IFC styling — the single source of truth for mesh colors,
6//! shared between the HTTP server, the native pipeline, and the browser-side
7//! WASM bindings (issue #913).
8//!
9//! This mirrors the [`crate::symbolic`] split: presentation logic that used
10//! to be copied into every consumer (`wasm-bindings`, `processing`,
11//! `apps/server`, the now-discontinued desktop app) lives here exactly once.
12//! See issue #913 for the design and rationale.
13//!
14//! Phase 0 (this commit) introduces only the two pieces with no decoder or
15//! geometry dependency — the canonical [`Rgba`] color type and the single
16//! [`default_color_for_type`] table — and wires nothing into the pipeline
17//! yet. The decoder-driven resolver (`StyleIndex`, `IfcStyledItem` /
18//! `IfcIndexedColourMap` / material-chain resolution) arrives in Phase 2.
19//!
20//! ## Canonical contracts
21//!
22//! - **`f32` is canonical; `u8` is transport-only.** Colors are [`Rgba`]
23//! ([`f32; 4`], straight-alpha, `0.0..=1.0`) end-to-end. The browser's
24//! 8-bit SharedArrayBuffer transport is expressed *only* through
25//! [`Rgba::to_rgba8`] / [`Rgba::from_rgba8`]; the backend stays exact.
26//! (Decision §8.3 of the plan.)
27//! - **One default table.** [`default_color_for_type`] is the only
28//! IFC-type → color map in Rust. A CI guard (Phase 1) fails the build if a
29//! second one appears.
30
31use ifc_lite_core::IfcType;
32
33pub(crate) mod fill;
34mod finish_join;
35mod indexed_colour;
36mod material;
37mod surface;
38// Public styling resolvers — the single shared implementation that both the
39// native pipeline and the browser `wasm-bindings` call (issue #913, Phase 2e).
40// `split_mesh_by_indexed_colour` is also public so the browser `processGeometryBatch`
41// path can restore the per-triangle palette split it lost in the #874 mesh-pipeline
42// unification (issue #858) — keeping one shared splitter rather than a wasm copy.
43pub use indexed_colour::{
44 resolve_indexed_colour_map_full, split_mesh_by_indexed_colour, FullIndexedColourMap,
45};
46pub use material::{
47 build_element_material_colors, build_material_style_index, flatten_material_color_index,
48 pick_material_style_for_submesh, pick_opaque_first, resolve_material_ids,
49 resolve_submesh_color,
50};
51pub use surface::{extract_surface_style_colors, extract_surface_style_specular, SpecularMaterial};
52// #5984: the finish of an already produced mesh, from the style its colour came from.
53pub use finish_join::{MeshFinishJoin, ModelFinishes};
54
55/// Alpha at or above which a color is treated as opaque.
56///
57/// Used by submesh material selection (Phase 2) to prefer glass (transparent)
58/// vs frame (opaque) styles. Matches the browser's `TRANSPARENCY_ALPHA_THRESHOLD`.
59pub const TRANSPARENCY_ALPHA_THRESHOLD: f32 = 0.95;
60
61/// Resolved appearance of one geometry item (the value side of the
62/// styled-item index keyed by geometry express id).
63///
64/// Lives here — not in `processor.rs` — because it is shared by the native
65/// pipeline, the canonical per-element producer ([`crate::element`]), and the
66/// browser `wasm-bindings` batch path, which lifts its flat `(id, rgba8)`
67/// wire arrays into this richer form via [`GeometryStyleInfo::from_color`].
68#[derive(Debug, Clone)]
69pub struct GeometryStyleInfo {
70 /// Apparent colour for rendering: IfcSurfaceStyleRendering.DiffuseColour
71 /// when authored, otherwise the SurfaceColour. Matches what most IFC
72 /// viewers display.
73 pub color: [f32; 4],
74 /// SurfaceColour, populated only when the file authored a distinct
75 /// DiffuseColour. Read by the WASM bridge's parallel extractor so the
76 /// GLB exporter can offer "Shading" as a colour source; the
77 /// processing-crate `MeshData` doesn't propagate it (server pipeline
78 /// has no GLB consumer yet).
79 pub shading_color: Option<[f32; 4]>,
80 pub material_name: Option<String>,
81}
82
83impl GeometryStyleInfo {
84 /// Lift a bare RGBA colour (e.g. from the browser prepass's flat
85 /// `styleIds`/`styleColors` wire arrays) into the rich form. No shading
86 /// colour, no material name — exactly the fidelity the wire carries.
87 pub fn from_color(color: [f32; 4]) -> Self {
88 Self {
89 color,
90 shading_color: None,
91 material_name: None,
92 }
93 }
94}
95
96/// Canonical straight-alpha RGBA color, components in `0.0..=1.0`.
97///
98/// Serializes transparently as a bare `[f32; 4]` JSON array, so it is a
99/// drop-in replacement for the `[f32; 4]` colors used across the pipeline
100/// today (e.g. `MeshData.color`).
101#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
102#[serde(transparent)]
103pub struct Rgba(pub [f32; 4]);
104
105impl Rgba {
106 /// Construct from components.
107 pub const fn new(r: f32, g: f32, b: f32, a: f32) -> Self {
108 Rgba([r, g, b, a])
109 }
110
111 /// Construct from a raw `[f32; 4]`.
112 pub const fn from_array(c: [f32; 4]) -> Self {
113 Rgba(c)
114 }
115
116 /// The underlying `[f32; 4]`.
117 pub const fn to_array(self) -> [f32; 4] {
118 self.0
119 }
120
121 /// The alpha component.
122 pub const fn alpha(self) -> f32 {
123 self.0[3]
124 }
125
126 /// `true` when alpha is below [`TRANSPARENCY_ALPHA_THRESHOLD`].
127 pub fn is_transparent(self) -> bool {
128 self.0[3] < TRANSPARENCY_ALPHA_THRESHOLD
129 }
130
131 /// Quantize to 8-bit RGBA for the browser SAB transport.
132 ///
133 /// Components are clamped to `0.0..=1.0` then rounded to nearest 1/255.
134 /// This is the *only* sanctioned quantization point (plan §8.3); the
135 /// backend never calls it.
136 pub fn to_rgba8(self) -> [u8; 4] {
137 let q = |c: f32| (c.clamp(0.0, 1.0) * 255.0).round() as u8;
138 [q(self.0[0]), q(self.0[1]), q(self.0[2]), q(self.0[3])]
139 }
140
141 /// Reconstruct from 8-bit RGBA (the inverse of [`Rgba::to_rgba8`],
142 /// modulo the 1/255 quantization step).
143 pub fn from_rgba8(c: [u8; 4]) -> Self {
144 Rgba([
145 c[0] as f32 / 255.0,
146 c[1] as f32 / 255.0,
147 c[2] as f32 / 255.0,
148 c[3] as f32 / 255.0,
149 ])
150 }
151}
152
153impl From<[f32; 4]> for Rgba {
154 fn from(c: [f32; 4]) -> Self {
155 Rgba(c)
156 }
157}
158
159impl From<Rgba> for [f32; 4] {
160 fn from(c: Rgba) -> Self {
161 c.0
162 }
163}
164
165/// The canonical default color for an IFC type.
166///
167/// This is the **union** of the historical `wasm-bindings` and `processing`
168/// tables (plan §8.1): every type keeps the value from whichever table
169/// defined it, and `IfcFurnishingElement` resolves to the browser's lighter
170/// wood (the value users see today). Types not listed fall through to neutral
171/// gray.
172pub fn default_color_for_type(ifc_type: IfcType) -> Rgba {
173 match ifc_type {
174 // Walls — light gray
175 IfcType::IfcWall | IfcType::IfcWallStandardCase => Rgba::new(0.85, 0.85, 0.85, 1.0),
176
177 // Slabs — darker gray
178 IfcType::IfcSlab => Rgba::new(0.7, 0.7, 0.7, 1.0),
179
180 // Roofs — brown-ish
181 IfcType::IfcRoof => Rgba::new(0.6, 0.5, 0.4, 1.0),
182
183 // Columns / beams / members — steel gray
184 IfcType::IfcColumn | IfcType::IfcBeam | IfcType::IfcMember => Rgba::new(0.6, 0.65, 0.7, 1.0),
185
186 // Windows — light blue, transparent
187 IfcType::IfcWindow => Rgba::new(0.6, 0.8, 1.0, 0.4),
188
189 // Doors — wood brown
190 IfcType::IfcDoor => Rgba::new(0.6, 0.45, 0.3, 1.0),
191
192 // Stairs (incl. stair flights — from the processing table)
193 IfcType::IfcStair | IfcType::IfcStairFlight => Rgba::new(0.75, 0.75, 0.75, 1.0),
194
195 // Railings
196 IfcType::IfcRailing => Rgba::new(0.4, 0.4, 0.45, 1.0),
197
198 // Plates / coverings
199 IfcType::IfcPlate | IfcType::IfcCovering => Rgba::new(0.8, 0.8, 0.8, 1.0),
200
201 // Curtain walls — glass blue (from the wasm table)
202 IfcType::IfcCurtainWall => Rgba::new(0.5, 0.7, 0.9, 0.5),
203
204 // Furniture — light wood (from the wasm table; §8.1)
205 IfcType::IfcFurnishingElement => Rgba::new(0.7, 0.55, 0.4, 1.0),
206
207 // Spaces — cyan, transparent
208 IfcType::IfcSpace => Rgba::new(0.2, 0.85, 1.0, 0.3),
209
210 // Spatial zones (modelled GFA volumes) — violet, transparent. A
211 // distinct hue from IfcSpace's cyan so net (room) vs gross (zone)
212 // areas read apart when both are shown (#1075).
213 IfcType::IfcSpatialZone => Rgba::new(0.72, 0.35, 0.95, 0.28),
214
215 // Opening elements — red-orange, transparent
216 IfcType::IfcOpeningElement => Rgba::new(1.0, 0.42, 0.29, 0.4),
217
218 // Site — green
219 IfcType::IfcSite => Rgba::new(0.4, 0.8, 0.3, 1.0),
220
221 // Building element proxy — generic gray (from the processing table)
222 IfcType::IfcBuildingElementProxy => Rgba::new(0.6, 0.6, 0.6, 1.0),
223
224 // Default — neutral gray
225 _ => Rgba::new(0.8, 0.8, 0.8, 1.0),
226 }
227}
228
229/// The colour a class ALWAYS renders with, overriding any authored styling, or
230/// `None` for the ordinary style precedence.
231///
232/// Only `IfcOpeningElement` (and its subtypes such as `IfcOpeningStandardCase`)
233/// is fixed. An opening is a virtual subtraction volume, not a surface anyone
234/// sees in the built object, so an authored style on it says nothing about
235/// appearance: Revit, for one, writes its neutral grey onto opening geometry
236/// through `IfcIndexedColourMap` / `IfcStyledItem`. Honouring that drew the
237/// opening as an opaque solid plugging the very hole it cuts, next to other
238/// openings in the translucent overlay, and an opaque shape then also qualified
239/// for GPU instancing (#5409). One overlay colour for every opening is the
240/// display contract.
241pub fn fixed_display_color_for_type(ifc_type: &IfcType) -> Option<Rgba> {
242 ifc_type
243 .is_subtype_of(IfcType::IfcOpeningElement)
244 .then(|| default_color_for_type(IfcType::IfcOpeningElement))
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 #[test]
252 fn rgba_array_round_trip() {
253 let c = Rgba::new(0.1, 0.2, 0.3, 0.4);
254 assert_eq!(c.to_array(), [0.1, 0.2, 0.3, 0.4]);
255 assert_eq!(Rgba::from_array([0.1, 0.2, 0.3, 0.4]), c);
256 let back: [f32; 4] = c.into();
257 assert_eq!(back, [0.1, 0.2, 0.3, 0.4]);
258 assert_eq!(Rgba::from([0.5, 0.6, 0.7, 0.8]).alpha(), 0.8);
259 }
260
261 #[test]
262 fn quantization_clamps_and_rounds() {
263 assert_eq!(Rgba::new(0.0, 1.0, 0.5, 1.0).to_rgba8(), [0, 255, 128, 255]);
264 // out-of-range components clamp, not wrap
265 assert_eq!(Rgba::new(-0.5, 1.5, 0.0, 2.0).to_rgba8(), [0, 255, 0, 255]);
266 }
267
268 #[test]
269 fn quantization_within_one_step() {
270 // Every 8-bit round trip stays within the documented 1/255 tolerance.
271 for raw in [0.0_f32, 0.123, 0.4, 0.42, 0.5, 0.555, 0.95, 1.0] {
272 let back = Rgba::from_rgba8(Rgba::new(raw, raw, raw, raw).to_rgba8()).to_array();
273 assert!((back[0] - raw).abs() <= 1.0 / 255.0, "drift too large for {raw}");
274 }
275 }
276
277 #[test]
278 fn transparency_threshold() {
279 assert!(Rgba::new(0.6, 0.8, 1.0, 0.4).is_transparent());
280 assert!(!Rgba::new(0.85, 0.85, 0.85, 1.0).is_transparent());
281 // exactly the threshold counts as opaque
282 assert!(!Rgba::new(0.0, 0.0, 0.0, TRANSPARENCY_ALPHA_THRESHOLD).is_transparent());
283 }
284
285 #[test]
286 fn defaults_cover_known_types() {
287 assert_eq!(
288 default_color_for_type(IfcType::IfcWall).to_array(),
289 [0.85, 0.85, 0.85, 1.0]
290 );
291 // IfcWindow is transparent by default
292 assert!(default_color_for_type(IfcType::IfcWindow).is_transparent());
293 // unmapped type → neutral gray fallback
294 assert_eq!(
295 default_color_for_type(IfcType::IfcProject).to_array(),
296 [0.8, 0.8, 0.8, 1.0]
297 );
298 }
299
300 #[test]
301 fn union_resolves_the_four_contested_types() {
302 // The four types that diverged between the historical tables (§2.2).
303 assert_eq!(
304 default_color_for_type(IfcType::IfcCurtainWall).to_array(),
305 [0.5, 0.7, 0.9, 0.5],
306 "curtain wall = wasm glass blue"
307 );
308 assert_eq!(
309 default_color_for_type(IfcType::IfcStairFlight).to_array(),
310 [0.75, 0.75, 0.75, 1.0],
311 "stair flight = processing gray (grouped with IfcStair)"
312 );
313 assert_eq!(
314 default_color_for_type(IfcType::IfcBuildingElementProxy).to_array(),
315 [0.6, 0.6, 0.6, 1.0],
316 "proxy = processing gray"
317 );
318 assert_eq!(
319 default_color_for_type(IfcType::IfcFurnishingElement).to_array(),
320 [0.7, 0.55, 0.4, 1.0],
321 "furnishing = wasm light wood, not processing's darker brown"
322 );
323 // IfcStair and IfcStairFlight must agree.
324 assert_eq!(
325 default_color_for_type(IfcType::IfcStair),
326 default_color_for_type(IfcType::IfcStairFlight)
327 );
328 }
329}