@ifc-lite/viewer 1.19.1 → 1.21.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 (106) hide show
  1. package/.turbo/turbo-build.log +59 -44
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +488 -0
  4. package/dist/assets/{basketViewActivator-CA2CTcVo.js → basketViewActivator-Bzw51jhm.js} +6 -6
  5. package/dist/assets/decode-worker-t2EGKAxO.js +1708 -0
  6. package/dist/assets/drawing-2d-Bjy8YPrg.js +257 -0
  7. package/dist/assets/exporters-u0sz2Upj.js +259119 -0
  8. package/dist/assets/geometry-controller.worker-NH8pZmrU.js +7 -0
  9. package/dist/assets/geometry.worker-Bp4rW_R1.js +1 -0
  10. package/dist/assets/ids-B7AXEv7h.js +4067 -0
  11. package/dist/assets/ifc-lite-DfZHk36-.js +7 -0
  12. package/dist/assets/ifc-lite_bg-DlKs5-yM.wasm +0 -0
  13. package/dist/assets/ifc-lite_bg-PqmRe3Ph.wasm +0 -0
  14. package/dist/assets/index-CSWgTe1s.css +1 -0
  15. package/dist/assets/{index-D8Epw-e7.js → index-DVNSvEMh.js} +40146 -35823
  16. package/dist/assets/laz-perf-Cvr_Lepg.js +1 -0
  17. package/dist/assets/laz-perf-DnSyzVYH.wasm +0 -0
  18. package/dist/assets/{native-bridge-DKmx1z95.js → native-bridge-BiD01jI9.js} +1 -1
  19. package/dist/assets/parser.worker-Bnbrl6gy.js +182 -0
  20. package/dist/assets/{sandbox-tccwm5Bo.js → sandbox-DPD1ROr0.js} +4 -4
  21. package/dist/assets/{server-client-LoWPK1N2.js → server-client-DP8fMPY9.js} +1 -1
  22. package/dist/assets/{wasm-bridge-BsJGgPMs.js → wasm-bridge-CErti6zX.js} +1 -1
  23. package/dist/assets/workerHelpers-CBbWSJmd.js +36 -0
  24. package/dist/index.html +8 -8
  25. package/index.html +1 -1
  26. package/package.json +10 -10
  27. package/src/components/viewer/BasketPresentationDock.tsx +3 -0
  28. package/src/components/viewer/CesiumOverlay.tsx +165 -120
  29. package/src/components/viewer/DeviationPanel.tsx +172 -0
  30. package/src/components/viewer/HierarchyPanel.tsx +29 -3
  31. package/src/components/viewer/HoverTooltip.tsx +5 -0
  32. package/src/components/viewer/IDSAuditSummary.tsx +389 -0
  33. package/src/components/viewer/IDSPanel.tsx +80 -26
  34. package/src/components/viewer/MainToolbar.tsx +60 -7
  35. package/src/components/viewer/MergeLayersBanner.tsx +108 -0
  36. package/src/components/viewer/MobileToolbar.tsx +326 -0
  37. package/src/components/viewer/PointCloudClasses.tsx +111 -0
  38. package/src/components/viewer/PointCloudLegend.tsx +119 -0
  39. package/src/components/viewer/PointCloudPanel.tsx +52 -1
  40. package/src/components/viewer/PropertiesPanel.tsx +37 -6
  41. package/src/components/viewer/RectSelectionOverlay.tsx +48 -0
  42. package/src/components/viewer/StatusBar.tsx +14 -0
  43. package/src/components/viewer/ViewerLayout.tsx +288 -95
  44. package/src/components/viewer/Viewport.tsx +86 -18
  45. package/src/components/viewer/ViewportContainer.tsx +25 -11
  46. package/src/components/viewer/ViewportOverlays.tsx +41 -26
  47. package/src/components/viewer/mouseHandlerTypes.ts +22 -0
  48. package/src/components/viewer/properties/GeoreferencingPanel.tsx +77 -8
  49. package/src/components/viewer/properties/MaterialCard.tsx +2 -2
  50. package/src/components/viewer/selectionHandlers.ts +41 -0
  51. package/src/components/viewer/tools/SectionPanel.tsx +181 -24
  52. package/src/components/viewer/tools/SectionVisualization.tsx +384 -3
  53. package/src/components/viewer/useAnimationLoop.ts +22 -0
  54. package/src/components/viewer/useMouseControls.ts +296 -3
  55. package/src/components/viewer/usePointCloudSync.ts +8 -1
  56. package/src/components/viewer/useRenderUpdates.ts +21 -1
  57. package/src/components/viewer/useTouchControls.ts +100 -41
  58. package/src/hooks/federationLoadGate.test.ts +90 -0
  59. package/src/hooks/federationLoadGate.ts +127 -0
  60. package/src/hooks/ids/idsDataAccessor.ts +11 -259
  61. package/src/hooks/ingest/pointCloudIngest.ts +127 -16
  62. package/src/hooks/useDrawingGeneration.ts +81 -8
  63. package/src/hooks/useIDS.ts +90 -10
  64. package/src/hooks/useIfcFederation.ts +94 -16
  65. package/src/hooks/useIfcLoader.ts +289 -64
  66. package/src/hooks/useViewerSelectors.ts +10 -0
  67. package/src/lib/geo/cesium-bridge.ts +84 -67
  68. package/src/lib/geo/clamp-anchor.test.ts +80 -0
  69. package/src/lib/geo/clamp-anchor.ts +57 -0
  70. package/src/lib/geo/effective-georef.test.ts +79 -1
  71. package/src/lib/geo/effective-georef.ts +83 -0
  72. package/src/lib/geo/reproject.ts +26 -13
  73. package/src/lib/geo/terrain-elevation.ts +166 -0
  74. package/src/lib/lens/adapter.ts +1 -1
  75. package/src/lib/llm/context-builder.ts +1 -1
  76. package/src/lib/perf/memoryAccounting.test.ts +92 -0
  77. package/src/lib/perf/memoryAccounting.ts +235 -0
  78. package/src/sdk/adapters/mutation-view.ts +1 -1
  79. package/src/store/constants.ts +39 -2
  80. package/src/store/index.ts +6 -1
  81. package/src/store/slices/cesiumSlice.ts +1 -1
  82. package/src/store/slices/idsSlice.ts +24 -0
  83. package/src/store/slices/loadingSlice.ts +12 -0
  84. package/src/store/slices/pointCloudSlice.ts +72 -1
  85. package/src/store/slices/sectionSlice.test.ts +590 -1
  86. package/src/store/slices/sectionSlice.ts +344 -17
  87. package/src/store/slices/uiSlice.merge-layers.test.ts +217 -0
  88. package/src/store/slices/uiSlice.ts +60 -2
  89. package/src/store/types.ts +42 -0
  90. package/src/store.ts +13 -0
  91. package/src/utils/acquireFileBuffer.test.ts +231 -0
  92. package/src/utils/acquireFileBuffer.ts +128 -0
  93. package/src/utils/ifcConfig.ts +24 -0
  94. package/src/utils/nativeSpatialDataStore.ts +20 -2
  95. package/src/utils/spatialHierarchy.test.ts +116 -0
  96. package/src/utils/spatialHierarchy.ts +23 -0
  97. package/tailwind.config.js +5 -0
  98. package/tsconfig.json +1 -0
  99. package/vite.config.ts +6 -0
  100. package/dist/assets/decode-worker-Collf_X_.js +0 -1320
  101. package/dist/assets/drawing-2d-DoxKMqbO.js +0 -257
  102. package/dist/assets/exporters-xbXqEDlO.js +0 -81590
  103. package/dist/assets/geometry.worker-DQEZB2rB.js +0 -1
  104. package/dist/assets/ids-2WdONLlu.js +0 -2033
  105. package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
  106. package/dist/assets/index-BXeEKqJG.css +0 -1
