Skip to main content

ifc_lite_wasm/api/
alignment_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//! IfcAlignment centerline extraction for the 3D viewport.
6//!
7//! IfcAlignment carries its geometry in the `Axis` curve (an
8//! `IfcAlignmentCurve` or an `IfcPolyline`), not a `Representation`. Rather
9//! than render it as a triangulated ribbon mesh — which reads as a thin solid
10//! strip and not the thin LINE users expect (matching IfcGrid axes and
11//! IfcAnnotation curves) — we sample the alignment directrix into a flat
12//! line-list vertex buffer and feed it through the renderer's existing
13//! `uploadAnnotationLines3D` line pipeline.
14//!
15//! The output is `[x0,y0,z0, x1,y1,z1, …]` line-list pairs in the renderer's
16//! **Y-up, RTC-subtracted, metres** world space — the exact frame the mesh
17//! pipeline produces after its IFC Z-up → WebGL Y-up swap (see
18//! `MeshDataJs::new` in `zero_copy.rs`), so alignment lines land on the same
19//! ground as the terrain meshes.
20
21use super::IfcAPI;
22use ifc_lite_core::{
23    build_entity_index, extract_length_unit_scale, EntityDecoder, EntityScanner, IfcType,
24};
25use ifc_lite_geometry::{AlignmentCurve, GeometryRouter};
26use wasm_bindgen::prelude::*;
27
28/// Station spacing for centerline sampling, in file length units. Mirrors the
29/// (now-removed) ribbon processor: 1 unit ≈ 1 m for metre files, with a hard
30/// sample cap so sub-metre-unit files on long alignments fall back to a
31/// coarser, length-proportional step instead of emitting millions of points.
32const SAMPLE_STEP_FILE_UNITS: f64 = 1.0;
33const MAX_SAMPLES: usize = 5_000;
34
35#[wasm_bindgen]
36impl IfcAPI {
37    /// Parse the file and return every `IfcAlignment` directrix as a flat
38    /// `Float32Array` of 3D line-list vertices `[x0,y0,z0, x1,y1,z1, …]` in
39    /// the renderer's Y-up world space (RTC-subtracted, metres). Consecutive
40    /// samples form line segments. Feed straight to
41    /// `renderer.uploadAnnotationLines3D(...)`.
42    ///
43    /// Returns an empty array when the file has no alignments (or none with a
44    /// resolvable Axis curve), so the caller can clear the overlay cheaply.
45    #[wasm_bindgen(js_name = parseAlignmentLines)]
46    pub fn parse_alignment_lines(&self, content: String) -> js_sys::Float32Array {
47        let verts = extract_alignment_line_vertices(&content);
48        js_sys::Float32Array::from(&verts[..])
49    }
50}
51
52/// Pure-Rust core (unit-testable without wasm-bindgen).
53pub(crate) fn extract_alignment_line_vertices(content: &str) -> Vec<f32> {
54    let entity_index = build_entity_index(content);
55    let mut decoder = EntityDecoder::with_index(content, entity_index);
56
57    // Unit scale (file units → metres) resolved the same way the mesh
58    // pipeline does, so the alignment shares the model's scale.
59    let mut project_scanner = EntityScanner::new(content);
60    let mut unit_scale = 1.0_f64;
61    while let Some((id, type_name, _, _)) = project_scanner.next_entity() {
62        if type_name == "IFCPROJECT" {
63            if let Ok(s) = extract_length_unit_scale(&mut decoder, id) {
64                unit_scale = s;
65            }
66            break;
67        }
68    }
69
70    // RTC offset (metres) — `detect_rtc_offset_from_first_element` returns
71    // (0,0,0) for models within 10 km of the origin, so this is a no-op for
72    // local files and a true shift for georeferenced infrastructure.
73    let router = GeometryRouter::with_scale(unit_scale);
74    let rtc = router.detect_rtc_offset_from_first_element(content, &mut decoder);
75
76    let mut out: Vec<f32> = Vec::new();
77    let mut scanner = EntityScanner::new(content);
78    while let Some((id, type_name, start, end)) = scanner.next_entity() {
79        if type_name != "IFCALIGNMENT" {
80            continue;
81        }
82        let Ok(entity) = decoder.decode_at_with_id(id, start, end) else {
83            continue;
84        };
85        let Some(axis) = locate_axis_curve(&entity, &mut decoder) else {
86            continue;
87        };
88        let Ok(Some(alignment)) = AlignmentCurve::parse(&axis, &mut decoder) else {
89            continue;
90        };
91        append_alignment_segments(&alignment, unit_scale, rtc, &mut out);
92    }
93    out
94}
95
96/// Sample one alignment's centerline and append its line-list segments to
97/// `out`, in renderer Y-up / RTC-subtracted / metres space.
98fn append_alignment_segments(
99    alignment: &AlignmentCurve,
100    unit_scale: f64,
101    rtc: (f64, f64, f64),
102    out: &mut Vec<f32>,
103) {
104    let length = alignment.horizontal_length();
105    if !(length.is_finite() && length > 0.0) {
106        return;
107    }
108
109    let raw_count = ((length / SAMPLE_STEP_FILE_UNITS).ceil() as usize).max(1);
110    let (step, count) = if raw_count > MAX_SAMPLES {
111        (length / MAX_SAMPLES as f64, MAX_SAMPLES + 1)
112    } else {
113        (SAMPLE_STEP_FILE_UNITS, raw_count + 1)
114    };
115
116    // Collect sampled vertices in renderer space.
117    let mut pts: Vec<[f32; 3]> = Vec::with_capacity(count);
118    for i in 0..count {
119        let station = (i as f64 * step).min(length);
120        let o = alignment.evaluate(station).origin;
121        // file units → metres
122        let mx = o.x * unit_scale - rtc.0;
123        let my = o.y * unit_scale - rtc.1;
124        let mz = o.z * unit_scale - rtc.2;
125        // IFC Z-up → WebGL Y-up: (x, z, -y). Matches MeshDataJs::new so the
126        // line lands on the same ground as the terrain meshes.
127        pts.push([mx as f32, mz as f32, -my as f32]);
128    }
129
130    // Emit as a line-list: each adjacent pair is one segment.
131    for w in pts.windows(2) {
132        out.extend_from_slice(&w[0]);
133        out.extend_from_slice(&w[1]);
134    }
135}
136
137/// Resolve an `IfcAlignment`'s directrix curve. IFC4X1 puts `Axis` at
138/// attribute 7; some publishers reuse `Representation` (6) or hang it at 8.
139/// Accept the first ref that resolves to an `IfcAlignmentCurve` or
140/// `IfcPolyline` (the two `AlignmentCurve::parse` understands).
141fn locate_axis_curve(
142    entity: &ifc_lite_core::DecodedEntity,
143    decoder: &mut EntityDecoder,
144) -> Option<ifc_lite_core::DecodedEntity> {
145    let alignment_curve = IfcType::from_str("IFCALIGNMENTCURVE");
146    for idx in [7usize, 8, 6] {
147        let Some(attr) = entity.get(idx) else { continue };
148        if attr.is_null() {
149            continue;
150        }
151        if let Ok(Some(resolved)) = decoder.resolve_ref(attr) {
152            if resolved.ifc_type == alignment_curve || resolved.ifc_type == IfcType::IfcPolyline {
153                return Some(resolved);
154            }
155        }
156    }
157    None
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    // Minimal IFC4X1 alignment: IfcAlignment whose Axis (attr 7) is a
165    // 3-point IfcPolyline directrix (0,0,0)->(10,0,0)->(10,10,0), metres.
166    const CONTENT: &str = r#"ISO-10303-21;
167HEADER;
168FILE_DESCRIPTION((''),'2;1');
169FILE_NAME('','',(''),(''),'','','');
170FILE_SCHEMA(('IFC4X1'));
171ENDSEC;
172DATA;
173#1=IFCCARTESIANPOINT((0.,0.,0.));
174#2=IFCCARTESIANPOINT((10.,0.,0.));
175#3=IFCCARTESIANPOINT((10.,10.,0.));
176#4=IFCPOLYLINE((#1,#2,#3));
177#10=IFCALIGNMENT('0aBcDeFgHiJkLmNoPqRsT0',$,'Test Alignment',$,$,$,$,#4,$);
178ENDSEC;
179END-ISO-10303-21;
180"#;
181
182    #[test]
183    fn emits_line_list_for_polyline_alignment() {
184        let verts = extract_alignment_line_vertices(CONTENT);
185        assert!(!verts.is_empty(), "alignment must emit centerline vertices");
186        // Flat [x,y,z] triples, even count of vertices (line-list pairs).
187        assert_eq!(verts.len() % 3, 0, "vertices must be xyz triples");
188        assert_eq!((verts.len() / 3) % 2, 0, "line-list = even vertex count");
189
190        // First sample is the directrix start (0,0,0) → renderer (0,0,-0).
191        assert!(verts[0].abs() < 1e-4, "start x≈0, got {}", verts[0]);
192        assert!(verts[1].abs() < 1e-4, "start y(elev)≈0, got {}", verts[1]);
193        assert!(verts[2].abs() < 1e-4, "start z≈0, got {}", verts[2]);
194
195        // The 20 m polyline lies in the plan (z_ifc = 0) so every renderer-Y
196        // (elevation) must stay 0, and the path must span ~10 m in renderer X
197        // and ~10 m in renderer Z (plan Y, negated).
198        let mut max_x = f32::MIN;
199        let mut max_abs_z = 0.0_f32;
200        for v in verts.chunks_exact(3) {
201            assert!(v[1].abs() < 1e-3, "planar alignment elevation must be ~0");
202            max_x = max_x.max(v[0]);
203            max_abs_z = max_abs_z.max(v[2].abs());
204        }
205        assert!((max_x - 10.0).abs() < 0.5, "max renderer-x ≈10, got {max_x}");
206        assert!((max_abs_z - 10.0).abs() < 0.5, "max |renderer-z| ≈10, got {max_abs_z}");
207    }
208
209    #[test]
210    fn empty_for_no_alignment() {
211        let none = "ISO-10303-21;\nHEADER;\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\nENDSEC;\nEND-ISO-10303-21;\n";
212        assert!(extract_alignment_line_vertices(none).is_empty());
213    }
214}