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