Skip to main content

ifc_lite_processing/symbolic/
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
5//! Canonical 2D symbol extraction shared between the HTTP server and the
6//! browser-side WASM bindings (issue #843 follow-up — full parity work).
7//!
8//! Walks an IFC once, extracts every symbolic primitive the renderer
9//! understands (polylines, circles, texts, fill areas, grid axes +
10//! bubbles), and returns pure-Rust serializable types. The browser path
11//! in `rust/wasm-bindings/src/api/symbolic.rs` wraps the result into its
12//! `wasm_bindgen` collection at the FFI boundary; the server path
13//! serializes the same data structures directly via serde.
14//!
15//! Primitive coverage matches the wasm pipeline that ships to production:
16//!
17//! - `IfcPolyline`, `IfcIndexedPolyCurve` → [`SymbolicPolyline`].
18//! - `IfcCircle` → [`SymbolicCircle`] (full circle).
19//! - `IfcEllipse` → [`SymbolicPolyline`] (64-segment tessellation).
20//! - `IfcTrimmedCurve` on `IfcCircle` → [`SymbolicPolyline`] (arc with
21//!   `PLANEANGLEUNIT` scaling, sense agreement, wrap-around). Near-
22//!   collinear arcs (large radius, small sagitta) collapse to a line.
23//! - `IfcCompositeCurve` → recurses into segments.
24//! - `IfcGeometricSet` / `IfcGeometricCurveSet` → recurses into elements.
25//! - `IfcMappedItem` → recurses into the mapped representation with
26//!   `MappingOrigin` + `MappingTarget` transform composition.
27//! - `IfcTextLiteral` / `IfcTextLiteralWithExtent` → [`SymbolicText`]
28//!   with placement composition, `BoxAlignment`, glyph cap height
29//!   derived from the extent box, colour via `IfcStyledItem` →
30//!   `IfcTextStyle`.
31//! - `IfcAnnotationFillArea` → [`SymbolicFillArea`] with outer ring,
32//!   optional hole rings, colour via `IfcStyledItem` → `IfcFillAreaStyle`.
33//! - `IfcGrid` → [`SymbolicPolyline`] (axis lines) + two [`SymbolicText`]
34//!   bubbles per axis end (outline glyph + tag text).
35//!
36//! Coordinate handling matches the wasm pipeline:
37//!
38//! - Per-product `ObjectPlacement` is resolved through the
39//!   `IfcLocalPlacement` chain; symbolic uses a 2D
40//!   translation-plus-rotation accumulation that intentionally diverges
41//!   from the 3D geometry router so floor-plan annotations aren't
42//!   distorted by parent rotations.
43//! - Per-representation `ContextOfItems.WorldCoordinateSystem` is
44//!   composed in when present (Plan reps occasionally use a different
45//!   WCS than Body).
46//! - The frame comes from the caller when it has one of its own — the native
47//!   server, whose meshes travel in the same response, hands in
48//!   `ProcessingResult::frame` (#4706) — and from the browser mesh frame
49//!   selection (`MeshFrame::for_overlay`) otherwise.
50//! - The whole RTC offset is subtracted — easting and northing into the
51//!   plan pair (whose Y axis is flipped to match the renderer's section-cut
52//!   handedness), elevation into `world_y` — and the frame's rotation, which
53//!   only the site tier carries, comes out of the plan pair and out of the
54//!   text baselines with it. `rebase::RenderFrameRebase` is the single place
55//!   either conversion happens.
56//!
57//! Style resolution:
58//!
59//! - A reverse index from styled-representation-item id to concrete
60//!   style refs is built up-front in O(n), unwrapping the deprecated
61//!   `IfcPresentationStyleAssignment` so downstream resolvers don't
62//!   need to know about it.
63//! - Text colour walks `IfcTextStyle.TextCharacterAppearance` →
64//!   `IfcTextStyleForDefinedFont.Colour` → `IfcColourRgb`.
65//! - Fill colour walks `IfcFillAreaStyle.FillStyles` → first
66//!   `IfcColourRgb`; hatching / tile fills are recognised but use a
67//!   default fill colour.
68
69
70use output_cap::SymbolicAccumulator;
71use rebase::RenderFrameRebase;
72use ifc_lite_core::{build_entity_index, keyword_eq, EntityDecoder, EntityScanner, IfcType};
73
74mod color;
75mod conic;
76mod fill;
77mod fill_provenance;
78mod provenance;
79pub use provenance::SymbolicDataWithProvenance;
80mod grid;
81mod item_walk;
82mod items;
83mod output_cap;
84mod output_cap_types;
85mod output_cap_validate;
86#[cfg(test)]
87mod items_cycle_tests;
88#[cfg(test)]
89mod output_cap_tests;
90mod primitives;
91mod rebase;
92mod text;
93mod transform;
94mod trimmed_curve;
95
96pub use output_cap_types::{SymbolicTruncation, SymbolicTruncationReason};
97pub use primitives::{
98    SymbolicCircle, SymbolicData, SymbolicFillArea, SymbolicGridAxis, SymbolicPolyline, SymbolicText,
99};
100
101use color::build_styled_item_index;
102use grid::extract_grid;
103use item_walk::extract_symbolic_item;
104use transform::{compose_transforms, parse_axis2_placement_2d, resolve_object_placement, Transform2D};
105
106// ────────────────────────────────────────────────────────────────────────────
107// Top-level extraction. Mirror of the wasm `parse_symbolic_representations`
108// scanner loop. Both paths feed the same `extract_*` helpers below so the
109// server and browser produce bit-identical symbol streams.
110// ────────────────────────────────────────────────────────────────────────────
111
112/// Scan an IFC file for `IfcGrid` and any product carrying a Plan /
113/// Annotation / FootPrint / Axis representation, and return the full
114/// symbolic primitive collection. Pure-Rust (no `wasm_bindgen`), so it
115/// works inside the HTTP server.
116pub fn extract_symbolic_data<T>(content: &T) -> SymbolicData
117where
118    T: AsRef<[u8]> + ?Sized,
119{
120    extract_symbolic_data_with_provenance(content).into_parts().0
121}
122
123/// Extract symbols with ordinal-bound direct fill provenance, preserving the legacy data shape.
124///
125/// Resolves the OVERLAY frame (`MeshFrame::for_overlay`): the frame a consumer
126/// that only parses the file can know. That is the browser's frame, and the
127/// wasm binding (`rust/wasm-bindings/src/api/symbolic.rs`) is its caller. A
128/// caller that also ran the native geometry pipeline must use
129/// [`extract_symbolic_data_with_provenance_in_frame`] instead, or its symbols
130/// and its meshes end up in two different frames (#4706).
131pub fn extract_symbolic_data_with_provenance<T>(content: &T) -> SymbolicDataWithProvenance
132where
133    T: AsRef<[u8]> + ?Sized,
134{
135    let mut out = SymbolicAccumulator::new();
136    extract_symbolic_data_into(content, &mut out);
137    out.into_provenance()
138}
139
140/// Extract symbols in a frame the caller already chose: the native pipeline's
141/// [`crate::ProcessingResult::frame`].
142///
143/// The server ships both streams in one response, so both must be in one
144/// frame. `process_geometry` selects `MeshFrame::SiteLocal` for a translated
145/// `IfcSite` and bakes its meshes as `Rᵀ · (P − t_site)`; before #4706 the
146/// symbolic stream resolved its own frame here and kept both the site
147/// translation and the site rotation the meshes had dropped. The frame is
148/// handed in rather than re-derived so the two answers cannot differ.
149pub fn extract_symbolic_data_with_provenance_in_frame<T>(
150    content: &T,
151    frame: crate::MeshFrame,
152) -> SymbolicDataWithProvenance
153where
154    T: AsRef<[u8]> + ?Sized,
155{
156    let mut out = SymbolicAccumulator::new();
157    extract_symbolic_data_into_frame(content, Some(frame), &mut out);
158    out.into_provenance()
159}
160
161/// The overlay-frame extraction, writing into a caller-supplied accumulator.
162///
163/// Split out so a test can supply an accumulator with a small injected cap
164/// and exercise the real path, instead of building a fixture that emits two
165/// million primitives to reach `MAX_SYMBOLIC_ELEMENTS`.
166fn extract_symbolic_data_into<T>(content: &T, out: &mut SymbolicAccumulator)
167where
168    T: AsRef<[u8]> + ?Sized,
169{
170    extract_symbolic_data_into_frame(content, None, out)
171}
172
173/// The extraction itself. `frame` is `None` when the caller has no frame of
174/// its own and this must resolve the overlay one.
175fn extract_symbolic_data_into_frame<T>(
176    content: &T,
177    frame: Option<crate::MeshFrame>,
178    out: &mut SymbolicAccumulator,
179)
180where
181    T: AsRef<[u8]> + ?Sized,
182{
183    let content = content.as_ref();
184    let entity_index = build_entity_index(content);
185    let mut decoder = EntityDecoder::with_index(content, entity_index);
186
187    // Reuse the geometry router for both unit-scale and the RTC offset.
188    // Not drained: meshes nothing. Pinned by rust/geometry/tests/issue_3821_auxiliary_routers_mesh_nothing.rs.
189    let router = ifc_lite_geometry::GeometryRouter::with_units(content, &mut decoder);
190    let unit_scale = router.unit_scale() as f32;
191
192    // Re-based by the frame the caller's meshes are in, or - when it has none
193    // - by the browser mesh frame this can resolve for itself (#4706).
194    let frame =
195        frame.unwrap_or_else(|| crate::MeshFrame::for_overlay(&router, content, &mut decoder));
196    let rebase = RenderFrameRebase::from_frame(frame);
197
198    // Pre-pass: build a reverse index from "styled representation-item id"
199    // to "list of style refs". Walked once at parse start (O(n)) so per-
200    // item colour lookup is O(1) later. See `resolve_color_via_styles()`
201    // for the chain (deprecated IfcPresentationStyleAssignment unwrap +
202    // IfcFillAreaStyle → IfcColourRgb).
203    let styled_items = build_styled_item_index(content, &mut decoder);
204
205    let mut scanner = EntityScanner::new(content);
206
207    while let Some((id, type_name, start, end)) = scanner.next_entity() {
208        // Stop the SCAN, not just the innermost item loop. Breaking only the
209        // inner loop still decodes every remaining product, resolves its
210        // placements and composes its transforms, which is not the "stop" this
211        // bound claims. Bounded by file size rather than by the fan-out, so it
212        // was never the DoS lever -- but it is work with a known-useless
213        // result.
214        if out.is_exhausted() {
215            break;
216        }
217        let is_grid = keyword_eq(type_name, "IFCGRID");
218        if !is_grid && !ifc_lite_core::has_geometry_by_name(type_name) {
219            // IfcGrid isn't in `has_geometry_by_name` (it's not a building
220            // element) but carries axis curves that we render as symbolic
221            // lines + bubbles + tags.
222            continue;
223        }
224        let Ok(entity) = decoder.decode_at_with_id(id, start, end) else {
225            continue;
226        };
227
228        if is_grid {
229            let grid_transform = resolve_object_placement(&entity, &mut decoder, unit_scale);
230            extract_grid(
231                &entity,
232                id,
233                &mut decoder,
234                unit_scale,
235                &grid_transform,
236                rebase,
237                out,
238            );
239            continue;
240        }
241
242        // Standard representation walk: IfcProductDefinitionShape → Plan /
243        // Annotation / FootPrint / Axis IfcShapeRepresentation → items.
244        let Some(representation_attr) = entity.get(6) else {
245            continue;
246        };
247        if representation_attr.is_null() {
248            continue;
249        }
250        let Ok(Some(representation)) = decoder.resolve_ref(representation_attr) else {
251            continue;
252        };
253        let Some(reps_attr) = representation.get(2) else {
254            continue;
255        };
256        let Ok(representations) = decoder.resolve_ref_list(reps_attr) else {
257            continue;
258        };
259
260        let ifc_type_name = entity.ifc_type.name().to_string();
261
262        let single_representation = representations.len() == 1;
263        for shape_rep in representations {
264            if shape_rep.ifc_type != IfcType::IfcShapeRepresentation {
265                continue;
266            }
267            let rep_identifier = shape_rep
268                .get(1)
269                .and_then(|a| a.as_string())
270                .unwrap_or("")
271                .to_string();
272            if !matches!(
273                rep_identifier.as_str(),
274                "Plan" | "Annotation" | "FootPrint" | "Axis"
275            ) {
276                continue;
277            }
278
279            // ObjectPlacement transform for this entity (translations
280            // accumulated directly, rotations accumulated to orient symbols).
281            let placement_transform = resolve_object_placement(&entity, &mut decoder, unit_scale);
282
283            // ContextOfItems WCS: some Plan reps use a different coord
284            // system than Body. Compose it in when present and non-trivial.
285            // `ContextOfItems` (IfcRepresentation attr 0) and
286            // `WorldCoordinateSystem` (IfcGeometricRepresentationContext
287            // attr 4) are both MANDATORY, so a dangling ref or absent
288            // attribute is malformed data, not a legitimate default —
289            // `unresolved()` per #2352's convention. A resolved
290            // `IfcGeometricRepresentationSubContext`, in contrast, derives
291            // its WCS from `ParentContext` and legitimately does not store
292            // one inline, so it alone stays `identity()`.
293            let context_transform = match shape_rep.get_ref(0) {
294                Some(context_ref) => match decoder.decode_by_id(context_ref) {
295                    Ok(context) if context.ifc_type == IfcType::IfcGeometricRepresentationContext => {
296                        match context.get_ref(4) {
297                            Some(wcs_ref) => match decoder.decode_by_id(wcs_ref) {
298                                Ok(wcs) => parse_axis2_placement_2d(&wcs, &mut decoder, unit_scale),
299                                Err(_) => Transform2D::unresolved(),
300                            },
301                            None => Transform2D::unresolved(),
302                        }
303                    }
304                    // SubContext inherits WCS from ParentContext — legitimately
305                    // has none inline (the wasm pipeline does the same).
306                    Ok(context) if context.ifc_type == IfcType::IfcGeometricRepresentationSubContext => {
307                        Transform2D::identity()
308                    }
309                    // Dangling context_ref, or a ref resolving to neither
310                    // Context nor SubContext: malformed data.
311                    _ => Transform2D::unresolved(),
312                },
313                None => Transform2D::unresolved(),
314            };
315            let combined_transform = if context_transform.tx.abs() > 0.001
316                || context_transform.ty.abs() > 0.001
317                || context_transform.tz.abs() > 0.001
318                || (context_transform.m00 - 1.0).abs() > 0.0001
319                || context_transform.m01.abs() > 0.0001
320                || context_transform.m10.abs() > 0.0001
321                || (context_transform.m11 - 1.0).abs() > 0.0001
322                || context_transform.tz.is_nan()
323            {
324                compose_transforms(&context_transform, &placement_transform)
325            } else {
326                placement_transform
327            };
328
329            let Some(items_attr) = shape_rep.get(3) else {
330                continue;
331            };
332            let Ok(items) = decoder.resolve_ref_list(items_attr) else {
333                continue;
334            };
335            let direct_fill_ids = fill_provenance::direct_fill_ids(&items, single_representation);
336            for item in items {
337                if out.is_exhausted() {
338                    break;
339                }
340                extract_symbolic_item(
341                    &item,
342                    &mut decoder,
343                    id,
344                    &ifc_type_name,
345                    &rep_identifier,
346                    unit_scale,
347                    &combined_transform,
348                    rebase,
349                    &styled_items,
350                    out,
351                    direct_fill_ids.contains(&item.id).then_some(item.id),
352                );
353            }
354        }
355    }
356
357}