@ifc-lite/viewer 1.17.1 → 1.17.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/.turbo/turbo-build.log +30 -28
  2. package/.turbo/turbo-typecheck.log +1 -41
  3. package/CHANGELOG.md +23 -0
  4. package/dist/assets/arrow-DJf2ErbF.js +20 -0
  5. package/dist/assets/basketViewActivator-aojwdomq.js +1 -0
  6. package/dist/assets/bcf-D5-QWGO9.js +281 -0
  7. package/dist/assets/{browser-BQdwnOUt.js → browser-CKs-FY1P.js} +1 -1
  8. package/dist/assets/drawing-2d-gWfpdfYe.js +257 -0
  9. package/dist/assets/epsg-index.generated-BjJrt_0S.js +1 -0
  10. package/dist/assets/exporters-C_6J153K.js +79896 -0
  11. package/dist/assets/geometry.worker-Nz9_YIqh.js +1 -0
  12. package/dist/assets/ids-B4jTqB1O.js +1 -0
  13. package/dist/assets/ifc-lite_bg-eSkBTizQ.wasm +0 -0
  14. package/dist/assets/index-jhBr1wbn.js +101666 -0
  15. package/dist/assets/index-pbE7itQS.css +1 -0
  16. package/dist/assets/lens-CSASnhAL.js +1 -0
  17. package/dist/assets/maplibre-gl-BpvwNKKy.js +811 -0
  18. package/dist/assets/{native-bridge-CN0ZMR2t.js → native-bridge-DSIyEYXG.js} +6 -4
  19. package/dist/assets/{arrow2-bb-jcVEo.js → parquet-CEXmQNRO.js} +2 -2
  20. package/dist/assets/sandbox-B79eavQ3.js +5933 -0
  21. package/dist/assets/server-client-D3bUPJJc.js +626 -0
  22. package/dist/assets/wasm-bridge-B0J07fZZ.js +1 -0
  23. package/dist/assets/zip-B-jFFAGa.js +12 -0
  24. package/dist/index.html +11 -2
  25. package/package.json +24 -19
  26. package/src/components/viewer/ExportChangesButton.tsx +18 -3
  27. package/src/components/viewer/ExportDialog.tsx +16 -3
  28. package/src/components/viewer/HierarchyPanel.tsx +6 -6
  29. package/src/components/viewer/PropertiesPanel.tsx +96 -60
  30. package/src/components/viewer/Section2DPanel.tsx +3 -2
  31. package/src/components/viewer/ViewportContainer.tsx +5 -4
  32. package/src/components/viewer/hierarchy/treeDataBuilder.ts +2 -1
  33. package/src/components/viewer/properties/EpsgLookupDialog.tsx +418 -0
  34. package/src/components/viewer/properties/GeoreferencingPanel.tsx +591 -0
  35. package/src/components/viewer/properties/LocationMap.tsx +289 -0
  36. package/src/components/viewer/properties/ModelMetadataPanel.tsx +3 -70
  37. package/src/hooks/bcfIdLookup.ts +13 -11
  38. package/src/hooks/ids/idsColorSystem.ts +3 -8
  39. package/src/hooks/useIDS.ts +31 -16
  40. package/src/hooks/useIfcFederation.ts +2 -2
  41. package/src/lib/geo/kmz-exporter.ts +112 -0
  42. package/src/lib/geo/reproject.ts +244 -0
  43. package/src/lib/lens/adapter.ts +3 -1
  44. package/src/main.tsx +1 -0
  45. package/src/sdk/adapters/export-adapter.ts +14 -1
  46. package/src/sdk/adapters/viewer-adapter.ts +5 -9
  47. package/src/sdk/adapters/visibility-adapter.ts +6 -9
  48. package/src/store/basketVisibleSet.ts +3 -4
  49. package/src/store/globalId.ts +79 -0
  50. package/src/store/index.ts +1 -0
  51. package/src/store/slices/mutationSlice.ts +178 -0
  52. package/src/store/slices/pinboardSlice.ts +4 -8
  53. package/vite.config.ts +17 -0
  54. package/dist/assets/Arrow.dom-DuPUrOxJ.js +0 -20
  55. package/dist/assets/arrow2_bg-BlXl-cSQ.js +0 -1
  56. package/dist/assets/basketViewActivator-DetjPnvt.js +0 -1
  57. package/dist/assets/geometry.worker-Bjm-ukng.js +0 -1
  58. package/dist/assets/ifc-lite_bg-DD0A7Yow.wasm +0 -0
  59. package/dist/assets/index-B3X21yXA.js +0 -229
  60. package/dist/assets/index-Ba4eoTe7.css +0 -1
  61. package/dist/assets/index-BybGZJTW.js +0 -189478
  62. package/dist/assets/module-6F3E5H7Y-tx0BadV3.js +0 -6
  63. package/dist/assets/wasm-bridge-D0bALkma.js +0 -1
