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