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