@@ -11,6 +11,7 @@ import {
11
11
  Eye,
12
12
  Building2,
13
13
  Layers,
14
+ Layers2,
14
15
  FileText,
15
16
  Calculator,
16
17
  Tag,
@@ -20,6 +21,7 @@ import {
20
21
  PenLine,
21
22
  Crosshair,
22
23
  } from 'lucide-react';
24
+ import { Badge } from '@/components/ui/badge';
23
25
  import { EditToolbar } from './PropertyEditor';
24
26
  import { Button } from '@/components/ui/button';
25
27
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -134,6 +136,10 @@ export function PropertiesPanel() {
134
136
  const cameraCallbacks = useViewerStore((s) => s.cameraCallbacks);
135
137
  const toggleEntityVisibility = useViewerStore((s) => s.toggleEntityVisibility);
136
138
  const isEntityVisible = useViewerStore((s) => s.isEntityVisible);
139
+ // Issue #540: surface a small "Layers merged" badge on walls when
140
+ // the user has the merge-layers load setting active so they
141
+ // understand the displayed solid is the aggregated representation.
142
+ const mergeLayersActive = useViewerStore((s) => s.mergeLayers);
137
143
  const { query, ifcDataStore, geometryResult, models, getQueryForModel } = useIfc();
138
144
 
139
145
  // Get model-aware query based on selectedEntity
@@ -1108,9 +1114,33 @@ export function PropertiesPanel() {
1108
1114
  <Building2 className="h-5 w-5 text-zinc-700 dark:text-zinc-300" />
1109
1115
  </div>
1110
1116
  <div className="flex-1 min-w-0 pt-0.5">
1111
- <h3 className="font-bold text-sm truncate uppercase tracking-tight text-zinc-900 dark:text-zinc-100">
1112
- {entityName || `${entityType}`}
1113
- </h3>
1117
+ <div className="flex items-start gap-2">
1118
+ <h3 className="font-bold text-sm truncate uppercase tracking-tight text-zinc-900 dark:text-zinc-100 min-w-0">
1119
+ {entityName || `${entityType}`}
1120
+ </h3>
1121
+ {/* Issue #540: indicate that the wall solid the user is
1122
+ looking at represents aggregated multilayer parts. We
1123
+ over-trigger on any IfcWall* class instead of probing
1124
+ the aggregation graph — the chip is cheap and
1125
+ informative, and walls that aren't actually layered
1126
+ simply confirm the user's selection is the parent. */}
1127
+ {mergeLayersActive && entityType?.toLowerCase().startsWith('ifcwall') && (
1128
+ <Tooltip>
1129
+ <TooltipTrigger asChild>
1130
+ <Badge
1131
+ variant="secondary"
1132
+ className="shrink-0 rounded-sm px-1.5 py-0 text-[9px] font-semibold uppercase tracking-wider gap-1 leading-none h-[18px] mt-0.5"
1133
+ >
1134
+ <Layers2 className="h-2.5 w-2.5" />
1135
+ Layers merged
1136
+ </Badge>
1137
+ </TooltipTrigger>
1138
+ <TooltipContent>
1139
+ Multilayer wall parts have been merged into the parent solid.
1140
+ </TooltipContent>
1141
+ </Tooltip>
1142
+ )}
1143
+ </div>
1114
1144
  <p className="text-xs font-mono text-zinc-500 dark:text-zinc-400">{entityType}</p>
1115
1145
  {/* Show associated type entity for occurrences */}
1116
1146
  {!renderedIsTypeEntity && renderedTypeProperties && (
@@ -1309,6 +1339,7 @@ export function PropertiesPanel() {
1309
1339
  coordinateInfo={(model?.geometryResult ?? geometryResult)?.coordinateInfo}
1310
1340
  geometryResult={model?.geometryResult ?? geometryResult}
1311
1341
  lengthUnitScale={lengthUnitScale}
1342
+ storeyElevations={activeDataStore?.spatialHierarchy?.storeyElevations}
1312
1343
  />
1313
1344
  </CollapsibleContent>
1314
1345
  </Collapsible>
@@ -1342,12 +1373,12 @@ export function PropertiesPanel() {
1342
1373
  modelId={selectedEntity.modelId}
1343
1374
  entityId={selectedEntity.expressId}
1344
1375
  attrName={attr.name}
1345
- currentValue={attr.value}
1376
+ currentValue={String(attr.value)}
1346
1377
  />
1347
1378
  ) : (
1348
1379
  <div className="overflow-x-auto scrollbar-thin scrollbar-thumb-zinc-300 dark:scrollbar-thumb-zinc-700 min-w-0">
1349
- <span className="font-medium whitespace-nowrap" title={attr.value}>
1350
- {attr.value}
1380
+ <span className="font-medium whitespace-nowrap" title={String(attr.value)}>
1381
+ {String(attr.value)}
1351
1382
  </span>
1352
1383
  </div>
1353
1384
  )}
@@ -0,0 +1,48 @@
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 overlay for the GPU rectangle-select drag (Ctrl/⌘ + LMB
7
+ * over the canvas in select mode). Renders an SVG outline whenever
8
+ * `rect` is non-null; the parent supplies / clears the prop in step
9
+ * with the mouse handler.
10
+ */
11
+
12
+ export interface RectSelectionRect {
13
+ x0: number;
14
+ y0: number;
15
+ x1: number;
16
+ y1: number;
17
+ }
18
+
19
+ export interface RectSelectionOverlayProps {
20
+ rect: RectSelectionRect | null;
21
+ }
22
+
23
+ export function RectSelectionOverlay({ rect }: RectSelectionOverlayProps) {
24
+ if (!rect) return null;
25
+ const left = Math.min(rect.x0, rect.x1);
26
+ const top = Math.min(rect.y0, rect.y1);
27
+ const width = Math.abs(rect.x1 - rect.x0);
28
+ const height = Math.abs(rect.y1 - rect.y0);
29
+ if (width < 1 || height < 1) return null;
30
+ return (
31
+ <svg
32
+ className="absolute inset-0 pointer-events-none"
33
+ style={{ width: '100%', height: '100%' }}
34
+ aria-hidden="true"
35
+ >
36
+ <rect
37
+ x={left}
38
+ y={top}
39
+ width={width}
40
+ height={height}
41
+ fill="rgba(20, 184, 166, 0.10)"
42
+ stroke="rgb(20, 184, 166)"
43
+ strokeWidth={1}
44
+ strokeDasharray="4 3"
45
+ />
46
+ </svg>
47
+ );
48
+ }
@@ -15,6 +15,7 @@ export function StatusBar() {
15
15
  const progress = useViewerStore((s) => s.progress);
16
16
  const error = useViewerStore((s) => s.error);
17
17
  const selectedStoreys = useViewerStore((s) => s.selectedStoreys);
18
+ const activeStreamCanceller = useViewerStore((s) => s.activeStreamCanceller);
18
19
  const webgpu = useWebGPU();
19
20
 
20
21
  const [fps, setFps] = useState(60);
@@ -108,6 +109,19 @@ export function StatusBar() {
108
109
  ) : (
109
110
  <span>Ready</span>
110
111
  )}
112
+ {/* Cancel button — only visible while a long-running stream
113
+ (LAS/LAZ/PLY/PCD/E57) is in flight. The loader hooks
114
+ register/clear the canceller around `await ingest.done`. */}
115
+ {activeStreamCanceller && (
116
+ <button
117
+ type="button"
118
+ onClick={() => activeStreamCanceller()}
119
+ className="px-2 py-0.5 rounded border border-destructive/40 text-destructive text-[10px] uppercase tracking-wider hover:bg-destructive hover:text-destructive-foreground transition-colors"
120
+ title="Cancel the active point cloud stream"
121
+ >
122
+ Cancel
123
+ </button>
124
+ )}
111
125
  </div>
112
126
 
113
127
  {/* Center: Model Stats */}
@@ -2,11 +2,12 @@
2
2
  * License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
4
 
5
- import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
5
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react';
6
6
  import { Panel, Group as PanelGroup, Separator as PanelResizeHandle } from 'react-resizable-panels';
7
7
  import type { PanelImperativeHandle } from 'react-resizable-panels';
8
8
  import { TooltipProvider } from '@/components/ui/tooltip';
9
9
  import { MainToolbar } from './MainToolbar';
10
+ import { MobileToolbar } from './MobileToolbar';
10
11
  import { HierarchyPanel } from './HierarchyPanel';
11
12
  import { PropertiesPanel } from './PropertiesPanel';
12
13
  import { AddElementPanel } from './AddElementPanel';
@@ -14,6 +15,7 @@ import { StatusBar } from './StatusBar';
14
15
  import { ViewportContainer } from './ViewportContainer';
15
16
  import { KeyboardShortcutsDialog, useKeyboardShortcutsDialog } from './KeyboardShortcutsDialog';
16
17
  import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
18
+ import { useIfc } from '@/hooks/useIfc';
17
19
  import { useViewerStore } from '@/store';
18
20
  import { EntityContextMenu } from './EntityContextMenu';
19
21
  import { useDuplicateShortcut } from './useDuplicateShortcut';
@@ -176,10 +178,22 @@ export function ViewerLayout() {
176
178
  cleanupRef.current = cleanup;
177
179
  }, [bottomHeight]);
178
180
 
179
- // Detect mobile viewport
181
+ // Track the gap between the layout viewport (innerHeight) and the visual viewport.
182
+ // On iOS Safari with bottom URL bar, dvh/innerHeight INCLUDES the URL bar area,
183
+ // so anything at `bottom: 0` lands behind it. visualViewport.height excludes
184
+ // the URL bar overlay, giving us the real visible bottom.
185
+ const bottomViewportInset = useVisualViewportBottomInset();
186
+
187
+ // Hide mobile floating buttons when the empty-state "Load IFC" card is showing.
188
+ const { models, geometryResult } = useIfc();
189
+ const hasModelsLoaded = models.size > 0 || ((geometryResult?.meshes?.length ?? 0) > 0);
190
+
191
+ // Detect mobile viewport — use both width check AND touch capability
180
192
  useEffect(() => {
181
193
  const checkMobile = () => {
182
- const mobile = window.innerWidth < 768;
194
+ const narrowScreen = window.innerWidth < 768;
195
+ const hasTouchScreen = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
196
+ const mobile = narrowScreen || (hasTouchScreen && window.innerWidth < 1024);
183
197
  setIsMobile(mobile);
184
198
  // Auto-collapse panels on mobile
185
199
  if (mobile) {
@@ -202,7 +216,7 @@ export function ViewerLayout() {
202
216
 
203
217
  return (
204
218
  <TooltipProvider delayDuration={300}>
205
- <div className="flex flex-col h-screen w-screen overflow-hidden bg-background text-foreground">
219
+ <div className="flex flex-col h-screen h-[100dvh] w-screen overflow-hidden bg-background text-foreground">
206
220
  {/* Keyboard Shortcuts Dialog */}
207
221
  <KeyboardShortcutsDialog open={shortcutsDialog.open} onClose={shortcutsDialog.close} />
208
222
 
@@ -212,9 +226,9 @@ export function ViewerLayout() {
212
226
  <CommandPalette open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen} />
213
227
  <SearchModal />
214
228
 
215
- {/* Main Toolbar */}
216
- <MainToolbar onShowShortcuts={shortcutsDialog.toggle} />
217
- <DesktopEntitlementBanner />
229
+ {/* Main Toolbar — use compact MobileToolbar on mobile */}
230
+ {isMobile ? <MobileToolbar /> : <MainToolbar onShowShortcuts={shortcutsDialog.toggle} />}
231
+ {!isMobile && <DesktopEntitlementBanner />}
218
232
 
219
233
  {/* Main Content Area - Desktop Layout */}
220
234
  {!isMobile && (
@@ -309,112 +323,291 @@ export function ViewerLayout() {
309
323
 
310
324
  {/* Main Content Area - Mobile Layout */}
311
325
  {isMobile && (
312
- <div className="flex-1 min-h-0 relative">
326
+ <div className="flex-1 min-h-0 relative overflow-hidden">
313
327
  {/* Full-screen Viewport */}
314
328
  <div className="h-full w-full">
315
329
  <ViewportContainer />
316
330
  </div>
317
331
 
332
+ {/* Backdrop overlay when sheet is open */}
333
+ {(!leftPanelCollapsed || !rightPanelCollapsed) && (
334
+ <div
335
+ className="absolute inset-0 bg-black/40 z-30 animate-in fade-in duration-200"
336
+ onClick={() => {
337
+ setLeftPanelCollapsed(true);
338
+ setRightPanelCollapsed(true);
339
+ }}
340
+ />
341
+ )}
342
+
318
343
  {/* Mobile Bottom Sheet - Hierarchy */}
319
344
  {!leftPanelCollapsed && (
320
- <div className="absolute inset-x-0 bottom-0 h-[50vh] bg-background border-t rounded-t-xl shadow-xl z-40 animate-in slide-in-from-bottom">
321
- <div className="flex items-center justify-between p-2 border-b">
322
- <span className="font-medium text-sm">Hierarchy</span>
323
- <button
324
- className="p-1 hover:bg-muted rounded"
325
- onClick={() => setLeftPanelCollapsed(true)}
326
- >
327
- <span className="sr-only">Close</span>
328
- <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
329
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
330
- </svg>
331
- </button>
332
- </div>
333
- <div className="h-[calc(50vh-48px)] overflow-auto">
334
- <HierarchyPanel />
335
- </div>
336
- </div>
345
+ <MobileBottomSheet
346
+ title="Hierarchy"
347
+ bottomInset={bottomViewportInset}
348
+ onClose={() => setLeftPanelCollapsed(true)}
349
+ >
350
+ <HierarchyPanel />
351
+ </MobileBottomSheet>
337
352
  )}
338
353
 
339
354
  {/* Mobile Bottom Sheet - Properties, BCF, IDS, or Lists */}
340
355
  {!rightPanelCollapsed && (
341
- <div className="absolute inset-x-0 bottom-0 h-[50vh] bg-background border-t rounded-t-xl shadow-xl z-40 animate-in slide-in-from-bottom">
342
- <div className="flex items-center justify-between p-2 border-b">
343
- <span className="font-medium text-sm">
344
- {activeAnalysisExtension ? activeAnalysisExtension.label : ganttPanelVisible ? 'Schedule' : scriptPanelVisible ? 'Script' : listPanelVisible ? 'Lists' : lensPanelVisible ? 'Lens' : idsPanelVisible ? 'IDS Validation' : bcfPanelVisible ? 'BCF Issues' : 'Inspector'}
345
- </span>
346
- <button
347
- className="p-1 hover:bg-muted rounded"
348
- onClick={() => {
349
- setRightPanelCollapsed(true);
350
- if (scriptPanelVisible) setScriptPanelVisible(false);
351
- if (listPanelVisible) setListPanelVisible(false);
352
- if (ganttPanelVisible) setGanttPanelVisible(false);
353
- if (bcfPanelVisible) setBcfPanelVisible(false);
354
- if (lensPanelVisible) setLensPanelVisible(false);
355
- if (idsPanelVisible) setIdsPanelVisible(false);
356
- if (activeAnalysisExtension) closeActiveAnalysisExtension();
357
- }}
358
- >
359
- <span className="sr-only">Close</span>
360
- <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
361
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
362
- </svg>
363
- </button>
364
- </div>
365
- <div className="h-[calc(50vh-48px)] overflow-auto">
366
- {activeBottomAnalysisExtension ? (
367
- activeBottomAnalysisExtension.renderPanel({ onClose: closeActiveAnalysisExtension })
368
- ) : activeRightAnalysisExtension ? (
369
- activeRightAnalysisExtension.renderPanel({ onClose: closeActiveAnalysisExtension })
370
- ) : ganttPanelVisible ? (
371
- <GanttPanel onClose={() => setGanttPanelVisible(false)} />
372
- ) : scriptPanelVisible ? (
373
- <ScriptPanel onClose={() => setScriptPanelVisible(false)} />
374
- ) : listPanelVisible ? (
375
- <ListPanel onClose={() => setListPanelVisible(false)} />
376
- ) : activeTool === 'addElement' ? (
377
- <AddElementPanel onClose={() => setActiveTool('select')} />
378
- ) : lensPanelVisible ? (
379
- <LensPanel onClose={() => setLensPanelVisible(false)} />
380
- ) : idsPanelVisible ? (
381
- <IDSPanel onClose={() => setIdsPanelVisible(false)} />
382
- ) : bcfPanelVisible ? (
383
- <BCFPanel onClose={() => setBcfPanelVisible(false)} />
384
- ) : (
385
- <PropertiesPanel />
386
- )}
387
- </div>
388
- </div>
389
- )}
390
-
391
- {/* Mobile Action Buttons */}
392
- <div className="absolute bottom-4 left-4 right-4 flex justify-center gap-2 z-30">
393
- <button
394
- className="px-4 py-2 bg-primary text-primary-foreground rounded-full shadow-lg text-sm font-medium"
395
- onClick={() => {
356
+ <MobileBottomSheet
357
+ title={activeAnalysisExtension ? activeAnalysisExtension.label : ganttPanelVisible ? 'Schedule' : scriptPanelVisible ? 'Script' : listPanelVisible ? 'Lists' : activeTool === 'addElement' ? 'Add element' : lensPanelVisible ? 'Lens' : idsPanelVisible ? 'IDS Validation' : bcfPanelVisible ? 'BCF Issues' : 'Properties'}
358
+ bottomInset={bottomViewportInset}
359
+ onClose={() => {
396
360
  setRightPanelCollapsed(true);
397
- setLeftPanelCollapsed(!leftPanelCollapsed);
398
- }}
399
- >
400
- Hierarchy
401
- </button>
402
- <button
403
- className="px-4 py-2 bg-primary text-primary-foreground rounded-full shadow-lg text-sm font-medium"
404
- onClick={() => {
405
- setLeftPanelCollapsed(true);
406
- setRightPanelCollapsed(!rightPanelCollapsed);
361
+ if (scriptPanelVisible) setScriptPanelVisible(false);
362
+ if (listPanelVisible) setListPanelVisible(false);
363
+ if (ganttPanelVisible) setGanttPanelVisible(false);
364
+ if (bcfPanelVisible) setBcfPanelVisible(false);
365
+ if (lensPanelVisible) setLensPanelVisible(false);
366
+ if (idsPanelVisible) setIdsPanelVisible(false);
367
+ if (activeAnalysisExtension) closeActiveAnalysisExtension();
368
+ if (activeTool === 'addElement') setActiveTool('select');
407
369
  }}
408
370
  >
409
- Inspector
410
- </button>
411
- </div>
371
+ {activeBottomAnalysisExtension ? (
372
+ activeBottomAnalysisExtension.renderPanel({ onClose: closeActiveAnalysisExtension })
373
+ ) : activeRightAnalysisExtension ? (
374
+ activeRightAnalysisExtension.renderPanel({ onClose: closeActiveAnalysisExtension })
375
+ ) : ganttPanelVisible ? (
376
+ <GanttPanel onClose={() => setGanttPanelVisible(false)} />
377
+ ) : scriptPanelVisible ? (
378
+ <ScriptPanel onClose={() => setScriptPanelVisible(false)} />
379
+ ) : listPanelVisible ? (
380
+ <ListPanel onClose={() => setListPanelVisible(false)} />
381
+ ) : activeTool === 'addElement' ? (
382
+ <AddElementPanel onClose={() => setActiveTool('select')} />
383
+ ) : lensPanelVisible ? (
384
+ <LensPanel onClose={() => setLensPanelVisible(false)} />
385
+ ) : idsPanelVisible ? (
386
+ <IDSPanel onClose={() => setIdsPanelVisible(false)} />
387
+ ) : bcfPanelVisible ? (
388
+ <BCFPanel onClose={() => setBcfPanelVisible(false)} />
389
+ ) : (
390
+ <PropertiesPanel />
391
+ )}
392
+ </MobileBottomSheet>
393
+ )}
394
+
395
+ {/* Mobile Floating Buttons — top-left, brutalist vocabulary (tight radii, visible
396
+ borders, uppercase caption) matching panel headers across the app.
397
+ Hidden in the empty state so the "Load IFC" card stays unobstructed. */}
398
+ {leftPanelCollapsed && rightPanelCollapsed && hasModelsLoaded && (
399
+ <div className="absolute top-4 left-4 flex flex-col gap-2.5 z-20">
400
+ <button
401
+ className="flex flex-col items-center gap-1 group touch-manipulation"
402
+ onClick={() => {
403
+ setRightPanelCollapsed(true);
404
+ setLeftPanelCollapsed(false);
405
+ }}
406
+ aria-label="Open Hierarchy"
407
+ >
408
+ <span className="grid place-items-center min-h-[44px] min-w-[44px] bg-background/90 backdrop-blur-sm border border-border rounded-md group-active:bg-foreground group-active:text-background transition-colors">
409
+ <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h10M4 18h7" /></svg>
410
+ </span>
411
+ <span className="text-[9px] font-bold uppercase tracking-wider text-muted-foreground leading-none">Hierarchy</span>
412
+ </button>
413
+ <button
414
+ className="flex flex-col items-center gap-1 group touch-manipulation"
415
+ onClick={() => {
416
+ setLeftPanelCollapsed(true);
417
+ setRightPanelCollapsed(false);
418
+ }}
419
+ aria-label="Open Properties"
420
+ >
421
+ <span className="grid place-items-center min-h-[44px] min-w-[44px] bg-background/90 backdrop-blur-sm border border-border rounded-md group-active:bg-foreground group-active:text-background transition-colors">
422
+ <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg>
423
+ </span>
424
+ <span className="text-[9px] font-bold uppercase tracking-wider text-muted-foreground leading-none">Properties</span>
425
+ </button>
426
+ </div>
427
+ )}
412
428
  </div>
413
429
  )}
414
430
 
415
- {/* Status Bar */}
416
- <StatusBar />
431
+ {/* Status Bar — hidden on mobile to maximize viewport space */}
432
+ {!isMobile && <StatusBar />}
417
433
  </div>
418
434
  </TooltipProvider>
419
435
  );
420
436
  }
437
+
438
+ /**
439
+ * Tracks the gap between the layout viewport (innerHeight) and the visual viewport.
440
+ * Returns the number of pixels the layout viewport extends below the visible area —
441
+ * i.e. how tall the iOS Safari URL bar overlay (or virtual keyboard) is.
442
+ */
443
+ function useVisualViewportBottomInset(): number {
444
+ const [inset, setInset] = useState(0);
445
+ useEffect(() => {
446
+ const vv = window.visualViewport;
447
+ if (!vv) return;
448
+ const update = () => {
449
+ const gap = window.innerHeight - vv.height - vv.offsetTop;
450
+ setInset(Math.max(0, Math.round(gap)));
451
+ };
452
+ update();
453
+ vv.addEventListener('resize', update);
454
+ vv.addEventListener('scroll', update);
455
+ return () => {
456
+ vv.removeEventListener('resize', update);
457
+ vv.removeEventListener('scroll', update);
458
+ };
459
+ }, []);
460
+ return inset;
461
+ }
462
+
463
+ /**
464
+ * Mobile bottom sheet with three snap states (dismissed / default / expanded).
465
+ * Drag the handle: down to shrink/dismiss, up to enlarge. Velocity-based flicks
466
+ * cross thresholds instantly; otherwise the sheet snaps to the closest state.
467
+ *
468
+ * `bottomInset` lifts the sheet above the iOS Safari URL bar overlay.
469
+ */
470
+ function MobileBottomSheet({
471
+ title,
472
+ onClose,
473
+ bottomInset,
474
+ children,
475
+ }: {
476
+ title: ReactNode;
477
+ onClose: () => void;
478
+ bottomInset: number;
479
+ children: ReactNode;
480
+ }) {
481
+ const sheetRef = useRef<HTMLDivElement>(null);
482
+ const dragRef = useRef<{ startY: number; startT: number; startHeight: number; active: boolean }>({
483
+ startY: 0,
484
+ startT: 0,
485
+ startHeight: 0,
486
+ active: false,
487
+ });
488
+
489
+ const SPRING = 'height 220ms cubic-bezier(0.2, 0, 0, 1)';
490
+
491
+ const getSnapPoints = useCallback(() => {
492
+ const h = window.visualViewport?.height ?? window.innerHeight;
493
+ return {
494
+ collapsed: 0,
495
+ defaultH: Math.round(h * 0.6),
496
+ expanded: Math.round(h * 0.92),
497
+ };
498
+ }, []);
499
+
500
+ const onPointerDown = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
501
+ if (e.pointerType === 'mouse' && e.button !== 0) return;
502
+ const sheet = sheetRef.current;
503
+ if (!sheet) return;
504
+ dragRef.current = {
505
+ startY: e.clientY,
506
+ startT: performance.now(),
507
+ startHeight: sheet.getBoundingClientRect().height,
508
+ active: true,
509
+ };
510
+ sheet.style.transition = 'none';
511
+ e.currentTarget.setPointerCapture(e.pointerId);
512
+ }, []);
513
+
514
+ const onPointerMove = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
515
+ const sheet = sheetRef.current;
516
+ if (!dragRef.current.active || !sheet) return;
517
+ const dy = e.clientY - dragRef.current.startY;
518
+ const { expanded } = getSnapPoints();
519
+ const newHeight = Math.max(0, Math.min(expanded, dragRef.current.startHeight - dy));
520
+ sheet.style.height = `${newHeight}px`;
521
+ }, [getSnapPoints]);
522
+
523
+ const onPointerUp = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
524
+ const sheet = sheetRef.current;
525
+ if (!dragRef.current.active || !sheet) return;
526
+ dragRef.current.active = false;
527
+ const dy = e.clientY - dragRef.current.startY;
528
+ const dt = Math.max(1, performance.now() - dragRef.current.startT);
529
+ // Positive velocity = upward drag (intent: enlarge).
530
+ const upwardVelocity = -dy / dt; // px/ms
531
+ const { collapsed, defaultH, expanded } = getSnapPoints();
532
+ const currentHeight = sheet.getBoundingClientRect().height;
533
+
534
+ sheet.style.transition = SPRING;
535
+
536
+ const snapTo = (h: number) => {
537
+ sheet.style.height = `${h}px`;
538
+ };
539
+
540
+ // Velocity-driven decisions take precedence over position.
541
+ if (upwardVelocity > 0.5) {
542
+ snapTo(expanded);
543
+ return;
544
+ }
545
+ if (upwardVelocity < -0.5) {
546
+ // Downward flick: from expanded → default, from default → dismiss.
547
+ if (dragRef.current.startHeight >= expanded - 8) {
548
+ snapTo(defaultH);
549
+ } else {
550
+ snapTo(collapsed);
551
+ window.setTimeout(onClose, 200);
552
+ }
553
+ return;
554
+ }
555
+
556
+ // Position-based snap: closest of the three targets.
557
+ const targets: Array<{ state: 'collapsed' | 'default' | 'expanded'; h: number }> = [
558
+ { state: 'collapsed', h: collapsed },
559
+ { state: 'default', h: defaultH },
560
+ { state: 'expanded', h: expanded },
561
+ ];
562
+ let closest = targets[1];
563
+ for (const t of targets) {
564
+ if (Math.abs(currentHeight - t.h) < Math.abs(currentHeight - closest.h)) closest = t;
565
+ }
566
+ snapTo(closest.h);
567
+ if (closest.state === 'collapsed') window.setTimeout(onClose, 200);
568
+ }, [getSnapPoints, onClose]);
569
+
570
+ // Initial height = default snap. Recompute when viewport changes (URL bar collapses).
571
+ useEffect(() => {
572
+ const sheet = sheetRef.current;
573
+ if (!sheet) return;
574
+ const { defaultH } = getSnapPoints();
575
+ sheet.style.height = `${defaultH}px`;
576
+ }, [getSnapPoints]);
577
+
578
+ return (
579
+ <div
580
+ ref={sheetRef}
581
+ className="absolute inset-x-0 flex flex-col bg-background border-t rounded-t-2xl shadow-2xl z-40 animate-in slide-in-from-bottom duration-300"
582
+ style={{ bottom: `${bottomInset}px` }}
583
+ >
584
+ {/* Drag affordance — generously sized for touch */}
585
+ <div
586
+ className="grid place-items-center pt-3 pb-2 cursor-grab active:cursor-grabbing touch-none select-none"
587
+ onPointerDown={onPointerDown}
588
+ onPointerMove={onPointerMove}
589
+ onPointerUp={onPointerUp}
590
+ onPointerCancel={onPointerUp}
591
+ role="button"
592
+ aria-label="Drag to resize or dismiss"
593
+ >
594
+ <div className="w-10 h-1.5 rounded-full bg-muted-foreground/40" />
595
+ </div>
596
+ <div className="flex items-center justify-between px-4 pb-2 shrink-0">
597
+ <span className="font-semibold text-sm">{title}</span>
598
+ <button
599
+ className="p-2 -mr-2 hover:bg-muted rounded-full active:bg-muted/80 touch-manipulation"
600
+ onClick={onClose}
601
+ aria-label="Close"
602
+ >
603
+ <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
604
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
605
+ </svg>
606
+ </button>
607
+ </div>
608
+ <div className="flex-1 min-h-0 overflow-auto overscroll-contain border-t">
609
+ {children}
610
+ </div>
611
+ </div>
612
+ );
613
+ }