openbim_ifcx_geometry/
presentation.rs

1//! Visibility, colour, opacity, and glTF PBR materials (`ifcx_alpha`).
2//!
3//! [`NodePresentation::from_attributes`] decodes one node's own presentation
4//! attributes. The functions [`is_visible`], [`resolve_basic_material`], and
5//! [`resolve_mesh_material`] apply the precedence of the upstream reference
6//! viewer (`src/viewer/render.ts` at buildingSMART/IFC5-development
7//! `1a63082`) to a node and its ancestors. [`crate::scene`] applies them
8//! while walking a composed tree, dropping invisible subtrees.
9//!
10//! | Attribute | Value |
11//! | --- | --- |
12//! | `usd::usdgeom::visibility` | `{"visibility": "inherited" \| "invisible"}` |
13//! | `bsi::ifc::presentation::diffuseColor` | `[r, g, b]`, linear, `0..=1` |
14//! | `bsi::ifc::presentation::opacity` | number, `0..=1` |
15//! | `gltf::material` | glTF 2.0 material subset, see [`GltfMaterial`] |
16//!
17//! # Material bindings
18//!
19//! In `ifcx_alpha` a node binds a material by inheriting from the material
20//! node (`"inherits": {"material": "<path>"}`); composition then gives the node
21//! the material's `bsi::ifc::presentation::*` and `gltf::material` attributes.
22//! The pre-alpha `usd::usdshade::material::outputs::surface` connections were
23//! converted to that form upstream: no upstream example uses them, and the
24//! reference viewer only lists the name for an outline icon. They are
25//! therefore not read here.
26//!
27//! # Precedence
28//!
29//! Given a node followed by its ancestors, nearest first:
30//!
31//! 1. A node is drawn only if neither it nor any ancestor is `invisible`.
32//! 2. A mesh uses the `gltf::material` of the nearest node that has one, with
33//!    glTF defaults for missing factors, and ignores the
34//!    `bsi::ifc::presentation` attributes.
35//! 3. Otherwise, and always for curves, colour comes from the nearest node
36//!    with `diffuseColor`, and opacity from that same node only. Without any
37//!    `diffuseColor` the colour is [`BasicMaterial::DEFAULT`], grey 0.6 and
38//!    opaque.
39//!
40//! Point clouds use neither; they carry their own per-point colours.
41//!
42//! Three choices differ from the reference viewer where its behaviour follows
43//! from JavaScript truthiness or branch order rather than intent:
44//!
45//! - Opacity `0` is fully transparent; the viewer skips falsy opacity values
46//!   and draws such objects opaque.
47//! - `visibility: "inherited"` hides nothing; the viewer also skips the
48//!   geometry of any node that merely has the attribute.
49//! - A `gltf::material` whose members are all `false` or `0` still selects
50//!   PBR; the viewer skips it.
51
52use serde_json::{Map, Value};
53
54use crate::json::{field, object};
55use crate::{Attributes, DecodeError};
56
57/// Attribute id of USD visibility.
58pub const VISIBILITY: &str = "usd::usdgeom::visibility";
59/// Attribute id of the IFC diffuse colour.
60pub const DIFFUSE_COLOR: &str = "bsi::ifc::presentation::diffuseColor";
61/// Attribute id of the IFC opacity.
62pub const OPACITY: &str = "bsi::ifc::presentation::opacity";
63/// Attribute id of a glTF PBR material.
64pub const GLTF_MATERIAL: &str = "gltf::material";
65
66fn wrong(at: impl Into<String>, expected: &'static str) -> DecodeError {
67    DecodeError::WrongShape {
68        at: at.into(),
69        expected,
70    }
71}
72
73/// USD visibility token of one node.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
75pub enum Visibility {
76    /// Visible if the parent is visible (USD's default).
77    Inherited,
78    /// Hidden, together with every descendant.
79    Invisible,
80}
81
82impl Visibility {
83    /// Decodes a `usd::usdgeom::visibility` value, `{"visibility": "<token>"}`.
84    pub fn from_value(value: &Value) -> Result<Self, DecodeError> {
85        let token = field(object(value, VISIBILITY)?, "visibility")?
86            .as_str()
87            .ok_or_else(|| wrong("visibility", "string"))?;
88        match token {
89            "inherited" => Ok(Self::Inherited),
90            "invisible" => Ok(Self::Invisible),
91            other => Err(DecodeError::UnknownToken {
92                at: "visibility".to_owned(),
93                token: other.to_owned(),
94                expected: "inherited or invisible",
95            }),
96        }
97    }
98}
99
100/// Whether a node is drawn, given whether its parent is (`true` for a root)
101/// and the node's own visibility. An `invisible` node hides its subtree; a
102/// descendant cannot become visible again.
103pub fn is_visible(parent_visible: bool, own: Option<Visibility>) -> bool {
104    parent_visible && own != Some(Visibility::Invisible)
105}
106
107/// glTF `alphaMode`.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
109pub enum AlphaMode {
110    #[default]
111    Opaque,
112    Mask,
113    Blend,
114}
115
116/// A normal map reference. The texture is recorded, not loaded.
117#[derive(Debug, Clone, PartialEq)]
118pub struct NormalTexture {
119    /// Texture location as written, usually a URI.
120    pub texture: String,
121    /// glTF `scale`, default 1.
122    pub scale: f32,
123}
124
125/// An occlusion map reference. The texture is recorded, not loaded.
126#[derive(Debug, Clone, PartialEq)]
127pub struct OcclusionTexture {
128    /// Texture location as written, usually a URI.
129    pub texture: String,
130    /// glTF `strength`, default 1.
131    pub strength: f32,
132}
133
134/// A `gltf::material` value with glTF 2.0 defaults filled in.
135///
136/// Factors are linear, as in glTF. Texture members keep the location string
137/// as written; nothing is fetched or decoded. The reference viewer maps only
138/// the RGB of `baseColorFactor`, `metallicFactor`, and `roughnessFactor`; the
139/// other members are kept for the scene and GLB export.
140#[derive(Debug, Clone, PartialEq)]
141pub struct GltfMaterial {
142    /// `pbrMetallicRoughness.baseColorFactor`, default `[1, 1, 1, 1]`.
143    pub base_color_factor: [f32; 4],
144    /// `pbrMetallicRoughness.baseColorTexture`.
145    pub base_color_texture: Option<String>,
146    /// `pbrMetallicRoughness.metallicFactor`, default 1.
147    pub metallic_factor: f32,
148    /// `pbrMetallicRoughness.roughnessFactor`, default 1.
149    pub roughness_factor: f32,
150    /// `pbrMetallicRoughness.metallicRoughnessTexture`.
151    pub metallic_roughness_texture: Option<String>,
152    pub normal_texture: Option<NormalTexture>,
153    pub occlusion_texture: Option<OcclusionTexture>,
154    pub emissive_texture: Option<String>,
155    /// Default `[0, 0, 0]`.
156    pub emissive_factor: [f32; 3],
157    /// Default [`AlphaMode::Opaque`].
158    pub alpha_mode: AlphaMode,
159    /// Default 0.5.
160    pub alpha_cutoff: f32,
161    /// Default `false`.
162    pub double_sided: bool,
163}
164
165impl Default for GltfMaterial {
166    fn default() -> Self {
167        Self {
168            base_color_factor: [1.0; 4],
169            base_color_texture: None,
170            metallic_factor: 1.0,
171            roughness_factor: 1.0,
172            metallic_roughness_texture: None,
173            normal_texture: None,
174            occlusion_texture: None,
175            emissive_texture: None,
176            emissive_factor: [0.0; 3],
177            alpha_mode: AlphaMode::Opaque,
178            alpha_cutoff: 0.5,
179            double_sided: false,
180        }
181    }
182}
183
184const GLTF_MEMBERS: [&str; 8] = [
185    "pbrMetallicRoughness",
186    "normalTexture",
187    "occlusionTexture",
188    "emissiveTexture",
189    "emissiveFactor",
190    "alphaMode",
191    "alphaCutoff",
192    "doubleSided",
193];
194
195impl GltfMaterial {
196    /// Decodes a `gltf::material` value. Returns `None` when the object sets
197    /// none of the members the reference viewer looks for, in which case the
198    /// node falls back to `bsi::ifc::presentation`. Unknown members are
199    /// ignored.
200    pub fn from_value(value: &Value) -> Result<Option<Self>, DecodeError> {
201        let object = object(value, GLTF_MATERIAL)?;
202        if !GLTF_MEMBERS.iter().any(|m| member(object, m).is_some()) {
203            return Ok(None);
204        }
205        let mut material = Self::default();
206        if let Some(pbr) = member(object, "pbrMetallicRoughness") {
207            let pbr = pbr
208                .as_object()
209                .ok_or_else(|| wrong("pbrMetallicRoughness", "object"))?;
210            let at = |name: &str| format!("pbrMetallicRoughness.{name}");
211            if let Some(v) = member(pbr, "baseColorFactor") {
212                material.base_color_factor =
213                    reals(v).ok_or_else(|| wrong(at("baseColorFactor"), "array of 4 numbers"))?;
214            }
215            material.base_color_texture = text(pbr, "baseColorTexture", at)?;
216            if let Some(v) = member(pbr, "metallicFactor") {
217                material.metallic_factor = real(v, at("metallicFactor"))?;
218            }
219            if let Some(v) = member(pbr, "roughnessFactor") {
220                material.roughness_factor = real(v, at("roughnessFactor"))?;
221            }
222            material.metallic_roughness_texture = text(pbr, "metallicRoughnessTexture", at)?;
223        }
224        if let Some((texture, scale)) = texture_info(object, "normalTexture", "scale")? {
225            material.normal_texture = Some(NormalTexture { texture, scale });
226        }
227        if let Some((texture, strength)) = texture_info(object, "occlusionTexture", "strength")? {
228            material.occlusion_texture = Some(OcclusionTexture { texture, strength });
229        }
230        material.emissive_texture = text(object, "emissiveTexture", str::to_owned)?;
231        if let Some(v) = member(object, "emissiveFactor") {
232            material.emissive_factor =
233                reals(v).ok_or_else(|| wrong("emissiveFactor", "array of 3 numbers"))?;
234        }
235        if let Some(v) = member(object, "alphaMode") {
236            material.alpha_mode = match v.as_str() {
237                Some("OPAQUE") => AlphaMode::Opaque,
238                Some("MASK") => AlphaMode::Mask,
239                Some("BLEND") => AlphaMode::Blend,
240                _ => {
241                    return Err(DecodeError::UnknownToken {
242                        at: "alphaMode".to_owned(),
243                        token: v.as_str().map_or_else(|| v.to_string(), str::to_owned),
244                        expected: "OPAQUE, MASK, or BLEND",
245                    })
246                }
247            };
248        }
249        if let Some(v) = member(object, "alphaCutoff") {
250            material.alpha_cutoff = real(v, "alphaCutoff")?;
251        }
252        if let Some(v) = member(object, "doubleSided") {
253            material.double_sided = v.as_bool().ok_or_else(|| wrong("doubleSided", "boolean"))?;
254        }
255        Ok(Some(material))
256    }
257}
258
259fn member<'v>(object: &'v Map<String, Value>, name: &str) -> Option<&'v Value> {
260    object.get(name).filter(|v| !v.is_null())
261}
262
263fn real(value: &Value, at: impl Into<String>) -> Result<f32, DecodeError> {
264    value
265        .as_f64()
266        .map(|v| v as f32)
267        .ok_or_else(|| wrong(at, "number"))
268}
269
270fn reals<const N: usize>(value: &Value) -> Option<[f32; N]> {
271    let items = value.as_array()?;
272    if items.len() != N {
273        return None;
274    }
275    let mut out = [0.0; N];
276    for (slot, item) in out.iter_mut().zip(items) {
277        *slot = item.as_f64()? as f32;
278    }
279    Some(out)
280}
281
282fn text(
283    object: &Map<String, Value>,
284    name: &str,
285    at: impl Fn(&str) -> String,
286) -> Result<Option<String>, DecodeError> {
287    member(object, name)
288        .map(|v| {
289            v.as_str()
290                .map(str::to_owned)
291                .ok_or_else(|| wrong(at(name), "string"))
292        })
293        .transpose()
294}
295
296fn texture_info(
297    object: &Map<String, Value>,
298    name: &str,
299    factor: &str,
300) -> Result<Option<(String, f32)>, DecodeError> {
301    let Some(value) = member(object, name) else {
302        return Ok(None);
303    };
304    let info = value.as_object().ok_or_else(|| wrong(name, "object"))?;
305    let at = |field: &str| format!("{name}.{field}");
306    let texture =
307        text(info, "texture", at)?.ok_or(DecodeError::MissingField { field: "texture" })?;
308    let factor = match member(info, factor) {
309        Some(v) => real(v, at(factor))?,
310        None => 1.0,
311    };
312    Ok(Some((texture, factor)))
313}
314
315/// One node's own presentation attributes, before any inheritance.
316#[derive(Debug, Clone, PartialEq, Default)]
317pub struct NodePresentation {
318    pub visibility: Option<Visibility>,
319    /// Linear RGB in `0..=1`, as written.
320    pub diffuse_color: Option<[f32; 3]>,
321    pub opacity: Option<f32>,
322    pub gltf_material: Option<GltfMaterial>,
323}
324
325impl NodePresentation {
326    /// Decodes the node's `usd::usdgeom::visibility`,
327    /// `bsi::ifc::presentation::diffuseColor` and `opacity`, and
328    /// `gltf::material`. Absent or `null` attributes stay `None`.
329    pub fn from_attributes<A: Attributes + ?Sized>(attributes: &A) -> Result<Self, DecodeError> {
330        let visibility = attributes
331            .attribute(VISIBILITY)
332            .map(Visibility::from_value)
333            .transpose()?;
334        let diffuse_color = attributes
335            .attribute(DIFFUSE_COLOR)
336            .map(decode_diffuse_color)
337            .transpose()?;
338        let opacity = attributes
339            .attribute(OPACITY)
340            .map(decode_opacity)
341            .transpose()?;
342        let gltf_material = match attributes.attribute(GLTF_MATERIAL) {
343            Some(v) => GltfMaterial::from_value(v)?,
344            None => None,
345        };
346        Ok(Self {
347            visibility,
348            diffuse_color,
349            opacity,
350            gltf_material,
351        })
352    }
353
354    /// Like [`from_attributes`](Self::from_attributes), but decodes each
355    /// attribute on its own: a malformed one is left `None` and reported
356    /// with its attribute id, and the others are still used.
357    pub(crate) fn from_attributes_lenient<A: Attributes + ?Sized>(
358        attributes: &A,
359        errors: &mut Vec<(&'static str, DecodeError)>,
360    ) -> Self {
361        fn keep<T>(
362            id: &'static str,
363            result: Result<Option<T>, DecodeError>,
364            errors: &mut Vec<(&'static str, DecodeError)>,
365        ) -> Option<T> {
366            result.unwrap_or_else(|e| {
367                errors.push((id, e));
368                None
369            })
370        }
371        let get = |id| attributes.attribute(id);
372        Self {
373            visibility: keep(
374                VISIBILITY,
375                get(VISIBILITY).map(Visibility::from_value).transpose(),
376                errors,
377            ),
378            diffuse_color: keep(
379                DIFFUSE_COLOR,
380                get(DIFFUSE_COLOR).map(decode_diffuse_color).transpose(),
381                errors,
382            ),
383            opacity: keep(
384                OPACITY,
385                get(OPACITY).map(decode_opacity).transpose(),
386                errors,
387            ),
388            gltf_material: keep(
389                GLTF_MATERIAL,
390                get(GLTF_MATERIAL).map_or(Ok(None), GltfMaterial::from_value),
391                errors,
392            ),
393        }
394    }
395}
396
397fn decode_diffuse_color(value: &Value) -> Result<[f32; 3], DecodeError> {
398    reals(value).ok_or_else(|| wrong(DIFFUSE_COLOR, "array of 3 numbers"))
399}
400
401fn decode_opacity(value: &Value) -> Result<f32, DecodeError> {
402    real(value, OPACITY)
403}
404
405/// Colour and opacity from `bsi::ifc::presentation`.
406#[derive(Debug, Clone, Copy, PartialEq)]
407pub struct BasicMaterial {
408    /// Linear RGB in `0..=1`.
409    pub color: [f32; 3],
410    /// `1` is opaque.
411    pub opacity: f32,
412}
413
414impl BasicMaterial {
415    /// What the reference viewer draws without any `diffuseColor`: grey 0.6,
416    /// opaque.
417    pub const DEFAULT: Self = Self {
418        color: [0.6; 3],
419        opacity: 1.0,
420    };
421
422    /// Whether the material needs blending.
423    pub fn is_transparent(&self) -> bool {
424        self.opacity < 1.0
425    }
426}
427
428impl Default for BasicMaterial {
429    fn default() -> Self {
430        Self::DEFAULT
431    }
432}
433
434/// The material a mesh is drawn with.
435#[derive(Debug, Clone, PartialEq)]
436pub enum Material {
437    /// From `bsi::ifc::presentation`, or the default grey.
438    Basic(BasicMaterial),
439    /// From `gltf::material`.
440    Pbr(GltfMaterial),
441}
442
443/// Colour and opacity for a node, from the node and its ancestors given
444/// nearest first. The nearest `diffuseColor` wins, and opacity comes from
445/// that same node (default 1); an opacity on a node without `diffuseColor` is
446/// not used. Used for curves, and for meshes without a glTF material.
447pub fn resolve_basic_material<'a, I>(node_then_ancestors: I) -> BasicMaterial
448where
449    I: IntoIterator<Item = &'a NodePresentation>,
450{
451    node_then_ancestors
452        .into_iter()
453        .find_map(|p| {
454            p.diffuse_color.map(|color| BasicMaterial {
455                color,
456                opacity: p.opacity.unwrap_or(1.0),
457            })
458        })
459        .unwrap_or_default()
460}
461
462/// The material for a mesh node, from the node and its ancestors given
463/// nearest first. The nearest `gltf::material` wins over any
464/// `bsi::ifc::presentation` attribute; without one, see
465/// [`resolve_basic_material`].
466pub fn resolve_mesh_material<'a, I>(node_then_ancestors: I) -> Material
467where
468    I: IntoIterator<Item = &'a NodePresentation>,
469    I::IntoIter: Clone,
470{
471    let nodes = node_then_ancestors.into_iter();
472    match nodes.clone().find_map(|p| p.gltf_material.as_ref()) {
473        Some(pbr) => Material::Pbr(pbr.clone()),
474        None => Material::Basic(resolve_basic_material(nodes)),
475    }
476}