ifc_lite_processing/appearance/
pdf_fill.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/.
4use super::{
5    authored::{self, refs, vector, Metadata},
6    canonical,
7    pdf_fill_provenance::Provenance,
8    pdf_fill_types::*,
9};
10use ifc_lite_core::{AttributeValue as A, IfcType};
11use rustc_hash::FxHashMap;
12use sha2::{Digest, Sha256};
13
14pub(super) const ALGORITHM: &str = "ifclite-pdf-fill-annotation-v1";
15
16/// Create opaque polygonal fill pages through canonical product authoring and
17/// geometry. An exact page converts as is; a page with visible unsupported
18/// content converts only when the request quotes its fidelity report digest,
19/// and the omissions are recorded with the annotation. Raster-only pages and
20/// geometric qualification failures refuse the whole page.
21pub fn plan_pdf_fill_annotation(
22    bytes: &[u8],
23    r: &PdfFillAnnotationRequest,
24) -> Result<PdfFillAnnotationPlan, String> {
25    plan_pdf_fill_annotation_with_clip(bytes, r, None)
26}
27
28/// Plan an annotation from an optional registered rectangle in native PDF
29/// space without changing the source-compatible request struct.
30pub fn plan_pdf_fill_annotation_with_clip(
31    bytes: &[u8],
32    r: &PdfFillAnnotationRequest,
33    conversion_clip_pdf: Option<[f64; 4]>,
34) -> Result<PdfFillAnnotationPlan, String> {
35    let prepared =
36        crate::pdf_vector::prepare_pdf_vector_page_with_clip(&r.page, conversion_clip_pdf)?;
37    let fidelity = &prepared.fidelity;
38    if let Some(accepted) = &r.accepted_fidelity_sha256 {
39        if *accepted != fidelity.sha256 {
40            return Err("Accepted PDF fidelity report does not match this page; review the current report".into());
41        }
42    }
43    let accept_partial = r.accepted_fidelity_sha256.is_some();
44    let geometry = crate::pdf_vector::fills::compose(&prepared, r.page.model_metres_from_pdf, accept_partial)?;
45    let extra_global_ids = [r.property_set_global_id.as_str(), r.property_relation_global_id.as_str()];
46    let metadata = Metadata {
47        schema: &r.schema,
48        source_revision: &r.source_revision,
49        next_express_id: r.next_express_id,
50        container_id: r.container_id,
51        global_id: &r.global_id,
52        containment_global_id: &r.containment_global_id,
53        extra_global_ids: &extra_global_ids,
54        name: &r.name,
55    };
56    let provenance = Provenance {
57        request: r,
58        prepared: &prepared,
59        grid_size_metres: geometry.grid_metres,
60        fill_regions: geometry.shapes.len(),
61    };
62    let mut reserve = 9usize + provenance.reserve();
63    for shape in &geometry.shapes {
64        reserve = reserve
65            .checked_add(4 + shape.rings.len() + shape.rings.iter().map(Vec::len).sum::<usize>())
66            .ok_or("PDF annotation entity budget overflow")?;
67    }
68    let authored::Authoring {
69        mut author,
70        mut source,
71        placement,
72        context_id,
73        owner,
74        scale,
75        rtc_offset,
76    } = authored::prepare(bytes, &metadata, &r.frame, reserve)?;
77    let mut items = Vec::new();
78    let mut styled = Vec::new();
79    let mut regions = Vec::new();
80    for shape in &geometry.shapes {
81        let mut boundaries = Vec::new();
82        for ring in &shape.rings {
83            let mut points = Vec::new();
84            for point in ring {
85                points.push(author.add(
86                    IfcType::IfcCartesianPoint,
87                    vec![vector([point[0] / scale, point[1] / scale, 0.])],
88                ));
89            }
90            points.push(*points.first().ok_or("PDF fill has an empty boundary")?);
91            boundaries.push(author.add(IfcType::IfcPolyline, vec![refs(&points)]));
92        }
93        let item = author.add(
94            IfcType::IfcAnnotationFillArea,
95            vec![
96                A::EntityRef(boundaries[0]),
97                if boundaries.len() == 1 {
98                    A::Null
99                } else {
100                    refs(&boundaries[1..])
101                },
102            ],
103        );
104        let rgb = shape.rgb;
105        let color = author.add(
106            IfcType::IfcColourRgb,
107            vec![
108                A::Null,
109                A::Float(rgb[0]),
110                A::Float(rgb[1]),
111                A::Float(rgb[2]),
112            ],
113        );
114        let style = author.add(
115            IfcType::IfcFillAreaStyle,
116            vec![
117                A::String("PDF solid fill".into()),
118                refs(&[color]),
119                A::Enum("T".into()),
120            ],
121        );
122        styled.push((
123            item,
124            author.add(
125                IfcType::IfcStyledItem,
126                vec![A::EntityRef(item), refs(&[style]), A::Null],
127            ),
128        ));
129        items.push(item);
130        regions.push(PdfFillRegion {
131            geometry_item_id: item,
132            source_operator_ordinal: shape.ordinal,
133            rgb,
134        });
135    }
136    let shape = author.add(
137        IfcType::IfcShapeRepresentation,
138        vec![
139            A::EntityRef(context_id),
140            A::String("Annotation".into()),
141            A::String("Annotation2D".into()),
142            refs(&items),
143        ],
144    );
145    let pds = author.add(
146        IfcType::IfcProductDefinitionShape,
147        vec![A::Null, A::Null, refs(&[shape])],
148    );
149    let mut attrs = vec![
150        A::String(r.global_id.clone()),
151        owner.clone(),
152        A::String(r.name.clone()),
153        A::String(format!("PDF vectors, page {}: {}", r.page.page_number, fidelity.describe())),
154        A::String("IfcLite:PdfVectorFills".into()),
155        A::EntityRef(placement),
156        A::EntityRef(pds),
157    ];
158    if r.schema == "IFC4X3" {
159        attrs.push(A::Enum("USERDEFINED".into()));
160    }
161    let annotation = author.add(IfcType::IfcAnnotation, attrs);
162    author.add(
163        IfcType::IfcRelContainedInSpatialStructure,
164        vec![
165            A::String(r.containment_global_id.clone()),
166            owner.clone(),
167            A::Null,
168            A::Null,
169            refs(&[annotation]),
170            A::EntityRef(r.container_id),
171        ],
172    );
173    let property_set_id = provenance.author(&mut author, &owner, annotation)?;
174    if author.plan.created.len() != reserve {
175        return Err("PDF annotation allocation preflight disagrees with authored rows".into());
176    }
177    source.decoder.inject_shared_cache(&author.entities);
178    let mut styles = crate::prepass::ResolvedPrepass::default();
179    for (item, styled) in styled {
180        let info = crate::style::fill::fill_style_from_styled_item(
181            &author.entities[&styled],
182            &mut source.decoder,
183        )
184        .ok_or("Generated PDF fill failed canonical style resolution")?;
185        styles.geometry_style_index.insert(item, info);
186    }
187    let meshes = canonical::produce(
188        &mut source,
189        annotation,
190        &FxHashMap::default(),
191        Some(&styles),
192    )?;
193    if meshes.len() != items.len()
194        || items.iter().any(|item| {
195            meshes
196                .iter()
197                .filter(|m| m.geometry_item_id == Some(*item))
198                .count()
199                != 1
200        })
201        || meshes.iter().any(|m| {
202            m.indices.is_empty() || m.positions.iter().chain(&m.normals).any(|v| !v.is_finite())
203        })
204    {
205        return Err(
206            "Canonical PDF annotation geometry lost or combined a visible fill region".into(),
207        );
208    }
209    // Explicit transport limits, independent of contour and work budgets. The
210    // host mirrors these before staging a multi-part annotation owner.
211    if meshes.iter().map(|m| m.positions.len() / 3).sum::<usize>() > 65_536
212        || meshes.iter().map(|m| m.indices.len() / 3).sum::<usize>() > 131_072
213    {
214        return Err("PDF annotation exceeds emitted mesh transport budget".into());
215    }
216    let mut geometry_work = geometry.work;
217    for (shape, region) in geometry.shapes.iter().zip(&regions) {
218        let mesh = meshes
219            .iter()
220            .find(|m| m.geometry_item_id == Some(region.geometry_item_id))
221            .ok_or("Missing PDF fill mesh")?;
222        let expected: Vec<[f64; 3]> = shape
223            .rings
224            .iter()
225            .flatten()
226            .map(|p| {
227                std::array::from_fn(|i| {
228                    r.frame.origin[i] + r.frame.axis_u[i] * p[0] + r.frame.axis_v[i] * p[1]
229                })
230            })
231            .collect();
232        let actual: Vec<[f64; 3]> = mesh
233            .positions
234            .chunks_exact(3)
235            .map(|p| std::array::from_fn(|i| f64::from(p[i]) + mesh.origin[i] + rtc_offset[i]))
236            .collect();
237        geometry_work = geometry_work
238            .checked_add((expected.len() as u64) * (actual.len() as u64) * 2)
239            .ok_or("PDF fill precision work overflow")?;
240        if geometry_work > 4_000_000 {
241            return Err("PDF fill canonical precision check exceeds shared work budget".into());
242        }
243        let tolerance2 = r.page.tolerance_metres * r.page.tolerance_metres;
244        let near = |a: &[f64; 3], b: &[f64; 3]| {
245            a.iter().zip(b).map(|(a, b)| (a - b) * (a - b)).sum::<f64>() <= tolerance2
246        };
247        if expected.iter().any(|p| !actual.iter().any(|q| near(p, q)))
248            || actual.iter().any(|p| !expected.iter().any(|q| near(p, q)))
249        {
250            return Err("Canonical PDF fill vertices exceed the requested metric precision; move the page nearer the model origin".into());
251        }
252    }
253    let source_ifc_sha256 = format!("{:x}", Sha256::digest(bytes));
254    let mut digest = Sha256::new();
255    digest.update(ALGORITHM.as_bytes());
256    digest.update(b"\0");
257    digest.update(source_ifc_sha256.as_bytes());
258    digest.update(serde_json::to_vec(r).map_err(|e| e.to_string())?);
259    Ok(PdfFillAnnotationPlan {
260        plan: author.plan,
261        annotation_id: annotation,
262        property_set_id,
263        meshes,
264        coordinate_space: "ifc-z-up",
265        rtc_offset,
266        frame: r.frame.clone(),
267        source_ifc_sha256,
268        source_pdf_sha256: r.page.pdf_sha256.clone(),
269        page_number: r.page.page_number,
270        request_sha256: format!("{:x}", digest.finalize()),
271        algorithm: ALGORITHM,
272        calibration_key: r.page.calibration_key.clone(),
273        tolerance_metres: r.page.tolerance_metres,
274        grid_size_metres: geometry.grid_metres,
275        geometry_work,
276        regions,
277        fidelity: prepared.fidelity,
278    })
279}
280
281#[cfg(test)]
282#[path = "pdf_fill_tests.rs"]
283mod tests;