Skip to main content

ifc_lite_processing/appearance/
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//! Opt-in appearance authoring over an effective IFC snapshot. No load-time
5//! geometry changes. Plans are applied atomically by the host mutation editor.
6mod budget;
7mod evaluated;
8mod evaluated_source;
9mod evaluated_openings;
10mod evaluated_replacement;
11mod evaluated_precision;
12mod evaluated_allocation;
13mod annotation;
14mod authored;
15mod pdf_fill;
16mod pdf_fill_types;
17pub use pdf_fill::plan_pdf_fill_annotation;
18pub use pdf_fill_types::{PdfFillAnnotationRequest,PdfFillAnnotationPlan,PdfFillRegion};
19mod captured;
20mod captured_types;
21pub use captured::plan_captured_mesh;
22pub use captured_types::{CapturedMesh, CapturedMeshRequest, CapturedMeshPlan};
23mod annotation_types;
24mod calibration;
25mod registration;
26mod registration_types;
27pub use registration::register_scan_correspondences;
28pub use registration_types::*;
29mod canonical;
30mod catalog;
31mod context;
32mod mapping;
33mod page;
34mod transfer;
35mod transfer_types;
36mod transfer_math;
37mod transfer_budget;
38mod transfer_surface;
39mod transfer_target;
40mod transfer_sampler;
41pub use transfer::plan_mesh_transfer;
42pub use transfer_types::*;
43mod atlas_plan;
44mod page_atlas;
45mod page_raster;
46mod page_types;
47mod page_source;
48mod page_material;
49mod source;
50mod texture_budget;
51mod types;
52use ifc_lite_core::IfcType;
53use serde_json::{json, Value};
54use source::{refs, Source};
55use std::collections::BTreeSet;
56pub use types::*;
57pub use annotation::plan_annotation_plane;
58pub use annotation_types::{AnnotationPlaneFrame, AnnotationPlaneRequest, AnnotationPlanePlan};
59pub use page::plan_page_appearance;
60pub use page_types::*;
61pub use calibration::{calibrate_appearance_plane, CalibratedPlane, PlaneCalibrationRequest};
62pub use catalog::{catalog_appearance, AppearanceCatalog, AppearanceCatalogProduct, AppearanceCatalogRequest, AppearanceCatalogType};
63
64fn reference(id: u32) -> Value {
65    Value::String(format!("#{id}"))
66}
67fn add(plan: &mut AppearancePlan, name: &str, attributes: Vec<Value>) -> u32 {
68    let id = plan.next_available_express_id;
69    plan.next_available_express_id += 1; // Capacity preflight below bounds the entire plan.
70    plan.created.push(CreatedEntity {
71        express_id: id,
72        r#type: name.into(),
73        attributes,
74    });
75    id
76}
77
78mod wire_text;
79
80fn validate_image_uri(uri: &str) -> Result<(), String> {
81    wire_text::validate(uri, "Image URI")?;
82    if uri.is_empty()
83        || uri.len() > 240
84        || !uri.is_ascii()
85        || uri.starts_with('/')
86        || uri.contains(['\\', ':', '?', '#', '%', '\''])
87        || uri.chars().any(char::is_control)
88        || uri
89            .split('/')
90            .any(|part| part.is_empty() || part == "." || part == "..")
91    {
92        return Err("Image URI must be a safe relative asset path".into());
93    }
94    Ok(())
95}
96
97/// `schema` must come from the host's validated parser; `nextExpressId` is its
98/// reserved allocator watermark, strictly greater than every effective-source id.
99/// Images must already have a model-owned, collision-free relative filename.
100/// Returned UVs describe an UNSPLIT canonical item; fragment consumers must remap
101/// source corners or refuse a mismatched vertex count, never truncate UV arrays.
102pub fn plan_appearance(
103    bytes: &[u8],
104    request: &AppearanceRequest,
105) -> Result<AppearancePlan, String> {
106    if request.product_ids.is_empty() { return Err("Appearance scope must contain 1..10000 products".into()); }
107    let mut source=Source::new(bytes)?;
108    if request.representation_policy==RepresentationPolicy::EvaluatedOccurrence {
109        let normalized=evaluated::prepare(bytes,request,&mut source)?;
110        let plan=plan_with_source(bytes,normalized.request(),&mut source)?;
111        return Ok(normalized.compose(plan,&source)?.0);
112    }
113    plan_with_source(bytes,request,&mut source)
114}
115
116fn plan_with_source(bytes:&[u8], request:&AppearanceRequest, source:&mut Source<'_>) -> Result<AppearancePlan,String> {
117    if request.schema != "IFC4" && request.schema != "IFC4X3" {
118        return Err("Appearance authoring requires IFC4 or IFC4X3".into());
119    }
120    if request.product_ids.len() > 10_000 {
121        return Err("Appearance scope must contain 1..10000 products".into());
122    }
123    let uri = &request.image_uri;
124    validate_image_uri(uri)?;
125    mapping::validate(&request.mapping)?;
126    texture_budget::preflight(source)?;
127    let textures = ifc_lite_geometry::build_texture_index(bytes, &mut source.decoder);
128    let max_id = source
129        .types
130        .last_key_value()
131        .map(|(id, _)| *id)
132        .unwrap_or(0);
133    if request.next_express_id <= max_id {
134        return Err("Stale allocator watermark overlaps effective IFC source".into());
135    }
136    let mut plan = AppearancePlan {
137        source_revision: request.source_revision.clone(),
138        next_express_id: request.next_express_id,
139        next_available_express_id: request.next_express_id,
140        ..Default::default()
141    };
142    let mut seen = BTreeSet::new();
143    let mut budget = budget::PlanBudget::default();
144    for &product in &request.product_ids {
145        if !seen.insert(product) {
146            continue;
147        }
148        let prepared = (|| {
149            let ids = source.product_items(product)?;
150            let mut items = Vec::new();
151            for id in ids {
152                // Existing non-surface styles remain on their StyledItem. An
153                // old style definition is never mutated: other owners may use it.
154                if let Some(styled) = source
155                    .styled_items
156                    .get(&id)
157                    .and_then(|ids| ids.first())
158                    .copied()
159                {
160                    let entity = source.entity(styled)?;
161                    for style in refs(entity.get(1))? {
162                        if !source
163                            .types
164                            .get(&style)
165                            .is_some_and(|t| t.is_subtype_of(IfcType::IfcPresentationStyle))
166                        {
167                            return Err("Indirect or invalid presentation style assignment".into());
168                        }
169                    }
170                }
171                items.push(mapping::map_item(source, request, product, id, &mut budget)?);
172            }
173            canonical::align_source_corners(source, product, &mut items, &textures, request)?;
174            Ok::<_, String>(items)
175        })();
176        // Resource refusal invalidates the whole command, never a partial success.
177        if budget.exhausted { return Err(budget::BUDGET_ERROR.into()); }
178        match prepared {
179            Ok(items) => plan.items.extend(items),
180            Err(reason) => plan.exclusions.push(Exclusion {
181                product_id: product,
182                reason,
183            }),
184        }
185    }
186    if plan.items.is_empty() {
187        return Ok(plan);
188    }
189    let needed = plan
190        .items
191        .len()
192        .checked_mul(3)
193        .and_then(|n| n.checked_add(5))
194        .ok_or("Appearance plan too large")?;
195    if u64::from(plan.next_express_id) + needed as u64 >= u64::from(u32::MAX) {
196        return Err("Appearance entity id capacity exceeded".into());
197    }
198    let image = add(
199        &mut plan,
200        "IfcImageTexture",
201        vec![
202            json!(if request.repeat_s { ".T." } else { ".F." }),
203            json!(if request.repeat_t { ".T." } else { ".F." }),
204            Value::Null,
205            Value::Null,
206            Value::Null,
207            json!(uri),
208        ],
209    );
210    let colour = add(
211        &mut plan,
212        "IfcColourRgb",
213        vec![Value::Null, json!(1.0), json!(1.0), json!(1.0)],
214    );
215    let shading = add(
216        &mut plan,
217        "IfcSurfaceStyleShading",
218        vec![reference(colour), json!(0.0)],
219    );
220    let texture_style = add(
221        &mut plan,
222        "IfcSurfaceStyleWithTextures",
223        vec![json!([reference(image)])],
224    );
225    let surface_style = add(
226        &mut plan,
227        "IfcSurfaceStyle",
228        vec![
229            json!("Image appearance"),
230            json!(".BOTH."),
231            json!([reference(shading), reference(texture_style)]),
232        ],
233    );
234    // Move item data out while accumulating mutation entities, avoiding copies
235    // of large texture arrays solely to satisfy mutable plan ownership.
236    let items = std::mem::take(&mut plan.items);
237    for item in &items {
238        let list = add(
239            &mut plan,
240            "IfcTextureVertexList",
241            vec![json!(item.tex_coords)],
242        );
243        add(
244            &mut plan,
245            "IfcIndexedTriangleTextureMap",
246            vec![
247                json!([reference(image)]),
248                reference(item.geometry_item_id),
249                reference(list),
250                json!(item.tex_coord_index),
251            ],
252        );
253        if let Some(old_maps) = source.texture_maps.get(&item.geometry_item_id) {
254            plan.removed.extend(old_maps);
255        }
256        if let Some(styled) = source
257            .styled_items
258            .get(&item.geometry_item_id)
259            .and_then(|ids| ids.first())
260            .copied()
261        {
262            let old = source.entity(styled)?;
263            let mut styles: Vec<Value> = refs(old.get(1))?
264                .into_iter()
265                .filter(|id| source.types.get(id) != Some(&IfcType::IfcSurfaceStyle))
266                .map(reference)
267                .collect();
268            styles.push(reference(surface_style));
269            plan.edits.push(PositionalEdit {
270                express_id: styled,
271                index: 1,
272                value: Value::Array(styles),
273            });
274        } else {
275            add(
276                &mut plan,
277                "IfcStyledItem",
278                vec![
279                    reference(item.geometry_item_id),
280                    json!([reference(surface_style)]),
281                    Value::Null,
282                ],
283            );
284        }
285    }
286    plan.items = items;
287    plan.removed.sort_unstable();
288    plan.removed.dedup();
289    Ok(plan)
290}
291
292#[cfg(test)]
293mod tests;