/* 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/. */ /** * Coordinate reprojection utilities. * * Converts projected coordinates (e.g. UTM eastings/northings) from an * IfcMapConversion + IfcProjectedCRS pair into WGS84 longitude/latitude * so they can be displayed on a web map. * * proj4 definitions are resolved from: * 1. The bundled EPSG index (@ifc-lite/data) — covers all 7000+ codes * 2. Programmatically constructed (UTM zones, well-known codes) * 3. Fetched from epsg.io at runtime as last resort */ import proj4 from 'proj4'; import type { MapConversion, ProjectedCRS } from '@ifc-lite/parser'; import type { CoordinateInfo } from '@ifc-lite/geometry'; import { lookupEpsgByCode } from '@ifc-lite/data'; import { getEffectiveHorizontalScale, resolveMapUnitToMetreScale } from './geo-scale'; import { resolvePrecisionDef } from './precision-grids'; export interface LatLon { lat: number; lon: number; } // Cache resolved projection definitions (from any source). const projDefCache = new Map(); const approxDatumWarningCache = new Set(); // Track datums where the bundled proj4 lacked any datum-shift parameters and we // couldn't supply a fallback — surfaced via diagnostics, warned once per datum. const unknownDatumWarningCache = new Set(); /** * Extract EPSG numeric code from a CRS name like "EPSG:32632" or "EPSG 2056". */ function extractEpsgCode(crs: ProjectedCRS): string | null { const match = crs.name?.match(/EPSG[:\s]*(\d+)/i); return match ? match[1] : null; } /** * Well-known CRS names that IFC authoring tools set without an EPSG: prefix. * Maps normalised name → EPSG code. Keys are lowercased before lookup. * * Authoring tools across regions emit different local conventions for the same * national grid; covering the common ones lets ifc-lite resolve a projection * for files that omit the "EPSG:" prefix entirely. */ const WELL_KNOWN_CRS: Record = { // Global / generic 'wgs 84': '4326', 'wgs84': '4326', 'wgs-84': '4326', 'nad83': '4269', 'nad27': '4267', 'etrs89': '4258', 'gcs_wgs_1984': '4326', // ArcGIS / Revit export alias 'gcs_north_american_1983': '4269', // Netherlands — Rijksdriehoeksmeting (RD New, EPSG:28992) 'rd': '28992', 'rd new': '28992', 'amersfoort / rd new': '28992', 'amersfoort rd new': '28992', 'stelsel van de rijksdriehoeksmeting': '28992', 'rijksdriehoeksmeting': '28992', 'nl_rd': '28992', // RD/NAP compound (horizontal RD + vertical NAP) 'rd new + nap height': '7415', 'amersfoort / rd new + nap height': '7415', // United Kingdom — Ordnance Survey GB (BNG, EPSG:27700) 'osgb 1936 / british national grid': '27700', 'osgb36 / british national grid': '27700', 'british national grid': '27700', 'bng': '27700', // Germany — DHDN / Gauss-Kruger zones + ETRS89 / UTM (most common) 'dhdn / gauss-kruger zone 2': '31466', 'dhdn / gauss-kruger zone 3': '31467', 'dhdn / gauss-kruger zone 4': '31468', 'dhdn / gauss-kruger zone 5': '31469', 'etrs89 / utm zone 32n': '25832', 'etrs89 / utm zone 33n': '25833', // Austria — MGI Lambert / Austrian Grid (EPSG:31287) 'mgi / austria lambert': '31287', 'austria lambert': '31287', // Switzerland — CH1903+ / LV95 (EPSG:2056) and legacy LV03 (EPSG:21781) 'ch1903+ / lv95': '2056', 'lv95': '2056', 'ch1903 / lv03': '21781', 'lv03': '21781', // Belgium — Lambert 2008 (EPSG:3812) and legacy Lambert 72 (EPSG:31370) 'belge 1972 / belgian lambert 72': '31370', 'belgian lambert 72': '31370', 'etrs89 / belgian lambert 2008': '3812', // France — RGF93 / Lambert-93 (EPSG:2154) 'rgf93 / lambert-93': '2154', 'rgf93 v1 / lambert-93': '2154', 'lambert-93': '2154', 'lambert 93': '2154', }; /** * Check if a proj4 definition is a geographic (longlat) CRS rather than a projected one. * Geographic CRS coordinates are in degrees, not metres. */ function isGeographicProj4(def: string): boolean { return /\+proj=longlat\b/.test(def); } /** * Build a proj4 definition string for a UTM zone. */ function utmProj4String(zone: string): string | null { const match = zone.match(/^(\d{1,2})([NS])$/i); if (!match) return null; const zoneNum = parseInt(match[1], 10); const isNorth = match[2].toUpperCase() === 'N'; if (zoneNum < 1 || zoneNum > 60) return null; return `+proj=utm +zone=${zoneNum}${isNorth ? '' : ' +south'} +datum=WGS84 +units=m +no_defs`; } /** * Datum-keyed +towgs84 approximations for CRSs whose canonical definition * relies on browser-unavailable grid files (NTv2, NADCON, etc.). Each entry * is the published Bursa-Wolf 7-parameter set for that specific datum, in * the Position Vector convention proj4 expects, with rotations in arc-seconds. * * Keys are lowercased datum names as they appear in EpsgIndexEntry.datum. A * lookup miss is intentional — it forces sanitizeProj4 to emit a diagnostic * rather than silently substitute parameters for the wrong region (an earlier * version of this table was keyed by ellipsoid alone, which caused every * Bessel-1841 CRS — RD/NL, Hermannskogel/AT, S-JTSK/CZ — to inherit Germany's * DHDN shift). Accuracy is typically ~1-5 m, sufficient for map display. */ const DATUM_TOWGS84: Record = { // United Kingdom — OSGB36 (Airy 1830) 'osgb 1936': '+towgs84=446.448,-125.157,542.06,0.15,0.247,0.842,-20.489', // North America — NAD27 (Clarke 1866) 'north american datum 1927': '+towgs84=-8,160,176,0,0,0,0', 'nad27': '+towgs84=-8,160,176,0,0,0,0', // NAD83 ≈ WGS84 to within ~1 m; identity is the standard browser approximation. 'north american datum 1983': '+towgs84=0,0,0,0,0,0,0', 'nad83': '+towgs84=0,0,0,0,0,0,0', // Germany — DHDN (Bessel 1841) 'deutsches hauptdreiecksnetz': '+towgs84=598.1,73.7,418.2,0.202,0.045,-2.455,6.7', 'dhdn': '+towgs84=598.1,73.7,418.2,0.202,0.045,-2.455,6.7', // Netherlands — Amersfoort (Bessel 1841). The bundled EPSG:28992 already // ships with the higher-precision Kadaster +towgs84, so this fires only // when a derived/compound CRS lacks it (e.g. some compound RD/NAP cases). 'amersfoort': '+towgs84=565.4171,50.3319,465.5524,1.9342,-1.6677,9.1019,4.0725', // Austria — MGI (Bessel 1841) 'militar-geographische institut': '+towgs84=577.326,90.129,463.919,5.137,1.474,5.297,2.4232', 'mgi': '+towgs84=577.326,90.129,463.919,5.137,1.474,5.297,2.4232', // Czech Republic — S-JTSK (Bessel 1841) 'system of the unified trigonometrical cadastral network': '+towgs84=570.8,85.7,462.8,4.998,1.587,5.261,3.56', 's-jtsk': '+towgs84=570.8,85.7,462.8,4.998,1.587,5.261,3.56', // New Zealand — NZGD49 (International 1924) 'new zealand geodetic datum 1949': '+towgs84=59.47,-5.04,187.44,0.47,-0.1,1.024,-4.5993', 'nzgd49': '+towgs84=59.47,-5.04,187.44,0.47,-0.1,1.024,-4.5993', // Australia — AGD84 (Australian National Spheroid) 'australian geodetic datum 1984': '+towgs84=-117.763,-51.51,139.061,0.292,0.443,0.277,-0.191', 'agd84': '+towgs84=-117.763,-51.51,139.061,0.292,0.443,0.277,-0.191', }; /** * Strip +nadgrids=... from a proj4 string and add a +towgs84 approximation * keyed by the datum name from the bundled EPSG index. Grid files cannot be * loaded in the browser; +towgs84 is the standard fallback. * * Skipped (returns input verbatim) when: * - No +nadgrids reference (or it's the no-op `@null`) * - A +towgs84 is already present (proj4 will honour it) */ function sanitizeProj4(def: string, code?: string | null, datumName?: string | null): string { if (!def.includes('+nadgrids') || def.includes('+nadgrids=@null')) return def; if (/\+towgs84=/.test(def)) { // Strip the unusable grid reference but keep the existing datum shift. return def.replace(/\+nadgrids=\S+/g, '').replace(/\s+/g, ' ').trim(); } const datumKey = datumName?.trim().toLowerCase() ?? ''; const towgs84 = datumKey ? DATUM_TOWGS84[datumKey] : undefined; if (!towgs84) { if (datumKey && !unknownDatumWarningCache.has(datumKey)) { unknownDatumWarningCache.add(datumKey); console.warn( `[reproject] EPSG:${code ?? '?'} ("${datumName}") needs browser-unavailable ` + 'datum grids and has no known +towgs84 fallback for its datum. ' + 'Positions will be aligned to the source CRS but may be tens of metres ' + 'off relative to WGS84/basemaps. Consider adding the datum to ' + 'DATUM_TOWGS84 in apps/viewer/src/lib/geo/reproject.ts.', ); } // Strip the grid reference; let proj4 fall through with no datum shift // rather than silently substitute a wrong-region transform. return def.replace(/\+nadgrids=\S+/g, '').replace(/\s+/g, ' ').trim(); } if (code && !approxDatumWarningCache.has(code)) { approxDatumWarningCache.add(code); console.warn( `[reproject] EPSG:${code} requires browser-unavailable datum grids; ` + `using approximate +towgs84 for "${datumName}" instead. ` + 'Expect metre-level XY differences for some locations.', ); } return def.replace(/\+nadgrids=\S+/g, '').replace(/\s+/g, ' ').trim() + ' ' + towgs84; } /** * Fetch a proj4 definition string from epsg.io (last-resort fallback). */ async function fetchProj4Def(epsgCode: string): Promise { try { const resp = await fetch(`https://epsg.io/${epsgCode}.proj4`); if (!resp.ok) return null; const text = (await resp.text()).trim(); if (!text || text.startsWith('<') || text.startsWith('{') || !text.includes('+')) { return null; } return text; } catch { return null; } } /** * Resolve a proj4 definition for the given ProjectedCRS. * * Resolution order: * 1. Cache hit * 2. Precision grid (NTv2/GeoTIFF) for codes where +towgs84 is too coarse * (RD/NL, OSGB/UK, BD72/BE) — fetched once from cdn.proj.org, gives * sub-decimeter accuracy. Falls through to (3) if the fetch fails. * 3. Bundled EPSG index (7000+ codes with proj4 strings) * 4. Well-known CRS name lookup (e.g. "WGS 84" → EPSG:4326) * 5. UTM zone heuristic (from CRS metadata — mapZone, name, description, mapProjection) * 6. Fetch from epsg.io (network fallback) */ export async function resolveProjection(crs: ProjectedCRS): Promise { let code = extractEpsgCode(crs); // 1. Check cache if (code && projDefCache.has(code)) { return projDefCache.get(code) ?? null; } // 2. Precision grid (NTv2/GeoTIFF). For Bessel-based national grids // (RD/NL, BD72/BE, OSGB/UK), the +towgs84 approximation that the bundled // entries carry is off by 80-200 m. proj4js can consume PROJ's GeoTIFF // datum-shift grids — load and register, then use a +nadgrids-based // proj4 string for sub-decimeter accuracy. if (code) { try { const precisionDef = await resolvePrecisionDef(code); if (precisionDef) { projDefCache.set(code, precisionDef); return precisionDef; } } catch (error) { console.warn(`[reproject] precision grid resolution failed for EPSG:${code}, falling back`, error); } } // 3. Bundled EPSG index (primary source — all 7000+ codes) if (code) { try { const entry = await lookupEpsgByCode(code); if (entry?.proj4) { const sanitized = sanitizeProj4(entry.proj4, code, entry.datum); projDefCache.set(code, sanitized); return sanitized; } } catch { // EPSG index not loaded yet, continue to fallbacks } } // 3. Well-known CRS name → EPSG code (handles "WGS 84", "NAD83", "RD New", etc.) if (!code) { const normalised = crs.name?.trim().toLowerCase() ?? ''; const wellKnownCode = WELL_KNOWN_CRS[normalised]; if (wellKnownCode) { code = wellKnownCode; if (projDefCache.has(code)) { return projDefCache.get(code) ?? null; } try { const entry = await lookupEpsgByCode(code); if (entry?.proj4) { const sanitized = sanitizeProj4(entry.proj4, code, entry.datum); projDefCache.set(code, sanitized); // For geographic CRS (longlat), check if we can infer a projected CRS // from the UTM zone metadata — a projected CRS is much more useful. // If we can't, fall through and return the geographic def below. } } catch { // continue } } } // 4. UTM zone heuristic — check mapZone, name, description, AND mapProjection if (crs.mapZone) { const def = utmProj4String(crs.mapZone); if (def) { if (code) projDefCache.set(code, def); return def; } } const name = crs.name?.toUpperCase() ?? ''; const utmMatch = name.match(/UTM\s+ZONE\s+(\d{1,2}[NS])/i) ?? crs.description?.match(/UTM\s+zone\s+(\d{1,2}[NS])/i) ?? crs.mapProjection?.match(/UTM\s+zone\s+(\d{1,2}[NS])/i); if (utmMatch) { const def = utmProj4String(utmMatch[1]); if (def) { if (code) projDefCache.set(code, def); return def; } } // If step 3 resolved a geographic CRS (e.g. EPSG:4326) and we couldn't // upgrade it to a projected CRS via the UTM heuristic, still return it — // reprojectToLatLon will handle the longlat identity case. if (code && projDefCache.has(code)) { return projDefCache.get(code) ?? null; } // 5. Network fallback — fetch from epsg.io if (code) { const raw = await fetchProj4Def(code); // We don't have a bundled EpsgIndexEntry here (otherwise we'd have hit // step 2), so fall back to parsing the datum name out of the proj4 // string itself when possible. `+datum=` is rare in modern proj4 output; // the typical hint is `+ellps=` which we already accept as a weak signal // inside DATUM_TOWGS84 keys above. // Only positive-cache a successful result, so a transient network // failure (offline/CDN hiccup/timeout) can be retried later instead of // permanently poisoning this EPSG code for the rest of the session. const fetched = raw ? sanitizeProj4(raw, code, null) : null; if (fetched) projDefCache.set(code, fetched); return fetched; } return null; } /** * Compute the model center in the projected CRS (easting, northing). * * The coordinate pipeline is: * 1. WASM extracts IFC positions (Z-up) and may apply RTC offset (wasmRtcOffset, Z-up) * 2. Mesh collector converts Z-up → Y-up: viewerX = ifcX, viewerY = ifcZ, viewerZ = -ifcY * 3. CoordinateHandler may apply originShift (Y-up) * * To recover IFC world coordinates (Z-up) from the viewer bounds: * world_yup = bounds_center + originShift + wasmRtcOffset_as_yup * ifc_x = world_yup.x, ifc_y = -world_yup.z, ifc_z = world_yup.y * * Then the projected CRS coordinates are: * easting = mapConversion.eastings + scale * (cos*ifc_x - sin*ifc_y) * northing = mapConversion.northings + scale * (sin*ifc_x + cos*ifc_y) */ function computeProjectedCenter( conversion: MapConversion, coordinateInfo: CoordinateInfo | undefined, mapUnitScale: number, lengthUnitScale: number, ): { easting: number; northing: number } { const { ifcX, ifcY } = computeModelCenterInIfcMeters(coordinateInfo); // Geometry coordinates (ifcX, ifcY) are already in metres — the geometry engine // converts from the IFC file's native unit during extraction. Only MapConversion // values (eastings, northings) are in the file's native unit and need scaling. // IfcMapConversion.Scale converts project length unit → map unit (e.g. 0.001 // for mm→m); since geometry is already in metres, use the effective scale — // see issue #595. const scale = getEffectiveHorizontalScale(conversion.scale, mapUnitScale, lengthUnitScale); const abscissa = conversion.xAxisAbscissa ?? 1.0; const ordinate = conversion.xAxisOrdinate ?? 0.0; const easting = conversion.eastings * mapUnitScale + scale * (abscissa * ifcX - ordinate * ifcY); const northing = conversion.northings * mapUnitScale + scale * (ordinate * ifcX + abscissa * ifcY); return { easting, northing }; } /** * Reproject the model center from the projected CRS to WGS84 lat/lon. * * Uses the model's actual geometry bounds + RTC offset to determine where * the model sits in the projected coordinate system, then reprojects to WGS84. * * @param conversion IfcMapConversion (offset, rotation, scale) * @param crs IfcProjectedCRS (EPSG code, mapUnitScale) * @param coordinateInfo Geometry coordinate info with bounds and RTC offset * @param lengthUnitScale IFC project length unit → metres (fallback when crs.mapUnitScale is absent) */ export async function reprojectToLatLon( conversion: MapConversion, crs: ProjectedCRS, coordinateInfo?: CoordinateInfo, lengthUnitScale = 1, ): Promise { const projDef = await resolveProjection(crs); if (!projDef) return null; // Geographic CRS (e.g. EPSG:4326) — eastings/northings are already lon/lat. // Don't add the model's geometry center (in meters) to degree-based coordinates. if (isGeographicProj4(projDef)) { const lon = conversion.eastings; const lat = conversion.northings; if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null; return { lat, lon }; } // MapConversion values use the unit from IfcProjectedCRS.MapUnit. If MapUnit // is not specified, the IFC spec defaults to the project's length unit. const mapScale = resolveMapUnitToMetreScale(crs.mapUnitScale, lengthUnitScale); const { easting, northing } = computeProjectedCenter(conversion, coordinateInfo, mapScale, lengthUnitScale); try { const [lon, lat] = proj4(projDef, 'WGS84', [easting, northing]); if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null; return { lat, lon }; } catch { return null; } } /** * Compute the model's center in IFC Z-up metres from coordinate info. * This is the geometry center before MapConversion is applied. */ export function computeModelCenterInIfcMeters( coordinateInfo?: CoordinateInfo, ): { ifcX: number; ifcY: number; ifcZ: number } { if (!coordinateInfo) return { ifcX: 0, ifcY: 0, ifcZ: 0 }; const bounds = coordinateInfo.originalBounds; const shift = coordinateInfo.originShift; const rtc = coordinateInfo.wasmRtcOffset; const rtcYup = rtc ? { x: rtc.x, y: rtc.z, z: -rtc.y } : { x: 0, y: 0, z: 0 }; const cx = (bounds.min.x + bounds.max.x) / 2; const cy = (bounds.min.y + bounds.max.y) / 2; const cz = (bounds.min.z + bounds.max.z) / 2; const worldYupX = cx + shift.x + rtcYup.x; const worldYupY = cy + shift.y + rtcYup.y; const worldYupZ = cz + shift.z + rtcYup.z; return { ifcX: worldYupX, ifcY: -worldYupZ, ifcZ: worldYupY, }; } /** * Reverse-project a WGS84 lat/lon into the IfcMapConversion eastings/northings * values that would place the model center at the given location. * * This accounts for the model's local geometry offset, rotation, and scale: * projected = eastings + scale * (cos*ifcX - sin*ifcY) * ⟹ eastings = projected - scale * (cos*ifcX - sin*ifcY) */ export async function reprojectFromLatLon( latLon: LatLon, crs: ProjectedCRS, conversion?: MapConversion, coordinateInfo?: CoordinateInfo, lengthUnitScale = 1, ): Promise<{ easting: number; northing: number } | null> { const projDef = await resolveProjection(crs); if (!projDef) return null; // Geographic CRS — coordinates are lon/lat in degrees, no projection needed. if (isGeographicProj4(projDef)) { return { easting: latLon.lon, northing: latLon.lat }; } try { const [projE, projN] = proj4('WGS84', projDef, [latLon.lon, latLon.lat]); if (!Number.isFinite(projE) || !Number.isFinite(projN)) return null; // Convert projected metres back to MapConversion's unit. // Geometry offsets (ifcX/Y) are already in metres. const mapScale = resolveMapUnitToMetreScale(crs.mapUnitScale, lengthUnitScale); const invScale = mapScale !== 0 ? 1 / mapScale : 1; const { ifcX, ifcY } = computeModelCenterInIfcMeters(coordinateInfo); // Effective horizontal scale for metre-converted geometry — see issue #595. const scale = getEffectiveHorizontalScale(conversion?.scale, mapScale, lengthUnitScale); const abscissa = conversion?.xAxisAbscissa ?? 1.0; const ordinate = conversion?.xAxisOrdinate ?? 0.0; // Result is in IFC native units (the reverse of: E_native * mapScale + geom_offset = E_metres) const easting = (projE - scale * (abscissa * ifcX - ordinate * ifcY)) * invScale; const northing = (projN - scale * (ordinate * ifcX + abscissa * ifcY)) * invScale; return { easting, northing }; } catch { return null; } } /** * Compute a building footprint rectangle from the model's bounding box and * reproject each corner to WGS84 for display as a GeoJSON polygon on a web map. * * Uses the shiftedBounds (scene-local after RTC) from CoordinateInfo, transforms * each corner through the MapConversion pipeline (rotation + scale + offset), * then reprojects to lat/lon. The result is a rotated rectangle matching the * model's XZ extent on the map. * * @param lengthUnitScale IFC project length unit → metres (fallback when crs.mapUnitScale is absent) * @returns A single GeoJSON-compatible polygon: closed ring of [lon, lat] pairs */ export async function computeFootprintGeoJSON( conversion: MapConversion, crs: ProjectedCRS, coordinateInfo: CoordinateInfo, lengthUnitScale = 1, ): Promise<[number, number][] | null> { const projDef = await resolveProjection(crs); if (!projDef) { console.warn('[footprint] failed to resolve projection for CRS:', crs.name); return null; } // Geographic CRS values are degrees, while the model bounds are metres. // Without a projected CRS / map conversion, we can show the model pin but // not a trustworthy footprint polygon. if (isGeographicProj4(projDef)) { return null; } // Effective horizontal scale for metre-converted geometry — see issue #595. const mapScale = resolveMapUnitToMetreScale(crs.mapUnitScale, lengthUnitScale); const scale = getEffectiveHorizontalScale(conversion.scale, mapScale, lengthUnitScale); const abscissa = conversion.xAxisAbscissa ?? 1.0; const ordinate = conversion.xAxisOrdinate ?? 0.0; const shift = coordinateInfo.originShift; const rtc = coordinateInfo.wasmRtcOffset; const rtcYup = rtc ? { x: rtc.x, z: -rtc.y } : { x: 0, z: 0 }; const bounds = coordinateInfo.shiftedBounds; // Four corners of the bounding box on the XZ plane (viewer Y-up) const corners = [ { x: bounds.min.x, z: bounds.min.z }, { x: bounds.max.x, z: bounds.min.z }, { x: bounds.max.x, z: bounds.max.z }, { x: bounds.min.x, z: bounds.max.z }, ]; const ring: [number, number][] = []; for (const c of corners) { // Scene-local → world Y-up const worldX = c.x + shift.x + rtcYup.x; const worldZ = c.z + shift.z + rtcYup.z; // Y-up → IFC Z-up: ifcX = worldX, ifcY = -worldZ const ifcX = worldX; const ifcY = -worldZ; // Geometry coords (ifcX/Y) are already in metres; MapConversion values // are converted to metres via mapScale. const easting = conversion.eastings * mapScale + scale * (abscissa * ifcX - ordinate * ifcY); const northing = conversion.northings * mapScale + scale * (ordinate * ifcX + abscissa * ifcY); // Projected CRS → WGS84 try { const [lon, lat] = proj4(projDef, 'WGS84', [easting, northing]); if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; ring.push([lon, lat]); } catch { return null; } } // Close the ring (GeoJSON requirement) ring.push(ring[0]); return ring; } /** * Query terrain elevation at a given lat/lon using the Open-Meteo elevation API. * Returns height in metres above sea level, or null on failure. */ export async function queryTerrainElevation(latLon: LatLon): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); try { const url = `https://api.open-meteo.com/v1/elevation?latitude=${latLon.lat}&longitude=${latLon.lon}`; const resp = await fetch(url, { signal: controller.signal }); if (!resp.ok) return null; const data = await resp.json(); const elev = data?.elevation?.[0]; return typeof elev === 'number' && Number.isFinite(elev) ? elev : null; } catch (err) { console.warn(`[reproject] queryTerrainElevation failed for ${latLon.lat},${latLon.lon}:`, err); return null; } finally { clearTimeout(timeoutId); } }