Skip to main content

ifc_lite_processing/
symbolic.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
63use ifc_lite_core::{
64    build_entity_index, AttributeValue, DecodedEntity, EntityDecoder, EntityScanner, IfcType,
65};
66use serde::{Deserialize, Serialize};
67use std::collections::HashMap;
68
69// ────────────────────────────────────────────────────────────────────────────
70// Pure-Rust serializable primitive types. The wasm-bindgen wrappers in
71// `rust/wasm-bindings/src/zero_copy.rs` are thin views over these.
72// ────────────────────────────────────────────────────────────────────────────
73
74/// A single 2D polyline for symbolic representations.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct SymbolicPolyline {
77    /// Express ID of the IFC entity that authored the curve.
78    pub express_id: u32,
79    /// Owning element's IFC type name.
80    pub ifc_type: String,
81    /// Flat 2D points `[x0, y0, x1, y1, …]` in metres.
82    pub points: Vec<f32>,
83    /// True if the curve is a closed loop.
84    pub closed: bool,
85    /// World-Y elevation captured from the placement chain or the
86    /// polyline's own 3D `IfcCartesianPoint` Z component.
87    pub world_y: f32,
88    /// Representation identifier (`Plan`, `Annotation`, `FootPrint`, `Axis`).
89    pub representation: String,
90}
91
92/// A single 2D circle / arc for symbolic representations.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct SymbolicCircle {
95    pub express_id: u32,
96    pub ifc_type: String,
97    pub center_x: f32,
98    pub center_y: f32,
99    pub radius: f32,
100    /// World-Y elevation (see [`SymbolicPolyline::world_y`]).
101    pub world_y: f32,
102    /// Start angle in radians (0 for full circle).
103    pub start_angle: f32,
104    /// End angle in radians (`TAU` for full circle).
105    pub end_angle: f32,
106    pub representation: String,
107}
108
109impl SymbolicCircle {
110    /// Full-circle constructor.
111    pub fn full(
112        express_id: u32,
113        ifc_type: String,
114        center_x: f32,
115        center_y: f32,
116        radius: f32,
117        world_y: f32,
118        representation: String,
119    ) -> Self {
120        Self {
121            express_id,
122            ifc_type,
123            center_x,
124            center_y,
125            radius,
126            world_y,
127            start_angle: 0.0,
128            end_angle: std::f32::consts::TAU,
129            representation,
130        }
131    }
132}
133
134/// A 2D text annotation (`IfcTextLiteral` / `IfcTextLiteralWithExtent`).
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct SymbolicText {
137    pub express_id: u32,
138    pub ifc_type: String,
139    /// Anchor point on the text baseline (model units).
140    pub x: f32,
141    pub y: f32,
142    /// Baseline orientation as a `(cos, sin)` pair. Defaults to `(1, 0)`.
143    pub dir_x: f32,
144    pub dir_y: f32,
145    /// Font height in model units (already unit-scaled).
146    pub height: f32,
147    /// UTF-8 text content (verbatim from the IFC literal — JS-side
148    /// decodes any `\X2\…\X0\` escape sequences).
149    pub content: String,
150    /// IFC `BoxAlignment` (`top-left`, `center`, `bottom-right`, …). Empty
151    /// string when absent.
152    pub alignment: String,
153    /// World-Y elevation (see [`SymbolicPolyline::world_y`]).
154    pub world_y: f32,
155    /// sRGB straight-alpha colour `[r, g, b, a]`. Defaults to dark-grey
156    /// when no IfcStyledItem chain resolves a colour.
157    pub color: [f32; 4],
158    /// Per-instance target screen-pixel cap height. `0.0` = renderer
159    /// global default (~14 px for body text).
160    pub target_px: f32,
161    pub representation: String,
162}
163
164/// A 2D filled region (`IfcAnnotationFillArea`).
165///
166/// Outer ring + optional inner rings (holes) packed into a single `points`
167/// buffer. `holes_offsets[i]` is the vertex index where hole `i` begins —
168/// outer ring spans `[0, holes_offsets[0])` (or all points when no holes).
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct SymbolicFillArea {
171    pub express_id: u32,
172    pub ifc_type: String,
173    /// All ring vertices: outer ring first, then each hole back-to-back.
174    /// Format: `[x0, y0, x1, y1, …]`.
175    pub points: Vec<f32>,
176    /// Inclusive prefix of where each hole begins (in vertex indices).
177    pub holes_offsets: Vec<u32>,
178    /// Fill colour sRGB, 0..1. Defaults to opaque black.
179    pub fill_color: [f32; 4],
180    /// Whether this fill carries a hatching style.
181    pub has_hatching: bool,
182    pub hatch_spacing: f32,
183    pub hatch_angle: f32,
184    /// Secondary cross-hatch angle. NaN if absent.
185    pub hatch_angle_secondary: f32,
186    pub hatch_line_width: f32,
187    pub world_y: f32,
188    pub representation: String,
189}
190
191/// A single `IfcGridAxis` tag + axis curve (server-friendly endpoint-pair
192/// representation; the wasm pipeline emits the same data via
193/// [`SymbolicPolyline`] axis lines and [`SymbolicText`] bubbles, both of
194/// which are also populated below).
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct SymbolicGridAxis {
197    pub express_id: u32,
198    pub grid_express_id: u32,
199    pub tag: String,
200    /// Endpoint pair `[x0, y0, x1, y1]` in metres (plan view).
201    pub endpoints: [f32; 4],
202    pub world_y: f32,
203}
204
205/// Server-friendly summary of the IFC's 2D symbol data.
206#[derive(Debug, Clone, Default, Serialize, Deserialize)]
207pub struct SymbolicData {
208    /// Axis endpoints for every `IfcGridAxis` (compact summary shape).
209    pub grid_axes: Vec<SymbolicGridAxis>,
210    /// All polylines (`IfcPolyline`, `IfcIndexedPolyCurve`, `IfcEllipse`
211    /// tessellations, `IfcTrimmedCurve` arcs, grid axis lines).
212    pub polylines: Vec<SymbolicPolyline>,
213    /// All circles (`IfcCircle` full disks).
214    pub circles: Vec<SymbolicCircle>,
215    /// All text annotations (`IfcTextLiteral`, grid bubble outlines + tags).
216    pub texts: Vec<SymbolicText>,
217    /// All filled regions (`IfcAnnotationFillArea`).
218    pub fills: Vec<SymbolicFillArea>,
219}
220
221impl SymbolicData {
222    /// Returns true if no symbolic primitives were extracted — the server
223    /// can omit the field from its response instead of emitting an empty
224    /// object.
225    pub fn is_empty(&self) -> bool {
226        self.grid_axes.is_empty()
227            && self.polylines.is_empty()
228            && self.circles.is_empty()
229            && self.texts.is_empty()
230            && self.fills.is_empty()
231    }
232}
233
234// ────────────────────────────────────────────────────────────────────────────
235// Top-level extraction. Mirror of the wasm `parse_symbolic_representations`
236// scanner loop. Both paths feed the same `extract_*` helpers below so the
237// server and browser produce bit-identical symbol streams.
238// ────────────────────────────────────────────────────────────────────────────
239
240/// Scan an IFC file for `IfcGrid` and any product carrying a Plan /
241/// Annotation / FootPrint / Axis representation, and return the full
242/// symbolic primitive collection. Pure-Rust (no `wasm_bindgen`), so it
243/// works inside the HTTP server.
244pub fn extract_symbolic_data<T>(content: &T) -> SymbolicData
245where
246    T: AsRef<[u8]> + ?Sized,
247{
248    let content = content.as_ref();
249    let entity_index = build_entity_index(content);
250    let mut decoder = EntityDecoder::with_index(content, entity_index);
251
252    // Reuse the geometry router for both unit-scale and the RTC offset.
253    let router = ifc_lite_geometry::GeometryRouter::with_units(content, &mut decoder);
254    let unit_scale = router.unit_scale() as f32;
255
256    // RTC offset detection matches the wasm path so the symbolic stream
257    // aligns with the mesh stream. The threshold (>10 km) is empirical —
258    // anything smaller is local-coord territory where RTC subtraction
259    // would shift things off-screen.
260    let rtc_offset = router.detect_rtc_offset_from_first_element(content, &mut decoder);
261    let needs_rtc = rtc_offset.0.abs() > 10_000.0
262        || rtc_offset.1.abs() > 10_000.0
263        || rtc_offset.2.abs() > 10_000.0;
264    let rtc_x = if needs_rtc { rtc_offset.0 as f32 } else { 0.0 };
265    let rtc_z = if needs_rtc { rtc_offset.2 as f32 } else { 0.0 };
266
267    // Pre-pass: build a reverse index from "styled representation-item id"
268    // to "list of style refs". Walked once at parse start (O(n)) so per-
269    // item colour lookup is O(1) later. See `resolve_color_via_styles()`
270    // for the chain (deprecated IfcPresentationStyleAssignment unwrap +
271    // IfcFillAreaStyle → IfcColourRgb).
272    let styled_items = build_styled_item_index(content, &mut decoder);
273
274    let mut out = SymbolicData::default();
275    let mut scanner = EntityScanner::new(content);
276
277    while let Some((id, type_name, start, end)) = scanner.next_entity() {
278        let is_grid = type_name == "IFCGRID";
279        if !is_grid && !ifc_lite_core::has_geometry_by_name(type_name) {
280            // IfcGrid isn't in `has_geometry_by_name` (it's not a building
281            // element) but carries axis curves that we render as symbolic
282            // lines + bubbles + tags.
283            continue;
284        }
285        let Ok(entity) = decoder.decode_at_with_id(id, start, end) else {
286            continue;
287        };
288
289        if is_grid {
290            let grid_transform = resolve_object_placement(&entity, &mut decoder, unit_scale);
291            extract_grid(
292                &entity,
293                id,
294                &mut decoder,
295                unit_scale,
296                &grid_transform,
297                rtc_x,
298                rtc_z,
299                &mut out,
300            );
301            continue;
302        }
303
304        // Standard representation walk: IfcProductDefinitionShape → Plan /
305        // Annotation / FootPrint / Axis IfcShapeRepresentation → items.
306        let Some(representation_attr) = entity.get(6) else {
307            continue;
308        };
309        if representation_attr.is_null() {
310            continue;
311        }
312        let Ok(Some(representation)) = decoder.resolve_ref(representation_attr) else {
313            continue;
314        };
315        let Some(reps_attr) = representation.get(2) else {
316            continue;
317        };
318        let Ok(representations) = decoder.resolve_ref_list(reps_attr) else {
319            continue;
320        };
321
322        let ifc_type_name = entity.ifc_type.name().to_string();
323
324        for shape_rep in representations {
325            if shape_rep.ifc_type != IfcType::IfcShapeRepresentation {
326                continue;
327            }
328            let rep_identifier = shape_rep
329                .get(1)
330                .and_then(|a| a.as_string())
331                .unwrap_or("")
332                .to_string();
333            if !matches!(
334                rep_identifier.as_str(),
335                "Plan" | "Annotation" | "FootPrint" | "Axis"
336            ) {
337                continue;
338            }
339
340            // ObjectPlacement transform for this entity (translations
341            // accumulated directly, rotations accumulated to orient symbols).
342            let placement_transform = resolve_object_placement(&entity, &mut decoder, unit_scale);
343
344            // ContextOfItems WCS: some Plan reps use a different coord
345            // system than Body. Compose it in when present and non-trivial.
346            let context_transform = match shape_rep.get_ref(0) {
347                Some(context_ref) => match decoder.decode_by_id(context_ref) {
348                    Ok(context) if context.ifc_type == IfcType::IfcGeometricRepresentationContext => {
349                        match context.get_ref(2) {
350                            Some(wcs_ref) => match decoder.decode_by_id(wcs_ref) {
351                                Ok(wcs) => parse_axis2_placement_2d(&wcs, &mut decoder, unit_scale),
352                                Err(_) => Transform2D::identity(),
353                            },
354                            None => Transform2D::identity(),
355                        }
356                    }
357                    // SubContext inherits from parent — left as identity
358                    // for now (the wasm pipeline does the same).
359                    _ => Transform2D::identity(),
360                },
361                None => Transform2D::identity(),
362            };
363            let combined_transform = if context_transform.tx.abs() > 0.001
364                || context_transform.ty.abs() > 0.001
365                || (context_transform.cos_theta - 1.0).abs() > 0.0001
366                || context_transform.sin_theta.abs() > 0.0001
367            {
368                compose_transforms(&context_transform, &placement_transform)
369            } else {
370                placement_transform
371            };
372
373            let Some(items_attr) = shape_rep.get(3) else {
374                continue;
375            };
376            let Ok(items) = decoder.resolve_ref_list(items_attr) else {
377                continue;
378            };
379            for item in items {
380                extract_symbolic_item(
381                    &item,
382                    &mut decoder,
383                    id,
384                    &ifc_type_name,
385                    &rep_identifier,
386                    unit_scale,
387                    &combined_transform,
388                    rtc_x,
389                    rtc_z,
390                    &styled_items,
391                    &mut out,
392                );
393            }
394        }
395    }
396
397    out
398}
399
400// ────────────────────────────────────────────────────────────────────────────
401// 2D transform primitives. Floor-plan symbolic rendering uses a custom
402// 2D-only transform: translations accumulate directly (not rotated by parent
403// rotations), but rotations DO accumulate so symbols orient correctly.
404// `tz` is strictly additive along the chain and lets each primitive carry
405// its storey elevation forward via `world_y`.
406// ────────────────────────────────────────────────────────────────────────────
407
408#[derive(Clone, Copy, Debug)]
409struct Transform2D {
410    tx: f32,
411    ty: f32,
412    tz: f32,
413    cos_theta: f32,
414    sin_theta: f32,
415}
416
417impl Transform2D {
418    fn identity() -> Self {
419        Self {
420            tx: 0.0,
421            ty: 0.0,
422            tz: 0.0,
423            cos_theta: 1.0,
424            sin_theta: 0.0,
425        }
426    }
427
428    fn transform_point(&self, x: f32, y: f32) -> (f32, f32) {
429        let rx = x * self.cos_theta - y * self.sin_theta;
430        let ry = x * self.sin_theta + y * self.cos_theta;
431        (rx + self.tx, ry + self.ty)
432    }
433}
434
435/// Compose two 2D transforms: `result = a * b` (apply `b` first, then `a`).
436fn compose_transforms(a: &Transform2D, b: &Transform2D) -> Transform2D {
437    let combined_cos = a.cos_theta * b.cos_theta - a.sin_theta * b.sin_theta;
438    let combined_sin = a.sin_theta * b.cos_theta + a.cos_theta * b.sin_theta;
439    let rtx = b.tx * a.cos_theta - b.ty * a.sin_theta;
440    let rty = b.tx * a.sin_theta + b.ty * a.cos_theta;
441    Transform2D {
442        tx: rtx + a.tx,
443        ty: rty + a.ty,
444        tz: a.tz + b.tz,
445        cos_theta: combined_cos,
446        sin_theta: combined_sin,
447    }
448}
449
450/// Resolve a product's `ObjectPlacement` (attribute 5) into a 2D transform.
451fn resolve_object_placement(
452    entity: &DecodedEntity,
453    decoder: &mut EntityDecoder,
454    unit_scale: f32,
455) -> Transform2D {
456    let Some(attr) = entity.get(5) else {
457        return Transform2D::identity();
458    };
459    if attr.is_null() {
460        return Transform2D::identity();
461    }
462    let Ok(Some(placement)) = decoder.resolve_ref(attr) else {
463        return Transform2D::identity();
464    };
465    resolve_placement_for_symbolic(&placement, decoder, unit_scale, 0)
466}
467
468/// Recursively resolve `IfcLocalPlacement` for 2D symbolic representations.
469/// Mirrors the wasm pipeline's accumulation rule exactly.
470fn resolve_placement_for_symbolic(
471    placement: &DecodedEntity,
472    decoder: &mut EntityDecoder,
473    unit_scale: f32,
474    depth: usize,
475) -> Transform2D {
476    if depth > 50 || placement.ifc_type != IfcType::IfcLocalPlacement {
477        return Transform2D::identity();
478    }
479
480    let parent_transform = match placement.get(0) {
481        Some(parent_attr) if !parent_attr.is_null() => match decoder.resolve_ref(parent_attr) {
482            Ok(Some(parent)) => {
483                resolve_placement_for_symbolic(&parent, decoder, unit_scale, depth + 1)
484            }
485            _ => Transform2D::identity(),
486        },
487        _ => Transform2D::identity(),
488    };
489
490    let local_transform = match placement.get(1) {
491        Some(rel_attr) if !rel_attr.is_null() => match decoder.resolve_ref(rel_attr) {
492            Ok(Some(rel))
493                if rel.ifc_type == IfcType::IfcAxis2Placement3D
494                    || rel.ifc_type == IfcType::IfcAxis2Placement2D =>
495            {
496                parse_axis2_placement_2d(&rel, decoder, unit_scale)
497            }
498            _ => Transform2D::identity(),
499        },
500        _ => Transform2D::identity(),
501    };
502
503    let combined_cos = parent_transform.cos_theta * local_transform.cos_theta
504        - parent_transform.sin_theta * local_transform.sin_theta;
505    let combined_sin = parent_transform.sin_theta * local_transform.cos_theta
506        + parent_transform.cos_theta * local_transform.sin_theta;
507
508    let rotated_local_tx = local_transform.tx * parent_transform.cos_theta
509        - local_transform.ty * parent_transform.sin_theta;
510    let rotated_local_ty = local_transform.tx * parent_transform.sin_theta
511        + local_transform.ty * parent_transform.cos_theta;
512
513    Transform2D {
514        tx: parent_transform.tx + rotated_local_tx,
515        ty: parent_transform.ty + rotated_local_ty,
516        tz: parent_transform.tz + local_transform.tz,
517        cos_theta: combined_cos,
518        sin_theta: combined_sin,
519    }
520}
521
522/// Parse `IfcAxis2Placement3D` / `IfcAxis2Placement2D` to a 2D transform.
523/// Floor-plan uses X-Y (Z is up) to match the section-cut coord system.
524fn parse_axis2_placement_2d(
525    placement: &DecodedEntity,
526    decoder: &mut EntityDecoder,
527    unit_scale: f32,
528) -> Transform2D {
529    let is_3d = placement.ifc_type == IfcType::IfcAxis2Placement3D;
530
531    let (tx, ty, tz) = match placement.get_ref(0) {
532        Some(loc_ref) => match decoder.decode_by_id(loc_ref) {
533            Ok(loc) if loc.ifc_type == IfcType::IfcCartesianPoint => {
534                let coords = loc
535                    .get(0)
536                    .and_then(|a| a.as_list())
537                    .map(|l| l.to_vec())
538                    .unwrap_or_default();
539                let raw_x = coords.first().and_then(|v| v.as_float()).unwrap_or(0.0) as f32;
540                let raw_y = coords.get(1).and_then(|v| v.as_float()).unwrap_or(0.0) as f32;
541                let raw_z = coords.get(2).and_then(|v| v.as_float()).unwrap_or(0.0) as f32;
542                (raw_x * unit_scale, raw_y * unit_scale, raw_z * unit_scale)
543            }
544            _ => (0.0, 0.0, 0.0),
545        },
546        None => (0.0, 0.0, 0.0),
547    };
548
549    // RefDirection lives at attr 2 for 3D, attr 1 for 2D.
550    let ref_dir_attr = if is_3d {
551        placement.get(2)
552    } else {
553        placement.get(1)
554    };
555    let (cos_theta, sin_theta) = match ref_dir_attr {
556        Some(attr) if !attr.is_null() => match attr.as_entity_ref() {
557            Some(ref_dir_id) => match decoder.decode_by_id(ref_dir_id) {
558                Ok(ref_dir) if ref_dir.ifc_type == IfcType::IfcDirection => {
559                    let ratios = ref_dir
560                        .get(0)
561                        .and_then(|a| a.as_list())
562                        .map(|l| l.to_vec())
563                        .unwrap_or_default();
564                    let dx = ratios.first().and_then(|v| v.as_float()).unwrap_or(1.0) as f32;
565                    let dy = ratios.get(1).and_then(|v| v.as_float()).unwrap_or(0.0) as f32;
566                    let dz = ratios.get(2).and_then(|v| v.as_float()).unwrap_or(0.0) as f32;
567                    let len = (dx * dx + dy * dy).sqrt();
568                    if len > 0.0001 {
569                        (dx / len, dy / len)
570                    } else if is_3d && dz.abs() > 0.0001 {
571                        // RefDirection purely in Z (vertical) — local X
572                        // points up/down, rotation is 0° in floor plan.
573                        (1.0, 0.0)
574                    } else {
575                        (1.0, 0.0)
576                    }
577                }
578                _ => (1.0, 0.0),
579            },
580            None => (1.0, 0.0),
581        },
582        _ => (1.0, 0.0),
583    };
584
585    Transform2D {
586        tx,
587        ty,
588        tz,
589        cos_theta,
590        sin_theta,
591    }
592}
593
594/// Parse `IfcCartesianTransformationOperator2D` / `…3D` for `IfcMappedItem`
595/// targets. The wasm pipeline currently only honours translation +
596/// uniform-scale rotation; we mirror that.
597fn parse_cartesian_transformation_operator(
598    operator: &DecodedEntity,
599    decoder: &mut EntityDecoder,
600    unit_scale: f32,
601) -> Transform2D {
602    // attr 2 = LocalOrigin (IfcCartesianPoint).
603    let (tx, ty) = match operator.get_ref(2) {
604        Some(loc_ref) => match decoder.decode_by_id(loc_ref) {
605            Ok(loc) if loc.ifc_type == IfcType::IfcCartesianPoint => {
606                let coords = loc
607                    .get(0)
608                    .and_then(|a| a.as_list())
609                    .map(|l| l.to_vec())
610                    .unwrap_or_default();
611                let x = coords.first().and_then(|v| v.as_float()).unwrap_or(0.0) as f32;
612                let y = coords.get(1).and_then(|v| v.as_float()).unwrap_or(0.0) as f32;
613                (x * unit_scale, y * unit_scale)
614            }
615            _ => (0.0, 0.0),
616        },
617        None => (0.0, 0.0),
618    };
619
620    // Axis1 (attr 0) gives the X direction for 2D / 3D operators.
621    let (cos_theta, sin_theta) = match operator.get_ref(0) {
622        Some(ax_ref) => match decoder.decode_by_id(ax_ref) {
623            Ok(ax) if ax.ifc_type == IfcType::IfcDirection => {
624                let ratios = ax
625                    .get(0)
626                    .and_then(|a| a.as_list())
627                    .map(|l| l.to_vec())
628                    .unwrap_or_default();
629                let dx = ratios.first().and_then(|v| v.as_float()).unwrap_or(1.0) as f32;
630                let dy = ratios.get(1).and_then(|v| v.as_float()).unwrap_or(0.0) as f32;
631                let len = (dx * dx + dy * dy).sqrt();
632                if len > 0.0001 {
633                    (dx / len, dy / len)
634                } else {
635                    (1.0, 0.0)
636                }
637            }
638            _ => (1.0, 0.0),
639        },
640        None => (1.0, 0.0),
641    };
642
643    Transform2D {
644        tx,
645        ty,
646        tz: 0.0,
647        cos_theta,
648        sin_theta,
649    }
650}
651
652// ────────────────────────────────────────────────────────────────────────────
653// Item dispatch. One function per IFC representation-item type; recursive
654// for set + mapped-item containers.
655// ────────────────────────────────────────────────────────────────────────────
656
657#[allow(clippy::too_many_arguments)]
658fn extract_symbolic_item(
659    item: &DecodedEntity,
660    decoder: &mut EntityDecoder,
661    express_id: u32,
662    ifc_type: &str,
663    rep_identifier: &str,
664    unit_scale: f32,
665    transform: &Transform2D,
666    rtc_x: f32,
667    rtc_z: f32,
668    styled_items: &HashMap<u32, Vec<u32>>,
669    out: &mut SymbolicData,
670) {
671    match item.ifc_type {
672        IfcType::IfcGeometricSet | IfcType::IfcGeometricCurveSet => {
673            if let Some(elements_attr) = item.get(0) {
674                if let Ok(elements) = decoder.resolve_ref_list(elements_attr) {
675                    for element in elements {
676                        extract_symbolic_item(
677                            &element,
678                            decoder,
679                            express_id,
680                            ifc_type,
681                            rep_identifier,
682                            unit_scale,
683                            transform,
684                            rtc_x,
685                            rtc_z,
686                            styled_items,
687                            out,
688                        );
689                    }
690                }
691            }
692        }
693        IfcType::IfcMappedItem => {
694            let Some(source_id) = item.get_ref(0) else { return };
695            let Ok(rep_map) = decoder.decode_by_id(source_id) else { return };
696
697            // MappingOrigin (rep_map attr 0) defines the local coord origin.
698            let mapping_origin_transform = match rep_map.get_ref(0) {
699                Some(origin_id) => match decoder.decode_by_id(origin_id) {
700                    Ok(origin) => parse_axis2_placement_2d(&origin, decoder, unit_scale),
701                    Err(_) => Transform2D::identity(),
702                },
703                None => Transform2D::identity(),
704            };
705            // MappingTarget (item attr 1) — additional transform.
706            let mapping_target_transform = match item.get_ref(1) {
707                Some(target_ref) => match decoder.decode_by_id(target_ref) {
708                    Ok(target) => parse_cartesian_transformation_operator(&target, decoder, unit_scale),
709                    Err(_) => Transform2D::identity(),
710                },
711                None => Transform2D::identity(),
712            };
713            let origin_with_target =
714                compose_transforms(&mapping_target_transform, &mapping_origin_transform);
715            let composed_transform = compose_transforms(transform, &origin_with_target);
716
717            if let Some(mapped_rep_id) = rep_map.get_ref(1) {
718                if let Ok(mapped_rep) = decoder.decode_by_id(mapped_rep_id) {
719                    if let Some(items_attr) = mapped_rep.get(3) {
720                        if let Ok(items) = decoder.resolve_ref_list(items_attr) {
721                            for sub_item in items {
722                                extract_symbolic_item(
723                                    &sub_item,
724                                    decoder,
725                                    express_id,
726                                    ifc_type,
727                                    rep_identifier,
728                                    unit_scale,
729                                    &composed_transform,
730                                    rtc_x,
731                                    rtc_z,
732                                    styled_items,
733                                    out,
734                                );
735                            }
736                        }
737                    }
738                }
739            }
740        }
741        IfcType::IfcPolyline => {
742            if let Some(points_attr) = item.get(0) {
743                if let Ok(point_entities) = decoder.resolve_ref_list(points_attr) {
744                    let mut points: Vec<f32> = Vec::with_capacity(point_entities.len() * 2);
745                    let mut first_z: Option<f32> = None;
746                    for pe in point_entities.iter() {
747                        if pe.ifc_type != IfcType::IfcCartesianPoint {
748                            continue;
749                        }
750                        let coords = match pe.get(0).and_then(|a| a.as_list()) {
751                            Some(c) => c,
752                            None => continue,
753                        };
754                        let local_x = coords.first().and_then(|v| v.as_float()).unwrap_or(0.0) as f32 * unit_scale;
755                        let local_y = coords.get(1).and_then(|v| v.as_float()).unwrap_or(0.0) as f32 * unit_scale;
756                        let local_z = coords.get(2).and_then(|v| v.as_float()).unwrap_or(0.0) as f32 * unit_scale;
757                        if first_z.is_none() {
758                            first_z = Some(local_z);
759                        }
760                        let (wx, wy) = transform.transform_point(local_x, local_y);
761                        let x = wx - rtc_x;
762                        let y = -wy + rtc_z; // Y-flip to match section-cut coord system
763                        if x.is_finite() && y.is_finite() {
764                            points.push(x);
765                            points.push(y);
766                        }
767                    }
768                    if points.len() >= 4 {
769                        let n = points.len();
770                        let is_closed = n >= 4
771                            && (points[0] - points[n - 2]).abs() < 0.001
772                            && (points[1] - points[n - 1]).abs() < 0.001;
773                        let world_y = first_z.unwrap_or(0.0) + transform.tz;
774                        out.polylines.push(SymbolicPolyline {
775                            express_id,
776                            ifc_type: ifc_type.to_string(),
777                            points,
778                            closed: is_closed,
779                            world_y,
780                            representation: rep_identifier.to_string(),
781                        });
782                    }
783                }
784            }
785        }
786        IfcType::IfcIndexedPolyCurve => {
787            let Some(points_ref) = item.get_ref(0) else { return };
788            let Ok(points_list) = decoder.decode_by_id(points_ref) else { return };
789            let Some(coord_list_attr) = points_list.get(0) else { return };
790            let Some(coord_list) = coord_list_attr.as_list() else { return };
791            let mut points: Vec<f32> = Vec::with_capacity(coord_list.len() * 2);
792            let mut first_z: Option<f32> = None;
793            for coord in coord_list {
794                let Some(coords) = coord.as_list() else { continue };
795                let local_x = coords.first().and_then(|v| v.as_float()).unwrap_or(0.0) as f32 * unit_scale;
796                let local_y = coords.get(1).and_then(|v| v.as_float()).unwrap_or(0.0) as f32 * unit_scale;
797                let local_z = coords.get(2).and_then(|v| v.as_float()).unwrap_or(0.0) as f32 * unit_scale;
798                if first_z.is_none() {
799                    first_z = Some(local_z);
800                }
801                let (wx, wy) = transform.transform_point(local_x, local_y);
802                let x = wx - rtc_x;
803                let y = -wy + rtc_z;
804                if x.is_finite() && y.is_finite() {
805                    points.push(x);
806                    points.push(y);
807                }
808            }
809            if points.len() >= 4 {
810                let n = points.len();
811                let is_closed = n >= 4
812                    && (points[0] - points[n - 2]).abs() < 0.001
813                    && (points[1] - points[n - 1]).abs() < 0.001;
814                let world_y = first_z.unwrap_or(0.0) + transform.tz;
815                out.polylines.push(SymbolicPolyline {
816                    express_id,
817                    ifc_type: ifc_type.to_string(),
818                    points,
819                    closed: is_closed,
820                    world_y,
821                    representation: rep_identifier.to_string(),
822                });
823            }
824        }
825        IfcType::IfcCircle => {
826            let radius = item.get(1).and_then(|a| a.as_float()).unwrap_or(0.0) as f32 * unit_scale;
827            if radius <= 0.0 || !radius.is_finite() {
828                return;
829            }
830            let (center_x, center_y, center_z) = circle_center(item, decoder, unit_scale);
831            if !center_x.is_finite() || !center_y.is_finite() {
832                return;
833            }
834            let (wx, wy) = transform.transform_point(center_x, center_y);
835            out.circles.push(SymbolicCircle::full(
836                express_id,
837                ifc_type.to_string(),
838                wx - rtc_x,
839                -wy + rtc_z,
840                radius,
841                center_z + transform.tz,
842                rep_identifier.to_string(),
843            ));
844        }
845        IfcType::IfcEllipse => {
846            let semi_a = item.get(1).and_then(|a| a.as_float()).unwrap_or(0.0) as f32 * unit_scale;
847            let semi_b = item.get(2).and_then(|a| a.as_float()).unwrap_or(0.0) as f32 * unit_scale;
848            if semi_a <= 0.0 || semi_b <= 0.0 || !semi_a.is_finite() || !semi_b.is_finite() {
849                return;
850            }
851            let (cx_local, cy_local, cz_local) = circle_center(item, decoder, unit_scale);
852            const SEGMENTS: usize = 64;
853            let mut points: Vec<f32> = Vec::with_capacity((SEGMENTS + 1) * 2);
854            for i in 0..=SEGMENTS {
855                let t = (i as f32) * std::f32::consts::TAU / (SEGMENTS as f32);
856                let lx = cx_local + semi_a * t.cos();
857                let ly = cy_local + semi_b * t.sin();
858                let (wx, wy) = transform.transform_point(lx, ly);
859                let x = wx - rtc_x;
860                let y = -wy + rtc_z;
861                if x.is_finite() && y.is_finite() {
862                    points.push(x);
863                    points.push(y);
864                }
865            }
866            if points.len() >= 4 {
867                out.polylines.push(SymbolicPolyline {
868                    express_id,
869                    ifc_type: ifc_type.to_string(),
870                    points,
871                    closed: true,
872                    world_y: cz_local + transform.tz,
873                    representation: rep_identifier.to_string(),
874                });
875            }
876        }
877        IfcType::IfcTrimmedCurve => {
878            extract_trimmed_curve(
879                item,
880                decoder,
881                express_id,
882                ifc_type,
883                rep_identifier,
884                unit_scale,
885                transform,
886                rtc_x,
887                rtc_z,
888                out,
889            );
890        }
891        IfcType::IfcCompositeCurve => {
892            if let Some(segments_attr) = item.get(0) {
893                if let Ok(segments) = decoder.resolve_ref_list(segments_attr) {
894                    for segment in segments {
895                        if let Some(curve_ref) = segment.get_ref(2) {
896                            if let Ok(parent_curve) = decoder.decode_by_id(curve_ref) {
897                                extract_symbolic_item(
898                                    &parent_curve,
899                                    decoder,
900                                    express_id,
901                                    ifc_type,
902                                    rep_identifier,
903                                    unit_scale,
904                                    transform,
905                                    rtc_x,
906                                    rtc_z,
907                                    styled_items,
908                                    out,
909                                );
910                            }
911                        }
912                    }
913                }
914            }
915        }
916        IfcType::IfcLine => {
917            // Infinite — no sensible 2D segment to emit.
918        }
919        IfcType::IfcTextLiteral | IfcType::IfcTextLiteralWithExtent => {
920            extract_text_literal(
921                item,
922                decoder,
923                express_id,
924                ifc_type,
925                rep_identifier,
926                unit_scale,
927                transform,
928                rtc_x,
929                rtc_z,
930                styled_items,
931                out,
932            );
933        }
934        IfcType::IfcAnnotationFillArea => {
935            extract_annotation_fill_area(
936                item,
937                decoder,
938                express_id,
939                ifc_type,
940                rep_identifier,
941                unit_scale,
942                transform,
943                rtc_x,
944                rtc_z,
945                styled_items,
946                out,
947            );
948        }
949        _ => {
950            // Unknown / unsupported curve type — skip silently.
951        }
952    }
953}
954
955/// Resolve a circle / ellipse Position → Location → (x, y, z) in metres.
956fn circle_center(
957    item: &DecodedEntity,
958    decoder: &mut EntityDecoder,
959    unit_scale: f32,
960) -> (f32, f32, f32) {
961    let Some(pos_ref) = item.get_ref(0) else {
962        return (0.0, 0.0, 0.0);
963    };
964    let Ok(placement) = decoder.decode_by_id(pos_ref) else {
965        return (0.0, 0.0, 0.0);
966    };
967    let Some(loc_ref) = placement.get_ref(0) else {
968        return (0.0, 0.0, 0.0);
969    };
970    let Ok(loc) = decoder.decode_by_id(loc_ref) else {
971        return (0.0, 0.0, 0.0);
972    };
973    let Some(coords) = loc.get(0).and_then(|a| a.as_list()) else {
974        return (0.0, 0.0, 0.0);
975    };
976    let x = coords.first().and_then(|v| v.as_float()).unwrap_or(0.0) as f32 * unit_scale;
977    let y = coords.get(1).and_then(|v| v.as_float()).unwrap_or(0.0) as f32 * unit_scale;
978    let z = coords.get(2).and_then(|v| v.as_float()).unwrap_or(0.0) as f32 * unit_scale;
979    (x, y, z)
980}
981
982/// Tessellate an `IfcTrimmedCurve` whose `BasisCurve` is an `IfcCircle`.
983/// Honours `PLANEANGLEUNIT` scaling, `SenseAgreement`, and wrap-around so
984/// the 2D arc matches the 3D arc on the same curve. Near-collinear arcs
985/// (large radius, small sagitta) collapse to a straight segment.
986#[allow(clippy::too_many_arguments)]
987fn extract_trimmed_curve(
988    item: &DecodedEntity,
989    decoder: &mut EntityDecoder,
990    express_id: u32,
991    ifc_type: &str,
992    rep_identifier: &str,
993    unit_scale: f32,
994    transform: &Transform2D,
995    rtc_x: f32,
996    rtc_z: f32,
997    out: &mut SymbolicData,
998) {
999    let Some(basis_ref) = item.get_ref(0) else { return };
1000    let Ok(basis_curve) = decoder.decode_by_id(basis_ref) else { return };
1001    if basis_curve.ifc_type != IfcType::IfcCircle {
1002        return;
1003    }
1004    let radius = basis_curve.get(1).and_then(|a| a.as_float()).unwrap_or(0.0) as f32 * unit_scale;
1005    if radius <= 0.0 || !radius.is_finite() {
1006        return;
1007    }
1008    let (center_x, center_y, center_z) = circle_center(&basis_curve, decoder, unit_scale);
1009    if !center_x.is_finite() || !center_y.is_finite() {
1010        return;
1011    }
1012    let world_y = center_z + transform.tz;
1013
1014    let angle_scale = decoder.plane_angle_to_radians() as f32;
1015    let raw_trim1: Option<f32> = item
1016        .get(1)
1017        .and_then(|a| a.as_list().and_then(|l| l.first().and_then(|v| v.as_float())))
1018        .map(|v| v as f32);
1019    let raw_trim2: Option<f32> = item
1020        .get(2)
1021        .and_then(|a| a.as_list().and_then(|l| l.first().and_then(|v| v.as_float())))
1022        .map(|v| v as f32);
1023    let sense = item
1024        .get(3)
1025        .and_then(|v| match v {
1026            AttributeValue::Enum(s) => Some(s == "T" || s == "TRUE" || s == ".T."),
1027            _ => None,
1028        })
1029        .unwrap_or(true);
1030
1031    let start_angle = raw_trim1.map(|v| v * angle_scale).unwrap_or(0.0);
1032    let mut end_angle = raw_trim2.map(|v| v * angle_scale).unwrap_or(std::f32::consts::TAU);
1033    if sense && end_angle < start_angle {
1034        end_angle += std::f32::consts::TAU;
1035    } else if !sense && end_angle > start_angle {
1036        end_angle -= std::f32::consts::TAU;
1037    }
1038    if !start_angle.is_finite() || !end_angle.is_finite() {
1039        return;
1040    }
1041
1042    let start_x = center_x + radius * start_angle.cos();
1043    let start_y = center_y + radius * start_angle.sin();
1044    let end_x = center_x + radius * end_angle.cos();
1045    let end_y = center_y + radius * end_angle.sin();
1046    let chord_dx = end_x - start_x;
1047    let chord_dy = end_y - start_y;
1048    let chord_len = (chord_dx * chord_dx + chord_dy * chord_dy).sqrt();
1049    let is_near_collinear = if chord_len > 0.0001 {
1050        let mid_angle = (start_angle + end_angle) / 2.0;
1051        let mid_x = center_x + radius * mid_angle.cos();
1052        let mid_y = center_y + radius * mid_angle.sin();
1053        let sagitta = ((end_y - start_y) * mid_x - (end_x - start_x) * mid_y
1054            + end_x * start_y
1055            - end_y * start_x)
1056            .abs()
1057            / chord_len;
1058        radius > 100.0 || sagitta < chord_len * 0.02 || radius > chord_len * 10.0
1059    } else {
1060        true
1061    };
1062
1063    if is_near_collinear {
1064        let (wsx, wsy) = transform.transform_point(start_x, start_y);
1065        let (wex, wey) = transform.transform_point(end_x, end_y);
1066        let points = vec![wsx - rtc_x, -wsy + rtc_z, wex - rtc_x, -wey + rtc_z];
1067        out.polylines.push(SymbolicPolyline {
1068            express_id,
1069            ifc_type: ifc_type.to_string(),
1070            points,
1071            closed: false,
1072            world_y,
1073            representation: rep_identifier.to_string(),
1074        });
1075    } else {
1076        let arc_length = (end_angle - start_angle).abs();
1077        let num_segments = ((arc_length * radius / 0.1) as usize).max(8).min(64);
1078        let mut points = Vec::with_capacity((num_segments + 1) * 2);
1079        for i in 0..=num_segments {
1080            let t = i as f32 / num_segments as f32;
1081            let angle = start_angle + t * (end_angle - start_angle);
1082            let local_x = center_x + radius * angle.cos();
1083            let local_y = center_y + radius * angle.sin();
1084            let (wx, wy) = transform.transform_point(local_x, local_y);
1085            let x = wx - rtc_x;
1086            let y = -wy + rtc_z;
1087            if x.is_finite() && y.is_finite() {
1088                points.push(x);
1089                points.push(y);
1090            }
1091        }
1092        if points.len() >= 4 {
1093            out.polylines.push(SymbolicPolyline {
1094                express_id,
1095                ifc_type: ifc_type.to_string(),
1096                points,
1097                closed: false,
1098                world_y,
1099                representation: rep_identifier.to_string(),
1100            });
1101        }
1102    }
1103}
1104
1105// ────────────────────────────────────────────────────────────────────────────
1106// Text extraction (IfcTextLiteral / IfcTextLiteralWithExtent).
1107// ────────────────────────────────────────────────────────────────────────────
1108
1109#[allow(clippy::too_many_arguments)]
1110fn extract_text_literal(
1111    item: &DecodedEntity,
1112    decoder: &mut EntityDecoder,
1113    express_id: u32,
1114    ifc_type: &str,
1115    rep_identifier: &str,
1116    unit_scale: f32,
1117    transform: &Transform2D,
1118    rtc_x: f32,
1119    rtc_z: f32,
1120    styled_items: &HashMap<u32, Vec<u32>>,
1121    out: &mut SymbolicData,
1122) {
1123    let content = match item.get(0).and_then(|a| a.as_string()) {
1124        Some(s) => s.to_string(),
1125        None => return,
1126    };
1127
1128    let placement_transform = match item.get_ref(1) {
1129        Some(p_ref) => match decoder.decode_by_id(p_ref) {
1130            Ok(p) => parse_axis2_placement_2d(&p, decoder, unit_scale),
1131            Err(_) => Transform2D::identity(),
1132        },
1133        None => Transform2D::identity(),
1134    };
1135    let composed = compose_transforms(transform, &placement_transform);
1136
1137    const CAP_TO_BOX_RATIO: f32 = 0.7;
1138    const FALLBACK_CAP_HEIGHT_M: f32 = 0.18;
1139    let height_model_units = if item.ifc_type == IfcType::IfcTextLiteralWithExtent {
1140        item.get_ref(3)
1141            .and_then(|extent_ref| decoder.decode_by_id(extent_ref).ok())
1142            .and_then(|extent| extent.get(1).and_then(|a| a.as_float()))
1143            .map(|h| (h as f32) * CAP_TO_BOX_RATIO)
1144            .unwrap_or(FALLBACK_CAP_HEIGHT_M / unit_scale.max(1e-6))
1145    } else {
1146        FALLBACK_CAP_HEIGHT_M / unit_scale.max(1e-6)
1147    };
1148
1149    let alignment = if item.ifc_type == IfcType::IfcTextLiteralWithExtent {
1150        item.get(4)
1151            .and_then(|a| a.as_string())
1152            .unwrap_or("")
1153            .to_string()
1154    } else {
1155        String::new()
1156    };
1157
1158    let (wx, wy) = composed.transform_point(0.0, 0.0);
1159    let color = resolve_color_via_styles(item.id, styled_items, decoder)
1160        .unwrap_or([0.05, 0.05, 0.05, 1.0]);
1161
1162    out.texts.push(SymbolicText {
1163        express_id,
1164        ifc_type: ifc_type.to_string(),
1165        x: wx - rtc_x,
1166        y: -wy + rtc_z,
1167        dir_x: composed.cos_theta,
1168        dir_y: -composed.sin_theta, // mirror to match Y-flipped coord system
1169        height: height_model_units * unit_scale,
1170        content,
1171        alignment,
1172        world_y: composed.tz,
1173        color,
1174        target_px: 0.0,
1175        representation: rep_identifier.to_string(),
1176    });
1177}
1178
1179// ────────────────────────────────────────────────────────────────────────────
1180// Fill area extraction (IfcAnnotationFillArea).
1181// ────────────────────────────────────────────────────────────────────────────
1182
1183#[allow(clippy::too_many_arguments)]
1184fn extract_annotation_fill_area(
1185    item: &DecodedEntity,
1186    decoder: &mut EntityDecoder,
1187    express_id: u32,
1188    ifc_type: &str,
1189    rep_identifier: &str,
1190    unit_scale: f32,
1191    transform: &Transform2D,
1192    rtc_x: f32,
1193    rtc_z: f32,
1194    styled_items: &HashMap<u32, Vec<u32>>,
1195    out: &mut SymbolicData,
1196) {
1197    let Some(outer_ref) = item.get_ref(0) else { return };
1198    let mut points = extract_curve_ring(outer_ref, decoder, unit_scale, transform, rtc_x, rtc_z);
1199    if points.len() < 6 {
1200        return;
1201    }
1202
1203    let mut holes_offsets: Vec<u32> = Vec::new();
1204    if let Some(inners_attr) = item.get(1) {
1205        if let Ok(inner_list) = decoder.resolve_ref_list(inners_attr) {
1206            for inner in inner_list {
1207                let hole = extract_curve_ring(inner.id, decoder, unit_scale, transform, rtc_x, rtc_z);
1208                if hole.len() >= 6 {
1209                    let vertex_index = (points.len() / 2) as u32;
1210                    holes_offsets.push(vertex_index);
1211                    points.extend(hole);
1212                }
1213            }
1214        }
1215    }
1216
1217    let fill_color = resolve_color_via_styles(item.id, styled_items, decoder)
1218        .unwrap_or([0.0, 0.0, 0.0, 1.0]);
1219    let world_y = sample_curve_world_y(outer_ref, decoder, unit_scale) + transform.tz;
1220
1221    out.fills.push(SymbolicFillArea {
1222        express_id,
1223        ifc_type: ifc_type.to_string(),
1224        points,
1225        holes_offsets,
1226        fill_color,
1227        has_hatching: false,
1228        hatch_spacing: 0.0,
1229        hatch_angle: 0.0,
1230        hatch_angle_secondary: f32::NAN,
1231        hatch_line_width: 0.0,
1232        world_y,
1233        representation: rep_identifier.to_string(),
1234    });
1235}
1236
1237/// Extract one ring of `(x, y)` points from any supported boundary curve.
1238/// Returns an empty vec on unsupported types or parse failure.
1239fn extract_curve_ring(
1240    curve_id: u32,
1241    decoder: &mut EntityDecoder,
1242    unit_scale: f32,
1243    transform: &Transform2D,
1244    rtc_x: f32,
1245    rtc_z: f32,
1246) -> Vec<f32> {
1247    let Ok(curve) = decoder.decode_by_id(curve_id) else {
1248        return Vec::new();
1249    };
1250    match curve.ifc_type {
1251        IfcType::IfcPolyline => {
1252            let Some(points_attr) = curve.get(0) else { return Vec::new() };
1253            let Ok(point_entities) = decoder.resolve_ref_list(points_attr) else {
1254                return Vec::new();
1255            };
1256            let mut out = Vec::with_capacity(point_entities.len() * 2);
1257            for pe in point_entities {
1258                if pe.ifc_type != IfcType::IfcCartesianPoint {
1259                    continue;
1260                }
1261                let Some(coords) = pe.get(0).and_then(|a| a.as_list()) else { continue };
1262                let x = coords.first().and_then(|v| v.as_float()).unwrap_or(0.0) as f32 * unit_scale;
1263                let y = coords.get(1).and_then(|v| v.as_float()).unwrap_or(0.0) as f32 * unit_scale;
1264                let (wx, wy) = transform.transform_point(x, y);
1265                out.push(wx - rtc_x);
1266                out.push(-wy + rtc_z);
1267            }
1268            out
1269        }
1270        IfcType::IfcIndexedPolyCurve => {
1271            let Some(points_ref) = curve.get_ref(0) else { return Vec::new() };
1272            let Ok(points_entity) = decoder.decode_by_id(points_ref) else { return Vec::new() };
1273            let Some(coord_list_attr) = points_entity.get(0) else { return Vec::new() };
1274            let Some(coord_list) = coord_list_attr.as_list() else { return Vec::new() };
1275            let mut out = Vec::with_capacity(coord_list.len() * 2);
1276            for tuple in coord_list {
1277                let Some(coords) = tuple.as_list() else { continue };
1278                let x = coords.first().and_then(|v| v.as_float()).unwrap_or(0.0) as f32 * unit_scale;
1279                let y = coords.get(1).and_then(|v| v.as_float()).unwrap_or(0.0) as f32 * unit_scale;
1280                let (wx, wy) = transform.transform_point(x, y);
1281                out.push(wx - rtc_x);
1282                out.push(-wy + rtc_z);
1283            }
1284            out
1285        }
1286        IfcType::IfcEllipse => {
1287            let semi_a = curve.get(1).and_then(|a| a.as_float()).unwrap_or(0.0) as f32 * unit_scale;
1288            let semi_b = curve.get(2).and_then(|a| a.as_float()).unwrap_or(0.0) as f32 * unit_scale;
1289            if semi_a <= 0.0 || semi_b <= 0.0 || !semi_a.is_finite() || !semi_b.is_finite() {
1290                return Vec::new();
1291            }
1292            let (cx_local, cy_local, _) = circle_center(&curve, decoder, unit_scale);
1293            const SEGMENTS: usize = 64;
1294            let mut out = Vec::with_capacity(SEGMENTS * 2);
1295            for i in 0..SEGMENTS {
1296                let theta = (i as f32) * std::f32::consts::TAU / (SEGMENTS as f32);
1297                let lx = cx_local + semi_a * theta.cos();
1298                let ly = cy_local + semi_b * theta.sin();
1299                let (wx, wy) = transform.transform_point(lx, ly);
1300                out.push(wx - rtc_x);
1301                out.push(-wy + rtc_z);
1302            }
1303            out
1304        }
1305        IfcType::IfcCircle => {
1306            let radius = curve.get(1).and_then(|a| a.as_float()).unwrap_or(0.0) as f32 * unit_scale;
1307            if radius <= 0.0 || !radius.is_finite() {
1308                return Vec::new();
1309            }
1310            let (cx_local, cy_local, _) = circle_center(&curve, decoder, unit_scale);
1311            let seg_count = if radius < 0.05 { 32 } else { 64 };
1312            let mut out = Vec::with_capacity(seg_count * 2);
1313            let two_pi = std::f32::consts::TAU;
1314            for i in 0..seg_count {
1315                let theta = (i as f32) * two_pi / (seg_count as f32);
1316                let lx = cx_local + radius * theta.cos();
1317                let ly = cy_local + radius * theta.sin();
1318                let (wx, wy) = transform.transform_point(lx, ly);
1319                out.push(wx - rtc_x);
1320                out.push(-wy + rtc_z);
1321            }
1322            out
1323        }
1324        _ => Vec::new(),
1325    }
1326}
1327
1328/// Peek at the boundary curve's first 3D point Z so a fill / line can carry
1329/// its elevation forward. Returns 0.0 for 2D-only curves.
1330fn sample_curve_world_y(curve_id: u32, decoder: &mut EntityDecoder, unit_scale: f32) -> f32 {
1331    let Ok(curve) = decoder.decode_by_id(curve_id) else { return 0.0 };
1332    match curve.ifc_type {
1333        IfcType::IfcPolyline => {
1334            let Some(points_attr) = curve.get(0) else { return 0.0 };
1335            let Ok(point_entities) = decoder.resolve_ref_list(points_attr) else { return 0.0 };
1336            for pe in point_entities {
1337                if pe.ifc_type != IfcType::IfcCartesianPoint {
1338                    continue;
1339                }
1340                if let Some(coords) = pe.get(0).and_then(|a| a.as_list()) {
1341                    let z = coords.get(2).and_then(|v| v.as_float()).unwrap_or(0.0) as f32 * unit_scale;
1342                    return z;
1343                }
1344            }
1345            0.0
1346        }
1347        IfcType::IfcCircle | IfcType::IfcEllipse => {
1348            let (_, _, z) = circle_center(&curve, decoder, unit_scale);
1349            z
1350        }
1351        IfcType::IfcIndexedPolyCurve => {
1352            let Some(points_ref) = curve.get_ref(0) else { return 0.0 };
1353            let Ok(points_entity) = decoder.decode_by_id(points_ref) else { return 0.0 };
1354            let Some(coord_list_attr) = points_entity.get(0) else { return 0.0 };
1355            let Some(coord_list) = coord_list_attr.as_list() else { return 0.0 };
1356            if let Some(first) = coord_list.first().and_then(|v| v.as_list()) {
1357                return first.get(2).and_then(|v| v.as_float()).unwrap_or(0.0) as f32 * unit_scale;
1358            }
1359            0.0
1360        }
1361        _ => 0.0,
1362    }
1363}
1364
1365// ────────────────────────────────────────────────────────────────────────────
1366// Grid extraction (axis lines + bubble/tag pairs).
1367// ────────────────────────────────────────────────────────────────────────────
1368
1369const BUBBLE_OFFSET_M: f32 = 1.2;
1370const BUBBLE_CAP_M: f32 = 2.0;
1371const BUBBLE_TARGET_PX: f32 = 32.0;
1372const TAG_CAP_M: f32 = 0.7;
1373const TAG_TARGET_PX: f32 = 14.0;
1374
1375#[allow(clippy::too_many_arguments)]
1376fn extract_grid(
1377    grid: &DecodedEntity,
1378    grid_id: u32,
1379    decoder: &mut EntityDecoder,
1380    unit_scale: f32,
1381    transform: &Transform2D,
1382    rtc_x: f32,
1383    rtc_z: f32,
1384    out: &mut SymbolicData,
1385) {
1386    for axis_attr_idx in [7usize, 8, 9] {
1387        let Some(axes_attr) = grid.get(axis_attr_idx) else { continue };
1388        let Ok(axes) = decoder.resolve_ref_list(axes_attr) else { continue };
1389        for axis in axes {
1390            if axis.ifc_type != IfcType::IfcGridAxis {
1391                continue;
1392            }
1393            let axis_id = axis.id;
1394            let tag = axis.get(0).and_then(|a| a.as_string()).unwrap_or("").to_string();
1395
1396            let Some(curve_ref) = axis.get_ref(1) else { continue };
1397            let Ok(curve) = decoder.decode_by_id(curve_ref) else { continue };
1398            let Some((p0, p1)) = sample_grid_axis_endpoints(&curve, decoder, unit_scale, transform)
1399            else {
1400                continue;
1401            };
1402
1403            let a = (p0.0 - rtc_x, -p0.1 + rtc_z);
1404            let b = (p1.0 - rtc_x, -p1.1 + rtc_z);
1405            let world_y = transform.tz;
1406
1407            // Compact server-friendly entry — keeps the existing endpoint-pair shape.
1408            out.grid_axes.push(SymbolicGridAxis {
1409                express_id: axis_id,
1410                grid_express_id: grid_id,
1411                tag: tag.clone(),
1412                endpoints: [a.0, a.1, b.0, b.1],
1413                world_y,
1414            });
1415
1416            // Axis line (browser pipeline: SymbolicPolyline + bubble texts).
1417            out.polylines.push(SymbolicPolyline {
1418                express_id: axis_id,
1419                ifc_type: "IfcGridAxis".to_string(),
1420                points: vec![a.0, a.1, b.0, b.1],
1421                closed: false,
1422                world_y,
1423                representation: "Axis".to_string(),
1424            });
1425
1426            // Unit direction along the axis for bubble offset.
1427            let dx = b.0 - a.0;
1428            let dy = b.1 - a.1;
1429            let len = (dx * dx + dy * dy).sqrt();
1430            if len < 1e-4 {
1431                continue;
1432            }
1433            let nx = dx / len;
1434            let ny = dy / len;
1435
1436            let cx0 = a.0 - nx * BUBBLE_OFFSET_M;
1437            let cy0 = a.1 - ny * BUBBLE_OFFSET_M;
1438            emit_bubble(axis_id, cx0, cy0, world_y, &tag, out);
1439
1440            let cx1 = b.0 + nx * BUBBLE_OFFSET_M;
1441            let cy1 = b.1 + ny * BUBBLE_OFFSET_M;
1442            emit_bubble(axis_id, cx1, cy1, world_y, &tag, out);
1443        }
1444    }
1445}
1446
1447/// Emit a bubble (transparent interior + black outline ○ + tag text) as
1448/// two stacked text instances. The shader's per-instance `target_px` keeps
1449/// them at the right relative size at every zoom level.
1450fn emit_bubble(axis_id: u32, cx: f32, cy: f32, world_y: f32, tag: &str, out: &mut SymbolicData) {
1451    out.texts.push(SymbolicText {
1452        express_id: axis_id,
1453        ifc_type: "IfcGridAxis".to_string(),
1454        x: cx,
1455        y: cy,
1456        dir_x: 1.0,
1457        dir_y: 0.0,
1458        height: BUBBLE_CAP_M,
1459        content: "\u{25EF}".to_string(),
1460        alignment: "center".to_string(),
1461        world_y,
1462        color: [0.0, 0.0, 0.0, 1.0],
1463        target_px: BUBBLE_TARGET_PX,
1464        representation: "Axis".to_string(),
1465    });
1466    out.texts.push(SymbolicText {
1467        express_id: axis_id,
1468        ifc_type: "IfcGridAxis".to_string(),
1469        x: cx,
1470        y: cy,
1471        dir_x: 1.0,
1472        dir_y: 0.0,
1473        height: TAG_CAP_M,
1474        content: tag.to_string(),
1475        alignment: "center".to_string(),
1476        world_y,
1477        color: [0.0, 0.0, 0.0, 1.0],
1478        target_px: TAG_TARGET_PX,
1479        representation: "Axis".to_string(),
1480    });
1481}
1482
1483/// Sample the two endpoints of an `IfcGridAxis` curve. In practice always
1484/// an `IfcPolyline` of two `IfcCartesianPoint`s, but we accept the general
1485/// polyline shape — first + last points of the list.
1486fn sample_grid_axis_endpoints(
1487    curve: &DecodedEntity,
1488    decoder: &mut EntityDecoder,
1489    unit_scale: f32,
1490    transform: &Transform2D,
1491) -> Option<((f32, f32), (f32, f32))> {
1492    if curve.ifc_type != IfcType::IfcPolyline {
1493        return None;
1494    }
1495    let pts_attr = curve.get(0)?;
1496    let point_entities = decoder.resolve_ref_list(pts_attr).ok()?;
1497    if point_entities.len() < 2 {
1498        return None;
1499    }
1500    let extract = |pe: &DecodedEntity| -> Option<(f32, f32)> {
1501        if pe.ifc_type != IfcType::IfcCartesianPoint {
1502            return None;
1503        }
1504        let coords = pe.get(0)?.as_list()?;
1505        let x = coords.first()?.as_float()? as f32 * unit_scale;
1506        let y = coords.get(1)?.as_float()? as f32 * unit_scale;
1507        Some(transform.transform_point(x, y))
1508    };
1509    let first = extract(&point_entities[0])?;
1510    let last = extract(&point_entities[point_entities.len() - 1])?;
1511    Some((first, last))
1512}
1513
1514// ────────────────────────────────────────────────────────────────────────────
1515// IfcStyledItem reverse index + colour resolution.
1516// ────────────────────────────────────────────────────────────────────────────
1517
1518fn build_styled_item_index(content: &[u8], decoder: &mut EntityDecoder) -> HashMap<u32, Vec<u32>> {
1519    let collect_refs = |attr: &AttributeValue| -> Vec<u32> {
1520        if let Some(list) = attr.as_list() {
1521            list.iter().filter_map(|v| v.as_entity_ref()).collect()
1522        } else if let Some(single) = attr.as_entity_ref() {
1523            vec![single]
1524        } else {
1525            Vec::new()
1526        }
1527    };
1528
1529    // Pass 1: presentation-style-assignment wrapper map.
1530    let mut wrappers: HashMap<u32, Vec<u32>> = HashMap::new();
1531    let mut scanner = EntityScanner::new(content);
1532    while let Some((id, type_name, start, end)) = scanner.next_entity() {
1533        if type_name != "IFCPRESENTATIONSTYLEASSIGNMENT" {
1534            continue;
1535        }
1536        let Ok(entity) = decoder.decode_at_with_id(id, start, end) else { continue };
1537        let Some(styles_attr) = entity.get(0) else { continue };
1538        let inner_refs = collect_refs(styles_attr);
1539        if !inner_refs.is_empty() {
1540            wrappers.insert(id, inner_refs);
1541        }
1542    }
1543
1544    // Pass 2: item → style index, unwrapping wrappers transparently.
1545    let mut out: HashMap<u32, Vec<u32>> = HashMap::new();
1546    let mut scanner = EntityScanner::new(content);
1547    while let Some((id, type_name, start, end)) = scanner.next_entity() {
1548        if type_name != "IFCSTYLEDITEM" {
1549            continue;
1550        }
1551        let Ok(entity) = decoder.decode_at_with_id(id, start, end) else { continue };
1552        let Some(item_ref) = entity.get_ref(0) else { continue };
1553        let Some(styles_attr) = entity.get(1) else { continue };
1554        let mut final_refs: Vec<u32> = Vec::new();
1555        for raw_ref in collect_refs(styles_attr) {
1556            if let Some(inner) = wrappers.get(&raw_ref) {
1557                final_refs.extend(inner.iter().copied());
1558            } else {
1559                final_refs.push(raw_ref);
1560            }
1561        }
1562        if !final_refs.is_empty() {
1563            out.entry(item_ref).or_default().extend(final_refs);
1564        }
1565    }
1566    out
1567}
1568
1569fn resolve_color_via_styles(
1570    item_id: u32,
1571    styled_items: &HashMap<u32, Vec<u32>>,
1572    decoder: &mut EntityDecoder,
1573) -> Option<[f32; 4]> {
1574    let style_refs = styled_items.get(&item_id)?;
1575    for style_ref in style_refs {
1576        if let Some(color) = extract_color_from_style_ref(*style_ref, decoder) {
1577            return Some(color);
1578        }
1579    }
1580    None
1581}
1582
1583fn extract_color_from_style_ref(style_ref: u32, decoder: &mut EntityDecoder) -> Option<[f32; 4]> {
1584    let style = decoder.decode_by_id(style_ref).ok()?;
1585    match style.ifc_type {
1586        IfcType::IfcFillAreaStyle => extract_color_from_fill_area_style(&style, decoder),
1587        IfcType::IfcTextStyle => extract_color_from_text_style(&style, decoder),
1588        _ => None,
1589    }
1590}
1591
1592fn extract_color_from_text_style(
1593    style: &DecodedEntity,
1594    decoder: &mut EntityDecoder,
1595) -> Option<[f32; 4]> {
1596    let appearance = decoder.decode_by_id(style.get_ref(1)?).ok()?;
1597    if appearance.ifc_type != IfcType::IfcTextStyleForDefinedFont {
1598        return None;
1599    }
1600    let colour = decoder.decode_by_id(appearance.get_ref(0)?).ok()?;
1601    if colour.ifc_type != IfcType::IfcColourRgb {
1602        return None;
1603    }
1604    let r = colour.get(1)?.as_float()? as f32;
1605    let g = colour.get(2)?.as_float()? as f32;
1606    let b = colour.get(3)?.as_float()? as f32;
1607    Some([r, g, b, 1.0])
1608}
1609
1610fn extract_color_from_fill_area_style(
1611    style: &DecodedEntity,
1612    decoder: &mut EntityDecoder,
1613) -> Option<[f32; 4]> {
1614    let fill_styles_attr = style.get(1)?;
1615    let fill_style_refs: Vec<u32> = if let Some(list) = fill_styles_attr.as_list() {
1616        list.iter().filter_map(|v| v.as_entity_ref()).collect()
1617    } else if let Some(single) = fill_styles_attr.as_entity_ref() {
1618        vec![single]
1619    } else {
1620        return None;
1621    };
1622    for fs_ref in fill_style_refs {
1623        let Ok(fs) = decoder.decode_by_id(fs_ref) else { continue };
1624        if fs.ifc_type == IfcType::IfcColourRgb {
1625            if let (Some(r), Some(g), Some(b)) = (
1626                fs.get(1).and_then(|v| v.as_float()),
1627                fs.get(2).and_then(|v| v.as_float()),
1628                fs.get(3).and_then(|v| v.as_float()),
1629            ) {
1630                return Some([r as f32, g as f32, b as f32, 1.0]);
1631            }
1632        }
1633    }
1634    None
1635}