Skip to main content

ifc_lite_wasm/api/
grid_lines.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//! Structural grid (`IfcGrid` / `IfcGridAxis`) extraction for the 3D viewport.
6//!
7//! `IfcGrid` carries its axes as `IfcGridAxis` curves on attributes 7/8/9
8//! (U/V/W axis lists), not as a triangulated `Representation`, so grids never
9//! produce a mesh in the streaming batch mesher. They are also commonly placed
10//! at survey / true-world coordinates (issue #945: a grid axis point reads at
11//! `[294091.5, 0, 0]` mm, the grid `ObjectPlacement` resolving to ~−10 km),
12//! while the mesh pipeline re-bases geometry into a unit-scaled, RTC-subtracted
13//! "render frame". Resolving the grid placement naively therefore lands the
14//! axes kilometres off the model.
15//!
16//! This module resolves each axis through the **same** transform pipeline the
17//! meshes use — full `IfcLocalPlacement` chain (`resolve_scaled_placement`) +
18//! `lengthUnitScale` + the same RTC offset
19//! (`detect_rtc_offset_from_first_element`, gated at 10 km and above) — and
20//! emits the endpoints in the renderer's **Y-up, RTC-subtracted, metres** world
21//! space (the exact frame `MeshDataJs::new` produces after its IFC Z-up → WebGL
22//! Y-up swap). Grids then line up with the streamed geometry by construction,
23//! mirroring `alignment_lines.rs`.
24
25use super::IfcAPI;
26use ifc_lite_core::{build_entity_index, DecodedEntity, EntityDecoder, EntityScanner, IfcType};
27use ifc_lite_geometry::GeometryRouter;
28use wasm_bindgen::prelude::*;
29
30// ═══════════════════════════════════════════════════════════════════════════
31// PURE-RUST CORE (unit-testable without wasm-bindgen)
32// ═══════════════════════════════════════════════════════════════════════════
33
34/// One resolved grid axis: its tag plus the two endpoints of its curve, in the
35/// renderer's Y-up / RTC-subtracted / metres render frame.
36#[derive(Clone, Debug, PartialEq)]
37pub(crate) struct GridAxis3D {
38    pub grid_id: u32,
39    pub axis_id: u32,
40    pub tag: String,
41    pub start: [f32; 3],
42    pub end: [f32; 3],
43}
44
45/// Parse the file and resolve every `IfcGridAxis` into render-frame endpoints.
46/// Returns an empty vec when the file has no grids (or none with a resolvable
47/// axis curve), so callers can clear the overlay cheaply.
48pub(crate) fn extract_grid_axes(content: &str) -> Vec<GridAxis3D> {
49    let entity_index = build_entity_index(content);
50    let mut decoder = EntityDecoder::with_index(content, entity_index);
51
52    // Reuse the geometry router for both unit-scale and the placement resolver,
53    // exactly like the mesh pipeline (and the symbolic builder).
54    let router = GeometryRouter::with_units(content, &mut decoder);
55    let unit_scale = router.unit_scale();
56
57    // RTC offset (metres). `detect_rtc_offset_from_first_element` returns
58    // (0,0,0) for models within 10 km of the origin, so this is a no-op for
59    // local files and a true shift for georeferenced models — the same offset
60    // the mesh pipeline applies.
61    let rtc = router.detect_rtc_offset_from_first_element(content, &mut decoder);
62
63    let mut out: Vec<GridAxis3D> = Vec::new();
64    let mut scanner = EntityScanner::new(content);
65    while let Some((id, type_name, start, end)) = scanner.next_entity() {
66        if type_name != "IFCGRID" {
67            continue;
68        }
69        let Ok(grid) = decoder.decode_at_with_id(id, start, end) else {
70            continue;
71        };
72        // Full placement chain for the grid, translation scaled to metres.
73        // Does NOT subtract RTC — we do that per point below.
74        let matrix = router
75            .resolve_scaled_placement(&grid, &mut decoder)
76            .unwrap_or([
77                1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
78            ]);
79        append_grid_axes(&grid, id, &mut decoder, unit_scale, &matrix, rtc, &mut out);
80    }
81    out
82}
83
84/// Walk an `IfcGrid`'s U/V/W axis lists (attributes 7, 8, 9), resolve each
85/// `IfcGridAxis` curve's endpoints through the render-frame transform, and push
86/// a [`GridAxis3D`] per axis.
87fn append_grid_axes(
88    grid: &DecodedEntity,
89    grid_id: u32,
90    decoder: &mut EntityDecoder,
91    unit_scale: f64,
92    matrix: &[f64; 16],
93    rtc: (f64, f64, f64),
94    out: &mut Vec<GridAxis3D>,
95) {
96    for axis_attr_idx in [7usize, 8, 9] {
97        let Some(axes_attr) = grid.get(axis_attr_idx) else {
98            continue;
99        };
100        let Ok(axes) = decoder.resolve_ref_list(axes_attr) else {
101            continue;
102        };
103        for axis in axes {
104            if axis.ifc_type != IfcType::IfcGridAxis {
105                continue;
106            }
107            let axis_id = axis.id;
108            let tag = axis
109                .get(0)
110                .and_then(|a| a.as_string())
111                .unwrap_or("")
112                .to_string();
113
114            // AxisCurve at attribute 1 — in practice always an IfcPolyline.
115            let Some(curve_ref) = axis.get_ref(1) else {
116                continue;
117            };
118            let Ok(curve) = decoder.decode_by_id(curve_ref) else {
119                continue;
120            };
121            let Some((p0, p1)) = sample_axis_endpoints(&curve, decoder) else {
122                continue;
123            };
124
125            let start = to_render_frame(p0, unit_scale, matrix, rtc);
126            let end = to_render_frame(p1, unit_scale, matrix, rtc);
127            out.push(GridAxis3D {
128                grid_id,
129                axis_id,
130                tag,
131                start,
132                end,
133            });
134        }
135    }
136}
137
138/// First and last `IfcCartesianPoint` of an axis curve, in raw file units
139/// (3D — the Z component is kept, unlike the 2D symbolic path). Returns `None`
140/// for non-polyline curves or fewer than two points.
141fn sample_axis_endpoints(
142    curve: &DecodedEntity,
143    decoder: &mut EntityDecoder,
144) -> Option<([f64; 3], [f64; 3])> {
145    if curve.ifc_type != IfcType::IfcPolyline {
146        return None;
147    }
148    let pts_attr = curve.get(0)?;
149    let points = decoder.resolve_ref_list(pts_attr).ok()?;
150    if points.len() < 2 {
151        return None;
152    }
153    let extract = |pe: &DecodedEntity| -> Option<[f64; 3]> {
154        if pe.ifc_type != IfcType::IfcCartesianPoint {
155            return None;
156        }
157        let coords = pe.get(0)?.as_list()?;
158        let x = coords.first()?.as_float()?;
159        let y = coords.get(1)?.as_float()?;
160        // 2D grid axis points are common; default Z to 0.
161        let z = coords.get(2).and_then(|v| v.as_float()).unwrap_or(0.0);
162        Some([x, y, z])
163    };
164    let first = extract(&points[0])?;
165    let last = extract(&points[points.len() - 1])?;
166    Some((first, last))
167}
168
169/// Transform a raw file-unit grid point into the renderer's Y-up /
170/// RTC-subtracted / metres render frame.
171///
172/// `matrix` is the grid's scaled placement (column-major, translation already
173/// in metres). The local point is scaled to metres before the matrix is
174/// applied, matching the mesh path (`scale_mesh` then `transform_mesh_world`).
175fn to_render_frame(
176    p: [f64; 3],
177    unit_scale: f64,
178    matrix: &[f64; 16],
179    rtc: (f64, f64, f64),
180) -> [f32; 3] {
181    let (x, y, z) = (p[0] * unit_scale, p[1] * unit_scale, p[2] * unit_scale);
182    // Column-major 4×4 · (x, y, z, 1): element (row, col) at index col*4 + row.
183    let wx = matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12];
184    let wy = matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13];
185    let wz = matrix[2] * x + matrix[6] * y + matrix[10] * z + matrix[14];
186    // RTC subtraction (metres, same offset the meshes use).
187    let rx = wx - rtc.0;
188    let ry = wy - rtc.1;
189    let rz = wz - rtc.2;
190    // IFC Z-up → WebGL Y-up: (x, z, -y). Matches MeshDataJs::new so grids land
191    // on the same ground as the streamed meshes.
192    [rx as f32, rz as f32, -ry as f32]
193}
194
195// ═══════════════════════════════════════════════════════════════════════════
196// JS-FRIENDLY TYPES
197// ═══════════════════════════════════════════════════════════════════════════
198
199/// One grid axis: tag + endpoints in renderer Y-up world space (metres).
200#[wasm_bindgen]
201pub struct GridAxisJs {
202    grid_id: u32,
203    axis_id: u32,
204    tag: String,
205    start: [f32; 3],
206    end: [f32; 3],
207}
208
209#[wasm_bindgen]
210impl GridAxisJs {
211    /// Express ID of the owning `IfcGrid`.
212    #[wasm_bindgen(getter, js_name = gridId)]
213    pub fn grid_id(&self) -> u32 {
214        self.grid_id
215    }
216
217    /// Express ID of the `IfcGridAxis`.
218    #[wasm_bindgen(getter, js_name = axisId)]
219    pub fn axis_id(&self) -> u32 {
220        self.axis_id
221    }
222
223    /// Axis tag (e.g. `"A"`, `"1"`); empty string when unauthored.
224    #[wasm_bindgen(getter)]
225    pub fn tag(&self) -> String {
226        self.tag.clone()
227    }
228
229    /// Start endpoint `[x, y, z]` in renderer Y-up world space (metres).
230    #[wasm_bindgen(getter)]
231    pub fn start(&self) -> js_sys::Float32Array {
232        js_sys::Float32Array::from(&self.start[..])
233    }
234
235    /// End endpoint `[x, y, z]` in renderer Y-up world space (metres).
236    #[wasm_bindgen(getter)]
237    pub fn end(&self) -> js_sys::Float32Array {
238        js_sys::Float32Array::from(&self.end[..])
239    }
240}
241
242impl From<&GridAxis3D> for GridAxisJs {
243    fn from(a: &GridAxis3D) -> Self {
244        Self {
245            grid_id: a.grid_id,
246            axis_id: a.axis_id,
247            tag: a.tag.clone(),
248            start: a.start,
249            end: a.end,
250        }
251    }
252}
253
254/// A collection of resolved grid axes.
255#[wasm_bindgen]
256pub struct GridAxisCollection {
257    axes: Vec<GridAxis3D>,
258}
259
260#[wasm_bindgen]
261impl GridAxisCollection {
262    /// Number of grid axes.
263    #[wasm_bindgen(getter)]
264    pub fn length(&self) -> usize {
265        self.axes.len()
266    }
267
268    /// Whether the collection is empty.
269    #[wasm_bindgen(getter, js_name = isEmpty)]
270    pub fn is_empty(&self) -> bool {
271        self.axes.is_empty()
272    }
273
274    /// Get the axis at `index`. Returns `undefined` for out-of-bounds index.
275    #[wasm_bindgen(js_name = getAxis)]
276    pub fn get_axis(&self, index: usize) -> Option<GridAxisJs> {
277        self.axes.get(index).map(GridAxisJs::from)
278    }
279}
280
281// ═══════════════════════════════════════════════════════════════════════════
282// IfcAPI METHODS
283// ═══════════════════════════════════════════════════════════════════════════
284
285#[wasm_bindgen]
286impl IfcAPI {
287    /// Parse the file and return every `IfcGridAxis` as a flat `Float32Array`
288    /// of 3D line-list vertices `[x0,y0,z0, x1,y1,z1, …]` (one segment per
289    /// axis) in the renderer's Y-up world space (RTC-subtracted, metres). Feed
290    /// straight to a line pipeline (e.g. `uploadAnnotationLines3D`).
291    ///
292    /// Returns an empty array when the file has no grids, so the caller can
293    /// clear the overlay cheaply.
294    #[wasm_bindgen(js_name = parseGridLines)]
295    pub fn parse_grid_lines(&self, content: String) -> js_sys::Float32Array {
296        let axes = extract_grid_axes(&content);
297        let mut verts: Vec<f32> = Vec::with_capacity(axes.len() * 6);
298        for a in &axes {
299            verts.extend_from_slice(&a.start);
300            verts.extend_from_slice(&a.end);
301        }
302        js_sys::Float32Array::from(&verts[..])
303    }
304
305    /// Parse the file and return structured per-axis data (tag + endpoints) in
306    /// the renderer's Y-up world space (RTC-subtracted, metres). Use this when
307    /// you also need the axis tags (to render grid bubbles / labels).
308    #[wasm_bindgen(js_name = parseGridAxes)]
309    pub fn parse_grid_axes(&self, content: String) -> GridAxisCollection {
310        GridAxisCollection {
311            axes: extract_grid_axes(&content),
312        }
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    // Minimal IFC4 grid: one IfcGrid (placement at origin) with a single
321    // IfcGridAxis "A" whose AxisCurve is a 2-point IfcPolyline
322    // (0,0)->(10,0), metres.
323    const LOCAL_GRID: &str = r#"ISO-10303-21;
324HEADER;
325FILE_DESCRIPTION((''),'2;1');
326FILE_NAME('','',(''),(''),'','','');
327FILE_SCHEMA(('IFC4'));
328ENDSEC;
329DATA;
330#1=IFCCARTESIANPOINT((0.,0.,0.));
331#2=IFCDIRECTION((0.,0.,1.));
332#3=IFCDIRECTION((1.,0.,0.));
333#4=IFCAXIS2PLACEMENT3D(#1,#2,#3);
334#5=IFCLOCALPLACEMENT($,#4);
335#10=IFCCARTESIANPOINT((0.,0.));
336#11=IFCCARTESIANPOINT((10.,0.));
337#12=IFCPOLYLINE((#10,#11));
338#13=IFCGRIDAXIS('A',#12,.T.);
339#20=IFCGRID('0aBcDeFgHiJkLmNoPqRsT0',$,'Grid',$,$,#5,$,(#13),$,$);
340ENDSEC;
341END-ISO-10303-21;
342"#;
343
344    #[test]
345    fn extracts_local_grid_axis() {
346        let axes = extract_grid_axes(LOCAL_GRID);
347        assert_eq!(axes.len(), 1, "expected one grid axis");
348        let a = &axes[0];
349        assert_eq!(a.tag, "A", "axis tag preserved");
350        // Start (0,0,0) → renderer (0,0,-0).
351        assert!(a.start[0].abs() < 1e-4, "start x≈0, got {}", a.start[0]);
352        assert!(a.start[1].abs() < 1e-4, "start y≈0, got {}", a.start[1]);
353        assert!(a.start[2].abs() < 1e-4, "start z≈0, got {}", a.start[2]);
354        // End (10,0,0) IFC → renderer Y-up (10, 0, -0).
355        assert!(
356            (a.end[0] - 10.0).abs() < 1e-3,
357            "end renderer-x ≈10, got {}",
358            a.end[0]
359        );
360        assert!(a.end[1].abs() < 1e-3, "end elevation ≈0, got {}", a.end[1]);
361    }
362
363    #[test]
364    fn flat_line_list_is_even_xyz_triples() {
365        // Mirror the flat line-list `parseGridLines` builds, without invoking
366        // the wasm method (js_sys types don't link on the native test target).
367        let axes = extract_grid_axes(LOCAL_GRID);
368        let mut verts: Vec<f32> = Vec::new();
369        for a in &axes {
370            verts.extend_from_slice(&a.start);
371            verts.extend_from_slice(&a.end);
372        }
373        assert!(!verts.is_empty(), "grid must emit line vertices");
374        assert_eq!(verts.len() % 3, 0, "vertices must be xyz triples");
375        assert_eq!((verts.len() / 3) % 2, 0, "line-list = even vertex count");
376        // One axis → one segment → 2 vertices → 6 floats.
377        assert_eq!(verts.len(), 6, "one axis → 6 floats");
378    }
379
380    #[test]
381    fn empty_for_no_grid() {
382        let none = "ISO-10303-21;\nHEADER;\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\nENDSEC;\nEND-ISO-10303-21;\n";
383        assert!(extract_grid_axes(none).is_empty());
384    }
385
386    #[test]
387    fn georeferenced_grid_rebased_near_origin() {
388        // Grid placement carries a ~10.4 km survey offset (metres here for
389        // simplicity); the axis point sits 10 m further along. After RTC the
390        // axis must land near the origin, not at ~10 km.
391        let content = r#"ISO-10303-21;
392HEADER;
393FILE_DESCRIPTION((''),'2;1');
394FILE_NAME('','',(''),(''),'','','');
395FILE_SCHEMA(('IFC4'));
396ENDSEC;
397DATA;
398#1=IFCCARTESIANPOINT((0.,0.,0.));
399#2=IFCDIRECTION((0.,0.,1.));
400#3=IFCDIRECTION((1.,0.,0.));
401#4=IFCAXIS2PLACEMENT3D(#1,#2,#3);
402#5=IFCLOCALPLACEMENT($,#4);
403/* a wall far out at survey coords so RTC detection trips (>10 km) */
404#6=IFCCARTESIANPOINT((10400000.,2000000.,0.));
405#7=IFCAXIS2PLACEMENT3D(#6,#2,#3);
406#8=IFCLOCALPLACEMENT($,#7);
407#9=IFCPRODUCTDEFINITIONSHAPE($,$,(#41));
408#40=IFCCARTESIANPOINT((10400000.,2000000.,0.));
409#41=IFCSHAPEREPRESENTATION($,'Body','Curve2D',(#42));
410#42=IFCPOLYLINE((#40,#40));
411#43=IFCWALL('1WaLLWaLLWaLLWaLLWaLL00',$,'W',$,$,#8,#9,$,$);
412/* grid placed at the same survey frame */
413#50=IFCCARTESIANPOINT((10400000.,2000000.,0.));
414#51=IFCAXIS2PLACEMENT3D(#50,#2,#3);
415#52=IFCLOCALPLACEMENT($,#51);
416#10=IFCCARTESIANPOINT((0.,0.));
417#11=IFCCARTESIANPOINT((10.,0.));
418#12=IFCPOLYLINE((#10,#11));
419#13=IFCGRIDAXIS('A',#12,.T.);
420#20=IFCGRID('0aBcDeFgHiJkLmNoPqRsT0',$,'Grid',$,$,#52,$,(#13),$,$);
421ENDSEC;
422END-ISO-10303-21;
423"#;
424        let axes = extract_grid_axes(content);
425        assert_eq!(axes.len(), 1, "expected one grid axis");
426        let a = &axes[0];
427        // The grid origin maps to ~origin after RTC (within a few metres of the
428        // wall sample used to detect the offset).
429        for c in a.start.iter().chain(a.end.iter()) {
430            assert!(
431                c.abs() < 1000.0,
432                "render-frame coord must be near origin after RTC, got {c}"
433            );
434        }
435    }
436}