/* 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/. */ /** * Compute the effective horizontal scale to apply to viewer-space coordinates * (which are already in metres) when transforming through IfcMapConversion. * * Per the IFC schema, IfcMapConversion.Scale converts LOCAL ENGINEERING * coordinates (in the project's length unit) to MAP coordinates (in the map * CRS unit). For a typical file with mm project units and m map units, the * Scale attribute is 0.001. * * The IFC formula is: * E_map_units = Eastings + (X_local * absc - Y_local * ordi) * Scale * * To produce metres for proj4, we multiply by mapUnitScale; and X_local can be * recovered from the metre-converted geometry as X_metres / lengthUnitScale. * Substituting: * E_metres = mapUnitScale * Eastings * + (mapUnitScale * Scale / lengthUnitScale) * * (X_metres * absc - Y_metres * ordi) * * So when geometry has already been converted to metres (as ifc-lite does), * the effective horizontal scale is (Scale * mapUnitScale) / lengthUnitScale. * For files where Scale is set per IFC spec to bridge the unit difference * (Scale = lengthUnitScale / mapUnitScale), this evaluates to 1.0 and the * geometry passes through unchanged. Applying the raw Scale would otherwise * double-scale and shrink/expand the model — see issue #595. */ export function getEffectiveHorizontalScale( ifcMapConversionScale: number | undefined, mapUnitScale: number, lengthUnitScale: number, ): number { const lus = lengthUnitScale > 0 ? lengthUnitScale : 1; const mus = mapUnitScale > 0 ? mapUnitScale : 1; const specEffective = ((ifcMapConversionScale ?? 1.0) * mus) / lus; // Heuristic for files that don't follow the IFC schema's unit-bridging rule. // // Spec: IfcMapConversion.Scale converts LOCAL ENGINEERING coords (in the // project length unit) to MAP coords (in MapUnit). So a file with mm // project units + m map units MUST set Scale=0.001 to bridge the gap, and // (Scale * mapUnitScale) / lengthUnitScale evaluates to 1 — geometry passes // through unchanged. // // Reality: Bonsai/IfcOpenShell, Revit's IFC exporter, and many CAD tools // either leave Scale unset (default 1.0) or hard-code Scale=1 regardless of // unit pairing. The author's intent in those cases is "geometry and offsets // share the same metric unit", but the spec-strict formula then multiplies // viewer-space metres by 1/lengthUnitScale (e.g. 1000x for mm projects), // inflating the model so far that proj4 extrapolates to the projection's // antipode (Hans's `IXAS_KW 018_georeffed.ifc`: 126500/480000 RD offsets + // mm units + Scale unset → South Pacific instead of the Netherlands). // // When the file's Scale is unset or exactly 1 AND the units don't match, // honour the practical intent: behave as if Scale had been set per spec // (effectiveScale = 1) so the metre-converted geometry passes through. // Files that genuinely use Scale ≠ 1 (e.g. units bridging a foot/metre // gap with Scale=0.3048) are left alone — they followed the spec. const rawScaleProvided = ifcMapConversionScale != null && Math.abs(ifcMapConversionScale - 1) > 1e-9; if (!rawScaleProvided && Math.abs(mus - lus) > 1e-9) { return 1; } return specEffective; } export interface ScaleUnitMismatch { /** Effective horizontal scale applied to viewer-space (metre) geometry. */ effectiveScale: number; /** Raw IfcMapConversion.Scale (or 1 if absent). */ rawScale: number; /** Map unit → metres factor (e.g. 1 for METRE, 0.001 for MILLIMETRE). */ mapUnitScale: number; /** Project length unit → metres factor. */ lengthUnitScale: number; /** * Scale value the file would need for the IFC formula to map local→map * coordinates without any extra scaling (i.e. lengthUnitScale / mapUnitScale). */ expectedScale: number; } /** * Detect when IfcMapConversion.Scale is inconsistent with the project and map * units. Per the IFC schema, Scale × mapUnitScale should equal lengthUnitScale * (i.e. effectiveScale = 1.0). A deviation usually means the authoring tool * forgot to set Scale to bridge a unit difference (e.g. mm project + m map * with Scale=1.0). Files like this render at the wrong size in any tool that * follows the schema strictly — see issue #595. * * Returns null when the values are consistent (within 0.5% of 1.0); otherwise * returns the diagnostic data so callers can surface a warning. */ export function detectScaleUnitMismatch( ifcMapConversionScale: number | undefined, mapUnitScale: number | undefined, lengthUnitScale: number | undefined, ): ScaleUnitMismatch | null { const lus = lengthUnitScale && lengthUnitScale > 0 ? lengthUnitScale : 1; const mus = mapUnitScale && mapUnitScale > 0 ? mapUnitScale : 1; const rawScale = ifcMapConversionScale ?? 1.0; const effectiveScale = (rawScale * mus) / lus; if (Math.abs(effectiveScale - 1) <= 0.005) return null; return { effectiveScale, rawScale, mapUnitScale: mus, lengthUnitScale: lus, expectedScale: lus / mus, }; } export function inferMapUnitScale( mapUnit: string | undefined, fallback?: number, ): number | undefined { if (!mapUnit) return fallback; const normalized = mapUnit.toUpperCase(); if (normalized.includes('US') && (normalized.includes('SURVEY') || normalized.includes('FTUS'))) { return 0.3048006096; } if (normalized.includes('FOOT') || normalized.includes('FEET')) return 0.3048; if (normalized.includes('MILLI')) return 0.001; if (normalized.includes('CENTI')) return 0.01; if (normalized.includes('DECI')) return 0.1; if (normalized.includes('KILO')) return 1000; if (normalized.includes('METRE') || normalized.includes('METER')) return 1; return fallback; } /** * Resolve the scale factor converting `IfcMapConversion.eastings/northings/ * orthogonalHeight` into metres (the unit proj4 expects). * * IFC4 spec: those offsets are in `IfcProjectedCRS.MapUnit`, falling back to * the project's `IfcUnitAssignment` LengthUnit when MapUnit is absent. * * Real-world practice diverges: Bonsai, IfcOpenShell, and most surveying * pipelines emit metre values regardless of the project's length unit because * survey CRS offsets come in metres. When MapUnit is absent AND the project * unit isn't metres, applying the spec interpretation pushes coords miles * outside the CRS's valid range, and projections like RD New / OSGB / Lambert * extrapolate to the projection's antipode (e.g. an RD easting of `126500` * read as mm = `126.5 m` → South Pacific instead of the Netherlands). * * Heuristic: when no explicit MapUnit is set, treat the offsets as metres. * Files that genuinely use non-metre offsets can set MapUnit explicitly * (e.g. `IfcProjectedCRS.MapUnit = MILLIMETRE`) to opt out. */ export function resolveMapUnitToMetreScale( mapUnitScaleFromCrs: number | undefined, lengthUnitScale: number, ): number { if (mapUnitScaleFromCrs && mapUnitScaleFromCrs > 0) return mapUnitScaleFromCrs; void lengthUnitScale; // parameter kept for future spec-strict override return 1; }