@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.
- package/.turbo/turbo-build.log +59 -44
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +488 -0
- package/dist/assets/{basketViewActivator-CA2CTcVo.js → basketViewActivator-Bzw51jhm.js} +6 -6
- package/dist/assets/decode-worker-t2EGKAxO.js +1708 -0
- package/dist/assets/drawing-2d-Bjy8YPrg.js +257 -0
- package/dist/assets/exporters-u0sz2Upj.js +259119 -0
- package/dist/assets/geometry-controller.worker-NH8pZmrU.js +7 -0
- package/dist/assets/geometry.worker-Bp4rW_R1.js +1 -0
- package/dist/assets/ids-B7AXEv7h.js +4067 -0
- package/dist/assets/ifc-lite-DfZHk36-.js +7 -0
- package/dist/assets/ifc-lite_bg-DlKs5-yM.wasm +0 -0
- package/dist/assets/ifc-lite_bg-PqmRe3Ph.wasm +0 -0
- package/dist/assets/index-CSWgTe1s.css +1 -0
- package/dist/assets/{index-D8Epw-e7.js → index-DVNSvEMh.js} +40146 -35823
- package/dist/assets/laz-perf-Cvr_Lepg.js +1 -0
- package/dist/assets/laz-perf-DnSyzVYH.wasm +0 -0
- package/dist/assets/{native-bridge-DKmx1z95.js → native-bridge-BiD01jI9.js} +1 -1
- package/dist/assets/parser.worker-Bnbrl6gy.js +182 -0
- package/dist/assets/{sandbox-tccwm5Bo.js → sandbox-DPD1ROr0.js} +4 -4
- package/dist/assets/{server-client-LoWPK1N2.js → server-client-DP8fMPY9.js} +1 -1
- package/dist/assets/{wasm-bridge-BsJGgPMs.js → wasm-bridge-CErti6zX.js} +1 -1
- package/dist/assets/workerHelpers-CBbWSJmd.js +36 -0
- package/dist/index.html +8 -8
- package/index.html +1 -1
- package/package.json +10 -10
- package/src/components/viewer/BasketPresentationDock.tsx +3 -0
- package/src/components/viewer/CesiumOverlay.tsx +165 -120
- package/src/components/viewer/DeviationPanel.tsx +172 -0
- package/src/components/viewer/HierarchyPanel.tsx +29 -3
- package/src/components/viewer/HoverTooltip.tsx +5 -0
- package/src/components/viewer/IDSAuditSummary.tsx +389 -0
- package/src/components/viewer/IDSPanel.tsx +80 -26
- package/src/components/viewer/MainToolbar.tsx +60 -7
- package/src/components/viewer/MergeLayersBanner.tsx +108 -0
- package/src/components/viewer/MobileToolbar.tsx +326 -0
- package/src/components/viewer/PointCloudClasses.tsx +111 -0
- package/src/components/viewer/PointCloudLegend.tsx +119 -0
- package/src/components/viewer/PointCloudPanel.tsx +52 -1
- package/src/components/viewer/PropertiesPanel.tsx +37 -6
- package/src/components/viewer/RectSelectionOverlay.tsx +48 -0
- package/src/components/viewer/StatusBar.tsx +14 -0
- package/src/components/viewer/ViewerLayout.tsx +288 -95
- package/src/components/viewer/Viewport.tsx +86 -18
- package/src/components/viewer/ViewportContainer.tsx +25 -11
- package/src/components/viewer/ViewportOverlays.tsx +41 -26
- package/src/components/viewer/mouseHandlerTypes.ts +22 -0
- package/src/components/viewer/properties/GeoreferencingPanel.tsx +77 -8
- package/src/components/viewer/properties/MaterialCard.tsx +2 -2
- package/src/components/viewer/selectionHandlers.ts +41 -0
- package/src/components/viewer/tools/SectionPanel.tsx +181 -24
- package/src/components/viewer/tools/SectionVisualization.tsx +384 -3
- package/src/components/viewer/useAnimationLoop.ts +22 -0
- package/src/components/viewer/useMouseControls.ts +296 -3
- package/src/components/viewer/usePointCloudSync.ts +8 -1
- package/src/components/viewer/useRenderUpdates.ts +21 -1
- package/src/components/viewer/useTouchControls.ts +100 -41
- package/src/hooks/federationLoadGate.test.ts +90 -0
- package/src/hooks/federationLoadGate.ts +127 -0
- package/src/hooks/ids/idsDataAccessor.ts +11 -259
- package/src/hooks/ingest/pointCloudIngest.ts +127 -16
- package/src/hooks/useDrawingGeneration.ts +81 -8
- package/src/hooks/useIDS.ts +90 -10
- package/src/hooks/useIfcFederation.ts +94 -16
- package/src/hooks/useIfcLoader.ts +289 -64
- package/src/hooks/useViewerSelectors.ts +10 -0
- package/src/lib/geo/cesium-bridge.ts +84 -67
- package/src/lib/geo/clamp-anchor.test.ts +80 -0
- package/src/lib/geo/clamp-anchor.ts +57 -0
- package/src/lib/geo/effective-georef.test.ts +79 -1
- package/src/lib/geo/effective-georef.ts +83 -0
- package/src/lib/geo/reproject.ts +26 -13
- package/src/lib/geo/terrain-elevation.ts +166 -0
- package/src/lib/lens/adapter.ts +1 -1
- package/src/lib/llm/context-builder.ts +1 -1
- package/src/lib/perf/memoryAccounting.test.ts +92 -0
- package/src/lib/perf/memoryAccounting.ts +235 -0
- package/src/sdk/adapters/mutation-view.ts +1 -1
- package/src/store/constants.ts +39 -2
- package/src/store/index.ts +6 -1
- package/src/store/slices/cesiumSlice.ts +1 -1
- package/src/store/slices/idsSlice.ts +24 -0
- package/src/store/slices/loadingSlice.ts +12 -0
- package/src/store/slices/pointCloudSlice.ts +72 -1
- package/src/store/slices/sectionSlice.test.ts +590 -1
- package/src/store/slices/sectionSlice.ts +344 -17
- package/src/store/slices/uiSlice.merge-layers.test.ts +217 -0
- package/src/store/slices/uiSlice.ts +60 -2
- package/src/store/types.ts +42 -0
- package/src/store.ts +13 -0
- package/src/utils/acquireFileBuffer.test.ts +231 -0
- package/src/utils/acquireFileBuffer.ts +128 -0
- package/src/utils/ifcConfig.ts +24 -0
- package/src/utils/nativeSpatialDataStore.ts +20 -2
- package/src/utils/spatialHierarchy.test.ts +116 -0
- package/src/utils/spatialHierarchy.ts +23 -0
- package/tailwind.config.js +5 -0
- package/tsconfig.json +1 -0
- package/vite.config.ts +6 -0
- package/dist/assets/decode-worker-Collf_X_.js +0 -1320
- package/dist/assets/drawing-2d-DoxKMqbO.js +0 -257
- package/dist/assets/exporters-xbXqEDlO.js +0 -81590
- package/dist/assets/geometry.worker-DQEZB2rB.js +0 -1
- package/dist/assets/ids-2WdONLlu.js +0 -2033
- package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
- 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
|
-
<
|
|
1112
|
-
|
|
1113
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
<
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
<
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
+
}
|