@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,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
|
+
}
|