@@ -0,0 +1,591 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Georeferencing panel - displays and allows editing of IfcProjectedCRS
7
+ * and IfcMapConversion entities with field-specific editing assistance.
8
+ */
9
+
10
+ import { useState, useCallback, useMemo } from 'react';
11
+ import { Globe, MapPin, PenLine, Check, X, Search, ChevronRight } from 'lucide-react';
12
+ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
13
+ import { Badge } from '@/components/ui/badge';
14
+ import { computeAngleToGridNorth, type GeoreferenceInfo, type MapConversion, type ProjectedCRS } from '@ifc-lite/parser';
15
+ import { useViewerStore } from '@/store';
16
+ import type { CoordinateInfo, GeometryResult } from '@ifc-lite/geometry';
17
+ import { EpsgLookupDialog, type EpsgResult } from './EpsgLookupDialog';
18
+ import { LocationMap } from './LocationMap';
19
+
20
+ // ── Field-specific assistance data ─────────────────────────────────────
21
+
22
+ const COMMON_DATUMS = ['WGS84', 'ETRS89', 'NAD83', 'NAD27', 'GRS80', 'Bessel 1841', 'Clarke 1866'];
23
+ const COMMON_PROJECTIONS = ['Transverse Mercator', 'UTM', 'Lambert Conformal Conic', 'Mercator', 'Stereographic', 'Oblique Mercator'];
24
+ const MAP_UNITS = ['METRE', 'FOOT', 'US SURVEY FOOT'];
25
+ const COMMON_VERTICAL_DATUMS = ['MSL', 'NAVD88', 'EVRF2007', 'EVRF2019', 'AHD', 'ODN', 'LN02'];
26
+
27
+ type FieldHint = {
28
+ placeholder?: string;
29
+ suggestions?: string[];
30
+ isSelect?: boolean;
31
+ helpText?: string;
32
+ };
33
+
34
+ function getFieldHint(entity: string, field: string): FieldHint {
35
+ if (entity === 'projectedCRS') {
36
+ switch (field) {
37
+ case 'name': return { placeholder: 'e.g. EPSG:4326', helpText: 'Use EPSG lookup to search' };
38
+ case 'description': return { placeholder: 'e.g. WGS 84 / UTM zone 32N' };
39
+ case 'geodeticDatum': return { placeholder: 'e.g. WGS84', suggestions: COMMON_DATUMS };
40
+ case 'verticalDatum': return { placeholder: 'e.g. MSL', suggestions: COMMON_VERTICAL_DATUMS };
41
+ case 'mapProjection': return { placeholder: 'e.g. Transverse Mercator', suggestions: COMMON_PROJECTIONS };
42
+ case 'mapZone': return { placeholder: 'e.g. 32N' };
43
+ case 'mapUnit': return { isSelect: true, suggestions: MAP_UNITS };
44
+ default: return {};
45
+ }
46
+ }
47
+ if (entity === 'mapConversion') {
48
+ switch (field) {
49
+ case 'eastings': return { placeholder: '0.0', helpText: 'X offset in map units' };
50
+ case 'northings': return { placeholder: '0.0', helpText: 'Y offset in map units' };
51
+ case 'orthogonalHeight': return { placeholder: '0.0', helpText: 'Z offset in metres' };
52
+ case 'xAxisAbscissa': return { placeholder: '1.0', helpText: 'cos(angle to grid north)' };
53
+ case 'xAxisOrdinate': return { placeholder: '0.0', helpText: 'sin(angle to grid north)' };
54
+ case 'scale': return { placeholder: '1.0', helpText: 'Usually 1.0 or close to it' };
55
+ default: return {};
56
+ }
57
+ }
58
+ return {};
59
+ }
60
+
61
+ // ── GeorefRow: a single editable field ─────────────────────────────────
62
+
63
+ interface GeorefRowProps {
64
+ label: string;
65
+ value: string | number | undefined | null;
66
+ suffix?: string;
67
+ isComputed?: boolean;
68
+ isNumber?: boolean;
69
+ editable?: boolean;
70
+ isMutated?: boolean;
71
+ fieldEntity?: string;
72
+ fieldName?: string;
73
+ onSave?: (value: string | number) => void;
74
+ }
75
+
76
+ function GeorefRow({ label, value, suffix, isComputed, isNumber, editable, isMutated, fieldEntity, fieldName, onSave }: GeorefRowProps) {
77
+ const [editing, setEditing] = useState(false);
78
+ const [editValue, setEditValue] = useState('');
79
+
80
+ const hint = useMemo(() => getFieldHint(fieldEntity ?? '', fieldName ?? ''), [fieldEntity, fieldName]);
81
+
82
+ const startEdit = useCallback(() => {
83
+ if (!editable || isComputed) return;
84
+ setEditValue(value != null ? String(value) : '');
85
+ setEditing(true);
86
+ }, [value, editable, isComputed]);
87
+
88
+ const commitEdit = useCallback((overrideValue?: string) => {
89
+ if (!onSave) { setEditing(false); return; }
90
+ const trimmed = (overrideValue ?? editValue).trim();
91
+ if (!trimmed && !hint.isSelect) { setEditing(false); return; }
92
+ if (isNumber) {
93
+ const num = parseFloat(trimmed);
94
+ if (!Number.isFinite(num)) { setEditing(false); return; }
95
+ onSave(num);
96
+ } else {
97
+ onSave(trimmed);
98
+ }
99
+ setEditing(false);
100
+ }, [editValue, isNumber, onSave, hint.isSelect]);
101
+
102
+ const cancelEdit = useCallback(() => {
103
+ setEditing(false);
104
+ }, []);
105
+
106
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
107
+ if (e.key === 'Enter') commitEdit();
108
+ if (e.key === 'Escape') cancelEdit();
109
+ }, [commitEdit, cancelEdit]);
110
+
111
+ const selectSuggestion = useCallback((s: string) => {
112
+ if (!onSave) return;
113
+ if (isNumber) {
114
+ const num = parseFloat(s);
115
+ if (Number.isFinite(num)) onSave(num);
116
+ } else {
117
+ onSave(s);
118
+ }
119
+ setEditing(false);
120
+ }, [onSave, isNumber]);
121
+
122
+ const displayValue = value != null ? String(value) : '-';
123
+
124
+ return (
125
+ <div
126
+ className={`flex items-start gap-2 px-3 py-1.5 min-w-0 ${
127
+ isMutated ? 'bg-purple-50/50 dark:bg-purple-950/30' : ''
128
+ } ${editable && !isComputed ? 'cursor-pointer hover:bg-zinc-50 dark:hover:bg-zinc-900/50 group/row' : ''}`}
129
+ onClick={!editing ? startEdit : undefined}
130
+ >
131
+ <span className="text-[11px] text-zinc-500 dark:text-zinc-400 shrink-0 pt-0.5 flex items-center gap-0.5 min-w-[110px]">
132
+ {isComputed && (
133
+ <Tooltip>
134
+ <TooltipTrigger asChild>
135
+ <span className="text-[10px] text-teal-500">*</span>
136
+ </TooltipTrigger>
137
+ <TooltipContent>Computed from XAxisAbscissa and XAxisOrdinate</TooltipContent>
138
+ </Tooltip>
139
+ )}
140
+ {label}
141
+ </span>
142
+ <div className="flex-1 flex flex-col items-end gap-0.5 min-w-0">
143
+ <div className="flex items-start gap-1 w-full justify-end">
144
+ {isMutated && !editing && (
145
+ <Badge variant="secondary" className="h-4 px-1 text-[9px] bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-300 border-purple-200 dark:border-purple-700 shrink-0 mt-0.5">
146
+ edited
147
+ </Badge>
148
+ )}
149
+ {editing ? (
150
+ <div className="flex flex-col gap-1 w-full" onClick={e => e.stopPropagation()}>
151
+ <div className="flex items-center gap-1">
152
+ {hint.isSelect ? (
153
+ <select
154
+ value={editValue}
155
+ onChange={e => { setEditValue(e.target.value); }}
156
+ className="flex-1 text-[11px] font-mono px-1.5 py-1 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"
157
+ autoFocus
158
+ >
159
+ <option value="">-- select --</option>
160
+ {hint.suggestions?.map(s => <option key={s} value={s}>{s}</option>)}
161
+ </select>
162
+ ) : (
163
+ <input
164
+ value={editValue}
165
+ onChange={e => setEditValue(e.target.value)}
166
+ onKeyDown={handleKeyDown}
167
+ placeholder={hint.placeholder}
168
+ 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"
169
+ autoFocus
170
+ />
171
+ )}
172
+ <button onClick={() => commitEdit()} className="p-0.5 text-green-600 hover:text-green-700 dark:text-green-400 shrink-0">
173
+ <Check className="h-3 w-3" />
174
+ </button>
175
+ <button onClick={cancelEdit} className="p-0.5 text-red-500 hover:text-red-600 dark:text-red-400 shrink-0">
176
+ <X className="h-3 w-3" />
177
+ </button>
178
+ </div>
179
+ {/* Suggestion chips for fields with common values */}
180
+ {hint.suggestions && !hint.isSelect && (
181
+ <div className="flex flex-wrap gap-1">
182
+ {hint.suggestions.map(s => (
183
+ <button
184
+ key={s}
185
+ onClick={() => selectSuggestion(s)}
186
+ className="text-[9px] font-mono px-1.5 py-0.5 border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:border-teal-400 hover:text-teal-700 dark:hover:text-teal-400 hover:bg-teal-50 dark:hover:bg-teal-950/50 transition-colors"
187
+ >
188
+ {s}
189
+ </button>
190
+ ))}
191
+ </div>
192
+ )}
193
+ {/* Help text */}
194
+ {hint.helpText && (
195
+ <span className="text-[9px] text-zinc-400 dark:text-zinc-500">{hint.helpText}</span>
196
+ )}
197
+ </div>
198
+ ) : (
199
+ <>
200
+ <span
201
+ className={`text-[11px] font-mono tabular-nums break-all text-right ${
202
+ isMutated
203
+ ? 'text-purple-700 dark:text-purple-300 font-semibold'
204
+ : 'text-teal-700 dark:text-teal-400'
205
+ }`}
206
+ title={displayValue}
207
+ >
208
+ {displayValue}
209
+ {suffix && <span className="text-zinc-400 dark:text-zinc-500 ml-0.5">{suffix}</span>}
210
+ </span>
211
+ {editable && !isComputed && (
212
+ <PenLine className="h-3 w-3 opacity-0 group-hover/row:opacity-100 transition-opacity text-zinc-400 shrink-0 mt-0.5" />
213
+ )}
214
+ </>
215
+ )}
216
+ </div>
217
+ </div>
218
+ </div>
219
+ );
220
+ }
221
+
222
+ // ── AngleRow: edit angle and auto-compute XAxisAbscissa/XAxisOrdinate ───
223
+
224
+ interface AngleRowProps {
225
+ angle: number | null;
226
+ editable?: boolean;
227
+ onAngleChange?: (abscissa: number, ordinate: number) => void;
228
+ }
229
+
230
+ function AngleRow({ angle, editable, onAngleChange }: AngleRowProps) {
231
+ const [editing, setEditing] = useState(false);
232
+ const [editValue, setEditValue] = useState('');
233
+
234
+ const startEdit = useCallback(() => {
235
+ if (!editable) return;
236
+ setEditValue(angle != null ? angle.toFixed(6) : '');
237
+ setEditing(true);
238
+ }, [angle, editable]);
239
+
240
+ const commitEdit = useCallback(() => {
241
+ if (!onAngleChange) return;
242
+ const deg = parseFloat(editValue.trim());
243
+ if (!Number.isFinite(deg)) return;
244
+ const rad = deg * (Math.PI / 180);
245
+ onAngleChange(Math.cos(rad), Math.sin(rad));
246
+ setEditing(false);
247
+ }, [editValue, onAngleChange]);
248
+
249
+ const cancelEdit = useCallback(() => setEditing(false), []);
250
+
251
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
252
+ if (e.key === 'Enter') commitEdit();
253
+ if (e.key === 'Escape') cancelEdit();
254
+ }, [commitEdit, cancelEdit]);
255
+
256
+ return (
257
+ <div
258
+ className={`flex items-start gap-2 px-3 py-1.5 min-w-0 ${editable ? 'cursor-pointer hover:bg-zinc-50 dark:hover:bg-zinc-900/50 group/row' : ''}`}
259
+ onClick={!editing ? startEdit : undefined}
260
+ >
261
+ <span className="text-[11px] text-zinc-500 dark:text-zinc-400 shrink-0 pt-0.5 flex items-center gap-0.5 min-w-[110px]">
262
+ <Tooltip>
263
+ <TooltipTrigger asChild>
264
+ <span className="text-[10px] text-teal-500">*</span>
265
+ </TooltipTrigger>
266
+ <TooltipContent>{editable ? 'Edit angle to auto-compute XAxisAbscissa/XAxisOrdinate' : 'Computed from XAxisAbscissa and XAxisOrdinate'}</TooltipContent>
267
+ </Tooltip>
268
+ Angle to Grid North
269
+ </span>
270
+ <div className="flex-1 flex items-start gap-1 min-w-0 justify-end">
271
+ {editing ? (
272
+ <div className="flex flex-col gap-1" onClick={e => e.stopPropagation()}>
273
+ <div className="flex items-center gap-1">
274
+ <input
275
+ value={editValue}
276
+ onChange={e => setEditValue(e.target.value)}
277
+ onKeyDown={handleKeyDown}
278
+ placeholder="0.0"
279
+ 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"
280
+ autoFocus
281
+ />
282
+ <span className="text-[10px] text-zinc-400">deg</span>
283
+ <button onClick={commitEdit} className="p-0.5 text-green-600 hover:text-green-700 dark:text-green-400 shrink-0">
284
+ <Check className="h-3 w-3" />
285
+ </button>
286
+ <button onClick={cancelEdit} className="p-0.5 text-red-500 hover:text-red-600 dark:text-red-400 shrink-0">
287
+ <X className="h-3 w-3" />
288
+ </button>
289
+ </div>
290
+ <span className="text-[9px] text-zinc-400 dark:text-zinc-500">Sets XAxisAbscissa = cos(angle), XAxisOrdinate = sin(angle)</span>
291
+ </div>
292
+ ) : (
293
+ <>
294
+ <span className="text-[11px] font-mono tabular-nums text-teal-700 dark:text-teal-400">
295
+ {angle != null ? parseFloat(angle.toFixed(6)) : '-'}
296
+ <span className="text-zinc-400 dark:text-zinc-500 ml-0.5">deg</span>
297
+ </span>
298
+ {editable && (
299
+ <PenLine className="h-3 w-3 opacity-0 group-hover/row:opacity-100 transition-opacity text-zinc-400 shrink-0 mt-0.5" />
300
+ )}
301
+ </>
302
+ )}
303
+ </div>
304
+ </div>
305
+ );
306
+ }
307
+
308
+ // ── Main Panel ─────────────────────────────────────────────────────────
309
+
310
+ export interface GeoreferencingPanelProps {
311
+ georef: GeoreferenceInfo | null;
312
+ modelId?: string;
313
+ enableEditing?: boolean;
314
+ schemaVersion?: string;
315
+ /** CoordinateInfo from the model's geometry (for map position calculation) */
316
+ coordinateInfo?: CoordinateInfo;
317
+ /** GeometryResult for KMZ export */
318
+ geometryResult?: GeometryResult | null;
319
+ }
320
+
321
+ export function GeoreferencingPanel({ georef, modelId, enableEditing, schemaVersion, coordinateInfo, geometryResult }: GeoreferencingPanelProps) {
322
+ const georefMutations = useViewerStore(s => s.georefMutations);
323
+ const setGeorefField = useViewerStore(s => s.setGeorefField);
324
+ const setGeorefFields = useViewerStore(s => s.setGeorefFields);
325
+ const [crsOpen, setCrsOpen] = useState(false);
326
+ const [conversionOpen, setConversionOpen] = useState(false);
327
+
328
+ useViewerStore(s => s.mutationVersion);
329
+
330
+ const mutations = modelId ? georefMutations?.get(modelId) : undefined;
331
+ const supportsStandardGeoreferencing = !schemaVersion?.toUpperCase().includes('2X3');
332
+
333
+ const mergedCRS = useMemo((): ProjectedCRS | undefined => {
334
+ const base = georef?.projectedCRS;
335
+ const muts = mutations?.projectedCRS;
336
+ if (!base && !muts) return undefined;
337
+ return {
338
+ id: base?.id ?? 0,
339
+ name: muts?.name ?? base?.name ?? '',
340
+ description: muts?.description ?? base?.description,
341
+ geodeticDatum: muts?.geodeticDatum ?? base?.geodeticDatum,
342
+ verticalDatum: muts?.verticalDatum ?? base?.verticalDatum,
343
+ mapProjection: muts?.mapProjection ?? base?.mapProjection,
344
+ mapZone: muts?.mapZone ?? base?.mapZone,
345
+ mapUnit: muts?.mapUnit ?? base?.mapUnit,
346
+ };
347
+ }, [georef, mutations]);
348
+
349
+ const mergedConversion = useMemo((): MapConversion | undefined => {
350
+ const base = georef?.mapConversion;
351
+ const muts = mutations?.mapConversion;
352
+ if (!base && !muts) return undefined;
353
+ return {
354
+ id: base?.id ?? 0,
355
+ sourceCRS: base?.sourceCRS ?? 0,
356
+ targetCRS: base?.targetCRS ?? 0,
357
+ eastings: muts?.eastings ?? base?.eastings ?? 0,
358
+ northings: muts?.northings ?? base?.northings ?? 0,
359
+ orthogonalHeight: muts?.orthogonalHeight ?? base?.orthogonalHeight ?? 0,
360
+ xAxisAbscissa: muts?.xAxisAbscissa ?? base?.xAxisAbscissa,
361
+ xAxisOrdinate: muts?.xAxisOrdinate ?? base?.xAxisOrdinate,
362
+ scale: muts?.scale ?? base?.scale,
363
+ };
364
+ }, [georef, mutations]);
365
+
366
+ const angleToGridNorth = useMemo(() => {
367
+ return computeAngleToGridNorth(mergedConversion?.xAxisAbscissa, mergedConversion?.xAxisOrdinate);
368
+ }, [mergedConversion]);
369
+
370
+ const mapUnitSuffix = useMemo(() => {
371
+ const mapUnit = mergedCRS?.mapUnit?.toUpperCase();
372
+ if (!mapUnit) return 'm';
373
+ if (mapUnit.includes('US') && mapUnit.includes('FOOT')) return 'ftUS';
374
+ if (mapUnit.includes('FOOT') || mapUnit.includes('FEET')) return 'ft';
375
+ return 'm';
376
+ }, [mergedCRS?.mapUnit]);
377
+
378
+ const isMutated = useCallback((entity: 'projectedCRS' | 'mapConversion', field: string): boolean => {
379
+ if (!mutations) return false;
380
+ const entityMuts = mutations[entity];
381
+ if (!entityMuts) return false;
382
+ return field in entityMuts;
383
+ }, [mutations]);
384
+
385
+ const handleSave = useCallback((entity: 'projectedCRS' | 'mapConversion', field: string, value: string | number) => {
386
+ if (!modelId || !setGeorefField) return;
387
+ const oldValue = entity === 'projectedCRS'
388
+ ? georef?.projectedCRS?.[field as keyof ProjectedCRS]
389
+ : georef?.mapConversion?.[field as keyof MapConversion];
390
+ setGeorefField(modelId, entity, field, value, oldValue as string | number | undefined);
391
+ }, [modelId, setGeorefField, georef]);
392
+
393
+ // Handle angle edit: compute and set both XAxisAbscissa and XAxisOrdinate
394
+ const handleAngleChange = useCallback((abscissa: number, ordinate: number) => {
395
+ if (!modelId || !setGeorefFields) return;
396
+ setGeorefFields(modelId, 'mapConversion', [
397
+ { field: 'xAxisAbscissa', value: abscissa, oldValue: georef?.mapConversion?.xAxisAbscissa },
398
+ { field: 'xAxisOrdinate', value: ordinate, oldValue: georef?.mapConversion?.xAxisOrdinate },
399
+ ]);
400
+ }, [modelId, setGeorefFields, georef]);
401
+
402
+ const initializeMapConversionDefaults = useCallback(() => {
403
+ if (!modelId || !setGeorefFields) return;
404
+ setGeorefFields(modelId, 'mapConversion', [
405
+ { field: 'eastings', value: georef?.mapConversion?.eastings ?? 0, oldValue: georef?.mapConversion?.eastings },
406
+ { field: 'northings', value: georef?.mapConversion?.northings ?? 0, oldValue: georef?.mapConversion?.northings },
407
+ { field: 'orthogonalHeight', value: georef?.mapConversion?.orthogonalHeight ?? 0, oldValue: georef?.mapConversion?.orthogonalHeight },
408
+ { field: 'xAxisAbscissa', value: georef?.mapConversion?.xAxisAbscissa ?? 1, oldValue: georef?.mapConversion?.xAxisAbscissa },
409
+ { field: 'xAxisOrdinate', value: georef?.mapConversion?.xAxisOrdinate ?? 0, oldValue: georef?.mapConversion?.xAxisOrdinate },
410
+ { field: 'scale', value: georef?.mapConversion?.scale ?? 1, oldValue: georef?.mapConversion?.scale },
411
+ ]);
412
+ setConversionOpen(true);
413
+ }, [modelId, setGeorefFields, georef]);
414
+
415
+ const handleEpsgSelect = useCallback((result: EpsgResult) => {
416
+ if (!modelId || !setGeorefFields) return;
417
+ const epsgName = `EPSG:${result.code}`;
418
+ const fieldUpdates: Array<{ field: string; value: string | number; oldValue?: string | number }> = [
419
+ { field: 'name', value: epsgName, oldValue: georef?.projectedCRS?.name },
420
+ ];
421
+ if (result.name) {
422
+ fieldUpdates.push({ field: 'description', value: result.name, oldValue: georef?.projectedCRS?.description });
423
+ }
424
+ if (result.datum) {
425
+ fieldUpdates.push({ field: 'geodeticDatum', value: result.datum, oldValue: georef?.projectedCRS?.geodeticDatum });
426
+ }
427
+ if (result.projection) {
428
+ fieldUpdates.push({ field: 'mapProjection', value: result.projection, oldValue: georef?.projectedCRS?.mapProjection });
429
+ }
430
+ if (result.unit) {
431
+ const unitUpper = result.unit.toUpperCase();
432
+ const mapUnit = unitUpper.includes('US') && (unitUpper.includes('SURVEY') || unitUpper.includes('FTUS'))
433
+ ? 'US SURVEY FOOT'
434
+ : unitUpper.includes('METRE') || unitUpper.includes('METER')
435
+ ? 'METRE'
436
+ : unitUpper.includes('FOOT') || unitUpper.includes('FEET')
437
+ ? 'FOOT'
438
+ : result.unit;
439
+ fieldUpdates.push({ field: 'mapUnit', value: mapUnit, oldValue: georef?.projectedCRS?.mapUnit });
440
+ }
441
+ setGeorefFields(modelId, 'projectedCRS', fieldUpdates);
442
+ if (!georef?.mapConversion && !mutations?.mapConversion) {
443
+ initializeMapConversionDefaults();
444
+ }
445
+ setCrsOpen(true);
446
+ }, [modelId, setGeorefFields, georef, mutations, initializeMapConversionDefaults]);
447
+
448
+ const hasData = mergedCRS || mergedConversion;
449
+ const editable = enableEditing && !!modelId && supportsStandardGeoreferencing;
450
+
451
+ if (enableEditing && !supportsStandardGeoreferencing) {
452
+ return (
453
+ <div className="px-2 py-1.5 flex items-center gap-2">
454
+ <Globe className="h-3 w-3 text-zinc-400" />
455
+ <span className="text-[10px] text-zinc-500 dark:text-zinc-400">
456
+ Georeferencing editing requires IFC4 or newer. IFC2X3 does not support IfcProjectedCRS or IfcMapConversion.
457
+ </span>
458
+ </div>
459
+ );
460
+ }
461
+
462
+ // When no georef data exists, show "Add Georeferencing" in edit mode
463
+ if (!hasData && !georef?.hasGeoreference) {
464
+ if (!editable) return null;
465
+ return (
466
+ <div className="px-2 py-1.5 flex items-center gap-2">
467
+ <Globe className="h-3 w-3 text-teal-500" />
468
+ <span className="text-[10px] text-zinc-500 dark:text-zinc-400 flex-1">No georeferencing</span>
469
+ <EpsgLookupDialog onSelect={handleEpsgSelect}>
470
+ <button className="flex items-center gap-1 text-[10px] text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 transition-colors px-1.5 py-0.5 border border-teal-300/50 dark:border-teal-700/50 hover:bg-teal-50 dark:hover:bg-teal-950/50">
471
+ <Globe className="h-2.5 w-2.5" />
472
+ Add Georeferencing
473
+ </button>
474
+ </EpsgLookupDialog>
475
+ </div>
476
+ );
477
+ }
478
+
479
+ return (
480
+ <div>
481
+ {/* CRS summary — always visible */}
482
+ <div className="px-2 py-1.5 flex items-center gap-2">
483
+ <Globe className="h-3 w-3 text-teal-500 shrink-0" />
484
+ {mergedCRS?.name && (
485
+ <span className="text-[10px] font-mono font-semibold text-teal-600 dark:text-teal-400">{mergedCRS.name}</span>
486
+ )}
487
+ {!mergedCRS?.name && (
488
+ <span className="text-[10px] text-zinc-500 dark:text-zinc-400">No projected CRS</span>
489
+ )}
490
+ {mergedCRS?.description && (
491
+ <span className="text-[10px] font-mono text-teal-500/60 truncate">{mergedCRS.description}</span>
492
+ )}
493
+ {editable && (
494
+ <EpsgLookupDialog onSelect={handleEpsgSelect}>
495
+ <button className="flex items-center gap-1 text-[9px] text-teal-500 hover:text-teal-700 dark:hover:text-teal-300 transition-colors ml-auto shrink-0">
496
+ <Search className="h-2.5 w-2.5" />
497
+ EPSG
498
+ </button>
499
+ </EpsgLookupDialog>
500
+ )}
501
+ </div>
502
+
503
+ {/* IfcProjectedCRS */}
504
+ {mergedCRS && (
505
+ <div>
506
+ <button
507
+ onClick={() => setCrsOpen(!crsOpen)}
508
+ className="flex items-center gap-2 w-full px-3 py-1.5 hover:bg-zinc-50 dark:hover:bg-zinc-900 text-left transition-colors border-b border-zinc-100 dark:border-zinc-900"
509
+ >
510
+ <ChevronRight className={`h-3 w-3 text-teal-500 shrink-0 transition-transform ${crsOpen ? 'rotate-90' : ''}`} />
511
+ <Globe className="h-3 w-3 text-teal-500 shrink-0" />
512
+ <span className="font-bold text-[11px] text-zinc-700 dark:text-zinc-300 uppercase tracking-wide flex-1 text-left">Projected CRS</span>
513
+ {!crsOpen && mergedCRS.name && (
514
+ <span className="text-[10px] font-mono text-teal-600/70 dark:text-teal-500/60 truncate max-w-[50%]">{mergedCRS.name}</span>
515
+ )}
516
+ </button>
517
+ {crsOpen && (
518
+ <div className="divide-y divide-zinc-100 dark:divide-zinc-900">
519
+ <GeorefRow label="Name" value={mergedCRS.name} editable={editable} isMutated={isMutated('projectedCRS', 'name')} fieldEntity="projectedCRS" fieldName="name" onSave={v => handleSave('projectedCRS', 'name', v)} />
520
+ <GeorefRow label="Description" value={mergedCRS.description} editable={editable} isMutated={isMutated('projectedCRS', 'description')} fieldEntity="projectedCRS" fieldName="description" onSave={v => handleSave('projectedCRS', 'description', v)} />
521
+ <GeorefRow label="GeodeticDatum" value={mergedCRS.geodeticDatum} editable={editable} isMutated={isMutated('projectedCRS', 'geodeticDatum')} fieldEntity="projectedCRS" fieldName="geodeticDatum" onSave={v => handleSave('projectedCRS', 'geodeticDatum', v)} />
522
+ <GeorefRow label="VerticalDatum" value={mergedCRS.verticalDatum} editable={editable} isMutated={isMutated('projectedCRS', 'verticalDatum')} fieldEntity="projectedCRS" fieldName="verticalDatum" onSave={v => handleSave('projectedCRS', 'verticalDatum', v)} />
523
+ <GeorefRow label="MapProjection" value={mergedCRS.mapProjection} editable={editable} isMutated={isMutated('projectedCRS', 'mapProjection')} fieldEntity="projectedCRS" fieldName="mapProjection" onSave={v => handleSave('projectedCRS', 'mapProjection', v)} />
524
+ <GeorefRow label="MapZone" value={mergedCRS.mapZone} editable={editable} isMutated={isMutated('projectedCRS', 'mapZone')} fieldEntity="projectedCRS" fieldName="mapZone" onSave={v => handleSave('projectedCRS', 'mapZone', v)} />
525
+ <GeorefRow label="MapUnit" value={mergedCRS.mapUnit} editable={editable} isMutated={isMutated('projectedCRS', 'mapUnit')} fieldEntity="projectedCRS" fieldName="mapUnit" onSave={v => handleSave('projectedCRS', 'mapUnit', v)} />
526
+ </div>
527
+ )}
528
+ </div>
529
+ )}
530
+
531
+ {!mergedCRS && editable && mergedConversion && (
532
+ <div className="px-3 py-2 border-b border-zinc-100 dark:border-zinc-900 flex items-center gap-2">
533
+ <span className="text-[10px] text-zinc-500 dark:text-zinc-400 flex-1">Coordinate operation exists, but projected CRS is missing.</span>
534
+ <EpsgLookupDialog onSelect={handleEpsgSelect}>
535
+ <button className="flex items-center gap-1 text-[9px] text-teal-500 hover:text-teal-700 dark:hover:text-teal-300 transition-colors shrink-0">
536
+ <Search className="h-2.5 w-2.5" />
537
+ Add CRS
538
+ </button>
539
+ </EpsgLookupDialog>
540
+ </div>
541
+ )}
542
+
543
+ {/* IfcMapConversion */}
544
+ {mergedConversion && (
545
+ <div>
546
+ <button
547
+ onClick={() => setConversionOpen(!conversionOpen)}
548
+ className="flex items-center gap-2 w-full px-3 py-1.5 hover:bg-zinc-50 dark:hover:bg-zinc-900 text-left transition-colors border-b border-zinc-100 dark:border-zinc-900"
549
+ >
550
+ <ChevronRight className={`h-3 w-3 text-teal-500 shrink-0 transition-transform ${conversionOpen ? 'rotate-90' : ''}`} />
551
+ <MapPin className="h-3 w-3 text-teal-500 shrink-0" />
552
+ <span className="font-bold text-[11px] text-zinc-700 dark:text-zinc-300 uppercase tracking-wide flex-1 text-left">Coordinate Operation</span>
553
+ {!conversionOpen && (
554
+ <span className="text-[10px] font-mono text-teal-600/70 dark:text-teal-500/60">
555
+ E {mergedConversion.eastings.toFixed(0)} N {mergedConversion.northings.toFixed(0)}
556
+ </span>
557
+ )}
558
+ </button>
559
+ {conversionOpen && (
560
+ <div className="divide-y divide-zinc-100 dark:divide-zinc-900">
561
+ <GeorefRow label="Type" value="IfcMapConversion" />
562
+ <GeorefRow label="Eastings" value={mergedConversion.eastings} suffix={mapUnitSuffix} isNumber editable={editable} isMutated={isMutated('mapConversion', 'eastings')} fieldEntity="mapConversion" fieldName="eastings" onSave={v => handleSave('mapConversion', 'eastings', v)} />
563
+ <GeorefRow label="Northings" value={mergedConversion.northings} suffix={mapUnitSuffix} isNumber editable={editable} isMutated={isMutated('mapConversion', 'northings')} fieldEntity="mapConversion" fieldName="northings" onSave={v => handleSave('mapConversion', 'northings', v)} />
564
+ <GeorefRow label="OrthogonalHeight" value={mergedConversion.orthogonalHeight} suffix={mapUnitSuffix} isNumber editable={editable} isMutated={isMutated('mapConversion', 'orthogonalHeight')} fieldEntity="mapConversion" fieldName="orthogonalHeight" onSave={v => handleSave('mapConversion', 'orthogonalHeight', v)} />
565
+ <GeorefRow label="XAxisAbscissa" value={mergedConversion.xAxisAbscissa} isNumber editable={editable} isMutated={isMutated('mapConversion', 'xAxisAbscissa')} fieldEntity="mapConversion" fieldName="xAxisAbscissa" onSave={v => handleSave('mapConversion', 'xAxisAbscissa', v)} />
566
+ <GeorefRow label="XAxisOrdinate" value={mergedConversion.xAxisOrdinate} isNumber editable={editable} isMutated={isMutated('mapConversion', 'xAxisOrdinate')} fieldEntity="mapConversion" fieldName="xAxisOrdinate" onSave={v => handleSave('mapConversion', 'xAxisOrdinate', v)} />
567
+ <AngleRow angle={angleToGridNorth} editable={editable} onAngleChange={handleAngleChange} />
568
+ <GeorefRow label="Scale" value={mergedConversion.scale} isNumber editable={editable} isMutated={isMutated('mapConversion', 'scale')} fieldEntity="mapConversion" fieldName="scale" onSave={v => handleSave('mapConversion', 'scale', v)} />
569
+ </div>
570
+ )}
571
+ </div>
572
+ )}
573
+
574
+ {!mergedConversion && editable && mergedCRS && (
575
+ <div className="px-3 py-2 border-b border-zinc-100 dark:border-zinc-900 flex items-center gap-2">
576
+ <span className="text-[10px] text-zinc-500 dark:text-zinc-400 flex-1">No coordinate operation. Add map coordinates, angle to grid north, and scale.</span>
577
+ <button
578
+ onClick={initializeMapConversionDefaults}
579
+ className="flex items-center gap-1 text-[9px] text-teal-500 hover:text-teal-700 dark:hover:text-teal-300 transition-colors shrink-0"
580
+ >
581
+ <MapPin className="h-2.5 w-2.5" />
582
+ Add Coordinates
583
+ </button>
584
+ </div>
585
+ )}
586
+
587
+ {/* Location minimap */}
588
+ <LocationMap mapConversion={mergedConversion} projectedCRS={mergedCRS} coordinateInfo={coordinateInfo} geometryResult={geometryResult} />
589
+ </div>
590
+ );
591
+ }