@ifc-lite/viewer 1.21.0 → 1.22.0

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 (87) hide show
  1. package/.turbo/turbo-build.log +57 -50
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +10 -0
  4. package/dist/assets/arrow-fie-E7fe.js +20 -0
  5. package/dist/assets/ascii-points-source-bTjLVmUX.js +1 -0
  6. package/dist/assets/{basketViewActivator-Bzw51jhm.js → basketViewActivator-EHAhHlwN.js} +12 -13
  7. package/dist/assets/bcf-Bhx-K17f.js +281 -0
  8. package/dist/assets/{browser-C5TFR7sH.js → browser-CVf8ATeW.js} +6 -6
  9. package/dist/assets/cesium-B4ZIU9jS.js +17742 -0
  10. package/dist/assets/decode-worker-CYqSjk1n.js +172 -0
  11. package/dist/assets/e57-source-CQHxE8n3.js +1 -0
  12. package/dist/assets/emscripten-module.browser-DcFZLAUx.js +1 -0
  13. package/dist/assets/exporters-KTio0Tdm.js +5723 -0
  14. package/dist/assets/geometry-controller.worker-Cm2P_EJr.js +7 -0
  15. package/dist/assets/geometry.worker-DchLBqZ8.js +1 -0
  16. package/dist/assets/{ids-B7AXEv7h.js → ids-CS7VCFin.js} +5 -5
  17. package/dist/assets/ifc-lite-C6wEhXa6.js +7 -0
  18. package/dist/assets/{ifc-lite_bg-DlKs5-yM.wasm → ifc-lite_bg-CSeT3fNI.wasm} +0 -0
  19. package/dist/assets/{ifc-lite_bg-PqmRe3Ph.wasm → ifc-lite_bg-ns4cSnX2.wasm} +0 -0
  20. package/dist/assets/{index-DVNSvEMh.js → index-8k9h-ANq.js} +60997 -59926
  21. package/dist/assets/index-BZC2YaOP.css +1 -0
  22. package/dist/assets/index-HqAIQkr6.js +22 -0
  23. package/dist/assets/inline-worker-BpBzlmd6.js +1 -0
  24. package/dist/assets/las-BW6LIc_j.js +1 -0
  25. package/dist/assets/las-source-C_IGrgRq.js +1 -0
  26. package/dist/assets/laz-source-jj3xI5Y4.js +125 -0
  27. package/dist/assets/maplibre-gl-C4LXKM6c.js +808 -0
  28. package/dist/assets/{native-bridge-BiD01jI9.js → native-bridge-DNrEhx2R.js} +5 -8
  29. package/dist/assets/{parser.worker-Bnbrl6gy.js → parser.worker-BcjkIo89.js} +2 -2
  30. package/dist/assets/pcd-source-Ck0UnVDn.js +3 -0
  31. package/dist/assets/ply-source-C8jjyzxE.js +4 -0
  32. package/dist/assets/{exporters-u0sz2Upj.js → sandbox-BSn5MyEJ.js} +11745 -7412
  33. package/dist/assets/{server-client-DP8fMPY9.js → server-client-D-kU2XAF.js} +4 -4
  34. package/dist/assets/{three-CDRZThFA.js → three-DwNDHx9-.js} +163 -171
  35. package/dist/assets/wasm-bridge-Cha08LdC.js +1 -0
  36. package/dist/assets/{workerHelpers-CBbWSJmd.js → workerHelpers-pUUnk9Wc.js} +1 -1
  37. package/dist/assets/zip-BJqVbRkU.js +2 -0
  38. package/dist/index.html +10 -12
  39. package/package.json +11 -11
  40. package/src/components/mcp/PlaygroundChat.tsx +90 -52
  41. package/src/components/viewer/CesiumOverlay.tsx +150 -91
  42. package/src/components/viewer/CesiumPlacementEditor.tsx +1009 -0
  43. package/src/components/viewer/ChatPanel.tsx +76 -93
  44. package/src/components/viewer/EntityContextMenu.tsx +68 -10
  45. package/src/components/viewer/MainToolbar.tsx +33 -3
  46. package/src/components/viewer/ViewportContainer.tsx +70 -16
  47. package/src/components/viewer/ViewportOverlays.tsx +2 -98
  48. package/src/components/viewer/chat/ByokKeyModal.tsx +338 -0
  49. package/src/components/viewer/chat/ByokStreamingPill.tsx +62 -0
  50. package/src/components/viewer/chat/ByokTrustDiagram.tsx +192 -0
  51. package/src/components/viewer/properties/GeoreferencingPanel.tsx +49 -52
  52. package/src/components/viewer/properties/ModelMetadataPanel.tsx +55 -44
  53. package/src/components/viewer/selectionHandlers.ts +7 -1
  54. package/src/lib/geo/cesium-bridge.ts +86 -50
  55. package/src/lib/geo/cesium-placement.test.ts +244 -0
  56. package/src/lib/geo/cesium-placement.ts +231 -0
  57. package/src/lib/geo/effective-georef.test.ts +74 -1
  58. package/src/lib/geo/effective-georef.ts +40 -93
  59. package/src/lib/geo/geo-scale.ts +104 -0
  60. package/src/lib/geo/reproject.test.ts +130 -0
  61. package/src/lib/geo/reproject.ts +37 -12
  62. package/src/lib/geo/terrain-elevation.ts +198 -89
  63. package/src/lib/lens/adapter.ts +52 -6
  64. package/src/lib/llm/clipboard-detect.test.ts +150 -0
  65. package/src/lib/llm/clipboard-detect.ts +90 -0
  66. package/src/lib/llm/models.ts +28 -0
  67. package/src/lib/llm/stream-direct.ts +16 -4
  68. package/src/lib/llm/types.ts +8 -0
  69. package/src/services/playground-model.ts +55 -0
  70. package/src/store/index.ts +4 -5
  71. package/src/store/slices/cesiumSlice.ts +100 -19
  72. package/src/store.ts +3 -0
  73. package/dist/assets/arrow-CZ5kQ26f.js +0 -20
  74. package/dist/assets/bcf-4K724hw0.js +0 -281
  75. package/dist/assets/cesium-DUOzBlqv.js +0 -17817
  76. package/dist/assets/decode-worker-t2EGKAxO.js +0 -1708
  77. package/dist/assets/emscripten-module.browser-CY5t0Vfq.js +0 -1
  78. package/dist/assets/geometry-controller.worker-NH8pZmrU.js +0 -7
  79. package/dist/assets/geometry.worker-Bp4rW_R1.js +0 -1
  80. package/dist/assets/ifc-lite-DfZHk36-.js +0 -7
  81. package/dist/assets/index-CSWgTe1s.css +0 -1
  82. package/dist/assets/index-XwKzDuw6.js +0 -22
  83. package/dist/assets/maplibre-gl-CGLcoNXc.js +0 -811
  84. package/dist/assets/sandbox-DPD1ROr0.js +0 -9700
  85. package/dist/assets/wasm-bridge-CErti6zX.js +0 -1
  86. package/dist/assets/zip-DBEtpeu6.js +0 -12
  87. package/src/components/viewer/CesiumSettingsDialog.tsx +0 -100
