Skip to main content

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
33mod indexed_colour;
34mod material;
35mod surface;
36// Public styling resolvers — the single shared implementation that both the
37// native pipeline and the browser `wasm-bindings` call (issue #913, Phase 2e).
38// `split_mesh_by_indexed_colour` is also public so the browser `processGeometryBatch`
39// path can restore the per-triangle palette split it lost in the #874 mesh-pipeline
40// unification (issue #858) — keeping one shared splitter rather than a wasm copy.
41pub use indexed_colour::{
42    resolve_indexed_colour_map_full, split_mesh_by_indexed_colour, FullIndexedColourMap,
43};
44pub use material::{
45    build_element_material_colors, build_material_style_index, flatten_material_color_index,
46    pick_material_style_for_submesh, pick_opaque_first, resolve_material_ids,
47    resolve_submesh_color,
48};
49pub use surface::extract_surface_style_colors;
50
51/// Alpha at or above which a color is treated as opaque.
52///
53/// Used by submesh material selection (Phase 2) to prefer glass (transparent)
54/// vs frame (opaque) styles. Matches the browser's `TRANSPARENCY_ALPHA_THRESHOLD`.
55pub const TRANSPARENCY_ALPHA_THRESHOLD: f32 = 0.95;
56
57/// Canonical straight-alpha RGBA color, components in `0.0..=1.0`.
58///
59/// Serializes transparently as a bare `[f32; 4]` JSON array, so it is a
60/// drop-in replacement for the `[f32; 4]` colors used across the pipeline
61/// today (e.g. `MeshData.color`).
62#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
63#[serde(transparent)]
64pub struct Rgba(pub [f32; 4]);
65
66impl Rgba {
67    /// Construct from components.
68    pub const fn new(r: f32, g: f32, b: f32, a: f32) -> Self {
69        Rgba([r, g, b, a])
70    }
71
72    /// Construct from a raw `[f32; 4]`.
73    pub const fn from_array(c: [f32; 4]) -> Self {
74        Rgba(c)
75    }
76
77    /// The underlying `[f32; 4]`.
78    pub const fn to_array(self) -> [f32; 4] {
79        self.0
80    }
81
82    /// The alpha component.
83    pub const fn alpha(self) -> f32 {
84        self.0[3]
85    }
86
87    /// `true` when alpha is below [`TRANSPARENCY_ALPHA_THRESHOLD`].
88    pub fn is_transparent(self) -> bool {
89        self.0[3] < TRANSPARENCY_ALPHA_THRESHOLD
90    }
91
92    /// Quantize to 8-bit RGBA for the browser SAB transport.
93    ///
94    /// Components are clamped to `0.0..=1.0` then rounded to nearest 1/255.
95    /// This is the *only* sanctioned quantization point (plan §8.3); the
96    /// backend never calls it.
97    pub fn to_rgba8(self) -> [u8; 4] {
98        let q = |c: f32| (c.clamp(0.0, 1.0) * 255.0).round() as u8;
99        [q(self.0[0]), q(self.0[1]), q(self.0[2]), q(self.0[3])]
100    }
101
102    /// Reconstruct from 8-bit RGBA (the inverse of [`Rgba::to_rgba8`],
103    /// modulo the 1/255 quantization step).
104    pub fn from_rgba8(c: [u8; 4]) -> Self {
105        Rgba([
106            c[0] as f32 / 255.0,
107            c[1] as f32 / 255.0,
108            c[2] as f32 / 255.0,
109            c[3] as f32 / 255.0,
110        ])
111    }
112}
113
114impl From<[f32; 4]> for Rgba {
115    fn from(c: [f32; 4]) -> Self {
116        Rgba(c)
117    }
118}
119
120impl From<Rgba> for [f32; 4] {
121    fn from(c: Rgba) -> Self {
122        c.0
123    }
124}
125
126/// The canonical default color for an IFC type.
127///
128/// This is the **union** of the historical `wasm-bindings` and `processing`
129/// tables (plan §8.1): every type keeps the value from whichever table
130/// defined it, and `IfcFurnishingElement` resolves to the browser's lighter
131/// wood (the value users see today). Types not listed fall through to neutral
132/// gray.
133pub fn default_color_for_type(ifc_type: IfcType) -> Rgba {
134    match ifc_type {
135        // Walls — light gray
136        IfcType::IfcWall | IfcType::IfcWallStandardCase => Rgba::new(0.85, 0.85, 0.85, 1.0),
137
138        // Slabs — darker gray
139        IfcType::IfcSlab => Rgba::new(0.7, 0.7, 0.7, 1.0),
140
141        // Roofs — brown-ish
142        IfcType::IfcRoof => Rgba::new(0.6, 0.5, 0.4, 1.0),
143
144        // Columns / beams / members — steel gray
145        IfcType::IfcColumn | IfcType::IfcBeam | IfcType::IfcMember => Rgba::new(0.6, 0.65, 0.7, 1.0),
146
147        // Windows — light blue, transparent
148        IfcType::IfcWindow => Rgba::new(0.6, 0.8, 1.0, 0.4),
149
150        // Doors — wood brown
151        IfcType::IfcDoor => Rgba::new(0.6, 0.45, 0.3, 1.0),
152
153        // Stairs (incl. stair flights — from the processing table)
154        IfcType::IfcStair | IfcType::IfcStairFlight => Rgba::new(0.75, 0.75, 0.75, 1.0),
155
156        // Railings
157        IfcType::IfcRailing => Rgba::new(0.4, 0.4, 0.45, 1.0),
158
159        // Plates / coverings
160        IfcType::IfcPlate | IfcType::IfcCovering => Rgba::new(0.8, 0.8, 0.8, 1.0),
161
162        // Curtain walls — glass blue (from the wasm table)
163        IfcType::IfcCurtainWall => Rgba::new(0.5, 0.7, 0.9, 0.5),
164
165        // Furniture — light wood (from the wasm table; §8.1)
166        IfcType::IfcFurnishingElement => Rgba::new(0.7, 0.55, 0.4, 1.0),
167
168        // Spaces — cyan, transparent
169        IfcType::IfcSpace => Rgba::new(0.2, 0.85, 1.0, 0.3),
170
171        // Opening elements — red-orange, transparent
172        IfcType::IfcOpeningElement => Rgba::new(1.0, 0.42, 0.29, 0.4),
173
174        // Site — green
175        IfcType::IfcSite => Rgba::new(0.4, 0.8, 0.3, 1.0),
176
177        // Building element proxy — generic gray (from the processing table)
178        IfcType::IfcBuildingElementProxy => Rgba::new(0.6, 0.6, 0.6, 1.0),
179
180        // Default — neutral gray
181        _ => Rgba::new(0.8, 0.8, 0.8, 1.0),
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn rgba_array_round_trip() {
191        let c = Rgba::new(0.1, 0.2, 0.3, 0.4);
192        assert_eq!(c.to_array(), [0.1, 0.2, 0.3, 0.4]);
193        assert_eq!(Rgba::from_array([0.1, 0.2, 0.3, 0.4]), c);
194        let back: [f32; 4] = c.into();
195        assert_eq!(back, [0.1, 0.2, 0.3, 0.4]);
196        assert_eq!(Rgba::from([0.5, 0.6, 0.7, 0.8]).alpha(), 0.8);
197    }
198
199    #[test]
200    fn quantization_clamps_and_rounds() {
201        assert_eq!(Rgba::new(0.0, 1.0, 0.5, 1.0).to_rgba8(), [0, 255, 128, 255]);
202        // out-of-range components clamp, not wrap
203        assert_eq!(Rgba::new(-0.5, 1.5, 0.0, 2.0).to_rgba8(), [0, 255, 0, 255]);
204    }
205
206    #[test]
207    fn quantization_within_one_step() {
208        // Every 8-bit round trip stays within the documented 1/255 tolerance.
209        for raw in [0.0_f32, 0.123, 0.4, 0.42, 0.5, 0.555, 0.95, 1.0] {
210            let back = Rgba::from_rgba8(Rgba::new(raw, raw, raw, raw).to_rgba8()).to_array();
211            assert!((back[0] - raw).abs() <= 1.0 / 255.0, "drift too large for {raw}");
212        }
213    }
214
215    #[test]
216    fn transparency_threshold() {
217        assert!(Rgba::new(0.6, 0.8, 1.0, 0.4).is_transparent());
218        assert!(!Rgba::new(0.85, 0.85, 0.85, 1.0).is_transparent());
219        // exactly the threshold counts as opaque
220        assert!(!Rgba::new(0.0, 0.0, 0.0, TRANSPARENCY_ALPHA_THRESHOLD).is_transparent());
221    }
222
223    #[test]
224    fn defaults_cover_known_types() {
225        assert_eq!(
226            default_color_for_type(IfcType::IfcWall).to_array(),
227            [0.85, 0.85, 0.85, 1.0]
228        );
229        // IfcWindow is transparent by default
230        assert!(default_color_for_type(IfcType::IfcWindow).is_transparent());
231        // unmapped type → neutral gray fallback
232        assert_eq!(
233            default_color_for_type(IfcType::IfcProject).to_array(),
234            [0.8, 0.8, 0.8, 1.0]
235        );
236    }
237
238    #[test]
239    fn union_resolves_the_four_contested_types() {
240        // The four types that diverged between the historical tables (§2.2).
241        assert_eq!(
242            default_color_for_type(IfcType::IfcCurtainWall).to_array(),
243            [0.5, 0.7, 0.9, 0.5],
244            "curtain wall = wasm glass blue"
245        );
246        assert_eq!(
247            default_color_for_type(IfcType::IfcStairFlight).to_array(),
248            [0.75, 0.75, 0.75, 1.0],
249            "stair flight = processing gray (grouped with IfcStair)"
250        );
251        assert_eq!(
252            default_color_for_type(IfcType::IfcBuildingElementProxy).to_array(),
253            [0.6, 0.6, 0.6, 1.0],
254            "proxy = processing gray"
255        );
256        assert_eq!(
257            default_color_for_type(IfcType::IfcFurnishingElement).to_array(),
258            [0.7, 0.55, 0.4, 1.0],
259            "furnishing = wasm light wood, not processing's darker brown"
260        );
261        // IfcStair and IfcStairFlight must agree.
262        assert_eq!(
263            default_color_for_type(IfcType::IfcStair),
264            default_color_for_type(IfcType::IfcStairFlight)
265        );
266    }
267}