Skip to main content

ifc_lite_processing/appearance/
catalog.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//! Domain scope metadata from the same effective snapshot used by the planner.
5use super::source::Source;
6use ifc_lite_core::{AttributeValue as A, IfcType};
7use serde::{Deserialize, Serialize};
8use std::collections::{BTreeMap, BTreeSet};
9
10#[derive(Debug, Clone, Deserialize, Serialize)]
11#[serde(rename_all = "camelCase", deny_unknown_fields)]
12pub struct AppearanceCatalogRequest {
13    pub schema: String,
14    pub source_revision: String,
15    pub product_ids: Vec<u32>,
16}
17#[derive(Debug, Clone, Serialize)]
18#[serde(rename_all = "camelCase")]
19pub struct AppearanceCatalogProduct {
20    pub product_id: u32,
21    pub ifc_class: String,
22    pub type_ids: Vec<u32>,
23}
24#[derive(Debug, Clone, Serialize)]
25#[serde(rename_all = "camelCase")]
26pub struct AppearanceCatalogType {
27    pub type_id: u32,
28    pub ifc_class: String,
29    /// Exact IFC EXPRESS attribute name on the wire, not a display-label alias.
30    #[serde(rename = "Name")]
31    pub name: Option<String>,
32}
33#[derive(Debug, Clone, Serialize)]
34#[serde(rename_all = "camelCase")]
35pub struct AppearanceCatalog {
36    pub source_revision: String,
37    pub products: Vec<AppearanceCatalogProduct>,
38    pub types: Vec<AppearanceCatalogType>,
39    /// Missing/deleted IDs and non-IfcProduct owners are explicitly ineligible.
40    pub missing_product_ids: Vec<u32>,
41}
42
43/// Build class/type selectors without geometry or a TypeScript overlay policy.
44/// Input must already contain effective host edits. All IDs are model-local.
45/// A refusal returns no partial catalogue; caller validates sourceRevision.
46pub fn catalog_appearance(bytes: &[u8], request: &AppearanceCatalogRequest) -> Result<AppearanceCatalog, String> {
47    if request.schema != "IFC4" && request.schema != "IFC4X3" {
48        return Err("Appearance catalog requires IFC4 or IFC4X3".into());
49    }
50    if request.product_ids.len() > 10_000 || request.product_ids.contains(&0) || request.source_revision.len() > 4096 {
51        return Err("Appearance catalog request exceeds its owner/revision budget".into());
52    }
53    // Shared canonical parse and its 128MiB/200k-entity/8m-value bounds. No
54    // geometry jobs, texture decode, or second source/overlay parser is added.
55    let mut source = Source::new(bytes)?;
56    let mut products = BTreeMap::<u32, (IfcType, BTreeSet<u32>)>::new();
57    let mut missing = BTreeSet::new();
58    for &id in &request.product_ids {
59        match source.types.get(&id).copied() {
60            Some(class) if class.is_subtype_of(IfcType::IfcProduct) => { products.entry(id).or_insert((class, BTreeSet::new())); }
61            _ => { missing.insert(id); }
62        }
63    }
64    let relation_ids: Vec<u32> = source.types.iter().filter_map(|(&id, class)|
65        (*class == IfcType::IfcRelDefinesByType).then_some(id)).collect();
66    let mut types = BTreeMap::new();
67    let mut memberships = 0usize;
68    let mut label_bytes = 0usize;
69    for id in relation_ids {
70        let relation = source.entity(id)?;
71        let related = relation.get(4).and_then(A::as_list)
72            .ok_or_else(|| format!("Invalid IfcRelDefinesByType #{id} RelatedObjects"))?;
73        let mut owners = BTreeSet::new();
74        for member in related {
75            let owner = member.as_entity_ref().ok_or_else(|| format!("Invalid IfcRelDefinesByType #{id} member"))?;
76            if products.contains_key(&owner) { owners.insert(owner); }
77        }
78        if owners.is_empty() { continue; }
79        let type_id = relation.get_ref(5).ok_or_else(|| format!("Missing IfcRelDefinesByType #{id} RelatingType"))?;
80        let class = source.types.get(&type_id).copied().filter(|class| class.is_subtype_of(IfcType::IfcTypeObject))
81            .ok_or_else(|| format!("Invalid IfcRelDefinesByType #{id} RelatingType #{type_id}"))?;
82        if let std::collections::btree_map::Entry::Vacant(entry) = types.entry(type_id) {
83            let entity = source.entity(type_id)?;
84            let name = match entity.get(2) {
85                None | Some(A::Null) => None,
86                Some(value) => Some(value.as_string().ok_or("Invalid IFC type Name")?),
87            };
88            label_bytes += name.map_or(0, str::len);
89            if label_bytes > 4 * 1024 * 1024 { return Err("Appearance catalog type names exceed 4 MiB metadata budget".into()); }
90            entry.insert(AppearanceCatalogType { type_id, ifc_class: class.name().into(), name: name.map(str::to_owned) });
91        }
92        for owner in owners {
93            if products.get_mut(&owner).expect("owner was selected above").1.insert(type_id) {
94                memberships += 1;
95                if memberships > 200_000 { return Err("Appearance catalog exceeds 200000 type memberships".into()); }
96            }
97        }
98    }
99    Ok(AppearanceCatalog {
100        source_revision: request.source_revision.clone(),
101        products: products.into_iter().map(|(product_id, (class, ids))| AppearanceCatalogProduct {
102            product_id, ifc_class: class.name().into(), type_ids: ids.into_iter().collect(),
103        }).collect(),
104        types: types.into_values().collect(), missing_product_ids: missing.into_iter().collect(),
105    })
106}
107
108#[cfg(test)]
109#[path = "catalog_tests.rs"]
110mod tests;