@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.
Files changed (52) hide show
  1. package/LICENSE +373 -0
  2. package/components.json +22 -0
  3. package/dist/assets/arrow2_bg-BoXCojjR.wasm +0 -0
  4. package/dist/assets/geometry.worker-DpnHtNr3.ts +157 -0
  5. package/dist/assets/ifc_lite_wasm_bg-Cd3m3f2h.wasm +0 -0
  6. package/dist/assets/index-DKe9Oy-s.css +1 -0
  7. package/dist/assets/index-Dzz3WVwq.js +637 -0
  8. package/dist/ifc_lite_wasm_bg.wasm +0 -0
  9. package/dist/index.html +13 -0
  10. package/dist/web-ifc.wasm +0 -0
  11. package/index.html +12 -0
  12. package/package.json +52 -0
  13. package/postcss.config.js +6 -0
  14. package/public/ifc_lite_wasm_bg.wasm +0 -0
  15. package/public/web-ifc.wasm +0 -0
  16. package/src/App.tsx +13 -0
  17. package/src/components/Viewport.tsx +723 -0
  18. package/src/components/ui/button.tsx +58 -0
  19. package/src/components/ui/collapsible.tsx +11 -0
  20. package/src/components/ui/context-menu.tsx +174 -0
  21. package/src/components/ui/dropdown-menu.tsx +175 -0
  22. package/src/components/ui/input.tsx +49 -0
  23. package/src/components/ui/progress.tsx +26 -0
  24. package/src/components/ui/scroll-area.tsx +47 -0
  25. package/src/components/ui/separator.tsx +27 -0
  26. package/src/components/ui/tabs.tsx +56 -0
  27. package/src/components/ui/tooltip.tsx +31 -0
  28. package/src/components/viewer/AxisHelper.tsx +125 -0
  29. package/src/components/viewer/BoxSelectionOverlay.tsx +53 -0
  30. package/src/components/viewer/EntityContextMenu.tsx +220 -0
  31. package/src/components/viewer/HierarchyPanel.tsx +363 -0
  32. package/src/components/viewer/HoverTooltip.tsx +82 -0
  33. package/src/components/viewer/KeyboardShortcutsDialog.tsx +104 -0
  34. package/src/components/viewer/MainToolbar.tsx +441 -0
  35. package/src/components/viewer/PropertiesPanel.tsx +288 -0
  36. package/src/components/viewer/StatusBar.tsx +141 -0
  37. package/src/components/viewer/ToolOverlays.tsx +311 -0
  38. package/src/components/viewer/ViewCube.tsx +195 -0
  39. package/src/components/viewer/ViewerLayout.tsx +190 -0
  40. package/src/components/viewer/Viewport.tsx +1136 -0
  41. package/src/components/viewer/ViewportContainer.tsx +49 -0
  42. package/src/components/viewer/ViewportOverlays.tsx +185 -0
  43. package/src/hooks/useIfc.ts +168 -0
  44. package/src/hooks/useKeyboardShortcuts.ts +142 -0
  45. package/src/index.css +177 -0
  46. package/src/lib/utils.ts +45 -0
  47. package/src/main.tsx +18 -0
  48. package/src/store.ts +471 -0
  49. package/src/webgpu-types.d.ts +20 -0
  50. package/tailwind.config.js +72 -0
  51. package/tsconfig.json +16 -0
  52. 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
+ }