);
}
// ── 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 */}
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.