From e0220b3dd00b9138246e416dd7c8c7fe972f3ccb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Louis=20Tr=C3=BCmpler?= <78563314+louistrue@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:25:14 +0200 Subject: [PATCH] fix(geometry): recover RTC offset when the IfcSite placement is forward-referenced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the streaming-parallel prepass the RTC (relative-to-center) offset is detected from the first ~50 geometry elements' placement chains against a partial, file-head entity index. When a georeferenced model emits the IfcSite *entity* early but its IfcAxis2Placement3D *location* — the point carrying the national-grid offset — as a high (late) express id, the element -> ... -> site chain can't resolve from the partial index and detection returns None. The full-index re-detect that recovers it was gated on `site_position.is_none()` alone, so it was skipped whenever the site entity had already been scanned. The path then fell to `scan_placement_bounds().rtc_offset()`, whose AABB centroid averages the near-origin relative placements against the lone far anchor and lands at ~half the true offset — large enough to pass the >10km shift gate but not enough to bring vertices into f32 range, so geometry shatters into sub-metre quantization noise. Re-detect against the full index whenever no placement samples resolved at all (`!detection_succeeded`), independent of site scan order. Verified end-to-end: affected georeferenced models now report the full-magnitude RTC instead of half. Adds a self-contained regression test (early IfcSite entity + forward-referenced site placement) that pins the partial-index miss / full-index recovery. --- ...streaming_rtc_early_site_late_placement.rs | 136 ++++++++++++++++++ rust/wasm-bindings/src/api/gpu_meshes.rs | 22 ++- 2 files changed, 153 insertions(+), 5 deletions(-) create mode 100644 rust/geometry/tests/streaming_rtc_early_site_late_placement.rs diff --git a/rust/geometry/tests/streaming_rtc_early_site_late_placement.rs b/rust/geometry/tests/streaming_rtc_early_site_late_placement.rs new file mode 100644 index 000000000..31c1cd867 --- /dev/null +++ b/rust/geometry/tests/streaming_rtc_early_site_late_placement.rs @@ -0,0 +1,136 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Regression for georeferenced f32 collapse on the streaming-parallel path +//! when the IfcSite *entity* is early but its placement *transform* is +//! forward-referenced LATE. +//! +//! `streaming_rtc_late_site.rs` covers the case where the whole IfcSite (entity +//! AND placement) lands past the meta-emit cut, which `gpu_meshes.rs` rescued by +//! re-detecting against a full index "when no offset was found AND the IfcSite +//! hasn't been scanned yet" (`site_position.is_none()`). +//! +//! That gate is insufficient. A national-grid IFC export commonly emits the +//! `IfcSite` record near the TOP of the file (so `site_position` is already set +//! at the meta cut) while the `IfcAxis2Placement3D` LOCATION carrying the +//! multi-megametre offset is a high express-id defined far below it. The first 50 +//! geometry elements reference that placement via PlacementRelTo, so the +//! element -> ... -> site chain still cannot resolve from the partial file-head +//! index: detection returns None. With the old gate the full-index re-detect was +//! skipped (because the site *was* scanned), and the path fell through to the +//! placement-bounds centroid — which averages the near-origin relative +//! placements against the lone far anchor and lands at ~half the true offset, +//! leaving the geometry stranded in the f32-collapse zone (~0.25 m ULP at a few +//! megametres). The browser confirms it as a `[stream] meta` RTC at ~half the +//! model's true world coordinates, so geometry sitting at ~N renders shifted to +//! ~N/2 and shatters into f32 noise. +//! +//! This pins the mechanism the corrected gate (`|| !detection_succeeded`) relies +//! on: at the meta cut the partial index MISSES the offset *even though the +//! IfcSite entity is present*, while the full index recovers it. + +use ifc_lite_core::{build_entity_index, EntityDecoder, EntityScanner, IfcType}; +use ifc_lite_geometry::GeometryRouter; + +/// A minimal, self-contained IFC where the IfcSite entity precedes the +/// meta-emit cut but its placement (and the national-grid point it carries) is +/// forward-referenced below the cut. No external fixture — always runs. +/// +/// Layout (file order): +/// #1 IfcWall -> placement #2, representation #9 (non-null) +/// #2 IfcLocalPlacement(PlacementRelTo = #4 (LATE), RelativePlacement = #3) +/// #3 IfcAxis2Placement3D(#7) -- element-local origin +/// #7 IfcCartesianPoint(0,0,0) +/// #9 IfcProductDefinitionShape -- only needs to be non-null +/// #10 IfcSite(ObjectPlacement = #4) -- site ENTITY is early +/// == meta-emit cut here == +/// #4 IfcLocalPlacement($, #5) -- site placement, LATE +/// #5 IfcAxis2Placement3D(#6) +/// #6 IfcCartesianPoint(500000, 4500000, 0) -- the offset, LATE +const IFC: &str = "\ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION((''),'2;1'); +FILE_NAME('','',(''),(''),'','',''); +FILE_SCHEMA(('IFC4')); +ENDSEC; +DATA; +#1=IFCWALL('0WALL0000000000000001',$,'W',$,$,#2,#9,$); +#2=IFCLOCALPLACEMENT(#4,#3); +#3=IFCAXIS2PLACEMENT3D(#7,$,$); +#7=IFCCARTESIANPOINT((0.,0.,0.)); +#9=IFCPRODUCTDEFINITIONSHAPE($,$,(#3)); +#10=IFCSITE('0SITE0000000000000001',$,$,$,$,#4,$,$,.ELEMENT.,$,$,0.,$,$); +#4=IFCLOCALPLACEMENT($,#5); +#5=IFCAXIS2PLACEMENT3D(#6,$,$); +#6=IFCCARTESIANPOINT((500000.0,4500000.0,0.)); +ENDSEC; +END-ISO-10303-21; +"; + +fn wall_jobs(content: &str) -> Vec<(u32, usize, usize, IfcType)> { + let mut jobs = Vec::new(); + let mut sc = EntityScanner::new(content); + while let Some((id, ty, s, e)) = sc.next_entity() { + if ty == "IFCWALL" { + jobs.push((id, s, e, IfcType::IfcWall)); + } + } + jobs +} + +#[test] +fn partial_index_misses_offset_even_with_early_site_full_index_recovers_it() { + let jobs = wall_jobs(IFC); + assert_eq!(jobs.len(), 1, "fixture must yield exactly one wall job"); + + // The streaming meta is emitted right after the sample threshold of geometry + // jobs is buffered, against the partial index built up to that scan point. + // Cut the content just before the late site placement (#4) is defined. + let cut = IFC.find("#4=IFCLOCALPLACEMENT").expect("site placement marker"); + let partial = &IFC[..cut]; + + // Distinguishing trait vs. ISSUE_129: the IfcSite ENTITY is already present + // at the cut, so the old `site_position.is_none()` gate would (wrongly) + // conclude "site scanned, no shift needed" and skip the full-index re-detect. + assert!( + partial.contains("IFCSITE"), + "the IfcSite entity must precede the meta cut for this regression" + ); + assert!( + !partial.contains("#4=IFCLOCALPLACEMENT"), + "the site PLACEMENT must be beyond the cut (forward reference)" + ); + + let is_large = + |t: (f64, f64, f64)| t.0.abs() > 10000.0 || t.1.abs() > 10000.0 || t.2.abs() > 10000.0; + let router = GeometryRouter::with_scale(1.0); // metres + + // Partial index at the meta cut: the forward-referenced site placement is + // unreachable, so the element placement chain cannot resolve and detection + // returns None (NOT a small "no shift" — the chain genuinely failed). + let partial_index = build_entity_index(partial); + let mut partial_decoder = EntityDecoder::with_index(partial, partial_index); + let partial_rtc = router.detect_rtc_offset_from_jobs(&jobs, &mut partial_decoder); + assert!( + partial_rtc.is_none() || !is_large(partial_rtc.unwrap()), + "partial index must MISS the late offset (the bug); got {partial_rtc:?}" + ); + + // Full index (the fix path): the complete wall -> site chain resolves, so the + // national-grid offset is recovered and a shift is applied. + let full_index = build_entity_index(IFC); + let mut full_decoder = EntityDecoder::with_index(IFC, full_index); + let full_rtc = router + .detect_rtc_offset_from_jobs(&jobs, &mut full_decoder) + .expect("full-index detection returns an offset"); + assert!( + is_large(full_rtc), + "full-index detection must recover the forward-referenced offset; got {full_rtc:?}" + ); + assert!( + (full_rtc.0 - 500000.0).abs() < 1.0 && (full_rtc.1 - 4500000.0).abs() < 1.0, + "recovered offset must match the site placement, not a half-magnitude centroid; got {full_rtc:?}" + ); +} diff --git a/rust/wasm-bindings/src/api/gpu_meshes.rs b/rust/wasm-bindings/src/api/gpu_meshes.rs index 6ff85f431..f4c8c3920 100644 --- a/rust/wasm-bindings/src/api/gpu_meshes.rs +++ b/rust/wasm-bindings/src/api/gpu_meshes.rs @@ -389,13 +389,25 @@ impl IfcAPI { // model), the element -> storey -> building -> site chain can't // resolve from the partial index, detection returns (0,0,0), and the // huge ~8e6 m world coordinates get cast to f32 downstream → ~0.5 m - // of vertex jitter. If no offset was found AND we haven't even - // scanned the IfcSite yet, re-detect against a FULL index so the - // complete chain resolves. Gated on both so the common early-site / - // origin-local model never pays for a second index build. + // of vertex jitter. Re-detect against a FULL index when no large + // offset was found AND either (a) we haven't scanned the IfcSite + // yet, or (b) the partial index resolved NO usable placement + // samples at all. Case (b) covers the inverse ordering: the + // IfcSite *entity* is early (so `site_position` is already set) but + // its IfcAxis2Placement3D *location* — where the national-grid + // offset actually lives — is forward-referenced past the file head, + // so the element→storey→building→site chain still can't resolve and + // detection returns None. Gating on `!detection_succeeded` instead + // of site scan order alone keeps the common early-site model that + // DID resolve a (0,0,0) "no shift" from paying for a second index + // build, while rescuing the forward-referenced-placement case that + // otherwise fell through to the placement-bounds centroid — which + // averages the near-origin relative placements against the lone far + // anchor and lands at ~half the true offset, leaving geometry + // stranded in the f32-collapse zone. // (`buildPrePassOnce` and the small-file tail already use a full // index, so only this early-meta path needs the fallback.) - if !is_large(rtc_offset) && site_position.is_none() { + if !is_large(rtc_offset) && (site_position.is_none() || !detection_succeeded) { let full_index = ifc_lite_core::build_entity_index(content); let mut full_decoder = EntityDecoder::with_index(content, full_index); if let Some(full_rtc) =