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 Y-axis is flipped (`y → -y + rtc_z`) to match the renderer's
49//! section-cut coordinate convention.
50//!
51//! Style resolution:
52//!
53//! - A reverse index from styled-representation-item id to concrete
54//! style refs is built up-front in O(n), unwrapping the deprecated
55//! `IfcPresentationStyleAssignment` so downstream resolvers don't
56//! need to know about it.
57//! - Text colour walks `IfcTextStyle.TextCharacterAppearance` →
58//! `IfcTextStyleForDefinedFont.Colour` → `IfcColourRgb`.
59//! - Fill colour walks `IfcFillAreaStyle.FillStyles` → first
60//! `IfcColourRgb`; hatching / tile fills are recognised but use a
61//! default fill colour.
62
63
64use ifc_lite_core::{build_entity_index, EntityDecoder, EntityScanner, IfcType};
65
66mod color;
67mod fill;
68mod grid;
69mod items;
70mod primitives;
71mod text;
72mod transform;
73
74pub use primitives::{
75 SymbolicCircle, SymbolicData, SymbolicFillArea, SymbolicGridAxis, SymbolicPolyline, SymbolicText,
76};
77
78use color::build_styled_item_index;
79use grid::extract_grid;
80use items::extract_symbolic_item;
81use transform::{compose_transforms, parse_axis2_placement_2d, resolve_object_placement, Transform2D};
82
83// ────────────────────────────────────────────────────────────────────────────
84// Top-level extraction. Mirror of the wasm `parse_symbolic_representations`
85// scanner loop. Both paths feed the same `extract_*` helpers below so the
86// server and browser produce bit-identical symbol streams.
87// ────────────────────────────────────────────────────────────────────────────
88
89/// Scan an IFC file for `IfcGrid` and any product carrying a Plan /
90/// Annotation / FootPrint / Axis representation, and return the full
91/// symbolic primitive collection. Pure-Rust (no `wasm_bindgen`), so it
92/// works inside the HTTP server.
93pub fn extract_symbolic_data<T>(content: &T) -> SymbolicData
94where
95 T: AsRef<[u8]> + ?Sized,
96{
97 let content = content.as_ref();
98 let entity_index = build_entity_index(content);
99 let mut decoder = EntityDecoder::with_index(content, entity_index);
100
101 // Reuse the geometry router for both unit-scale and the RTC offset.
102 let router = ifc_lite_geometry::GeometryRouter::with_units(content, &mut decoder);
103 let unit_scale = router.unit_scale() as f32;
104
105 // RTC offset detection matches the wasm path so the symbolic stream
106 // aligns with the mesh stream. The threshold (>10 km) is empirical —
107 // anything smaller is local-coord territory where RTC subtraction
108 // would shift things off-screen.
109 let rtc_offset = router.detect_rtc_offset_from_first_element(content, &mut decoder);
110 let needs_rtc = rtc_offset.0.abs() > 10_000.0
111 || rtc_offset.1.abs() > 10_000.0
112 || rtc_offset.2.abs() > 10_000.0;
113 let rtc_x = if needs_rtc { rtc_offset.0 as f32 } else { 0.0 };
114 let rtc_z = if needs_rtc { rtc_offset.2 as f32 } else { 0.0 };
115
116 // Pre-pass: build a reverse index from "styled representation-item id"
117 // to "list of style refs". Walked once at parse start (O(n)) so per-
118 // item colour lookup is O(1) later. See `resolve_color_via_styles()`
119 // for the chain (deprecated IfcPresentationStyleAssignment unwrap +
120 // IfcFillAreaStyle → IfcColourRgb).
121 let styled_items = build_styled_item_index(content, &mut decoder);
122
123 let mut out = SymbolicData::default();
124 let mut scanner = EntityScanner::new(content);
125
126 while let Some((id, type_name, start, end)) = scanner.next_entity() {
127 let is_grid = type_name == "IFCGRID";
128 if !is_grid && !ifc_lite_core::has_geometry_by_name(type_name) {
129 // IfcGrid isn't in `has_geometry_by_name` (it's not a building
130 // element) but carries axis curves that we render as symbolic
131 // lines + bubbles + tags.
132 continue;
133 }
134 let Ok(entity) = decoder.decode_at_with_id(id, start, end) else {
135 continue;
136 };
137
138 if is_grid {
139 let grid_transform = resolve_object_placement(&entity, &mut decoder, unit_scale);
140 extract_grid(
141 &entity,
142 id,
143 &mut decoder,
144 unit_scale,
145 &grid_transform,
146 rtc_x,
147 rtc_z,
148 &mut out,
149 );
150 continue;
151 }
152
153 // Standard representation walk: IfcProductDefinitionShape → Plan /
154 // Annotation / FootPrint / Axis IfcShapeRepresentation → items.
155 let Some(representation_attr) = entity.get(6) else {
156 continue;
157 };
158 if representation_attr.is_null() {
159 continue;
160 }
161 let Ok(Some(representation)) = decoder.resolve_ref(representation_attr) else {
162 continue;
163 };
164 let Some(reps_attr) = representation.get(2) else {
165 continue;
166 };
167 let Ok(representations) = decoder.resolve_ref_list(reps_attr) else {
168 continue;
169 };
170
171 let ifc_type_name = entity.ifc_type.name().to_string();
172
173 for shape_rep in representations {
174 if shape_rep.ifc_type != IfcType::IfcShapeRepresentation {
175 continue;
176 }
177 let rep_identifier = shape_rep
178 .get(1)
179 .and_then(|a| a.as_string())
180 .unwrap_or("")
181 .to_string();
182 if !matches!(
183 rep_identifier.as_str(),
184 "Plan" | "Annotation" | "FootPrint" | "Axis"
185 ) {
186 continue;
187 }
188
189 // ObjectPlacement transform for this entity (translations
190 // accumulated directly, rotations accumulated to orient symbols).
191 let placement_transform = resolve_object_placement(&entity, &mut decoder, unit_scale);
192
193 // ContextOfItems WCS: some Plan reps use a different coord
194 // system than Body. Compose it in when present and non-trivial.
195 let context_transform = match shape_rep.get_ref(0) {
196 Some(context_ref) => match decoder.decode_by_id(context_ref) {
197 Ok(context) if context.ifc_type == IfcType::IfcGeometricRepresentationContext => {
198 match context.get_ref(2) {
199 Some(wcs_ref) => match decoder.decode_by_id(wcs_ref) {
200 Ok(wcs) => parse_axis2_placement_2d(&wcs, &mut decoder, unit_scale),
201 Err(_) => Transform2D::identity(),
202 },
203 None => Transform2D::identity(),
204 }
205 }
206 // SubContext inherits from parent — left as identity
207 // for now (the wasm pipeline does the same).
208 _ => Transform2D::identity(),
209 },
210 None => Transform2D::identity(),
211 };
212 let combined_transform = if context_transform.tx.abs() > 0.001
213 || context_transform.ty.abs() > 0.001
214 || (context_transform.cos_theta - 1.0).abs() > 0.0001
215 || context_transform.sin_theta.abs() > 0.0001
216 {
217 compose_transforms(&context_transform, &placement_transform)
218 } else {
219 placement_transform
220 };
221
222 let Some(items_attr) = shape_rep.get(3) else {
223 continue;
224 };
225 let Ok(items) = decoder.resolve_ref_list(items_attr) else {
226 continue;
227 };
228 for item in items {
229 extract_symbolic_item(
230 &item,
231 &mut decoder,
232 id,
233 &ifc_type_name,
234 &rep_identifier,
235 unit_scale,
236 &combined_transform,
237 rtc_x,
238 rtc_z,
239 &styled_items,
240 &mut out,
241 );
242 }
243 }
244 }
245
246 out
247}