Skip to main content

ifc_lite_processing/
stream_meta.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//! Shared streaming pre-pass meta resolution.
6//!
7//! The browser pre-passes (`buildPrePassOnce` / `buildPrePassStreaming` in
8//! `wasm-bindings`) each need the same bundle of load-time metadata before
9//! workers can start meshing: the length/plane-angle unit scales, the RTC
10//! (relative-to-centre) frame, and the building rotation from `IfcSite`.
11//! This module is the single home for that resolution logic so the three
12//! call sites can no longer drift.
13//!
14//! Only the RESOLUTION logic lives here — the wasm side keeps ownership of
15//! WHEN the resulting [`StreamMeta`] is emitted. In particular the streaming
16//! pre-pass still emits its `meta` event MID-SCAN (as soon as
17//! `META_EMIT_JOB_THRESHOLD` geometry jobs are buffered, near the top of the
18//! file) so workers spin up early — the ~17 s → ~3 s time-to-first-geometry
19//! win on a 986 MB file. This helper does not change that timing; it only
20//! factors out the two-vs-three-stage RTC ladder that the two emission sites
21//! previously copied.
22//!
23//! Everything here COMPOSES the existing canonical primitives:
24//! [`resolve_unit_scales`](crate::prepass::resolve_unit_scales),
25//! [`EntityDecoder::seed_unit_scales`],
26//! [`GeometryRouter::with_scale`],
27//! [`GeometryRouter::detect_rtc_anchor_for_file`],
28//! [`GeometryRouter::detect_rtc_offset_for_file`], the shared
29//! [`coord_is_large`](ifc_lite_core::limits::coord_is_large) predicate inside
30//! the ladder, and [`MeshFrame::select`] for the frame the ladder's answer
31//! turns into.
32
33use crate::mesh_frame::MeshFrame;
34use ifc_lite_core::limits::coord_is_large;
35use ifc_lite_core::EntityDecoder;
36use ifc_lite_geometry::GeometryRouter;
37
38/// Which RTC-detection ladder [`resolve_stream_meta`] should run.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum MetaMode {
41    /// Streaming early-meta: the caller's `decoder` sees only a PARTIAL entity
42    /// index (the file head scanned so far), so RTC detection runs the 3-stage
43    /// fallback ladder — partial-index detect → full-index re-detect (triggered
44    /// when no large offset was found AND either the `IfcSite` has not been
45    /// scanned yet OR the partial index resolved no usable placement chain) →
46    /// placement-bounds last resort — instead of silently defaulting to
47    /// no-shift and rendering f32 vertex jitter on models whose world offset
48    /// lives in late spatial placements.
49    ///
50    /// `scanned_through` is how far into `content` the caller's index reaches,
51    /// as a byte offset at a record boundary. Stage 1 samples only that head,
52    /// because nothing past it can resolve a placement chain against a partial
53    /// index: sampling the whole file there would decode every entity in the
54    /// tail, have all of them abstain, and add a full-file walk (measured
55    /// 235 ms on a 343 MB fixture; 7 of 7 fixtures walked to EOF and none of
56    /// them contributed a sample) to the mid-scan emission that exists to keep
57    /// time-to-first-geometry short.
58    StreamingPartial { scanned_through: usize },
59    /// The caller's `decoder` already sees the FULL entity index (the
60    /// small-file streaming tail, or the single-pass `buildPrePassOnce`), so a
61    /// single [`GeometryRouter::detect_rtc_offset_for_file`] is correct.
62    SmallFileSingle,
63}
64
65/// The load-time metadata both pre-passes emit before workers start meshing.
66#[derive(Debug, Clone, Copy, PartialEq)]
67pub struct StreamMeta {
68    /// IFC length unit → metres.
69    pub length_unit_scale: f64,
70    /// IFC plane-angle unit → radians.
71    pub plane_angle_to_radians: f64,
72    /// The frame the workers mesh into. Carries the offset and the
73    /// needs-shift decision as one value (see [`MeshFrame`]); the wire fields
74    /// `rtcOffset` and `needsShift` are read off it.
75    pub frame: MeshFrame,
76    /// Z-rotation of the `IfcSite` placement, if any.
77    pub building_rotation: Option<f64>,
78}
79
80/// Resolve the full [`StreamMeta`] bundle for one pre-pass emission point.
81///
82/// Seeds the caller's `decoder` with the resolved unit scales (so nothing
83/// downstream re-pays the `IFCPROJECT` hunt) and leaves it seeded on return.
84/// The caller owns emission — this only computes.
85///
86/// Takes no job list: the RTC sample window is the file's, not the caller's
87/// (see [`GeometryRouter::detect_rtc_offset_for_file`], #4611). `mode` selects
88/// the ladder, which is about what the caller's `decoder` can RESOLVE, not
89/// about which entities it wants sampled.
90pub fn resolve_stream_meta(
91    mode: MetaMode,
92    content: &[u8],
93    project_id: Option<u32>,
94    site_position: Option<(u32, usize, usize)>,
95    decoder: &mut EntityDecoder,
96) -> StreamMeta {
97    // Unit scales via the shared resolver (handles a missing project-id hint
98    // and partial-index chains internally), then seed the decoder.
99    let unit_scales = crate::prepass::resolve_unit_scales(content, project_id, decoder);
100    let length_unit_scale = unit_scales.length_unit_scale;
101    decoder.seed_unit_scales(length_unit_scale, unit_scales.plane_angle_to_radians);
102
103    // Not drained: meshes nothing. Pinned by rust/geometry/tests/issue_3821_auxiliary_routers_mesh_nothing.rs.
104    let router = GeometryRouter::with_scale(length_unit_scale);
105
106    let detected = match mode {
107        MetaMode::StreamingPartial { scanned_through } => {
108            resolve_partial_rtc(&router, content, scanned_through, site_position, decoder)
109        }
110        MetaMode::SmallFileSingle => router.detect_rtc_offset_for_file(content, decoder),
111    };
112    // No site tier here: the browser meshes in world axes and reports the
113    // site rotation separately as `building_rotation`. The native pipeline
114    // passes the site translation to the same selector, and that one argument
115    // is the whole difference between the two frames.
116    let frame = MeshFrame::select(None, detected);
117
118    let building_rotation =
119        site_position.and_then(|pos| resolve_building_rotation(pos, &router, decoder));
120
121    StreamMeta {
122        length_unit_scale,
123        plane_angle_to_radians: unit_scales.plane_angle_to_radians,
124        frame,
125        building_rotation,
126    }
127}
128
129/// The streaming early-meta 3-stage RTC ladder against a PARTIAL index.
130///
131/// 1. Detect over the canonical window restricted to the scanned head, which is
132///    all a partial index can resolve placements in. Still file order and still
133///    not the caller's job list (#4611); the head is a fact about the index, not
134///    about the schedule.
135/// 2. If no large offset was found AND either the `IfcSite` hasn't been
136///    scanned yet OR the partial index resolved NO usable placement samples,
137///    re-detect against a freshly built FULL index. A successful "no shift"
138///    (0,0,0) that DID resolve samples must not pay for this.
139/// 3. Last resort: only when NO detection (partial or full) found any usable
140///    placement translation, fall back to the raw placement-bounds scan
141///    (unit-scaled to metres).
142///
143/// `None` only when every stage came back without a coordinate to judge;
144/// `Some(RtcVerdict::Small)` is a detection that resolved samples and
145/// concluded "no shift", which the later stages must not override.
146///
147/// Mirrors the server needs-shift decision so a browser and the native
148/// pipeline re-base a given model identically.
149fn resolve_partial_rtc(
150    router: &GeometryRouter,
151    content: &[u8],
152    scanned_through: usize,
153    site_position: Option<(u32, usize, usize)>,
154    decoder: &mut EntityDecoder,
155) -> Option<ifc_lite_core::RtcVerdict> {
156    let head = &content[..scanned_through.min(content.len())];
157    let mut rtc_offset = router.detect_rtc_anchor_for_file(head, decoder);
158    let found_large = rtc_offset.is_some_and(coord_is_large);
159
160    if !found_large && (site_position.is_none() || rtc_offset.is_none()) {
161        let full_index = crate::build_entity_index_parallel(content);
162        let mut full_decoder = EntityDecoder::with_index(content, full_index);
163        if let Some(full_rtc) = router.detect_rtc_anchor_for_file(content, &mut full_decoder) {
164            // The full index resolved the placement chain: a successful
165            // detection whether it shifts (large) or not. It replaces a
166            // partial-pass "no data", and a partial-pass "no shift" only when
167            // the full pass found the shift the partial index could not see.
168            if coord_is_large(full_rtc) || rtc_offset.is_none() {
169                rtc_offset = Some(full_rtc);
170            }
171        }
172    }
173
174    // Stage 3 spells the bounds arm that `detect_rtc_offset_for_file` also has,
175    // deliberately: the ladder must run its full-index re-detect BETWEEN the
176    // sampler and the bounds scan, and folding the two together would let a
177    // full-pass abstention reach the bounds scan even when stage 1 had already
178    // resolved a usable "no shift".
179    rtc_offset.map(ifc_lite_core::RtcVerdict::of_anchor).or_else(|| {
180        // scan_placement_bounds reads raw IfcCartesianPoint values (FILE
181        // units); rtc_offset applies the unit scale before the 10 km gate.
182        ifc_lite_core::scan_placement_bounds(content).rtc_offset(router.unit_scale())
183    })
184}
185
186/// Building rotation = Z-rotation of the `IfcSite` scaled placement, composing
187/// the router's placement resolution with the shared rotation extractor.
188fn resolve_building_rotation(
189    site_pos: (u32, usize, usize),
190    router: &GeometryRouter,
191    decoder: &mut EntityDecoder,
192) -> Option<f64> {
193    let (site_id, start, end) = site_pos;
194    let site_entity = decoder.decode_at_with_id(site_id, start, end).ok()?;
195    let matrix = router.resolve_scaled_placement(&site_entity, decoder).ok()?;
196    ifc_lite_geometry::rotation_angle_about_z(&matrix)
197}
198
199#[cfg(test)]
200#[path = "stream_meta_tests.rs"]
201mod tests;