@@ -0,0 +1,192 @@
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
+ * Visual proof of where the API key (and chat content) goes when a BYOK model
7
+ * is in use. Two stacked paths:
8
+ * row 1 (active) browser ────► api.provider.com
9
+ * row 2 (blocked) browser ─► our server ─► api.provider.com (struck out)
10
+ *
11
+ * The shape of the diagram is the same for every provider — only the API host
12
+ * label rotates. Renders crisply in light and dark mode via Tailwind utility
13
+ * classes.
14
+ */
15
+
16
+ interface ByokTrustDiagramProps {
17
+ apiHost: string;
18
+ }
19
+
20
+ export function ByokTrustDiagram({ apiHost }: ByokTrustDiagramProps) {
21
+ return (
22
+ <svg
23
+ viewBox="0 0 520 200"
24
+ xmlns="http://www.w3.org/2000/svg"
25
+ role="img"
26
+ aria-label={`Diagram: requests go directly from your browser to ${apiHost}, not via our server.`}
27
+ className="w-full h-auto"
28
+ >
29
+ <defs>
30
+ <marker
31
+ id="byok-arrow-active"
32
+ viewBox="0 0 10 10"
33
+ refX="9"
34
+ refY="5"
35
+ markerUnits="strokeWidth"
36
+ markerWidth="8"
37
+ markerHeight="8"
38
+ orient="auto"
39
+ >
40
+ <path d="M 0 0 L 10 5 L 0 10 z" className="fill-emerald-500" />
41
+ </marker>
42
+ <marker
43
+ id="byok-arrow-blocked"
44
+ viewBox="0 0 10 10"
45
+ refX="9"
46
+ refY="5"
47
+ markerUnits="strokeWidth"
48
+ markerWidth="8"
49
+ markerHeight="8"
50
+ orient="auto"
51
+ >
52
+ <path d="M 0 0 L 10 5 L 0 10 z" className="fill-muted-foreground" />
53
+ </marker>
54
+ </defs>
55
+
56
+ {/* Row 1 — the path that's actually used */}
57
+ <g transform="translate(0, 18)">
58
+ <text x="0" y="-4" className="text-[10px] uppercase tracking-wider fill-emerald-600 dark:fill-emerald-400 font-semibold">
59
+ ✓ How your requests actually flow
60
+ </text>
61
+
62
+ {/* Browser box */}
63
+ <rect
64
+ x="2"
65
+ y="6"
66
+ width="120"
67
+ height="48"
68
+ rx="8"
69
+ className="fill-background stroke-emerald-500"
70
+ strokeWidth="1.75"
71
+ />
72
+ <text x="62" y="35" textAnchor="middle" className="text-[12px] fill-foreground font-medium">
73
+ Your browser
74
+ </text>
75
+
76
+ {/* Arrow */}
77
+ <line
78
+ x1="124"
79
+ y1="30"
80
+ x2="346"
81
+ y2="30"
82
+ className="stroke-emerald-500"
83
+ strokeWidth="2"
84
+ markerEnd="url(#byok-arrow-active)"
85
+ />
86
+ <text x="235" y="22" textAnchor="middle" className="text-[10px] fill-emerald-600 dark:fill-emerald-400 font-mono">
87
+ HTTPS · direct
88
+ </text>
89
+
90
+ {/* Provider API box (highlighted) */}
91
+ <rect
92
+ x="350"
93
+ y="6"
94
+ width="168"
95
+ height="48"
96
+ rx="8"
97
+ className="fill-emerald-500/10 stroke-emerald-500"
98
+ strokeWidth="1.75"
99
+ />
100
+ <text x="434" y="35" textAnchor="middle" className="text-[12px] fill-foreground font-mono">
101
+ {apiHost}
102
+ </text>
103
+ </g>
104
+
105
+ {/* Row 2 — what we are NOT doing */}
106
+ <g transform="translate(0, 116)" opacity="0.55">
107
+ <text x="0" y="-4" className="text-[10px] uppercase tracking-wider fill-destructive font-semibold" opacity="1">
108
+ ✗ What we never do
109
+ </text>
110
+
111
+ {/* Browser box (muted) */}
112
+ <rect
113
+ x="2"
114
+ y="6"
115
+ width="100"
116
+ height="44"
117
+ rx="6"
118
+ className="fill-background stroke-muted-foreground"
119
+ strokeWidth="1"
120
+ strokeDasharray="3 3"
121
+ />
122
+ <text x="52" y="33" textAnchor="middle" className="text-[11px] fill-muted-foreground">
123
+ Your browser
124
+ </text>
125
+
126
+ {/* Arrow 1 (muted, dashed) */}
127
+ <line
128
+ x1="104"
129
+ y1="28"
130
+ x2="186"
131
+ y2="28"
132
+ className="stroke-muted-foreground"
133
+ strokeWidth="1.25"
134
+ strokeDasharray="3 3"
135
+ markerEnd="url(#byok-arrow-blocked)"
136
+ />
137
+
138
+ {/* "Our server" box — struck through */}
139
+ <rect
140
+ x="190"
141
+ y="6"
142
+ width="120"
143
+ height="44"
144
+ rx="6"
145
+ className="fill-background stroke-muted-foreground"
146
+ strokeWidth="1"
147
+ strokeDasharray="3 3"
148
+ />
149
+ <text x="250" y="33" textAnchor="middle" className="text-[11px] fill-muted-foreground">
150
+ our server
151
+ </text>
152
+ {/* Strike-through across the "our server" box */}
153
+ <line
154
+ x1="184"
155
+ y1="44"
156
+ x2="316"
157
+ y2="12"
158
+ className="stroke-destructive"
159
+ strokeWidth="2.5"
160
+ opacity="0.85"
161
+ />
162
+
163
+ {/* Arrow 2 (muted, dashed) */}
164
+ <line
165
+ x1="312"
166
+ y1="28"
167
+ x2="394"
168
+ y2="28"
169
+ className="stroke-muted-foreground"
170
+ strokeWidth="1.25"
171
+ strokeDasharray="3 3"
172
+ markerEnd="url(#byok-arrow-blocked)"
173
+ />
174
+
175
+ {/* Provider API box (muted) */}
176
+ <rect
177
+ x="398"
178
+ y="6"
179
+ width="120"
180
+ height="44"
181
+ rx="6"
182
+ className="fill-background stroke-muted-foreground"
183
+ strokeWidth="1"
184
+ strokeDasharray="3 3"
185
+ />
186
+ <text x="458" y="33" textAnchor="middle" className="text-[11px] fill-muted-foreground font-mono">
187
+ {apiHost}
188
+ </text>
189
+ </g>
190
+ </svg>
191
+ );
192
+ }
@@ -16,8 +16,13 @@ import { useViewerStore } from '@/store';
16
16
  import type { CoordinateInfo, GeometryResult } from '@ifc-lite/geometry';
