/* 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 panel - displays and allows editing of IfcProjectedCRS * and IfcMapConversion entities with field-specific editing assistance. */ import { useState, useCallback, useMemo } from 'react'; import { Globe, MapPin, PenLine, Check, X, Search, ChevronRight, Mountain, AlertTriangle } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Badge } from '@/components/ui/badge'; import { computeAngleToGridNorth, type GeoreferenceInfo, type MapConversion, type ProjectedCRS } from '@ifc-lite/parser'; import { useViewerStore } from '@/store'; import { posthog } from '@/lib/analytics'; import type { CoordinateInfo, GeometryResult } from '@ifc-lite/geometry'; import { EpsgLookupDialog, type EpsgResult } from './EpsgLookupDialog'; import { FederationAlignmentControls } from './FederationAlignmentControls'; import { PrecisionGridBadge } from './PrecisionGridBadge'; import { LocationMap, type PickedPosition } from './LocationMap'; import { computeOrthogonalHeightForBaseAltitude } from '@/lib/geo/cesium-placement'; import { detectScaleUnitMismatch, mergeMapConversion, mergeProjectedCRS, supportsStandardGeoreferencing, } from '@/lib/geo/effective-georef'; import { useIfc } from '@/hooks/useIfc'; import { toast } from '@/components/ui/toast'; // ── Field-specific assistance data ───────────────────────────────────── const COMMON_DATUMS = ['WGS84', 'ETRS89', 'NAD83', 'NAD27', 'GRS80', 'Bessel 1841', 'Clarke 1866']; const COMMON_PROJECTIONS = ['Transverse Mercator', 'UTM', 'Lambert Conformal Conic', 'Mercator', 'Stereographic', 'Oblique Mercator']; const MAP_UNITS = ['METRE', 'FOOT', 'US SURVEY FOOT']; const COMMON_VERTICAL_DATUMS = ['MSL', 'NAVD88', 'EVRF2007', 'EVRF2019', 'AHD', 'ODN', 'LN02']; type FieldHint = { placeholder?: string; suggestions?: string[]; isSelect?: boolean; helpText?: string; }; function getFieldHint(entity: string, field: string): FieldHint { if (entity === 'projectedCRS') { switch (field) { case 'name': return { placeholder: 'e.g. EPSG:4326', helpText: 'Use EPSG lookup to search' }; case 'description': return { placeholder: 'e.g. WGS 84 / UTM zone 32N' }; case 'geodeticDatum': return { placeholder: 'e.g. WGS84', suggestions: COMMON_DATUMS }; case 'verticalDatum': return { placeholder: 'e.g. MSL', suggestions: COMMON_VERTICAL_DATUMS }; case 'mapProjection': return { placeholder: 'e.g. Transverse Mercator', suggestions: COMMON_PROJECTIONS }; case 'mapZone': return { placeholder: 'e.g. 32N' }; case 'mapUnit': return { isSelect: true, suggestions: MAP_UNITS }; default: return {}; } } if (entity === 'mapConversion') { switch (field) { case 'eastings': return { placeholder: '0.0', helpText: 'X offset in map units' }; case 'northings': return { placeholder: '0.0', helpText: 'Y offset in map units' }; case 'orthogonalHeight': return { placeholder: '0.0', helpText: 'Z offset in metres' }; case 'xAxisAbscissa': return { placeholder: '1.0', helpText: 'cos(angle to grid north)' }; case 'xAxisOrdinate': return { placeholder: '0.0', helpText: 'sin(angle to grid north)' }; case 'scale': return { placeholder: '1.0', helpText: 'Usually 1.0 or close to it' }; default: return {}; } } return {}; } // ── GeorefRow: a single editable field ───────────────────────────────── interface GeorefRowProps { label: string; value: string | number | undefined | null; suffix?: string; isComputed?: boolean; isNumber?: boolean; editable?: boolean; isMutated?: boolean; fieldEntity?: string; fieldName?: string; onSave?: (value: string | number) => void; /** Extra inline content rendered after the value (e.g. terrain height button) */ children?: React.ReactNode; } function GeorefRow({ label, value, suffix, isComputed, isNumber, editable, isMutated, fieldEntity, fieldName, onSave, children }: GeorefRowProps) { const [editing, setEditing] = useState(false); const [editValue, setEditValue] = useState(''); const hint = useMemo(() => getFieldHint(fieldEntity ?? '', fieldName ?? ''), [fieldEntity, fieldName]); const startEdit = useCallback(() => { if (!editable || isComputed) return; setEditValue(value != null ? String(value) : ''); setEditing(true); }, [value, editable, isComputed]); const commitEdit = useCallback((overrideValue?: string) => { if (!onSave) { setEditing(false); return; } const trimmed = (overrideValue ?? editValue).trim(); if (!trimmed && !hint.isSelect) { setEditing(false); return; } if (isNumber) { const num = parseFloat(trimmed); if (!Number.isFinite(num)) { setEditing(false); return; } onSave(num); } else { onSave(trimmed); } setEditing(false); }, [editValue, isNumber, onSave, hint.isSelect]); const cancelEdit = useCallback(() => { setEditing(false); }, []); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') cancelEdit(); }, [commitEdit, cancelEdit]); const selectSuggestion = useCallback((s: string) => { if (!onSave) return; if (isNumber) { const num = parseFloat(s); if (Number.isFinite(num)) onSave(num); } else { onSave(s); } setEditing(false); }, [onSave, isNumber]); const displayValue = value != null ? String(value) : '-'; return (
{isComputed && ( * Computed from XAxisAbscissa and XAxisOrdinate )} {label}
{isMutated && !editing && ( edited )} {editing ? (
e.stopPropagation()}>
{hint.isSelect ? ( ) : ( setEditValue(e.target.value)} onKeyDown={handleKeyDown} placeholder={hint.placeholder} className="flex-1 min-w-0 text-[11px] font-mono px-1.5 py-0.5 border border-teal-400 dark:border-teal-600 bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 outline-none focus:ring-1 focus:ring-teal-400 placeholder:text-zinc-400/50" autoFocus /> )}
{/* Suggestion chips for fields with common values */} {hint.suggestions && !hint.isSelect && (
{hint.suggestions.map(s => ( ))}
)} {/* Help text */} {hint.helpText && ( {hint.helpText} )}
) : ( <> {displayValue} {suffix && {suffix}} {editable && !isComputed && ( )} )}
{children}
); } // ── AngleRow: edit angle and auto-compute XAxisAbscissa/XAxisOrdinate ─── interface AngleRowProps { angle: number | null; editable?: boolean; onAngleChange?: (abscissa: number, ordinate: number) => void; } function AngleRow({ angle, editable, onAngleChange }: AngleRowProps) { const [editing, setEditing] = useState(false); const [editValue, setEditValue] = useState(''); const startEdit = useCallback(() => { if (!editable) return; setEditValue(angle != null ? angle.toFixed(6) : ''); setEditing(true); }, [angle, editable]); const commitEdit = useCallback(() => { if (!onAngleChange) return; const deg = parseFloat(editValue.trim()); if (!Number.isFinite(deg)) return; const rad = deg * (Math.PI / 180); onAngleChange(Math.cos(rad), Math.sin(rad)); setEditing(false); }, [editValue, onAngleChange]); const cancelEdit = useCallback(() => setEditing(false), []); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') cancelEdit(); }, [commitEdit, cancelEdit]); return (
* {editable ? 'Edit angle to auto-compute XAxisAbscissa/XAxisOrdinate' : 'Computed from XAxisAbscissa and XAxisOrdinate'} Angle to Grid North
{editing ? (
e.stopPropagation()}>
setEditValue(e.target.value)} onKeyDown={handleKeyDown} placeholder="0.0" className="w-28 text-[11px] font-mono px-1.5 py-0.5 border border-teal-400 dark:border-teal-600 bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 outline-none focus:ring-1 focus:ring-teal-400 placeholder:text-zinc-400/50" autoFocus /> deg
Sets XAxisAbscissa = cos(angle), XAxisOrdinate = sin(angle)
) : ( <> {angle != null ? parseFloat(angle.toFixed(6)) : '-'} deg {editable && ( )} )}
); } // ── Main Panel ───────────────────────────────────────────────────────── export interface GeoreferencingPanelProps { georef: GeoreferenceInfo | null; modelId?: string; enableEditing?: boolean; schemaVersion?: string; /** CoordinateInfo from the model's geometry (for map position calculation) */ coordinateInfo?: CoordinateInfo; /** GeometryResult for KMZ export */ geometryResult?: GeometryResult | null; /** IFC project length unit → metres (e.g. 0.001 for mm models). Default 1. */ lengthUnitScale?: number; /** IfcBuildingStorey elevations (express id → metres, viewer-Y aligned). * Used to anchor the model's ground floor to terrain. */ storeyElevations?: Map; } export function GeoreferencingPanel({ georef, modelId, enableEditing, schemaVersion, coordinateInfo, geometryResult, lengthUnitScale, storeyElevations }: GeoreferencingPanelProps) { const georefMutations = useViewerStore(s => s.georefMutations); const setGeorefField = useViewerStore(s => s.setGeorefField); const setGeorefFields = useViewerStore(s => s.setGeorefFields); const cesiumEnabled = useViewerStore(s => s.cesiumEnabled); const cesiumTerrainHeight = useViewerStore(s => s.cesiumTerrainHeight); const cesiumTerrainSource = useViewerStore(s => s.cesiumTerrainSource); const cesiumSourceModelId = useViewerStore(s => s.cesiumSourceModelId); const models = useViewerStore(s => s.models); const loading = useViewerStore(s => s.loading); const { addModel, clearAllModels } = useIfc(); // Only show terrain actions when this panel's model is the one backing the Cesium overlay const isActiveCesiumModel = !!modelId && modelId === cesiumSourceModelId; const [crsOpen, setCrsOpen] = useState(false); const [conversionOpen, setConversionOpen] = useState(false); const [showReloadPrompt, setShowReloadPrompt] = useState(false); useViewerStore(s => s.mutationVersion); const mutations = modelId ? georefMutations?.get(modelId) : undefined; const isLegacySiteGeoreference = georef?.source === 'siteLocation'; const canUseStandardGeoreferencing = supportsStandardGeoreferencing(schemaVersion, georef); const mergedCRS = useMemo((): ProjectedCRS | undefined => { return mergeProjectedCRS(georef?.projectedCRS, mutations?.projectedCRS, lengthUnitScale ?? 1); }, [georef?.projectedCRS, mutations?.projectedCRS, lengthUnitScale]); const mergedConversion = useMemo((): MapConversion | undefined => { return mergeMapConversion(georef?.mapConversion, mutations?.mapConversion); }, [georef?.mapConversion, mutations?.mapConversion]); const angleToGridNorth = useMemo(() => { return computeAngleToGridNorth(mergedConversion?.xAxisAbscissa, mergedConversion?.xAxisOrdinate); }, [mergedConversion]); const scaleMismatch = useMemo(() => { if (!mergedConversion) return null; return detectScaleUnitMismatch( mergedConversion.scale, mergedCRS?.mapUnitScale, lengthUnitScale, ); }, [mergedConversion, mergedCRS?.mapUnitScale, lengthUnitScale]); const mapUnitSuffix = useMemo(() => { const mapUnit = mergedCRS?.mapUnit?.toUpperCase(); if (!mapUnit) return 'm'; if (mapUnit.includes('US') && mapUnit.includes('FOOT')) return 'ftUS'; if (mapUnit.includes('FOOT') || mapUnit.includes('FEET')) return 'ft'; return 'm'; }, [mergedCRS?.mapUnit]); /** * Given a target world altitude (metres) for the model's ground floor * (the storey nearest elevation 0, falling back to bounds.min.y when * no storeys are present), return the IfcMapConversion.OrthogonalHeight * value (in map units, rounded to 0.01) that would put the ground floor * there — accounting for any RTC / origin shifts the geometry pipeline * applied. This mirrors the auto-clamp formula so the "Set * OrthogonalHeight to Cesium terrain elevation" button produces the same * world position as toggling the clamp. */ const oHeightForBaseAltitude = useCallback((targetBaseAltitude: number): number => { return computeOrthogonalHeightForBaseAltitude({ coordinateInfo, projectedCRS: mergedCRS, lengthUnitScale: lengthUnitScale ?? 1, storeyElevations, targetBaseAltitude, }); }, [coordinateInfo, mergedCRS, lengthUnitScale, storeyElevations]); const isMutated = useCallback((entity: 'projectedCRS' | 'mapConversion', field: string): boolean => { if (!mutations) return false; const entityMuts = mutations[entity]; if (!entityMuts) return false; return field in entityMuts; }, [mutations]); const requestAlignmentReload = useCallback(() => { if (models.size > 1) { setShowReloadPrompt(true); } }, [models.size]); const reloadModelsForAlignment = useCallback(async () => { const state = useViewerStore.getState(); const snapshot = Array.from(state.models.values()).sort((a, b) => (a.loadedAt ?? 0) - (b.loadedAt ?? 0)); const missingSource = snapshot.find(model => !model.sourceFile); if (snapshot.length < 2) { setShowReloadPrompt(false); return; } if (missingSource) { toast.error(`Cannot reload ${missingSource.name}: source file is not available`); return; } try { clearAllModels(); for (const model of snapshot) { const sourceFile = model.sourceFile; if (!sourceFile) continue; const reloadedModelId = await addModel(sourceFile, { name: model.name, modelId: model.id, loadedAt: model.loadedAt, visible: model.visible, collapsed: model.collapsed, }); if (!reloadedModelId) { throw new Error(`Failed to reload ${model.name}`); } if (model.visible === false) { useViewerStore.getState().setModelVisibility(model.id, false); } } setShowReloadPrompt(false); toast.success('Reloaded models for edited georeferencing'); } catch (error) { toast.error(error instanceof Error ? error.message : 'Reload failed'); } }, [addModel, clearAllModels]); const handleSave = useCallback((entity: 'projectedCRS' | 'mapConversion', field: string, value: string | number) => { if (!modelId || !setGeorefField) return; const oldValue = entity === 'projectedCRS' ? mergedCRS?.[field as keyof ProjectedCRS] : mergedConversion?.[field as keyof MapConversion]; setGeorefField(modelId, entity, field, value, oldValue as string | number | undefined); posthog.capture('georeference_set', { method: 'crs_field', entity, field }); requestAlignmentReload(); }, [modelId, setGeorefField, mergedCRS, mergedConversion, requestAlignmentReload]); // Handle angle edit: compute and set both XAxisAbscissa and XAxisOrdinate const handleAngleChange = useCallback((abscissa: number, ordinate: number) => { if (!modelId || !setGeorefFields) return; setGeorefFields(modelId, 'mapConversion', [ { field: 'xAxisAbscissa', value: abscissa, oldValue: mergedConversion?.xAxisAbscissa }, { field: 'xAxisOrdinate', value: ordinate, oldValue: mergedConversion?.xAxisOrdinate }, ]); posthog.capture('georeference_set', { method: 'true_north' }); requestAlignmentReload(); }, [modelId, setGeorefFields, mergedConversion, requestAlignmentReload]); // Handle position picked from the map (reverse-projected easting/northing + optional terrain height) const handleApplyPosition = useCallback((position: PickedPosition) => { if (!modelId || !setGeorefFields) return; const fields: Array<{ field: string; value: number; oldValue?: number }> = [ { field: 'eastings', value: position.easting, oldValue: mergedConversion?.eastings }, { field: 'northings', value: position.northing, oldValue: mergedConversion?.northings }, ]; if (position.terrainHeight !== null) { // position.terrainHeight is the world altitude where the user wants the // base of the model — translate to OrthogonalHeight using the same // bounds/shift accounting as the auto-clamp path. fields.push({ field: 'orthogonalHeight', value: oHeightForBaseAltitude(position.terrainHeight), oldValue: mergedConversion?.orthogonalHeight, }); } setGeorefFields(modelId, 'mapConversion', fields); posthog.capture('georeference_set', { method: 'map_pick', has_terrain_height: position.terrainHeight !== null, }); setConversionOpen(true); requestAlignmentReload(); }, [modelId, setGeorefFields, mergedConversion, requestAlignmentReload, oHeightForBaseAltitude]); const initializeMapConversionDefaults = useCallback(() => { if (!modelId || !setGeorefFields) return; setGeorefFields(modelId, 'mapConversion', [ { field: 'eastings', value: mergedConversion?.eastings ?? 0, oldValue: mergedConversion?.eastings }, { field: 'northings', value: mergedConversion?.northings ?? 0, oldValue: mergedConversion?.northings }, { field: 'orthogonalHeight', value: mergedConversion?.orthogonalHeight ?? 0, oldValue: mergedConversion?.orthogonalHeight }, { field: 'xAxisAbscissa', value: mergedConversion?.xAxisAbscissa ?? 1, oldValue: mergedConversion?.xAxisAbscissa }, { field: 'xAxisOrdinate', value: mergedConversion?.xAxisOrdinate ?? 0, oldValue: mergedConversion?.xAxisOrdinate }, { field: 'scale', value: mergedConversion?.scale ?? 1, oldValue: mergedConversion?.scale }, ]); setConversionOpen(true); requestAlignmentReload(); }, [modelId, setGeorefFields, mergedConversion, requestAlignmentReload]); const handleEpsgSelect = useCallback((result: EpsgResult) => { if (!modelId || !setGeorefFields) return; const epsgName = `EPSG:${result.code}`; const fieldUpdates: Array<{ field: string; value: string | number; oldValue?: string | number }> = [ { field: 'name', value: epsgName, oldValue: mergedCRS?.name }, ]; if (result.name) { fieldUpdates.push({ field: 'description', value: result.name, oldValue: mergedCRS?.description }); } if (result.datum) { fieldUpdates.push({ field: 'geodeticDatum', value: result.datum, oldValue: mergedCRS?.geodeticDatum }); } if (result.projection) { fieldUpdates.push({ field: 'mapProjection', value: result.projection, oldValue: mergedCRS?.mapProjection }); } if (result.unit) { const unitUpper = result.unit.toUpperCase(); const mapUnit = unitUpper.includes('US') && (unitUpper.includes('SURVEY') || unitUpper.includes('FTUS')) ? 'US SURVEY FOOT' : unitUpper.includes('METRE') || unitUpper.includes('METER') ? 'METRE' : unitUpper.includes('FOOT') || unitUpper.includes('FEET') ? 'FOOT' : result.unit; fieldUpdates.push({ field: 'mapUnit', value: mapUnit, oldValue: mergedCRS?.mapUnit }); } setGeorefFields(modelId, 'projectedCRS', fieldUpdates); if (!mergedConversion && !mutations?.mapConversion) { initializeMapConversionDefaults(); } setCrsOpen(true); requestAlignmentReload(); }, [modelId, setGeorefFields, mergedCRS, mergedConversion, mutations, initializeMapConversionDefaults, requestAlignmentReload]); const hasData = mergedCRS || mergedConversion; const editable = enableEditing && !!modelId && canUseStandardGeoreferencing; // When no georef data exists, show "Add Georeferencing" in edit mode if (!hasData && !georef?.hasGeoreference) { if (!editable) return null; return (
No georeferencing
); } return (
{showReloadPrompt && (

Georeference saved. Reload loaded models to recompute 3D alignment?

)} {/* Only flag the legacy-site / unsupported-schema state when there is actually nothing extractable to show. If we have a projectedCRS or mapConversion (even partially), the data sections below speak for themselves — the schema notice is just noise that contradicts the live data the properties panel already renders. */} {!canUseStandardGeoreferencing && !mergedCRS && !mergedConversion && (
{isLegacySiteGeoreference ? 'Showing legacy IfcSite geolocation from IFC2X3. This view is read-only.' : 'Georeferencing editing requires IFC4 or newer. IFC2X3 does not support IfcProjectedCRS or IfcMapConversion.'}
)} {/* Federation alignment badge + anchor / re-align controls. Hidden when only one model is loaded — alignment is a federation concept. */} {modelId && models.size > 1 && } {/* CRS summary — always visible */}
{mergedCRS?.name && ( {mergedCRS.name} )} {!mergedCRS?.name && ( No projected CRS )} {mergedCRS?.description && ( {mergedCRS.description} )} {mergedCRS?.name && } {editable && ( )}
{/* IfcProjectedCRS */} {mergedCRS && (
{crsOpen && (
handleSave('projectedCRS', 'name', v)} /> handleSave('projectedCRS', 'description', v)} /> handleSave('projectedCRS', 'geodeticDatum', v)} /> handleSave('projectedCRS', 'verticalDatum', v)} /> handleSave('projectedCRS', 'mapProjection', v)} /> handleSave('projectedCRS', 'mapZone', v)} /> handleSave('projectedCRS', 'mapUnit', v)} />
)}
)} {!mergedCRS && editable && mergedConversion && (
Coordinate operation exists, but projected CRS is missing.
)} {/* IfcMapConversion */} {mergedConversion && (
{conversionOpen && (
handleSave('mapConversion', 'eastings', v)} /> handleSave('mapConversion', 'northings', v)} /> handleSave('mapConversion', 'orthogonalHeight', v)}> handleSave('mapConversion', 'orthogonalHeight', oHeightForBaseAltitude(h))} /> handleSave('mapConversion', 'xAxisAbscissa', v)} /> handleSave('mapConversion', 'xAxisOrdinate', v)} /> handleSave('mapConversion', 'scale', v)} /> {scaleMismatch && (
Scale inconsistent with project/map units.{' '} Per IFC schema, IfcMapConversion.Scale should bridge the unit difference between the project length unit and map CRS unit. Current Scale = {scaleMismatch.rawScale}; expected ≈{' '} {scaleMismatch.expectedScale.toPrecision(4)}. Geometry is being placed at {scaleMismatch.effectiveScale.toPrecision(4)}× its physical size — adjust Scale (or MapUnit) to fix.
)}
)}
)} {!mergedConversion && editable && mergedCRS && (
No coordinate operation. Add map coordinates, angle to grid north, and scale.
)} {/* Sampled surface height — only when Cesium overlay is active */} {cesiumEnabled && isActiveCesiumModel && mergedConversion && (
Visible surface height {cesiumTerrainHeight !== null ? ( {cesiumTerrainHeight.toFixed(1)} m ) : ( querying... )}
{cesiumTerrainSource && (
sampled via {cesiumTerrainSource}
)} {cesiumTerrainHeight !== null && editable && modelId && (
)}
)} {/* Location minimap */}
); } /** Small button to apply Cesium terrain height to OrthogonalHeight field */ function TerrainHeightButton({ modelId, editable, onApply }: { modelId?: string; editable?: boolean; onApply: (height: number) => void; }) { const cesiumEnabled = useViewerStore(s => s.cesiumEnabled); const terrainHeight = useViewerStore(s => s.cesiumTerrainHeight); const terrainSource = useViewerStore(s => s.cesiumTerrainSource); const sourceModelId = useViewerStore(s => s.cesiumSourceModelId); // Only show when this panel's model is the active Cesium model if (!cesiumEnabled || terrainHeight === null || !editable || !modelId || modelId !== sourceModelId) return null; return ( Set OrthogonalHeight to sampled terrain height ({terrainHeight.toFixed(1)} m {terrainSource ? ` via ${terrainSource}` : ''}) ); }