@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,288 @@
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
+ import { useMemo } from 'react';
6
+ import {
7
+ Copy,
8
+ Focus,
9
+ EyeOff,
10
+ Eye,
11
+ Building2,
12
+ Layers,
13
+ FileText,
14
+ Calculator,
15
+ } from 'lucide-react';
16
+ import { Button } from '@/components/ui/button';
17
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
18
+ import { ScrollArea } from '@/components/ui/scroll-area';
19
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
20
+ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
21
+ import { Separator } from '@/components/ui/separator';
22
+ import { useViewerStore } from '@/store';
23
+ import { useIfc } from '@/hooks/useIfc';
24
+
25
+ interface PropertySet {
26
+ name: string;
27
+ properties: Array<{ name: string; value: unknown }>;
28
+ }
29
+
30
+ interface QuantitySet {
31
+ name: string;
32
+ quantities: Array<{ name: string; value: number; type: number }>;
33
+ }
34
+
35
+ export function PropertiesPanel() {
36
+ const selectedEntityId = useViewerStore((s) => s.selectedEntityId);
37
+ const cameraCallbacks = useViewerStore((s) => s.cameraCallbacks);
38
+ const toggleEntityVisibility = useViewerStore((s) => s.toggleEntityVisibility);
39
+ const isEntityVisible = useViewerStore((s) => s.isEntityVisible);
40
+ const { query, ifcDataStore } = useIfc();
41
+
42
+ // Get spatial location info
43
+ const spatialInfo = useMemo(() => {
44
+ if (!selectedEntityId || !ifcDataStore?.spatialHierarchy) return null;
45
+
46
+ const hierarchy = ifcDataStore.spatialHierarchy;
47
+ let storeyId: number | null = null;
48
+
49
+ for (const [sid, elementIds] of hierarchy.byStorey) {
50
+ if ((elementIds as number[]).includes(selectedEntityId)) {
51
+ storeyId = sid as number;
52
+ break;
53
+ }
54
+ }
55
+
56
+ if (!storeyId) return null;
57
+
58
+ return {
59
+ storeyId,
60
+ storeyName: ifcDataStore.entities.getName(storeyId) || `Storey #${storeyId}`,
61
+ elevation: hierarchy.storeyElevations.get(storeyId),
62
+ };
63
+ }, [selectedEntityId, ifcDataStore]);
64
+
65
+ // Get quantities
66
+ const quantities = useMemo((): QuantitySet[] => {
67
+ if (!selectedEntityId || !ifcDataStore?.quantities) return [];
68
+ return ifcDataStore.quantities.getForEntity(selectedEntityId);
69
+ }, [selectedEntityId, ifcDataStore]);
70
+
71
+ if (!selectedEntityId || !query) {
72
+ return (
73
+ <div className="h-full flex flex-col border-l bg-card">
74
+ <div className="p-3 border-b">
75
+ <h2 className="font-semibold text-sm">Properties</h2>
76
+ </div>
77
+ <div className="flex-1 flex items-center justify-center text-muted-foreground text-sm p-4 text-center">
78
+ Select an object to view properties
79
+ </div>
80
+ </div>
81
+ );
82
+ }
83
+
84
+ const entityNode = query.entity(selectedEntityId);
85
+ const properties: PropertySet[] = entityNode.properties();
86
+ const entityType = entityNode.type;
87
+ const entityName = entityNode.name;
88
+ const entityGlobalId = entityNode.globalId;
89
+
90
+ const copyToClipboard = (text: string) => {
91
+ navigator.clipboard.writeText(text);
92
+ };
93
+
94
+ return (
95
+ <div className="h-full flex flex-col border-l bg-card">
96
+ {/* Entity Header */}
97
+ <div className="p-3 border-b space-y-2">
98
+ <div className="flex items-start gap-3">
99
+ <div className="p-2 rounded-lg bg-primary/10 shrink-0">
100
+ <Building2 className="h-5 w-5 text-primary" />
101
+ </div>
102
+ <div className="flex-1 min-w-0">
103
+ <h3 className="font-semibold text-sm truncate">
104
+ {entityName || `${entityType}`}
105
+ </h3>
106
+ <p className="text-xs text-muted-foreground">{entityType}</p>
107
+ </div>
108
+ <div className="flex gap-1 shrink-0">
109
+ <Tooltip>
110
+ <TooltipTrigger asChild>
111
+ <Button
112
+ variant="ghost"
113
+ size="icon-xs"
114
+ onClick={() => {
115
+ if (selectedEntityId && cameraCallbacks.frameSelection) {
116
+ cameraCallbacks.frameSelection();
117
+ }
118
+ }}
119
+ >
120
+ <Focus className="h-3.5 w-3.5" />
121
+ </Button>
122
+ </TooltipTrigger>
123
+ <TooltipContent>Zoom to</TooltipContent>
124
+ </Tooltip>
125
+ <Tooltip>
126
+ <TooltipTrigger asChild>
127
+ <Button
128
+ variant="ghost"
129
+ size="icon-xs"
130
+ onClick={() => {
131
+ if (selectedEntityId) {
132
+ toggleEntityVisibility(selectedEntityId);
133
+ }
134
+ }}
135
+ >
136
+ {selectedEntityId && isEntityVisible(selectedEntityId) ? (
137
+ <EyeOff className="h-3.5 w-3.5" />
138
+ ) : (
139
+ <Eye className="h-3.5 w-3.5" />
140
+ )}
141
+ </Button>
142
+ </TooltipTrigger>
143
+ <TooltipContent>
144
+ {selectedEntityId && isEntityVisible(selectedEntityId) ? 'Hide' : 'Show'}
145
+ </TooltipContent>
146
+ </Tooltip>
147
+ </div>
148
+ </div>
149
+
150
+ {/* GlobalId */}
151
+ {entityGlobalId && (
152
+ <div className="flex items-center gap-2">
153
+ <code className="flex-1 text-xs bg-muted px-2 py-1 rounded truncate font-mono">
154
+ {entityGlobalId}
155
+ </code>
156
+ <Button
157
+ variant="ghost"
158
+ size="icon-xs"
159
+ onClick={() => copyToClipboard(entityGlobalId)}
160
+ >
161
+ <Copy className="h-3 w-3" />
162
+ </Button>
163
+ </div>
164
+ )}
165
+
166
+ {/* Spatial Location */}
167
+ {spatialInfo && (
168
+ <div className="flex items-center gap-2 text-xs bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 px-2 py-1.5 rounded">
169
+ <Layers className="h-3.5 w-3.5" />
170
+ <span className="font-medium">{spatialInfo.storeyName}</span>
171
+ {spatialInfo.elevation !== undefined && (
172
+ <span className="text-emerald-600/70 dark:text-emerald-400/70">
173
+ ({spatialInfo.elevation.toFixed(2)}m)
174
+ </span>
175
+ )}
176
+ </div>
177
+ )}
178
+ </div>
179
+
180
+ {/* Tabs */}
181
+ <Tabs defaultValue="properties" className="flex-1 flex flex-col overflow-hidden">
182
+ <TabsList className="w-full justify-start rounded-none border-b bg-transparent h-9 p-0">
183
+ <TabsTrigger
184
+ value="properties"
185
+ className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent"
186
+ >
187
+ <FileText className="h-3.5 w-3.5 mr-1.5" />
188
+ Properties
189
+ </TabsTrigger>
190
+ <TabsTrigger
191
+ value="quantities"
192
+ className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent"
193
+ >
194
+ <Calculator className="h-3.5 w-3.5 mr-1.5" />
195
+ Quantities
196
+ </TabsTrigger>
197
+ </TabsList>
198
+
199
+ <ScrollArea className="flex-1">
200
+ <TabsContent value="properties" className="m-0 p-3">
201
+ {properties.length === 0 ? (
202
+ <p className="text-sm text-muted-foreground">No property sets</p>
203
+ ) : (
204
+ <div className="space-y-2">
205
+ {properties.map((pset: PropertySet) => (
206
+ <PropertySetCard key={pset.name} pset={pset} />
207
+ ))}
208
+ </div>
209
+ )}
210
+ </TabsContent>
211
+
212
+ <TabsContent value="quantities" className="m-0 p-3">
213
+ {quantities.length === 0 ? (
214
+ <p className="text-sm text-muted-foreground">No quantities</p>
215
+ ) : (
216
+ <div className="space-y-2">
217
+ {quantities.map((qset: QuantitySet) => (
218
+ <QuantitySetCard key={qset.name} qset={qset} />
219
+ ))}
220
+ </div>
221
+ )}
222
+ </TabsContent>
223
+ </ScrollArea>
224
+ </Tabs>
225
+ </div>
226
+ );
227
+ }
228
+
229
+ function PropertySetCard({ pset }: { pset: PropertySet }) {
230
+ return (
231
+ <Collapsible defaultOpen className="border rounded-lg">
232
+ <CollapsibleTrigger className="flex items-center justify-between w-full p-2.5 hover:bg-muted/50 rounded-t-lg text-left">
233
+ <span className="font-medium text-sm">{pset.name}</span>
234
+ <span className="text-xs text-muted-foreground">{pset.properties.length}</span>
235
+ </CollapsibleTrigger>
236
+ <CollapsibleContent>
237
+ <Separator />
238
+ <div className="divide-y">
239
+ {pset.properties.map((prop: { name: string; value: unknown }) => (
240
+ <div key={prop.name} className="flex justify-between gap-2 px-2.5 py-1.5 text-sm">
241
+ <span className="text-muted-foreground truncate">{prop.name}</span>
242
+ <span className="text-right truncate font-medium">
243
+ {prop.value !== null && prop.value !== undefined ? String(prop.value) : '—'}
244
+ </span>
245
+ </div>
246
+ ))}
247
+ </div>
248
+ </CollapsibleContent>
249
+ </Collapsible>
250
+ );
251
+ }
252
+
253
+ function QuantitySetCard({ qset }: { qset: QuantitySet }) {
254
+ const formatValue = (value: number, type: number): string => {
255
+ const formatted = value.toLocaleString(undefined, { maximumFractionDigits: 3 });
256
+ switch (type) {
257
+ case 0: return `${formatted} m`;
258
+ case 1: return `${formatted} m²`;
259
+ case 2: return `${formatted} m³`;
260
+ case 3: return formatted;
261
+ case 4: return `${formatted} kg`;
262
+ case 5: return `${formatted} s`;
263
+ default: return formatted;
264
+ }
265
+ };
266
+
267
+ return (
268
+ <Collapsible defaultOpen className="border rounded-lg border-blue-200 dark:border-blue-900">
269
+ <CollapsibleTrigger className="flex items-center justify-between w-full p-2.5 hover:bg-blue-50 dark:hover:bg-blue-950/50 rounded-t-lg text-left">
270
+ <span className="font-medium text-sm text-blue-700 dark:text-blue-400">{qset.name}</span>
271
+ <span className="text-xs text-blue-500/70">{qset.quantities.length}</span>
272
+ </CollapsibleTrigger>
273
+ <CollapsibleContent>
274
+ <Separator className="bg-blue-200 dark:bg-blue-900" />
275
+ <div className="divide-y divide-blue-100 dark:divide-blue-900/50">
276
+ {qset.quantities.map((q: { name: string; value: number; type: number }) => (
277
+ <div key={q.name} className="flex justify-between gap-2 px-2.5 py-1.5 text-sm">
278
+ <span className="text-muted-foreground truncate">{q.name}</span>
279
+ <span className="text-right font-mono text-blue-700 dark:text-blue-400">
280
+ {formatValue(q.value, q.type)}
281
+ </span>
282
+ </div>
283
+ ))}
284
+ </div>
285
+ </CollapsibleContent>
286
+ </Collapsible>
287
+ );
288
+ }
@@ -0,0 +1,141 @@
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
+ import { useMemo, useState, useEffect } from 'react';
6
+ import { Boxes, Triangle, CheckCircle2, AlertCircle } from 'lucide-react';
7
+ import { Separator } from '@/components/ui/separator';
8
+ import { formatNumber, formatBytes } from '@/lib/utils';
9
+ import { useViewerStore } from '@/store';
10
+ import { useIfc } from '@/hooks/useIfc';
11
+
12
+ export function StatusBar() {
13
+ const { loading, geometryResult, ifcDataStore } = useIfc();
14
+ const progress = useViewerStore((s) => s.progress);
15
+ const error = useViewerStore((s) => s.error);
16
+ const selectedStorey = useViewerStore((s) => s.selectedStorey);
17
+
18
+ const [fps, setFps] = useState(60);
19
+ const [memory, setMemory] = useState(0);
20
+ const [webgpuSupported, setWebgpuSupported] = useState<boolean | null>(null);
21
+
22
+ // Check WebGPU support
23
+ useEffect(() => {
24
+ setWebgpuSupported('gpu' in navigator);
25
+ }, []);
26
+
27
+ // FPS counter (simplified)
28
+ useEffect(() => {
29
+ let frameCount = 0;
30
+ let lastTime = performance.now();
31
+ let animationId: number;
32
+
33
+ const measureFps = () => {
34
+ frameCount++;
35
+ const currentTime = performance.now();
36
+
37
+ if (currentTime - lastTime >= 1000) {
38
+ setFps(frameCount);
39
+ frameCount = 0;
40
+ lastTime = currentTime;
41
+ }
42
+
43
+ animationId = requestAnimationFrame(measureFps);
44
+ };
45
+
46
+ animationId = requestAnimationFrame(measureFps);
47
+ return () => cancelAnimationFrame(animationId);
48
+ }, []);
49
+
50
+ // Memory usage (if available)
51
+ useEffect(() => {
52
+ const updateMemory = () => {
53
+ if ((performance as any).memory) {
54
+ setMemory((performance as any).memory.usedJSHeapSize);
55
+ }
56
+ };
57
+
58
+ updateMemory();
59
+ const interval = setInterval(updateMemory, 2000);
60
+ return () => clearInterval(interval);
61
+ }, []);
62
+
63
+ const stats = useMemo(() => {
64
+ if (!geometryResult) {
65
+ return { elements: 0, triangles: 0 };
66
+ }
67
+ return {
68
+ elements: geometryResult.meshes?.length ?? 0,
69
+ triangles: geometryResult.totalTriangles ?? 0,
70
+ };
71
+ }, [geometryResult]);
72
+
73
+ const visibleElements = useMemo(() => {
74
+ if (!selectedStorey || !ifcDataStore?.spatialHierarchy) {
75
+ return stats.elements;
76
+ }
77
+ const storeyElements = ifcDataStore.spatialHierarchy.byStorey.get(selectedStorey);
78
+ return (storeyElements as number[] | undefined)?.length ?? stats.elements;
79
+ }, [selectedStorey, ifcDataStore, stats.elements]);
80
+
81
+ return (
82
+ <div className="h-7 px-3 border-t bg-muted/30 flex items-center justify-between text-xs text-muted-foreground">
83
+ {/* Left: Status */}
84
+ <div className="flex items-center gap-3">
85
+ {loading ? (
86
+ <span className="text-primary">{progress?.phase || 'Loading...'}</span>
87
+ ) : error ? (
88
+ <span className="text-destructive">{error}</span>
89
+ ) : (
90
+ <span>Ready</span>
91
+ )}
92
+ </div>
93
+
94
+ {/* Center: Model Stats */}
95
+ <div className="flex items-center gap-4">
96
+ <div className="flex items-center gap-1.5">
97
+ <Boxes className="h-3.5 w-3.5" />
98
+ <span>
99
+ {formatNumber(visibleElements)}
100
+ {selectedStorey && stats.elements !== visibleElements && (
101
+ <span className="opacity-60"> / {formatNumber(stats.elements)}</span>
102
+ )}
103
+ {' '}elements
104
+ </span>
105
+ </div>
106
+
107
+ <Separator orientation="vertical" className="h-3.5" />
108
+
109
+ <div className="flex items-center gap-1.5">
110
+ <Triangle className="h-3.5 w-3.5" />
111
+ <span>{formatNumber(stats.triangles)} tris</span>
112
+ </div>
113
+ </div>
114
+
115
+ {/* Right: Performance */}
116
+ <div className="flex items-center gap-3">
117
+ <span className={fps < 30 ? 'text-destructive' : fps < 50 ? 'text-yellow-500' : ''}>
118
+ {fps} FPS
119
+ </span>
120
+
121
+ {memory > 0 && (
122
+ <>
123
+ <Separator orientation="vertical" className="h-3.5" />
124
+ <span>{formatBytes(memory)}</span>
125
+ </>
126
+ )}
127
+
128
+ <Separator orientation="vertical" className="h-3.5" />
129
+
130
+ <div className="flex items-center gap-1">
131
+ {webgpuSupported ? (
132
+ <CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
133
+ ) : (
134
+ <AlertCircle className="h-3.5 w-3.5 text-yellow-500" />
135
+ )}
136
+ <span>{webgpuSupported ? 'WebGPU' : 'WebGL'}</span>
137
+ </div>
138
+ </div>
139
+ </div>
140
+ );
141
+ }