17
17
  import { EpsgLookupDialog, type EpsgResult } from './EpsgLookupDialog';
18
18
  import { LocationMap, type PickedPosition } from './LocationMap';
19
- import { findClampAnchorY } from '@/lib/geo/clamp-anchor';
20
- import { detectScaleUnitMismatch, mergeMapConversion, mergeProjectedCRS } from '@/lib/geo/effective-georef';
19
+ import { computeOrthogonalHeightForBaseAltitude } from '@/lib/geo/cesium-placement';
20
+ import {
21
+ detectScaleUnitMismatch,
22
+ mergeMapConversion,
23
+ mergeProjectedCRS,
24
+ supportsStandardGeoreferencing,
25
+ } from '@/lib/geo/effective-georef';
21
26
  import { useIfc } from '@/hooks/useIfc';
22
27
  import { toast } from '@/components/ui/toast';
23
28
 
@@ -335,9 +340,8 @@ export function GeoreferencingPanel({ georef, modelId, enableEditing, schemaVers
335
340
  const setGeorefField = useViewerStore(s => s.setGeorefField);
336
341
  const setGeorefFields = useViewerStore(s => s.setGeorefFields);
337
342
  const cesiumEnabled = useViewerStore(s => s.cesiumEnabled);
338
- const terrainClamp = useViewerStore(s => s.cesiumTerrainClamp);
339
- const setCesiumTerrainClamp = useViewerStore(s => s.setCesiumTerrainClamp);
340
343
  const cesiumTerrainHeight = useViewerStore(s => s.cesiumTerrainHeight);
344
+ const cesiumTerrainSource = useViewerStore(s => s.cesiumTerrainSource);
341
345
  const cesiumSourceModelId = useViewerStore(s => s.cesiumSourceModelId);
342
346
  const models = useViewerStore(s => s.models);
343
347
  const loading = useViewerStore(s => s.loading);
@@ -351,7 +355,8 @@ export function GeoreferencingPanel({ georef, modelId, enableEditing, schemaVers
351
355
  useViewerStore(s => s.mutationVersion);
352
356
 
353
357
  const mutations = modelId ? georefMutations?.get(modelId) : undefined;
354
- const supportsStandardGeoreferencing = !schemaVersion?.toUpperCase().includes('2X3');
358
+ const isLegacySiteGeoreference = georef?.source === 'siteLocation';
359
+ const canUseStandardGeoreferencing = supportsStandardGeoreferencing(schemaVersion, georef);
355
360
 
356
361
  const mergedCRS = useMemo((): ProjectedCRS | undefined => {
357
362
  return mergeProjectedCRS(georef?.projectedCRS, mutations?.projectedCRS, lengthUnitScale ?? 1);
@@ -382,13 +387,6 @@ export function GeoreferencingPanel({ georef, modelId, enableEditing, schemaVers
382
387
  return 'm';
383
388
  }, [mergedCRS?.mapUnit]);
384
389
 
385
- // Convert meters to map units (Cesium always returns meters)
386
- const metersToMapUnit = useCallback((meters: number): number => {
387
- if (mapUnitSuffix === 'ftUS') return meters / 0.3048006096;
388
- if (mapUnitSuffix === 'ft') return meters / 0.3048;
389
- return meters; // already meters
390
- }, [mapUnitSuffix]);
391
-
392
390
  /**
393
391
  * Given a target world altitude (metres) for the model's ground floor
394
392
  * (the storey nearest elevation 0, falling back to bounds.min.y when
@@ -400,14 +398,14 @@ export function GeoreferencingPanel({ georef, modelId, enableEditing, schemaVers
400
398
  * world position as toggling the clamp.
401
399
  */
402
400
  const oHeightForBaseAltitude = useCallback((targetBaseAltitude: number): number => {
403
- const bounds = coordinateInfo?.originalBounds;
404
- const anchorY = findClampAnchorY(bounds, storeyElevations);
405
- const shiftY = coordinateInfo?.originShift?.y ?? 0;
406
- // RTC offset is in IFC Z-up; viewer Y-up takes its Z component.
407
- const rtcYupY = coordinateInfo?.wasmRtcOffset?.z ?? 0;
408
- const targetOHeightMeters = targetBaseAltitude - shiftY - rtcYupY - anchorY;
409
- return Math.round(metersToMapUnit(targetOHeightMeters) * 100) / 100;
410
- }, [coordinateInfo, storeyElevations, metersToMapUnit]);
401
+ return computeOrthogonalHeightForBaseAltitude({
402
+ coordinateInfo,
403
+ projectedCRS: mergedCRS,
404
+ lengthUnitScale: lengthUnitScale ?? 1,
405
+ storeyElevations,
406
+ targetBaseAltitude,
407
+ });
408
+ }, [coordinateInfo, mergedCRS, lengthUnitScale, storeyElevations]);
411
409
 
412
410
  const isMutated = useCallback((entity: 'projectedCRS' | 'mapConversion', field: string): boolean => {
413
411
  if (!mutations) return false;
@@ -467,14 +465,8 @@ export function GeoreferencingPanel({ georef, modelId, enableEditing, schemaVers
467
465
  ? mergedCRS?.[field as keyof ProjectedCRS]
468
466
  : mergedConversion?.[field as keyof MapConversion];
469
467
  setGeorefField(modelId, entity, field, value, oldValue as string | number | undefined);
470
- // Editing OrthogonalHeight implies "I want this exact altitude" — auto
471
- // -release the terrain clamp so the new value actually takes effect
472
- // (with clamp on, placement is locked to terrain regardless of oHeight).
473
- if (entity === 'mapConversion' && field === 'orthogonalHeight' && terrainClamp) {
474
- setCesiumTerrainClamp(false);
475
- }
476
468
  requestAlignmentReload();
477
- }, [modelId, setGeorefField, mergedCRS, mergedConversion, requestAlignmentReload, terrainClamp, setCesiumTerrainClamp]);
469
+ }, [modelId, setGeorefField, mergedCRS, mergedConversion, requestAlignmentReload]);
478
470
 
479
471
  // Handle angle edit: compute and set both XAxisAbscissa and XAxisOrdinate
480
472
  const handleAngleChange = useCallback((abscissa: number, ordinate: number) => {
@@ -557,18 +549,7 @@ export function GeoreferencingPanel({ georef, modelId, enableEditing, schemaVers
557
549
  }, [modelId, setGeorefFields, mergedCRS, mergedConversion, mutations, initializeMapConversionDefaults, requestAlignmentReload]);
558
550
 
559
551
  const hasData = mergedCRS || mergedConversion;
560
- const editable = enableEditing && !!modelId && supportsStandardGeoreferencing;
561
-
562
- if (enableEditing && !supportsStandardGeoreferencing) {
563
- return (
564
- <div className="px-2 py-1.5 flex items-center gap-2">
565
- <Globe className="h-3 w-3 text-zinc-400" />
566
- <span className="text-[10px] text-zinc-500 dark:text-zinc-400">
567
- Georeferencing editing requires IFC4 or newer. IFC2X3 does not support IfcProjectedCRS or IfcMapConversion.
568
- </span>
569
- </div>
570
- );
571
- }
552
+ const editable = enableEditing && !!modelId && canUseStandardGeoreferencing;
572
553
 
573
554
  // When no georef data exists, show "Add Georeferencing" in edit mode
574
555
  if (!hasData && !georef?.hasGeoreference) {
@@ -616,6 +597,21 @@ export function GeoreferencingPanel({ georef, modelId, enableEditing, schemaVers
616
597
  </div>
617
598
  </div>
618
599
  )}
600
+ {/* Only flag the legacy-site / unsupported-schema state when there is
601
+ actually nothing extractable to show. If we have a projectedCRS or
602
+ mapConversion (even partially), the data sections below speak for
603
+ themselves — the schema notice is just noise that contradicts the
604
+ live data the properties panel already renders. */}
605
+ {!canUseStandardGeoreferencing && !mergedCRS && !mergedConversion && (
606
+ <div className="px-3 py-1.5 flex items-center gap-2 border-b border-zinc-100 dark:border-zinc-900">
607
+ <Globe className="h-3 w-3 text-zinc-400 shrink-0" />
608
+ <span className="text-[10px] text-zinc-500 dark:text-zinc-400">
609
+ {isLegacySiteGeoreference
610
+ ? 'Showing legacy IfcSite geolocation from IFC2X3. This view is read-only.'
611
+ : 'Georeferencing editing requires IFC4 or newer. IFC2X3 does not support IfcProjectedCRS or IfcMapConversion.'}
612
+ </span>
613
+ </div>
614
+ )}
619
615
  {/* CRS summary — always visible */}
620
616
  <div className="px-2 py-1.5 flex items-center gap-2">
621
617
  <Globe className="h-3 w-3 text-teal-500 shrink-0" />
@@ -751,28 +747,25 @@ export function GeoreferencingPanel({ georef, modelId, enableEditing, schemaVers
751
747
  </div>
752
748
  )}
753
749
 
754
- {/* Terrain clamp toggle — only when Cesium overlay is active */}
750
+ {/* Sampled surface height — only when Cesium overlay is active */}
755
751
  {cesiumEnabled && isActiveCesiumModel && mergedConversion && (
756
752
  <div className="px-3 py-1.5 border-t border-zinc-100 dark:border-zinc-900 space-y-1">
757
753
  <div className="flex items-center gap-2">
758
754
  <Mountain className="h-3 w-3 text-teal-500 shrink-0" />
759
- <label className="flex items-center gap-1.5 cursor-pointer flex-1">
760
- <input
761
- type="checkbox"
762
- checked={terrainClamp}
763
- onChange={(e) => setCesiumTerrainClamp(e.target.checked)}
764
- className="accent-teal-500 h-3 w-3"
765
- />
766
- <span className="text-[10px] text-zinc-600 dark:text-zinc-400">Clamp to terrain</span>
767
- </label>
755
+ <span className="text-[10px] text-zinc-600 dark:text-zinc-400 flex-1">Visible surface height</span>
768
756
  {cesiumTerrainHeight !== null ? (
769
- <span className="text-[9px] font-mono text-teal-500">
757
+ <span className="text-[9px] font-mono text-teal-500" title={cesiumTerrainSource ?? undefined}>
770
758
  {cesiumTerrainHeight.toFixed(1)} m
771
759
  </span>
772
760
  ) : (
773
761
  <span className="text-[9px] font-mono text-zinc-400">querying...</span>
774
762
  )}
775
763
  </div>
764
+ {cesiumTerrainSource && (
765
+ <div className="ml-5 text-[9px] text-zinc-500 dark:text-zinc-400">
766
+ sampled via {cesiumTerrainSource}
767
+ </div>
768
+ )}
776
769
  {cesiumTerrainHeight !== null && editable && modelId && (
777
770
  <div className="flex items-center gap-1 ml-5">
778
771
  <button
@@ -780,7 +773,7 @@ export function GeoreferencingPanel({ georef, modelId, enableEditing, schemaVers
780
773
  className="text-[9px] text-teal-500 hover:text-teal-700 dark:hover:text-teal-300 transition-colors flex items-center gap-0.5"
781
774
  >
782
775
  <Mountain className="h-2.5 w-2.5" />
783
- Set OrthogonalHeight to {cesiumTerrainHeight.toFixed(1)} m
776
+ Set OrthogonalHeight to sampled terrain height ({cesiumTerrainHeight.toFixed(1)} m)
784
777
  </button>
785
778
  </div>
786
779
  )}
@@ -809,6 +802,7 @@ function TerrainHeightButton({ modelId, editable, onApply }: {
809
802
  }) {
810
803
  const cesiumEnabled = useViewerStore(s => s.cesiumEnabled);
811
804
  const terrainHeight = useViewerStore(s => s.cesiumTerrainHeight);
805
+ const terrainSource = useViewerStore(s => s.cesiumTerrainSource);
812
806
  const sourceModelId = useViewerStore(s => s.cesiumSourceModelId);
813
807
 
814
808
  // Only show when this panel's model is the active Cesium model
@@ -828,7 +822,10 @@ function TerrainHeightButton({ modelId, editable, onApply }: {
828
822
  <span>{terrainHeight.toFixed(1)} m</span>
829
823
  </button>
830
824
  </TooltipTrigger>
831
- <TooltipContent>Set OrthogonalHeight to Cesium terrain elevation ({terrainHeight.toFixed(1)} m)</TooltipContent>
825
+ <TooltipContent>
826
+ Set OrthogonalHeight to sampled terrain height ({terrainHeight.toFixed(1)} m
827
+ {terrainSource ? ` via ${terrainSource}` : ''})
828
+ </TooltipContent>
832
829
  </Tooltip>
833
830
  );
834
831
  }
@@ -124,7 +124,12 @@ export function ModelMetadataPanel({ model }: { model: FederatedModel }) {
124
124
  </div>
125
125
  </div>
126
126
 
127
- <ScrollArea className="flex-1">
127
+ {/* `min-h-0` is required: without it `flex-1` falls back to
128
+ min-height:auto and the ScrollArea grows past the panel's
129
+ height instead of constraining the inner viewport, so the map
130
+ (and any tall content underneath) overflowed past the right
131
+ panel's clip box. */}
132
+ <ScrollArea className="flex-1 min-h-0">
128
133
  {/* File Information */}
129
134
  <div className="border-b border-zinc-200 dark:border-zinc-800">
130
135
  <div className="p-3 bg-zinc-50 dark:bg-zinc-900/50">
@@ -172,49 +177,11 @@ export function ModelMetadataPanel({ model }: { model: FederatedModel }) {
172
177
  </div>
173
178
  )}
174
179
 
175
- {/* Entity Statistics */}
176
- <div className="border-b border-zinc-200 dark:border-zinc-800">
177
- <div className="p-3 bg-zinc-50 dark:bg-zinc-900/50">
178
- <h4 className="font-bold text-xs uppercase tracking-wide text-zinc-700 dark:text-zinc-300">
179
- Statistics
180
- </h4>
181
- </div>
182
- <div className="divide-y divide-zinc-100 dark:divide-zinc-900">
183
- <div className="flex items-center gap-3 px-3 py-2">
184
- <Database className="h-3.5 w-3.5 text-zinc-400 shrink-0" />
185
- <span className="text-xs text-zinc-500">Total Entities</span>
186
- <span className="text-xs font-mono text-zinc-900 dark:text-zinc-100 ml-auto">
187
- {dataStore?.entityCount?.toLocaleString() ?? 'N/A'}
188
- </span>
189
- </div>
190
- <div className="flex items-center gap-3 px-3 py-2">
191
- <Layers className="h-3.5 w-3.5 text-zinc-400 shrink-0" />
192
- <span className="text-xs text-zinc-500">Building Storeys</span>
193
- <span className="text-xs font-mono text-zinc-900 dark:text-zinc-100 ml-auto">
194
- {stats.storeys}
195
- </span>
196
- </div>
197
- <div className="flex items-center gap-3 px-3 py-2">
198
- <Building2 className="h-3.5 w-3.5 text-zinc-400 shrink-0" />
199
- <span className="text-xs text-zinc-500">Elements with Geometry</span>
200
- <span className="text-xs font-mono text-zinc-900 dark:text-zinc-100 ml-auto">
201
- {stats.elementsWithGeometry.toLocaleString()}
202
- </span>
203
- </div>
204
- <div className="flex items-center gap-3 px-3 py-2">
205
- <Hash className="h-3.5 w-3.5 text-zinc-400 shrink-0" />
206
- <span className="text-xs text-zinc-500">Max Express ID</span>
207
- <span className="text-xs font-mono text-zinc-900 dark:text-zinc-100 ml-auto">
208
- {model.maxExpressId.toLocaleString()}
209
- </span>
210
- </div>
211
- </div>
212
- </div>
213
-
214
- {/* Georeferencing */}
215
- <GeoreferencingPanel georef={georef} modelId={model.id} enableEditing schemaVersion={model.schemaVersion} coordinateInfo={model.geometryResult?.coordinateInfo} geometryResult={model.geometryResult} lengthUnitScale={unitInfo?.scale} />
216
-
217
- {/* IfcProject Data */}
180
+ {/* IfcProject Data — placed near the top so the model's name,
181
+ description, and project-level psets are the first thing users
182
+ see after file info. Previously the section was at the bottom
183
+ of the panel (below the map), which buried critical project
184
+ identity below scrollable georeferencing content. */}
218
185
  {projectData && (
219
186
  <div className="border-b border-zinc-200 dark:border-zinc-800">
220
187
  <div className="p-3 bg-zinc-50 dark:bg-zinc-900/50">
@@ -262,6 +229,50 @@ export function ModelMetadataPanel({ model }: { model: FederatedModel }) {
262
229
  )}
263
230
  </div>
264
231
  )}
232
+
233
+ {/* Entity Statistics */}
234
+ <div className="border-b border-zinc-200 dark:border-zinc-800">
235
+ <div className="p-3 bg-zinc-50 dark:bg-zinc-900/50">
236
+ <h4 className="font-bold text-xs uppercase tracking-wide text-zinc-700 dark:text-zinc-300">
237
+ Statistics
238
+ </h4>
239
+ </div>
240
+ <div className="divide-y divide-zinc-100 dark:divide-zinc-900">
241
+ <div className="flex items-center gap-3 px-3 py-2">
242
+ <Database className="h-3.5 w-3.5 text-zinc-400 shrink-0" />
243
+ <span className="text-xs text-zinc-500">Total Entities</span>
244
+ <span className="text-xs font-mono text-zinc-900 dark:text-zinc-100 ml-auto">
245
+ {dataStore?.entityCount?.toLocaleString() ?? 'N/A'}
246
+ </span>
247
+ </div>
248
+ <div className="flex items-center gap-3 px-3 py-2">
249
+ <Layers className="h-3.5 w-3.5 text-zinc-400 shrink-0" />
250
+ <span className="text-xs text-zinc-500">Building Storeys</span>
251
+ <span className="text-xs font-mono text-zinc-900 dark:text-zinc-100 ml-auto">
252
+ {stats.storeys}
253
+ </span>
254
+ </div>
255
+ <div className="flex items-center gap-3 px-3 py-2">
256
+ <Building2 className="h-3.5 w-3.5 text-zinc-400 shrink-0" />
257
+ <span className="text-xs text-zinc-500">Elements with Geometry</span>
258
+ <span className="text-xs font-mono text-zinc-900 dark:text-zinc-100 ml-auto">
259
+ {stats.elementsWithGeometry.toLocaleString()}
260
+ </span>
261
+ </div>
262
+ <div className="flex items-center gap-3 px-3 py-2">
263
+ <Hash className="h-3.5 w-3.5 text-zinc-400 shrink-0" />
264
+ <span className="text-xs text-zinc-500">Max Express ID</span>
265
+ <span className="text-xs font-mono text-zinc-900 dark:text-zinc-100 ml-auto">
266
+ {model.maxExpressId.toLocaleString()}
267
+ </span>
268
+ </div>
269
+ </div>
270
+ </div>
271
+
272
+ {/* Georeferencing — kept at the bottom because it embeds a
273
+ tall location map; placing it earlier would push the
274
+ statistics + project metadata below the fold. */}
275
+ <GeoreferencingPanel georef={georef} modelId={model.id} enableEditing schemaVersion={model.schemaVersion} coordinateInfo={model.geometryResult?.coordinateInfo} geometryResult={model.geometryResult} lengthUnitScale={unitInfo?.scale} />
265
276
  </ScrollArea>
266
277
  </div>
267
278
  );
@@ -564,7 +564,13 @@ export function commitAddElementSlabPolygon(): void {
564
564
  */
565
565
  export async function handleContextMenu(ctx: MouseHandlerContext, e: MouseEvent): Promise<void> {
566
566
  e.preventDefault();
567
- const { canvas, renderer } = ctx;
567
+ const { canvas, renderer, mouseState } = ctx;
568
+ // Right-drag is the pan gesture (see useMouseControls). Some browsers
569
+ // still fire `contextmenu` after a tiny right-drag — skip when the
570
+ // user actually moved, so panning never accidentally pops the menu.
571
+ if (mouseState.didDrag) {
572
+ return;
573
+ }
568
574
  const rect = canvas.getBoundingClientRect();
569
575
  const x = e.clientX - rect.left;
570
576
  const y = e.clientY - rect.top;