/* 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/. */ /** * Georeferencing / federation alignment helpers. * * Extracted verbatim from useIfcFederation.ts so the unified model-load path * (useIfcLoader's finalizeModel) can reuse them without a circular dependency. * Behaviour-preserving move — do not change the georef maths or the issue-#595 / * issue-#658 comments, which encode subtle alignment behaviour. */ import { type IfcDataStore, type MapConversion, type ProjectedCRS, } from '@ifc-lite/parser'; import type { CoordinateInfo } from '@ifc-lite/geometry'; import { useViewerStore, type FederatedModel } from '../../store/index.js'; import { getEffectiveGeoreference, getEffectiveHorizontalScale, hasStandardGeoreferencing, type GeorefMutationDataLike } from '../../lib/geo/effective-georef.js'; import { resolveMapUnitToMetreScale } from '../../lib/geo/geo-scale.js'; import { resolveProjection } from '../../lib/geo/reproject.js'; import proj4 from 'proj4'; type FederatedGeometryResult = NonNullable; export interface ModelGeoref { mapConversion: MapConversion; projectedCRS: ProjectedCRS; lengthUnitScale: number; coordinateInfo?: CoordinateInfo; } interface AffineTransform3D { m00: number; m01: number; m02: number; tx: number; m10: number; m11: number; m12: number; ty: number; m20: number; m21: number; m22: number; tz: number; } function getMapUnitScale(georef: ModelGeoref): number { return resolveMapUnitToMetreScale(georef.projectedCRS.mapUnitScale, georef.lengthUnitScale ?? 1); } function getAxis(georef: ModelGeoref): { a: number; o: number; scale: number; denom: number } { const conversion = georef.mapConversion; const a = conversion.xAxisAbscissa ?? 1; const o = conversion.xAxisOrdinate ?? 0; // Use the effective horizontal scale: viewer geometry is already in metres, // so applying IfcMapConversion.Scale raw would double-scale — see issue #595. const mapUnitScale = resolveMapUnitToMetreScale(georef.projectedCRS.mapUnitScale, georef.lengthUnitScale ?? 1); const scale = getEffectiveHorizontalScale(conversion.scale, mapUnitScale, georef.lengthUnitScale ?? 1); const denom = Math.max(a * a + o * o, 1e-12); return { a, o, scale, denom }; } export function extractModelGeoref( dataStore: IfcDataStore, coordinateInfo?: CoordinateInfo, mutations?: GeorefMutationDataLike, ): ModelGeoref | null { const georef = getEffectiveGeoreference(dataStore, coordinateInfo, mutations); // Only TRUE georeferencing (real IfcMapConversion + IfcProjectedCRS) may drive // federation alignment. A file with no IfcMapConversion gets a synthesised // `source: 'siteLocation'` georef (EPSG:4326 from IfcSite RefLatitude/Longitude/ // Elevation) so it can still be pinned on the location map — but those are // geographic degrees plus a raw, un-unit-scaled site elevation, not a projected // metric frame. buildGeorefAlignmentTransform assumes projected eastings/ // northings/height in metres, so feeding it site data places the second model // kilometres away: the BIMcollab ARC/STR pair share a site GUID but carry // RefElevation 0 vs 20000 mm, and the height term lands ARC ~20 km below STR. // Such models have no real georef relationship, so leave them in their own local // frames where they overlay correctly. hasStandardGeoreferencing() excludes // 'siteLocation' (see effective-georef.test.ts). (Regression from #658.) if (!hasStandardGeoreferencing(georef) || !georef?.mapConversion || !georef.projectedCRS?.name) { return null; } return { mapConversion: georef.mapConversion, projectedCRS: georef.projectedCRS, lengthUnitScale: georef.lengthUnitScale, coordinateInfo, }; } function crsKey(crs: ProjectedCRS): string { return `${crs.name ?? ''}|${crs.geodeticDatum ?? ''}|${crs.mapProjection ?? ''}|${crs.mapZone ?? ''}`.toUpperCase(); } function canAlignInSameProjectedCrs(a: ModelGeoref, b: ModelGeoref): boolean { return crsKey(a.projectedCRS) === crsKey(b.projectedCRS); } function totalYupOffset(coordinateInfo?: CoordinateInfo): { x: number; y: number; z: number } { const shift = coordinateInfo?.originShift ?? { x: 0, y: 0, z: 0 }; const rtc = coordinateInfo?.wasmRtcOffset; const rtcYup = rtc ? { x: rtc.x, y: rtc.z, z: -rtc.y } : { x: 0, y: 0, z: 0 }; return { x: shift.x + rtcYup.x, y: shift.y + rtcYup.y, z: shift.z + rtcYup.z, }; } function emptyBounds() { return { min: { x: Infinity, y: Infinity, z: Infinity }, max: { x: -Infinity, y: -Infinity, z: -Infinity }, }; } function zeroBounds() { return { min: { x: 0, y: 0, z: 0 }, max: { x: 0, y: 0, z: 0 }, }; } function updateBounds(bounds: ReturnType, x: number, y: number, z: number): boolean { if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return false; bounds.min.x = Math.min(bounds.min.x, x); bounds.min.y = Math.min(bounds.min.y, y); bounds.min.z = Math.min(bounds.min.z, z); bounds.max.x = Math.max(bounds.max.x, x); bounds.max.y = Math.max(bounds.max.y, y); bounds.max.z = Math.max(bounds.max.z, z); return true; } function buildGeorefAlignmentTransform(source: ModelGeoref, reference: ModelGeoref): AffineTransform3D | null { const sourceConv = source.mapConversion; const refConv = reference.mapConversion; const sourceAxis = getAxis(source); const refAxis = getAxis(reference); const refDenom = refAxis.scale * refAxis.denom; if (Math.abs(refDenom) < 1e-12) return null; const sourceMapUnitScale = getMapUnitScale(source); const refMapUnitScale = getMapUnitScale(reference); const sourceOffset = totalYupOffset(source.coordinateInfo); const refOffset = totalYupOffset(reference.coordinateInfo); const eVx = sourceAxis.scale * sourceAxis.a; const eVz = sourceAxis.scale * sourceAxis.o; const eC = sourceConv.eastings * sourceMapUnitScale + sourceAxis.scale * (sourceAxis.a * sourceOffset.x + sourceAxis.o * sourceOffset.z) - refConv.eastings * refMapUnitScale; const nVx = sourceAxis.scale * sourceAxis.o; const nVz = -sourceAxis.scale * sourceAxis.a; const nC = sourceConv.northings * sourceMapUnitScale + sourceAxis.scale * (sourceAxis.o * sourceOffset.x - sourceAxis.a * sourceOffset.z) - refConv.northings * refMapUnitScale; const hC = sourceConv.orthogonalHeight * sourceMapUnitScale + sourceOffset.y - refConv.orthogonalHeight * refMapUnitScale; const invRefDenom = 1 / refDenom; const xVx = (refAxis.a * eVx + refAxis.o * nVx) * invRefDenom; const xVz = (refAxis.a * eVz + refAxis.o * nVz) * invRefDenom; const xC = (refAxis.a * eC + refAxis.o * nC) * invRefDenom - refOffset.x; const yVx = (-refAxis.o * eVx + refAxis.a * nVx) * invRefDenom; const yVz = (-refAxis.o * eVz + refAxis.a * nVz) * invRefDenom; // NOTE: the refOffset handling is intentionally asymmetric between X and Z and // must NOT be "symmetrised". refOffset is subtracted from the FINAL viewer // coordinate on every axis. X maps positively (`tx = +xC`), so its offset is // folded into xC above. Z maps to the NEGATED north axis (`tz = -yC`), so its // offset is applied after the negation, leaving yC offset-free here. This // matches alignGeometryAcrossCrs: alignedZ = refWorldZ - refOffset.z with // refWorldZ = -ifcYr. Folding -refOffset.z into yC would flip its sign. const yC = (-refAxis.o * eC + refAxis.a * nC) * invRefDenom; return { m00: xVx, m01: 0, m02: xVz, tx: xC, m10: 0, m11: 1, m12: 0, ty: hC - refOffset.y, m20: -yVx, m21: 0, m22: -yVz, tz: -yC - refOffset.z, }; } function isIdentityTransform(transform: AffineTransform3D): boolean { const eps = 1e-7; return Math.abs(transform.m00 - 1) < eps && Math.abs(transform.m01) < eps && Math.abs(transform.m02) < eps && Math.abs(transform.tx) < eps && Math.abs(transform.m10) < eps && Math.abs(transform.m11 - 1) < eps && Math.abs(transform.m12) < eps && Math.abs(transform.ty) < eps && Math.abs(transform.m20) < eps && Math.abs(transform.m21) < eps && Math.abs(transform.m22 - 1) < eps && Math.abs(transform.tz) < eps; } function applyAlignmentTransformAndUpdateBounds( geometry: FederatedGeometryResult, transform: AffineTransform3D, referenceInfo?: CoordinateInfo, ): void { const bounds = emptyBounds(); let found = false; for (const mesh of geometry.meshes) { const positions = mesh.positions; // Positions may be in the element's local frame (world = origin + position) // on the wasm path. Fold the per-mesh origin into the world coord BEFORE the // alignment affine; the result is written as absolute reference-frame coords // and the stale origin is cleared below (else the renderer's model-matrix // translate would double-count it). No-op when origin is absent/[0,0,0]. const o = mesh.origin; const ox = o ? o[0] : 0, oy = o ? o[1] : 0, oz = o ? o[2] : 0; for (let i = 0; i < positions.length; i += 3) { const x = positions[i] + ox; const y = positions[i + 1] + oy; const z = positions[i + 2] + oz; if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) { continue; } const alignedX = transform.m00 * x + transform.m01 * y + transform.m02 * z + transform.tx; const alignedY = transform.m10 * x + transform.m11 * y + transform.m12 * z + transform.ty; const alignedZ = transform.m20 * x + transform.m21 * y + transform.m22 * z + transform.tz; positions[i] = alignedX; positions[i + 1] = alignedY; positions[i + 2] = alignedZ; found = updateBounds(bounds, alignedX, alignedY, alignedZ) || found; } // Positions are now absolute in the reference viewer frame; drop the stale // local-frame origin so downstream consumers don't re-add it. if (o) mesh.origin = [0, 0, 0]; // Rotate normals by the transform's 3×3 linear part (translation omitted) // and renormalize. CRS alignment is a rigid rotation, so the linear part // itself is the correct transform for normals; degenerate results from // zero-length or non-finite inputs are left in place. const normals = mesh.normals; if (normals && normals.length >= 3) { for (let i = 0; i < normals.length; i += 3) { const nx = normals[i]; const ny = normals[i + 1]; const nz = normals[i + 2]; if (!Number.isFinite(nx) || !Number.isFinite(ny) || !Number.isFinite(nz)) { continue; } const rx = transform.m00 * nx + transform.m01 * ny + transform.m02 * nz; const ry = transform.m10 * nx + transform.m11 * ny + transform.m12 * nz; const rz = transform.m20 * nx + transform.m21 * ny + transform.m22 * nz; const len = Math.sqrt(rx * rx + ry * ry + rz * rz); if (!Number.isFinite(len) || len < 1e-12) { continue; } normals[i] = rx / len; normals[i + 1] = ry / len; normals[i + 2] = rz / len; } } } geometry.coordinateInfo = { originShift: referenceInfo?.originShift ?? { x: 0, y: 0, z: 0 }, originalBounds: found ? bounds : zeroBounds(), shiftedBounds: found ? bounds : zeroBounds(), hasLargeCoordinates: referenceInfo?.hasLargeCoordinates ?? false, wasmRtcOffset: referenceInfo?.wasmRtcOffset, buildingRotation: referenceInfo?.buildingRotation, }; } /** * Reproject every vertex from a source model's georeference into the reference * model's viewer-space frame using proj4 between the two projected CRSs. * * Used for federated loads where models declare different IfcProjectedCRSs * (e.g. EPSG:28992 + EPSG:7415 mixed RD/NAP Dutch sets, or EPSG:25831 UTM + * EPSG:28992 mixed). The pipeline per vertex: * * viewer(Yup) ──(source RTC/shift, axis swap)──▶ IFC(Zup, source) * IFC(source) ──(source MapConversion)──────────▶ source projected (eS,nS,hS) * projected ──(proj4: srcDef → refDef)────────▶ reference projected (eR,nR) * projected ──(reference MapConversion inverse)▶ IFC(Zup, reference) * IFC(ref) ──(axis swap, reference RTC/shift)─▶ viewer(Yup, reference frame) * * Vertical: height passes through unchanged. Browser-side proj4 has no vertical * datum transforms (no NTv2/gtx grids), so cross-CRS vertical mismatches are * left for the user to resolve via the per-model orthogonalHeight editor. * * Normals are NOT rotated. Cross-CRS rotations between projected systems in the * same locality are sub-degree, and recomputing per-vertex would require a * Jacobian per mesh — acceptable trade-off for now, document if it bites. */ async function alignGeometryAcrossCrs( geometry: FederatedGeometryResult, source: ModelGeoref, reference: ModelGeoref, ): Promise { const sourceProjDef = await resolveProjection(source.projectedCRS); const refProjDef = await resolveProjection(reference.projectedCRS); if (!sourceProjDef || !refProjDef) return false; const sourceMapUnitScale = getMapUnitScale(source); const refMapUnitScale = getMapUnitScale(reference); const sourceAxis = getAxis(source); const refAxis = getAxis(reference); const sourceOffset = totalYupOffset(source.coordinateInfo); const refOffset = totalYupOffset(reference.coordinateInfo); const refDenom = refAxis.scale * refAxis.denom; if (Math.abs(refDenom) < 1e-12) return false; const invRefDenom = 1 / refDenom; const sourceConv = source.mapConversion; const refConv = reference.mapConversion; const bounds = emptyBounds(); let found = false; let projFailures = 0; let attempts = 0; let firstProjError: unknown = null; for (const mesh of geometry.meshes) { const positions = mesh.positions; // Fold the per-element local-frame origin into the world coord before the // reprojection (proj4 is nonlinear, so it must run on the absolute world // vertex). Output is absolute reference-frame coords; the stale origin is // cleared below. No-op when origin is absent/[0,0,0]. const o = mesh.origin; const oox = o ? o[0] : 0, ooy = o ? o[1] : 0, ooz = o ? o[2] : 0; for (let i = 0; i < positions.length; i += 3) { const vx = positions[i] + oox; const vy = positions[i + 1] + ooy; const vz = positions[i + 2] + ooz; if (!Number.isFinite(vx) || !Number.isFinite(vy) || !Number.isFinite(vz)) continue; // viewer(Y-up, source-local) → world(Y-up) → IFC(Z-up, source) const wx = vx + sourceOffset.x; const wy = vy + sourceOffset.y; const wz = vz + sourceOffset.z; const ifcXs = wx; const ifcYs = -wz; const ifcZs = wy; // IFC(source) → source projected (apply source MapConversion) const eS = sourceConv.eastings * sourceMapUnitScale + sourceAxis.scale * (sourceAxis.a * ifcXs - sourceAxis.o * ifcYs); const nS = sourceConv.northings * sourceMapUnitScale + sourceAxis.scale * (sourceAxis.o * ifcXs + sourceAxis.a * ifcYs); const hS = sourceConv.orthogonalHeight * sourceMapUnitScale + ifcZs; // source projected → reference projected via proj4 attempts += 1; let eR: number; let nR: number; try { const projected = proj4(sourceProjDef, refProjDef, [eS, nS]); eR = projected[0]; nR = projected[1]; } catch (error) { projFailures += 1; if (firstProjError == null) firstProjError = error; continue; } if (!Number.isFinite(eR) || !Number.isFinite(nR)) { projFailures += 1; continue; } // Height transformed under identity (no vertical datum hop in browser). const hR = hS; // reference projected → IFC(reference): invert reference MapConversion const dE = eR - refConv.eastings * refMapUnitScale; const dN = nR - refConv.northings * refMapUnitScale; const ifcXr = invRefDenom * (refAxis.a * dE + refAxis.o * dN); const ifcYr = invRefDenom * (-refAxis.o * dE + refAxis.a * dN); const ifcZr = hR - refConv.orthogonalHeight * refMapUnitScale; // IFC(Z-up, reference) → world(Y-up) → viewer(Y-up, reference-local) const refWorldX = ifcXr; const refWorldY = ifcZr; const refWorldZ = -ifcYr; const alignedX = refWorldX - refOffset.x; const alignedY = refWorldY - refOffset.y; const alignedZ = refWorldZ - refOffset.z; positions[i] = alignedX; positions[i + 1] = alignedY; positions[i + 2] = alignedZ; found = updateBounds(bounds, alignedX, alignedY, alignedZ) || found; } // Positions are now absolute in the reference viewer frame; drop the stale // local-frame origin so downstream consumers don't re-add it. if (o) mesh.origin = [0, 0, 0]; } if (!found) { console.warn( `[ifc-lite] Cross-CRS alignment failed: ${projFailures}/${attempts} ` + `vertex transforms failed for ${source.projectedCRS.name} → ${reference.projectedCRS.name}; ` + 'no vertices were successfully reprojected. Leaving geometry untouched.', firstProjError, ); return false; } geometry.coordinateInfo = { originShift: reference.coordinateInfo?.originShift ?? { x: 0, y: 0, z: 0 }, originalBounds: bounds, shiftedBounds: bounds, hasLargeCoordinates: reference.coordinateInfo?.hasLargeCoordinates ?? false, wasmRtcOffset: reference.coordinateInfo?.wasmRtcOffset, buildingRotation: reference.coordinateInfo?.buildingRotation, }; if (projFailures > 0) { console.warn( `[ifc-lite] Cross-CRS alignment: ${projFailures}/${attempts} vertex transforms ` + `failed from ${source.projectedCRS.name} to ${reference.projectedCRS.name}. ` + 'Those vertices are left at their original positions.', firstProjError, ); } return true; } export type FederationAlignmentStatus = 'same-crs' | 'reprojected' | 'identity' | 'failed'; /** * Route alignment to the right strategy based on whether the source and * reference share a projected CRS. Returns a status describing how the model * was placed in the federation, suitable for surfacing in the UI. */ export async function alignGeometryToReference( geometry: FederatedGeometryResult, source: ModelGeoref, reference: ModelGeoref, ): Promise { if (canAlignInSameProjectedCrs(source, reference)) { const transform = buildGeorefAlignmentTransform(source, reference); if (!transform) return 'failed'; if (isIdentityTransform(transform)) return 'identity'; applyAlignmentTransformAndUpdateBounds(geometry, transform, reference.coordinateInfo); return 'same-crs'; } const ok = await alignGeometryAcrossCrs(geometry, source, reference); return ok ? 'reprojected' : 'failed'; } /** * Select the federation anchor model. * * Resolution order: * 1. `anchorModelIdOverride` from the store, if it points to a loaded model * with a valid georeference. * 2. Earliest `loadedAt` model with a valid georeference (the default — gives * a stable anchor across loads while letting the user override when they * want a different model to drive the world frame). */ export function findReferenceGeorefModel(): { modelId: string; georef: ModelGeoref } | null { const state = useViewerStore.getState(); const override = state.anchorModelIdOverride; if (override) { const model = state.models.get(override) as FederatedModel | undefined; if (model?.ifcDataStore && model.geometryResult) { const georef = extractModelGeoref( model.ifcDataStore, model.geometryResult.coordinateInfo, state.georefMutations.get(override), ); if (georef) return { modelId: override, georef }; } // Fall through if the override no longer resolves — keeps loads // recoverable even if the user removed the anchor they had pinned. } const modelEntries = Array.from(state.models.entries()) as Array<[string, FederatedModel]>; const sorted = [...modelEntries].sort(([, a], [, b]) => (a.loadedAt ?? 0) - (b.loadedAt ?? 0)); for (const [modelId, model] of sorted) { if (!model.ifcDataStore || !model.geometryResult) continue; const georef = extractModelGeoref( model.ifcDataStore, model.geometryResult.coordinateInfo, state.georefMutations.get(modelId), ); if (georef) return { modelId, georef }; } return null; }