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//! - RTC offset is auto-detected from the first geometry-bearing
47//!   element and subtracted alongside the mesh pipeline.
48//! - The whole RTC offset is subtracted — easting and northing into the
49//!   plan pair (whose Y axis is flipped to match the renderer's section-cut
50//!   handedness), elevation into `world_y`. `rebase::RenderFrameRebase` is
51//!   the single place that conversion happens.
52//!
53//! Style resolution:
54//!
55//! - A reverse index from styled-representation-item id to concrete
56//!   style refs is built up-front in O(n), unwrapping the deprecated
57//!   `IfcPresentationStyleAssignment` so downstream resolvers don't
58//!   need to know about it.
59//! - Text colour walks `IfcTextStyle.TextCharacterAppearance` →
60//!   `IfcTextStyleForDefinedFont.Colour` → `IfcColourRgb`.
61//! - Fill colour walks `IfcFillAreaStyle.FillStyles` → first
62//!   `IfcColourRgb`; hatching / tile fills are recognised but use a
63//!   default fill colour.
64
65
66use output_cap::SymbolicAccumulator;
67use rebase::RenderFrameRebase;
68use ifc_lite_core::{build_entity_index, keyword_eq, EntityDecoder, EntityScanner, IfcType};
69
70mod color;
71mod conic;
72mod fill;
73mod fill_provenance;
74mod provenance;
75pub use provenance::SymbolicDataWithProvenance;
76mod grid;
77mod item_walk;
78mod items;
79mod output_cap;
80mod output_cap_types;
81mod output_cap_validate;
82#[cfg(test)]
83mod items_cycle_tests;
84#[cfg(test)]
85mod output_cap_tests;
86mod primitives;
87mod rebase;
88mod text;
89mod transform;
90mod trimmed_curve;
91
92pub use output_cap_types::{SymbolicTruncation, SymbolicTruncationReason};
93pub use primitives::{
94    SymbolicCircle, SymbolicData, SymbolicFillArea, SymbolicGridAxis, SymbolicPolyline, SymbolicText,
95};
96
97use color::build_styled_item_index;
98use grid::extract_grid;
99use item_walk::extract_symbolic_item;
100use transform::{compose_transforms, parse_axis2_placement_2d, resolve_object_placement, Transform2D};
101
102// ────────────────────────────────────────────────────────────────────────────
103// Top-level extraction. Mirror of the wasm `parse_symbolic_representations`
104// scanner loop. Both paths feed the same `extract_*` helpers below so the
105// server and browser produce bit-identical symbol streams.
106// ────────────────────────────────────────────────────────────────────────────
107
108/// Scan an IFC file for `IfcGrid` and any product carrying a Plan /
109/// Annotation / FootPrint / Axis representation, and return the full
110/// symbolic primitive collection. Pure-Rust (no `wasm_bindgen`), so it
111/// works inside the HTTP server.
112pub fn extract_symbolic_data<T>(content: &T) -> SymbolicData
113where
114    T: AsRef<[u8]> + ?Sized,
115{
116    extract_symbolic_data_with_provenance(content).into_parts().0
117}
118
119/// Extract symbols with ordinal-bound direct fill provenance, preserving the legacy data shape.
120pub fn extract_symbolic_data_with_provenance<T>(content: &T) -> SymbolicDataWithProvenance
121where
122    T: AsRef<[u8]> + ?Sized,
123{
124    let mut out = SymbolicAccumulator::new();
125    extract_symbolic_data_into(content, &mut out);
126    out.into_provenance()
127}
128
129/// The extraction itself, writing into a caller-supplied accumulator.
130///
131/// Split out so a test can supply an accumulator with a small injected cap
132/// and exercise the real path, instead of building a fixture that emits two
133/// million primitives to reach `MAX_SYMBOLIC_ELEMENTS`. Production has exactly
134/// one caller, above, which supplies the real cap via `Default`.
135fn extract_symbolic_data_into<T>(content: &T, out: &mut SymbolicAccumulator)
136where
137    T: AsRef<[u8]> + ?Sized,
138{
139    let content = content.as_ref();
140    let entity_index = build_entity_index(content);
141    let mut decoder = EntityDecoder::with_index(content, entity_index);
142
143    // Reuse the geometry router for both unit-scale and the RTC offset.
144    // Not drained: meshes nothing. Pinned by rust/geometry/tests/issue_3821_auxiliary_routers_mesh_nothing.rs.
145    let router = ifc_lite_geometry::GeometryRouter::with_units(content, &mut decoder);
146    let unit_scale = router.unit_scale() as f32;
147
148    // RTC offset detection matches the wasm path so the symbolic stream
149    // aligns with the mesh stream. The threshold (>10 km) is empirical —
150    // anything smaller is local-coord territory where RTC subtraction
151    // would shift things off-screen.
152    let rtc_offset = router.detect_rtc_offset_from_first_element(content, &mut decoder);
153    let rebase = RenderFrameRebase::from_rtc_offset(rtc_offset);
154
155    // Pre-pass: build a reverse index from "styled representation-item id"
156    // to "list of style refs". Walked once at parse start (O(n)) so per-
157    // item colour lookup is O(1) later. See `resolve_color_via_styles()`
158    // for the chain (deprecated IfcPresentationStyleAssignment unwrap +
159    // IfcFillAreaStyle → IfcColourRgb).
160    let styled_items = build_styled_item_index(content, &mut decoder);
161
162    let mut scanner = EntityScanner::new(content);
163
164    while let Some((id, type_name, start, end)) = scanner.next_entity() {
165        // Stop the SCAN, not just the innermost item loop. Breaking only the
166        // inner loop still decodes every remaining product, resolves its
167        // placements and composes its transforms, which is not the "stop" this
168        // bound claims. Bounded by file size rather than by the fan-out, so it
169        // was never the DoS lever -- but it is work with a known-useless
170        // result.
171        if out.is_exhausted() {
172            break;
173        }
174        let is_grid = keyword_eq(type_name, "IFCGRID");
175        if !is_grid && !ifc_lite_core::has_geometry_by_name(type_name) {
176            // IfcGrid isn't in `has_geometry_by_name` (it's not a building
177            // element) but carries axis curves that we render as symbolic
178            // lines + bubbles + tags.
179            continue;
180        }
181        let Ok(entity) = decoder.decode_at_with_id(id, start, end) else {
182            continue;
183        };
184
185        if is_grid {
186            let grid_transform = resolve_object_placement(&entity, &mut decoder, unit_scale);
187            extract_grid(
188                &entity,
189                id,
190                &mut decoder,
191                unit_scale,
192                &grid_transform,
193                rebase,
194                out,
195            );
196            continue;
197        }
198
199        // Standard representation walk: IfcProductDefinitionShape → Plan /
200        // Annotation / FootPrint / Axis IfcShapeRepresentation → items.
201        let Some(representation_attr) = entity.get(6) else {
202            continue;
203        };
204        if representation_attr.is_null() {
205            continue;
206        }
207        let Ok(Some(representation)) = decoder.resolve_ref(representation_attr) else {
208            continue;
209        };
210        let Some(reps_attr) = representation.get(2) else {
211            continue;
212        };
213        let Ok(representations) = decoder.resolve_ref_list(reps_attr) else {
214            continue;
215        };
216
217        let ifc_type_name = entity.ifc_type.name().to_string();
218
219        let single_representation = representations.len() == 1;
220        for shape_rep in representations {
221            if shape_rep.ifc_type != IfcType::IfcShapeRepresentation {
222                continue;
223            }
224            let rep_identifier = shape_rep
225                .get(1)
226                .and_then(|a| a.as_string())
227                .unwrap_or("")
228                .to_string();
229            if !matches!(
230                rep_identifier.as_str(),
231                "Plan" | "Annotation" | "FootPrint" | "Axis"
232            ) {
233                continue;
234            }
235
236            // ObjectPlacement transform for this entity (translations
237            // accumulated directly, rotations accumulated to orient symbols).
238            let placement_transform = resolve_object_placement(&entity, &mut decoder, unit_scale);
239
240            // ContextOfItems WCS: some Plan reps use a different coord
241            // system than Body. Compose it in when present and non-trivial.
242            // `ContextOfItems` (IfcRepresentation attr 0) and
243            // `WorldCoordinateSystem` (IfcGeometricRepresentationContext
244            // attr 4) are both MANDATORY, so a dangling ref or absent
245            // attribute is malformed data, not a legitimate default —
246            // `unresolved()` per #2352's convention. A resolved
247            // `IfcGeometricRepresentationSubContext`, in contrast, derives
248            // its WCS from `ParentContext` and legitimately does not store
249            // one inline, so it alone stays `identity()`.
250            let context_transform = match shape_rep.get_ref(0) {
251                Some(context_ref) => match decoder.decode_by_id(context_ref) {
252                    Ok(context) if context.ifc_type == IfcType::IfcGeometricRepresentationContext => {
253                        match context.get_ref(4) {
254                            Some(wcs_ref) => match decoder.decode_by_id(wcs_ref) {
255                                Ok(wcs) => parse_axis2_placement_2d(&wcs, &mut decoder, unit_scale),
256                                Err(_) => Transform2D::unresolved(),
257                            },
258                            None => Transform2D::unresolved(),
259                        }
260                    }
261                    // SubContext inherits WCS from ParentContext — legitimately
262                    // has none inline (the wasm pipeline does the same).
263                    Ok(context) if context.ifc_type == IfcType::IfcGeometricRepresentationSubContext => {
264                        Transform2D::identity()
265                    }
266                    // Dangling context_ref, or a ref resolving to neither
267                    // Context nor SubContext: malformed data.
268                    _ => Transform2D::unresolved(),
269                },
270                None => Transform2D::unresolved(),
271            };
272            let combined_transform = if context_transform.tx.abs() > 0.001
273                || context_transform.ty.abs() > 0.001
274                || context_transform.tz.abs() > 0.001
275                || (context_transform.m00 - 1.0).abs() > 0.0001
276                || context_transform.m01.abs() > 0.0001
277                || context_transform.m10.abs() > 0.0001
278                || (context_transform.m11 - 1.0).abs() > 0.0001
279                || context_transform.tz.is_nan()
280            {
281                compose_transforms(&context_transform, &placement_transform)
282            } else {
283                placement_transform
284            };
285
286            let Some(items_attr) = shape_rep.get(3) else {
287                continue;
288            };
289            let Ok(items) = decoder.resolve_ref_list(items_attr) else {
290                continue;
291            };
292            let direct_fill_ids = fill_provenance::direct_fill_ids(&items, single_representation);
293            for item in items {
294                if out.is_exhausted() {
295                    break;
296                }
297                extract_symbolic_item(
298                    &item,
299                    &mut decoder,
300                    id,
301                    &ifc_type_name,
302                    &rep_identifier,
303                    unit_scale,
304                    &combined_transform,
305                    rebase,
306                    &styled_items,
307                    out,
308                    direct_fill_ids.contains(&item.id).then_some(item.id),
309                );
310            }
311        }
312    }
313
314}