@ifc-lite/viewer 1.0.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/LICENSE +373 -0
- package/components.json +22 -0
- package/dist/assets/arrow2_bg-BoXCojjR.wasm +0 -0
- package/dist/assets/geometry.worker-DpnHtNr3.ts +157 -0
- package/dist/assets/ifc_lite_wasm_bg-Cd3m3f2h.wasm +0 -0
- package/dist/assets/index-DKe9Oy-s.css +1 -0
- package/dist/assets/index-Dzz3WVwq.js +637 -0
- package/dist/ifc_lite_wasm_bg.wasm +0 -0
- package/dist/index.html +13 -0
- package/dist/web-ifc.wasm +0 -0
- package/index.html +12 -0
- package/package.json +52 -0
- package/postcss.config.js +6 -0
- package/public/ifc_lite_wasm_bg.wasm +0 -0
- package/public/web-ifc.wasm +0 -0
- package/src/App.tsx +13 -0
- package/src/components/Viewport.tsx +723 -0
- package/src/components/ui/button.tsx +58 -0
- package/src/components/ui/collapsible.tsx +11 -0
- package/src/components/ui/context-menu.tsx +174 -0
- package/src/components/ui/dropdown-menu.tsx +175 -0
- package/src/components/ui/input.tsx +49 -0
- package/src/components/ui/progress.tsx +26 -0
- package/src/components/ui/scroll-area.tsx +47 -0
- package/src/components/ui/separator.tsx +27 -0
- package/src/components/ui/tabs.tsx +56 -0
- package/src/components/ui/tooltip.tsx +31 -0
- package/src/components/viewer/AxisHelper.tsx +125 -0
- package/src/components/viewer/BoxSelectionOverlay.tsx +53 -0
- package/src/components/viewer/EntityContextMenu.tsx +220 -0
- package/src/components/viewer/HierarchyPanel.tsx +363 -0
- package/src/components/viewer/HoverTooltip.tsx +82 -0
- package/src/components/viewer/KeyboardShortcutsDialog.tsx +104 -0
- package/src/components/viewer/MainToolbar.tsx +441 -0
- package/src/components/viewer/PropertiesPanel.tsx +288 -0
- package/src/components/viewer/StatusBar.tsx +141 -0
- package/src/components/viewer/ToolOverlays.tsx +311 -0
- package/src/components/viewer/ViewCube.tsx +195 -0
- package/src/components/viewer/ViewerLayout.tsx +190 -0
- package/src/components/viewer/Viewport.tsx +1136 -0
- package/src/components/viewer/ViewportContainer.tsx +49 -0
- package/src/components/viewer/ViewportOverlays.tsx +185 -0
- package/src/hooks/useIfc.ts +168 -0
- package/src/hooks/useKeyboardShortcuts.ts +142 -0
- package/src/index.css +177 -0
- package/src/lib/utils.ts +45 -0
- package/src/main.tsx +18 -0
- package/src/store.ts +471 -0
- package/src/webgpu-types.d.ts +20 -0
- package/tailwind.config.js +72 -0
- package/tsconfig.json +16 -0
- package/vite.config.ts +45 -0
|
@@ -0,0 +1,125 @@
|
|
|
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
|
+
* Axis Helper component - shows XYZ coordinate system following IFC standard (Z-up)
|
|
7
|
+
* Note: While WebGL uses Y-up internally, IFC convention is Z-up, so we display
|
|
8
|
+
* the axes with Z pointing upward to match what users expect in IFC/BIM context.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
interface AxisHelperProps {
|
|
12
|
+
rotationX?: number;
|
|
13
|
+
rotationY?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function AxisHelper({ rotationX = -25, rotationY = 45 }: AxisHelperProps) {
|
|
17
|
+
const size = 50;
|
|
18
|
+
const axisLength = 20;
|
|
19
|
+
const labelOffset = 26;
|
|
20
|
+
|
|
21
|
+
// Convert from WebGL convention (Y-up) to IFC display convention (Z-up)
|
|
22
|
+
// In the viewer, Y is up in 3D space, but we relabel:
|
|
23
|
+
// - WebGL X -> Display X (right)
|
|
24
|
+
// - WebGL Y -> Display Z (up in IFC)
|
|
25
|
+
// - WebGL Z -> Display Y (forward in IFC)
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
className="relative select-none"
|
|
30
|
+
style={{
|
|
31
|
+
width: size,
|
|
32
|
+
height: size,
|
|
33
|
+
perspective: 200,
|
|
34
|
+
}}
|
|
35
|
+
>
|
|
36
|
+
<div
|
|
37
|
+
className="relative w-full h-full"
|
|
38
|
+
style={{
|
|
39
|
+
transformStyle: 'preserve-3d',
|
|
40
|
+
transform: `rotateX(${rotationX}deg) rotateY(${rotationY}deg)`,
|
|
41
|
+
}}
|
|
42
|
+
>
|
|
43
|
+
{/* X Axis - Red (pointing right) */}
|
|
44
|
+
<div
|
|
45
|
+
className="absolute bg-red-500"
|
|
46
|
+
style={{
|
|
47
|
+
width: axisLength,
|
|
48
|
+
height: 2,
|
|
49
|
+
left: size / 2,
|
|
50
|
+
top: size / 2 - 1,
|
|
51
|
+
transformOrigin: 'left center',
|
|
52
|
+
transform: 'rotateY(0deg)',
|
|
53
|
+
}}
|
|
54
|
+
/>
|
|
55
|
+
<div
|
|
56
|
+
className="absolute text-red-500 font-bold text-xs"
|
|
57
|
+
style={{
|
|
58
|
+
left: size / 2 + labelOffset,
|
|
59
|
+
top: size / 2 - 6,
|
|
60
|
+
transform: `rotateY(${-rotationY}deg) rotateX(${-rotationX}deg)`,
|
|
61
|
+
transformStyle: 'preserve-3d',
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
64
|
+
X
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
{/* Z Axis - Blue (pointing up in IFC) - this is WebGL Y */}
|
|
68
|
+
<div
|
|
69
|
+
className="absolute bg-blue-500"
|
|
70
|
+
style={{
|
|
71
|
+
width: 2,
|
|
72
|
+
height: axisLength,
|
|
73
|
+
left: size / 2 - 1,
|
|
74
|
+
top: size / 2 - axisLength,
|
|
75
|
+
transformOrigin: 'center bottom',
|
|
76
|
+
}}
|
|
77
|
+
/>
|
|
78
|
+
<div
|
|
79
|
+
className="absolute text-blue-500 font-bold text-xs"
|
|
80
|
+
style={{
|
|
81
|
+
left: size / 2 - 4,
|
|
82
|
+
top: size / 2 - labelOffset - 6,
|
|
83
|
+
transform: `rotateY(${-rotationY}deg) rotateX(${-rotationX}deg)`,
|
|
84
|
+
transformStyle: 'preserve-3d',
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
Z
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
{/* Y Axis - Green (pointing into screen in IFC) - this is WebGL -Z */}
|
|
91
|
+
<div
|
|
92
|
+
className="absolute bg-green-500"
|
|
93
|
+
style={{
|
|
94
|
+
width: axisLength,
|
|
95
|
+
height: 2,
|
|
96
|
+
left: size / 2,
|
|
97
|
+
top: size / 2 - 1,
|
|
98
|
+
transformOrigin: 'left center',
|
|
99
|
+
transform: 'rotateY(-90deg)',
|
|
100
|
+
}}
|
|
101
|
+
/>
|
|
102
|
+
<div
|
|
103
|
+
className="absolute text-green-500 font-bold text-xs"
|
|
104
|
+
style={{
|
|
105
|
+
left: size / 2 - 4,
|
|
106
|
+
top: size / 2 + 6,
|
|
107
|
+
transform: `translateZ(${labelOffset}px) rotateY(${-rotationY}deg) rotateX(${-rotationX}deg)`,
|
|
108
|
+
transformStyle: 'preserve-3d',
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
Y
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
{/* Origin point */}
|
|
115
|
+
<div
|
|
116
|
+
className="absolute w-2 h-2 bg-white rounded-full border border-gray-400"
|
|
117
|
+
style={{
|
|
118
|
+
left: size / 2 - 4,
|
|
119
|
+
top: size / 2 - 4,
|
|
120
|
+
}}
|
|
121
|
+
/>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
* Box selection overlay for drag-to-select
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useViewerStore } from '@/store';
|
|
10
|
+
|
|
11
|
+
export function BoxSelectionOverlay() {
|
|
12
|
+
const boxSelect = useViewerStore((s) => s.boxSelect);
|
|
13
|
+
|
|
14
|
+
if (!boxSelect.isSelecting) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Calculate rectangle bounds
|
|
19
|
+
const left = Math.min(boxSelect.startX, boxSelect.currentX);
|
|
20
|
+
const top = Math.min(boxSelect.startY, boxSelect.currentY);
|
|
21
|
+
const width = Math.abs(boxSelect.currentX - boxSelect.startX);
|
|
22
|
+
const height = Math.abs(boxSelect.currentY - boxSelect.startY);
|
|
23
|
+
|
|
24
|
+
// Don't render if the box is too small
|
|
25
|
+
if (width < 5 && height < 5) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div
|
|
31
|
+
className="fixed pointer-events-none border-2 border-primary bg-primary/10 z-30"
|
|
32
|
+
style={{
|
|
33
|
+
left,
|
|
34
|
+
top,
|
|
35
|
+
width,
|
|
36
|
+
height,
|
|
37
|
+
}}
|
|
38
|
+
>
|
|
39
|
+
{/* Corner handles for visual feedback */}
|
|
40
|
+
<div className="absolute -left-1 -top-1 w-2 h-2 bg-primary rounded-full" />
|
|
41
|
+
<div className="absolute -right-1 -top-1 w-2 h-2 bg-primary rounded-full" />
|
|
42
|
+
<div className="absolute -left-1 -bottom-1 w-2 h-2 bg-primary rounded-full" />
|
|
43
|
+
<div className="absolute -right-1 -bottom-1 w-2 h-2 bg-primary rounded-full" />
|
|
44
|
+
|
|
45
|
+
{/* Dimensions label */}
|
|
46
|
+
{(width > 50 || height > 30) && (
|
|
47
|
+
<div className="absolute -bottom-6 left-1/2 -translate-x-1/2 text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded whitespace-nowrap">
|
|
48
|
+
{Math.round(width)} x {Math.round(height)}
|
|
49
|
+
</div>
|
|
50
|
+
)}
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
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
|
+
* Context menu for entity interactions
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
10
|
+
import {
|
|
11
|
+
Focus,
|
|
12
|
+
EyeOff,
|
|
13
|
+
Eye,
|
|
14
|
+
Layers,
|
|
15
|
+
Copy,
|
|
16
|
+
Maximize2,
|
|
17
|
+
Building2,
|
|
18
|
+
} from 'lucide-react';
|
|
19
|
+
import { useViewerStore } from '@/store';
|
|
20
|
+
import { useIfc } from '@/hooks/useIfc';
|
|
21
|
+
|
|
22
|
+
export function EntityContextMenu() {
|
|
23
|
+
const contextMenu = useViewerStore((s) => s.contextMenu);
|
|
24
|
+
const closeContextMenu = useViewerStore((s) => s.closeContextMenu);
|
|
25
|
+
const isolateEntity = useViewerStore((s) => s.isolateEntity);
|
|
26
|
+
const hideEntity = useViewerStore((s) => s.hideEntity);
|
|
27
|
+
const showAll = useViewerStore((s) => s.showAll);
|
|
28
|
+
const setSelectedEntityId = useViewerStore((s) => s.setSelectedEntityId);
|
|
29
|
+
const setSelectedEntityIds = useViewerStore((s) => s.setSelectedEntityIds);
|
|
30
|
+
const cameraCallbacks = useViewerStore((s) => s.cameraCallbacks);
|
|
31
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
32
|
+
const { ifcDataStore } = useIfc();
|
|
33
|
+
|
|
34
|
+
// Close menu when clicking outside
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
37
|
+
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
|
38
|
+
closeContextMenu();
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
if (contextMenu.isOpen) {
|
|
43
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
44
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
45
|
+
}
|
|
46
|
+
}, [contextMenu.isOpen, closeContextMenu]);
|
|
47
|
+
|
|
48
|
+
// Close on escape
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
51
|
+
if (e.key === 'Escape') {
|
|
52
|
+
closeContextMenu();
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
if (contextMenu.isOpen) {
|
|
57
|
+
document.addEventListener('keydown', handleEscape);
|
|
58
|
+
return () => document.removeEventListener('keydown', handleEscape);
|
|
59
|
+
}
|
|
60
|
+
}, [contextMenu.isOpen, closeContextMenu]);
|
|
61
|
+
|
|
62
|
+
const handleZoomTo = useCallback(() => {
|
|
63
|
+
if (contextMenu.entityId) {
|
|
64
|
+
setSelectedEntityId(contextMenu.entityId);
|
|
65
|
+
cameraCallbacks.fitAll?.();
|
|
66
|
+
}
|
|
67
|
+
closeContextMenu();
|
|
68
|
+
}, [contextMenu.entityId, setSelectedEntityId, cameraCallbacks, closeContextMenu]);
|
|
69
|
+
|
|
70
|
+
const handleIsolate = useCallback(() => {
|
|
71
|
+
if (contextMenu.entityId) {
|
|
72
|
+
isolateEntity(contextMenu.entityId);
|
|
73
|
+
}
|
|
74
|
+
closeContextMenu();
|
|
75
|
+
}, [contextMenu.entityId, isolateEntity, closeContextMenu]);
|
|
76
|
+
|
|
77
|
+
const handleHide = useCallback(() => {
|
|
78
|
+
if (contextMenu.entityId) {
|
|
79
|
+
hideEntity(contextMenu.entityId);
|
|
80
|
+
}
|
|
81
|
+
closeContextMenu();
|
|
82
|
+
}, [contextMenu.entityId, hideEntity, closeContextMenu]);
|
|
83
|
+
|
|
84
|
+
const handleShowAll = useCallback(() => {
|
|
85
|
+
showAll();
|
|
86
|
+
closeContextMenu();
|
|
87
|
+
}, [showAll, closeContextMenu]);
|
|
88
|
+
|
|
89
|
+
const handleSelectSimilar = useCallback(() => {
|
|
90
|
+
if (!contextMenu.entityId || !ifcDataStore) {
|
|
91
|
+
closeContextMenu();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Get the type of the selected entity
|
|
96
|
+
const entity = ifcDataStore.entities;
|
|
97
|
+
let entityType: string | null = null;
|
|
98
|
+
|
|
99
|
+
for (let i = 0; i < entity.count; i++) {
|
|
100
|
+
if (entity.expressId[i] === contextMenu.entityId) {
|
|
101
|
+
entityType = entity.getTypeName(contextMenu.entityId);
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (entityType) {
|
|
107
|
+
// Select all entities of the same type
|
|
108
|
+
const sameTypeIds: number[] = [];
|
|
109
|
+
for (let i = 0; i < entity.count; i++) {
|
|
110
|
+
if (entity.getTypeName(entity.expressId[i]) === entityType) {
|
|
111
|
+
sameTypeIds.push(entity.expressId[i]);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
setSelectedEntityIds(sameTypeIds);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
closeContextMenu();
|
|
118
|
+
}, [contextMenu.entityId, ifcDataStore, setSelectedEntityIds, closeContextMenu]);
|
|
119
|
+
|
|
120
|
+
const handleSelectSameStorey = useCallback(() => {
|
|
121
|
+
if (!contextMenu.entityId || !ifcDataStore?.spatialHierarchy) {
|
|
122
|
+
closeContextMenu();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const storeyId = ifcDataStore.spatialHierarchy.elementToStorey.get(contextMenu.entityId);
|
|
127
|
+
if (storeyId) {
|
|
128
|
+
const storeyElements = ifcDataStore.spatialHierarchy.byStorey.get(storeyId);
|
|
129
|
+
if (storeyElements) {
|
|
130
|
+
setSelectedEntityIds(Array.from(storeyElements));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
closeContextMenu();
|
|
135
|
+
}, [contextMenu.entityId, ifcDataStore, setSelectedEntityIds, closeContextMenu]);
|
|
136
|
+
|
|
137
|
+
const handleCopyId = useCallback(() => {
|
|
138
|
+
if (contextMenu.entityId && ifcDataStore) {
|
|
139
|
+
const globalId = ifcDataStore.entities.getGlobalId(contextMenu.entityId);
|
|
140
|
+
if (globalId) {
|
|
141
|
+
navigator.clipboard.writeText(globalId);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
closeContextMenu();
|
|
145
|
+
}, [contextMenu.entityId, ifcDataStore, closeContextMenu]);
|
|
146
|
+
|
|
147
|
+
if (!contextMenu.isOpen) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Get entity info for display
|
|
152
|
+
let entityName = '';
|
|
153
|
+
let entityType = '';
|
|
154
|
+
if (contextMenu.entityId && ifcDataStore) {
|
|
155
|
+
entityName = ifcDataStore.entities.getName(contextMenu.entityId) || '';
|
|
156
|
+
entityType = ifcDataStore.entities.getTypeName(contextMenu.entityId) || '';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<div
|
|
161
|
+
ref={menuRef}
|
|
162
|
+
className="fixed z-50 bg-popover border rounded-lg shadow-lg py-1 min-w-48"
|
|
163
|
+
style={{
|
|
164
|
+
left: contextMenu.screenX,
|
|
165
|
+
top: contextMenu.screenY,
|
|
166
|
+
}}
|
|
167
|
+
>
|
|
168
|
+
{contextMenu.entityId && (
|
|
169
|
+
<>
|
|
170
|
+
{/* Entity Header */}
|
|
171
|
+
<div className="px-3 py-2 border-b">
|
|
172
|
+
<div className="font-medium text-sm truncate">
|
|
173
|
+
{entityName || `${entityType} #${contextMenu.entityId}`}
|
|
174
|
+
</div>
|
|
175
|
+
<div className="text-xs text-muted-foreground">{entityType}</div>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
<MenuItem icon={Maximize2} label="Zoom to" onClick={handleZoomTo} />
|
|
179
|
+
<MenuItem icon={Focus} label="Isolate" onClick={handleIsolate} />
|
|
180
|
+
<MenuItem icon={EyeOff} label="Hide" onClick={handleHide} />
|
|
181
|
+
|
|
182
|
+
<div className="h-px bg-border my-1" />
|
|
183
|
+
|
|
184
|
+
<MenuItem icon={Layers} label={`Select all ${entityType}`} onClick={handleSelectSimilar} />
|
|
185
|
+
<MenuItem icon={Building2} label="Select same storey" onClick={handleSelectSameStorey} />
|
|
186
|
+
|
|
187
|
+
<div className="h-px bg-border my-1" />
|
|
188
|
+
|
|
189
|
+
<MenuItem icon={Copy} label="Copy GlobalId" onClick={handleCopyId} />
|
|
190
|
+
</>
|
|
191
|
+
)}
|
|
192
|
+
|
|
193
|
+
{!contextMenu.entityId && (
|
|
194
|
+
<>
|
|
195
|
+
<MenuItem icon={Eye} label="Show all" onClick={handleShowAll} />
|
|
196
|
+
</>
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
interface MenuItemProps {
|
|
203
|
+
icon: React.ComponentType<{ className?: string }>;
|
|
204
|
+
label: string;
|
|
205
|
+
onClick: () => void;
|
|
206
|
+
disabled?: boolean;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function MenuItem({ icon: Icon, label, onClick, disabled }: MenuItemProps) {
|
|
210
|
+
return (
|
|
211
|
+
<button
|
|
212
|
+
className="w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
|
|
213
|
+
onClick={onClick}
|
|
214
|
+
disabled={disabled}
|
|
215
|
+
>
|
|
216
|
+
<Icon className="h-4 w-4 text-muted-foreground" />
|
|
217
|
+
<span>{label}</span>
|
|
218
|
+
</button>
|
|
219
|
+
);
|
|
220
|
+
}
|