@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,311 @@
|
|
|
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
|
+
* Tool-specific overlays for measure and section tools
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useCallback } from 'react';
|
|
10
|
+
import { X, Trash2, Ruler, Slice } from 'lucide-react';
|
|
11
|
+
import { Button } from '@/components/ui/button';
|
|
12
|
+
import { useViewerStore, type Measurement } from '@/store';
|
|
13
|
+
|
|
14
|
+
export function ToolOverlays() {
|
|
15
|
+
const activeTool = useViewerStore((s) => s.activeTool);
|
|
16
|
+
|
|
17
|
+
if (activeTool === 'measure') {
|
|
18
|
+
return <MeasureOverlay />;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (activeTool === 'section') {
|
|
22
|
+
return <SectionOverlay />;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function MeasureOverlay() {
|
|
29
|
+
const measurements = useViewerStore((s) => s.measurements);
|
|
30
|
+
const pendingMeasurePoint = useViewerStore((s) => s.pendingMeasurePoint);
|
|
31
|
+
const deleteMeasurement = useViewerStore((s) => s.deleteMeasurement);
|
|
32
|
+
const clearMeasurements = useViewerStore((s) => s.clearMeasurements);
|
|
33
|
+
const setActiveTool = useViewerStore((s) => s.setActiveTool);
|
|
34
|
+
|
|
35
|
+
const handleClear = useCallback(() => {
|
|
36
|
+
clearMeasurements();
|
|
37
|
+
}, [clearMeasurements]);
|
|
38
|
+
|
|
39
|
+
const handleClose = useCallback(() => {
|
|
40
|
+
setActiveTool('select');
|
|
41
|
+
}, [setActiveTool]);
|
|
42
|
+
|
|
43
|
+
const handleDeleteMeasurement = useCallback((id: string) => {
|
|
44
|
+
deleteMeasurement(id);
|
|
45
|
+
}, [deleteMeasurement]);
|
|
46
|
+
|
|
47
|
+
// Calculate total distance
|
|
48
|
+
const totalDistance = measurements.reduce((sum, m) => sum + m.distance, 0);
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<>
|
|
52
|
+
{/* Measure Tool Panel */}
|
|
53
|
+
<div className="absolute top-4 left-1/2 -translate-x-1/2 bg-background/95 backdrop-blur-sm rounded-lg border shadow-lg p-3 min-w-64 z-30">
|
|
54
|
+
<div className="flex items-center justify-between gap-4 mb-3">
|
|
55
|
+
<div className="flex items-center gap-2">
|
|
56
|
+
<Ruler className="h-4 w-4 text-primary" />
|
|
57
|
+
<span className="font-medium text-sm">Measure Tool</span>
|
|
58
|
+
</div>
|
|
59
|
+
<div className="flex items-center gap-1">
|
|
60
|
+
<Button variant="ghost" size="icon-sm" onClick={handleClear} title="Clear all">
|
|
61
|
+
<Trash2 className="h-4 w-4" />
|
|
62
|
+
</Button>
|
|
63
|
+
<Button variant="ghost" size="icon-sm" onClick={handleClose} title="Close">
|
|
64
|
+
<X className="h-4 w-4" />
|
|
65
|
+
</Button>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<div className="text-xs text-muted-foreground mb-3">
|
|
70
|
+
Click on the model to place measurement points
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
{measurements.length > 0 ? (
|
|
74
|
+
<div className="space-y-2">
|
|
75
|
+
{measurements.map((m, i) => (
|
|
76
|
+
<MeasurementItem
|
|
77
|
+
key={m.id}
|
|
78
|
+
measurement={m}
|
|
79
|
+
index={i}
|
|
80
|
+
onDelete={handleDeleteMeasurement}
|
|
81
|
+
/>
|
|
82
|
+
))}
|
|
83
|
+
{measurements.length > 1 && (
|
|
84
|
+
<div className="flex items-center justify-between border-t pt-2 mt-2 text-sm font-medium">
|
|
85
|
+
<span>Total</span>
|
|
86
|
+
<span className="font-mono">{formatDistance(totalDistance)}</span>
|
|
87
|
+
</div>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
) : (
|
|
91
|
+
<div className="text-center py-4 text-muted-foreground text-sm">
|
|
92
|
+
No measurements yet
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{/* Instruction hint */}
|
|
98
|
+
<div className="absolute bottom-20 left-1/2 -translate-x-1/2 bg-primary text-primary-foreground px-4 py-2 rounded-full text-sm shadow-lg z-30">
|
|
99
|
+
{pendingMeasurePoint
|
|
100
|
+
? 'Click to set end point'
|
|
101
|
+
: 'Click on model to set start point'}
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
{/* Render measurement lines and labels */}
|
|
105
|
+
<MeasurementOverlays measurements={measurements} pending={pendingMeasurePoint} />
|
|
106
|
+
</>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface MeasurementItemProps {
|
|
111
|
+
measurement: Measurement;
|
|
112
|
+
index: number;
|
|
113
|
+
onDelete: (id: string) => void;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function MeasurementItem({ measurement, index, onDelete }: MeasurementItemProps) {
|
|
117
|
+
return (
|
|
118
|
+
<div className="flex items-center justify-between bg-muted/50 rounded px-2 py-1 text-sm">
|
|
119
|
+
<span className="text-muted-foreground">#{index + 1}</span>
|
|
120
|
+
<span className="font-mono">{formatDistance(measurement.distance)}</span>
|
|
121
|
+
<Button
|
|
122
|
+
variant="ghost"
|
|
123
|
+
size="icon-sm"
|
|
124
|
+
className="h-5 w-5"
|
|
125
|
+
onClick={() => onDelete(measurement.id)}
|
|
126
|
+
>
|
|
127
|
+
<X className="h-3 w-3" />
|
|
128
|
+
</Button>
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface MeasurementOverlaysProps {
|
|
134
|
+
measurements: Measurement[];
|
|
135
|
+
pending: { screenX: number; screenY: number } | null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function MeasurementOverlays({ measurements, pending }: MeasurementOverlaysProps) {
|
|
139
|
+
return (
|
|
140
|
+
<>
|
|
141
|
+
{/* Completed measurements */}
|
|
142
|
+
{measurements.map((m) => (
|
|
143
|
+
<div key={m.id}>
|
|
144
|
+
{/* Line connecting start and end */}
|
|
145
|
+
<svg
|
|
146
|
+
className="absolute inset-0 pointer-events-none z-20"
|
|
147
|
+
style={{ overflow: 'visible' }}
|
|
148
|
+
>
|
|
149
|
+
<line
|
|
150
|
+
x1={m.start.screenX}
|
|
151
|
+
y1={m.start.screenY}
|
|
152
|
+
x2={m.end.screenX}
|
|
153
|
+
y2={m.end.screenY}
|
|
154
|
+
stroke="hsl(var(--primary))"
|
|
155
|
+
strokeWidth="2"
|
|
156
|
+
strokeDasharray="5,5"
|
|
157
|
+
/>
|
|
158
|
+
<circle
|
|
159
|
+
cx={m.start.screenX}
|
|
160
|
+
cy={m.start.screenY}
|
|
161
|
+
r="4"
|
|
162
|
+
fill="hsl(var(--primary))"
|
|
163
|
+
/>
|
|
164
|
+
<circle
|
|
165
|
+
cx={m.end.screenX}
|
|
166
|
+
cy={m.end.screenY}
|
|
167
|
+
r="4"
|
|
168
|
+
fill="hsl(var(--primary))"
|
|
169
|
+
/>
|
|
170
|
+
</svg>
|
|
171
|
+
|
|
172
|
+
{/* Distance label at midpoint */}
|
|
173
|
+
<div
|
|
174
|
+
className="absolute pointer-events-none z-20 bg-primary text-primary-foreground px-2 py-0.5 rounded text-xs font-mono -translate-x-1/2 -translate-y-1/2"
|
|
175
|
+
style={{
|
|
176
|
+
left: (m.start.screenX + m.end.screenX) / 2,
|
|
177
|
+
top: (m.start.screenY + m.end.screenY) / 2,
|
|
178
|
+
}}
|
|
179
|
+
>
|
|
180
|
+
{formatDistance(m.distance)}
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
))}
|
|
184
|
+
|
|
185
|
+
{/* Pending point */}
|
|
186
|
+
{pending && (
|
|
187
|
+
<svg
|
|
188
|
+
className="absolute inset-0 pointer-events-none z-20"
|
|
189
|
+
style={{ overflow: 'visible' }}
|
|
190
|
+
>
|
|
191
|
+
<circle
|
|
192
|
+
cx={pending.screenX}
|
|
193
|
+
cy={pending.screenY}
|
|
194
|
+
r="6"
|
|
195
|
+
fill="none"
|
|
196
|
+
stroke="hsl(var(--primary))"
|
|
197
|
+
strokeWidth="2"
|
|
198
|
+
/>
|
|
199
|
+
<circle
|
|
200
|
+
cx={pending.screenX}
|
|
201
|
+
cy={pending.screenY}
|
|
202
|
+
r="3"
|
|
203
|
+
fill="hsl(var(--primary))"
|
|
204
|
+
/>
|
|
205
|
+
</svg>
|
|
206
|
+
)}
|
|
207
|
+
</>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function SectionOverlay() {
|
|
212
|
+
const sectionPlane = useViewerStore((s) => s.sectionPlane);
|
|
213
|
+
const setSectionPlaneAxis = useViewerStore((s) => s.setSectionPlaneAxis);
|
|
214
|
+
const setSectionPlanePosition = useViewerStore((s) => s.setSectionPlanePosition);
|
|
215
|
+
const toggleSectionPlane = useViewerStore((s) => s.toggleSectionPlane);
|
|
216
|
+
const flipSectionPlane = useViewerStore((s) => s.flipSectionPlane);
|
|
217
|
+
const setActiveTool = useViewerStore((s) => s.setActiveTool);
|
|
218
|
+
|
|
219
|
+
const handleClose = useCallback(() => {
|
|
220
|
+
setActiveTool('select');
|
|
221
|
+
}, [setActiveTool]);
|
|
222
|
+
|
|
223
|
+
const handleAxisChange = useCallback((axis: 'x' | 'y' | 'z') => {
|
|
224
|
+
setSectionPlaneAxis(axis);
|
|
225
|
+
}, [setSectionPlaneAxis]);
|
|
226
|
+
|
|
227
|
+
const handlePositionChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
228
|
+
setSectionPlanePosition(Number(e.target.value));
|
|
229
|
+
}, [setSectionPlanePosition]);
|
|
230
|
+
|
|
231
|
+
return (
|
|
232
|
+
<div className="absolute top-4 left-1/2 -translate-x-1/2 bg-background/95 backdrop-blur-sm rounded-lg border shadow-lg p-3 min-w-72 z-30">
|
|
233
|
+
<div className="flex items-center justify-between gap-4 mb-3">
|
|
234
|
+
<div className="flex items-center gap-2">
|
|
235
|
+
<Slice className="h-4 w-4 text-primary" />
|
|
236
|
+
<span className="font-medium text-sm">Section Plane</span>
|
|
237
|
+
</div>
|
|
238
|
+
<Button variant="ghost" size="icon-sm" onClick={handleClose} title="Close">
|
|
239
|
+
<X className="h-4 w-4" />
|
|
240
|
+
</Button>
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
<div className="space-y-4">
|
|
244
|
+
{/* Axis Selection */}
|
|
245
|
+
<div>
|
|
246
|
+
<label className="text-xs text-muted-foreground mb-2 block">Axis</label>
|
|
247
|
+
<div className="flex gap-1">
|
|
248
|
+
{(['x', 'y', 'z'] as const).map((axis) => (
|
|
249
|
+
<Button
|
|
250
|
+
key={axis}
|
|
251
|
+
variant={sectionPlane.axis === axis ? 'default' : 'outline'}
|
|
252
|
+
size="sm"
|
|
253
|
+
className="flex-1"
|
|
254
|
+
onClick={() => handleAxisChange(axis)}
|
|
255
|
+
>
|
|
256
|
+
{axis.toUpperCase()}
|
|
257
|
+
</Button>
|
|
258
|
+
))}
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
{/* Position Slider */}
|
|
263
|
+
<div>
|
|
264
|
+
<div className="flex items-center justify-between mb-2">
|
|
265
|
+
<label className="text-xs text-muted-foreground">Position</label>
|
|
266
|
+
<span className="text-xs font-mono">{sectionPlane.position}%</span>
|
|
267
|
+
</div>
|
|
268
|
+
<input
|
|
269
|
+
type="range"
|
|
270
|
+
min="0"
|
|
271
|
+
max="100"
|
|
272
|
+
value={sectionPlane.position}
|
|
273
|
+
onChange={handlePositionChange}
|
|
274
|
+
className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary"
|
|
275
|
+
/>
|
|
276
|
+
</div>
|
|
277
|
+
|
|
278
|
+
{/* Actions */}
|
|
279
|
+
<div className="flex gap-2">
|
|
280
|
+
<Button
|
|
281
|
+
variant={sectionPlane.enabled ? 'default' : 'outline'}
|
|
282
|
+
size="sm"
|
|
283
|
+
className="flex-1"
|
|
284
|
+
onClick={toggleSectionPlane}
|
|
285
|
+
>
|
|
286
|
+
{sectionPlane.enabled ? 'Disable' : 'Enable'}
|
|
287
|
+
</Button>
|
|
288
|
+
<Button variant="outline" size="sm" className="flex-1" onClick={flipSectionPlane}>
|
|
289
|
+
Flip
|
|
290
|
+
</Button>
|
|
291
|
+
</div>
|
|
292
|
+
|
|
293
|
+
<div className="text-xs text-muted-foreground text-center">
|
|
294
|
+
Section plane cuts the model along the selected axis
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function formatDistance(meters: number): string {
|
|
302
|
+
if (meters < 0.01) {
|
|
303
|
+
return `${(meters * 1000).toFixed(1)} mm`;
|
|
304
|
+
} else if (meters < 1) {
|
|
305
|
+
return `${(meters * 100).toFixed(1)} cm`;
|
|
306
|
+
} else if (meters < 1000) {
|
|
307
|
+
return `${meters.toFixed(2)} m`;
|
|
308
|
+
} else {
|
|
309
|
+
return `${(meters / 1000).toFixed(2)} km`;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
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 { useState, useRef, useCallback, useEffect, useImperativeHandle, forwardRef } from 'react';
|
|
6
|
+
import { cn } from '@/lib/utils';
|
|
7
|
+
|
|
8
|
+
interface ViewCubeProps {
|
|
9
|
+
onViewChange?: (view: string) => void;
|
|
10
|
+
onDrag?: (deltaX: number, deltaY: number) => void;
|
|
11
|
+
rotationX?: number;
|
|
12
|
+
rotationY?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ViewCubeRef {
|
|
16
|
+
updateRotation: (x: number, y: number) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const FACE_VIEWS: Record<string, { rx: number; ry: number }> = {
|
|
20
|
+
front: { rx: 0, ry: 0 },
|
|
21
|
+
back: { rx: 0, ry: 180 },
|
|
22
|
+
top: { rx: -90, ry: 0 },
|
|
23
|
+
bottom: { rx: 90, ry: 0 },
|
|
24
|
+
right: { rx: 0, ry: -90 },
|
|
25
|
+
left: { rx: 0, ry: 90 },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const FACES = [
|
|
29
|
+
{ id: 'front', label: 'FRONT', transform: (h: number) => `translateZ(${h}px)` },
|
|
30
|
+
{ id: 'back', label: 'BACK', transform: (h: number) => `translateZ(${-h}px) rotateY(180deg)` },
|
|
31
|
+
{ id: 'top', label: 'TOP', transform: (h: number) => `translateY(${-h}px) rotateX(90deg)` },
|
|
32
|
+
{ id: 'bottom', label: 'BTM', transform: (h: number) => `translateY(${h}px) rotateX(-90deg)` },
|
|
33
|
+
{ id: 'right', label: 'RIGHT', transform: (h: number) => `translateX(${h}px) rotateY(90deg)` },
|
|
34
|
+
{ id: 'left', label: 'LEFT', transform: (h: number) => `translateX(${-h}px) rotateY(-90deg)` },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
export const ViewCube = forwardRef<ViewCubeRef, ViewCubeProps>(
|
|
38
|
+
({ onViewChange, onDrag, rotationX = -25, rotationY = 45 }, ref) => {
|
|
39
|
+
const [hovered, setHovered] = useState<string | null>(null);
|
|
40
|
+
const [isMouseDown, setIsMouseDown] = useState(false);
|
|
41
|
+
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
|
|
42
|
+
const didDragRef = useRef(false);
|
|
43
|
+
const isDraggingRef = useRef(false);
|
|
44
|
+
const onDragRef = useRef(onDrag);
|
|
45
|
+
const rotationContainerRef = useRef<HTMLDivElement>(null);
|
|
46
|
+
const rafRef = useRef<number | null>(null);
|
|
47
|
+
const pendingRotationRef = useRef<{ x: number; y: number } | null>(null);
|
|
48
|
+
|
|
49
|
+
// Keep onDrag ref up to date
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
onDragRef.current = onDrag;
|
|
52
|
+
}, [onDrag]);
|
|
53
|
+
|
|
54
|
+
// Expose updateRotation method via ref for direct updates (no React re-renders)
|
|
55
|
+
useImperativeHandle(ref, () => ({
|
|
56
|
+
updateRotation: (x: number, y: number) => {
|
|
57
|
+
if (!rotationContainerRef.current) return;
|
|
58
|
+
|
|
59
|
+
// Store pending rotation
|
|
60
|
+
pendingRotationRef.current = { x, y };
|
|
61
|
+
|
|
62
|
+
// Cancel any pending animation frame
|
|
63
|
+
if (rafRef.current !== null) {
|
|
64
|
+
cancelAnimationFrame(rafRef.current);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Batch updates via requestAnimationFrame for smooth 60fps
|
|
68
|
+
rafRef.current = requestAnimationFrame(() => {
|
|
69
|
+
if (rotationContainerRef.current && pendingRotationRef.current) {
|
|
70
|
+
rotationContainerRef.current.style.transform = `rotateX(${pendingRotationRef.current.x}deg) rotateY(${pendingRotationRef.current.y}deg)`;
|
|
71
|
+
pendingRotationRef.current = null;
|
|
72
|
+
}
|
|
73
|
+
rafRef.current = null;
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
}), []);
|
|
77
|
+
|
|
78
|
+
// Initial rotation from props (only on mount)
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (rotationContainerRef.current) {
|
|
81
|
+
rotationContainerRef.current.style.transform = `rotateX(${rotationX}deg) rotateY(${rotationY}deg)`;
|
|
82
|
+
}
|
|
83
|
+
}, []); // Empty deps - only set initial rotation
|
|
84
|
+
|
|
85
|
+
const size = 60;
|
|
86
|
+
const half = size / 2;
|
|
87
|
+
|
|
88
|
+
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
89
|
+
// Track mouse position for potential drag
|
|
90
|
+
dragStartRef.current = { x: e.clientX, y: e.clientY };
|
|
91
|
+
didDragRef.current = false;
|
|
92
|
+
isDraggingRef.current = false;
|
|
93
|
+
setIsMouseDown(true);
|
|
94
|
+
}, []);
|
|
95
|
+
|
|
96
|
+
// Document-level mouse handlers
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (!isMouseDown) {
|
|
99
|
+
document.body.style.cursor = '';
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const handleDocumentMouseMove = (e: MouseEvent) => {
|
|
104
|
+
if (!dragStartRef.current) return;
|
|
105
|
+
|
|
106
|
+
const deltaX = e.clientX - dragStartRef.current.x;
|
|
107
|
+
const deltaY = e.clientY - dragStartRef.current.y;
|
|
108
|
+
|
|
109
|
+
// Start dragging after threshold (distinguishes from clicks)
|
|
110
|
+
if (!isDraggingRef.current && (Math.abs(deltaX) > 5 || Math.abs(deltaY) > 5)) {
|
|
111
|
+
isDraggingRef.current = true;
|
|
112
|
+
didDragRef.current = true;
|
|
113
|
+
document.body.style.cursor = 'grabbing';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (isDraggingRef.current) {
|
|
117
|
+
onDragRef.current?.(deltaX * 2, deltaY * 2);
|
|
118
|
+
dragStartRef.current = { x: e.clientX, y: e.clientY };
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const handleDocumentMouseUp = () => {
|
|
123
|
+
setIsMouseDown(false);
|
|
124
|
+
isDraggingRef.current = false;
|
|
125
|
+
dragStartRef.current = null;
|
|
126
|
+
document.body.style.cursor = '';
|
|
127
|
+
// Reset didDragRef after a brief delay to allow click to check it
|
|
128
|
+
setTimeout(() => {
|
|
129
|
+
didDragRef.current = false;
|
|
130
|
+
}, 50);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
document.addEventListener('mousemove', handleDocumentMouseMove);
|
|
134
|
+
document.addEventListener('mouseup', handleDocumentMouseUp);
|
|
135
|
+
|
|
136
|
+
return () => {
|
|
137
|
+
document.removeEventListener('mousemove', handleDocumentMouseMove);
|
|
138
|
+
document.removeEventListener('mouseup', handleDocumentMouseUp);
|
|
139
|
+
document.body.style.cursor = '';
|
|
140
|
+
};
|
|
141
|
+
}, [isMouseDown]);
|
|
142
|
+
|
|
143
|
+
const handleFaceClick = useCallback((face: string) => {
|
|
144
|
+
// Only trigger click if we didn't drag
|
|
145
|
+
if (!didDragRef.current) {
|
|
146
|
+
onViewChange?.(face);
|
|
147
|
+
}
|
|
148
|
+
}, [onViewChange]);
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<div
|
|
152
|
+
className="relative select-none"
|
|
153
|
+
style={{
|
|
154
|
+
width: size,
|
|
155
|
+
height: size,
|
|
156
|
+
perspective: 200,
|
|
157
|
+
}}
|
|
158
|
+
onMouseDown={handleMouseDown}
|
|
159
|
+
>
|
|
160
|
+
<div
|
|
161
|
+
ref={rotationContainerRef}
|
|
162
|
+
className="relative w-full h-full"
|
|
163
|
+
style={{
|
|
164
|
+
transformStyle: 'preserve-3d',
|
|
165
|
+
transform: `rotateX(${rotationX}deg) rotateY(${rotationY}deg)`,
|
|
166
|
+
}}
|
|
167
|
+
>
|
|
168
|
+
{FACES.map(({ id, label, transform }) => (
|
|
169
|
+
<button
|
|
170
|
+
key={id}
|
|
171
|
+
type="button"
|
|
172
|
+
className={cn(
|
|
173
|
+
'absolute w-full h-full flex items-center justify-center text-[10px] font-bold transition-colors cursor-pointer',
|
|
174
|
+
'bg-card/95 border border-border/50',
|
|
175
|
+
hovered === id ? 'bg-primary/30 border-primary text-primary' : 'hover:bg-muted'
|
|
176
|
+
)}
|
|
177
|
+
style={{
|
|
178
|
+
transform: transform(half),
|
|
179
|
+
backfaceVisibility: 'hidden',
|
|
180
|
+
}}
|
|
181
|
+
onMouseEnter={() => setHovered(id)}
|
|
182
|
+
onMouseLeave={() => setHovered(null)}
|
|
183
|
+
onClick={() => handleFaceClick(id)}
|
|
184
|
+
>
|
|
185
|
+
{label}
|
|
186
|
+
</button>
|
|
187
|
+
))}
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
ViewCube.displayName = 'ViewCube';
|
|
194
|
+
|
|
195
|
+
export { FACE_VIEWS };
|
|
@@ -0,0 +1,190 @@
|
|
|
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 { useEffect } from 'react';
|
|
6
|
+
import { Panel, Group as PanelGroup, Separator as PanelResizeHandle } from 'react-resizable-panels';
|
|
7
|
+
import { TooltipProvider } from '@/components/ui/tooltip';
|
|
8
|
+
import { MainToolbar } from './MainToolbar';
|
|
9
|
+
import { HierarchyPanel } from './HierarchyPanel';
|
|
10
|
+
import { PropertiesPanel } from './PropertiesPanel';
|
|
11
|
+
import { StatusBar } from './StatusBar';
|
|
12
|
+
import { ViewportContainer } from './ViewportContainer';
|
|
13
|
+
import { KeyboardShortcutsDialog, useKeyboardShortcutsDialog } from './KeyboardShortcutsDialog';
|
|
14
|
+
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
|
|
15
|
+
import { useViewerStore } from '@/store';
|
|
16
|
+
import { EntityContextMenu } from './EntityContextMenu';
|
|
17
|
+
import { HoverTooltip } from './HoverTooltip';
|
|
18
|
+
import { BoxSelectionOverlay } from './BoxSelectionOverlay';
|
|
19
|
+
|
|
20
|
+
export function ViewerLayout() {
|
|
21
|
+
// Initialize keyboard shortcuts
|
|
22
|
+
useKeyboardShortcuts();
|
|
23
|
+
const shortcutsDialog = useKeyboardShortcutsDialog();
|
|
24
|
+
|
|
25
|
+
// Initialize theme on mount
|
|
26
|
+
const theme = useViewerStore((s) => s.theme);
|
|
27
|
+
const isMobile = useViewerStore((s) => s.isMobile);
|
|
28
|
+
const setIsMobile = useViewerStore((s) => s.setIsMobile);
|
|
29
|
+
const leftPanelCollapsed = useViewerStore((s) => s.leftPanelCollapsed);
|
|
30
|
+
const rightPanelCollapsed = useViewerStore((s) => s.rightPanelCollapsed);
|
|
31
|
+
const setLeftPanelCollapsed = useViewerStore((s) => s.setLeftPanelCollapsed);
|
|
32
|
+
const setRightPanelCollapsed = useViewerStore((s) => s.setRightPanelCollapsed);
|
|
33
|
+
|
|
34
|
+
// Detect mobile viewport
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const checkMobile = () => {
|
|
37
|
+
const mobile = window.innerWidth < 768;
|
|
38
|
+
setIsMobile(mobile);
|
|
39
|
+
// Auto-collapse panels on mobile
|
|
40
|
+
if (mobile) {
|
|
41
|
+
setLeftPanelCollapsed(true);
|
|
42
|
+
setRightPanelCollapsed(true);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
checkMobile();
|
|
47
|
+
window.addEventListener('resize', checkMobile);
|
|
48
|
+
return () => window.removeEventListener('resize', checkMobile);
|
|
49
|
+
}, [setIsMobile, setLeftPanelCollapsed, setRightPanelCollapsed]);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
document.documentElement.classList.toggle('dark', theme === 'dark');
|
|
53
|
+
}, [theme]);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<TooltipProvider delayDuration={300}>
|
|
57
|
+
<div className="flex flex-col h-screen w-screen overflow-hidden bg-background text-foreground">
|
|
58
|
+
{/* Keyboard Shortcuts Dialog */}
|
|
59
|
+
<KeyboardShortcutsDialog open={shortcutsDialog.open} onClose={shortcutsDialog.close} />
|
|
60
|
+
|
|
61
|
+
{/* Global Overlays */}
|
|
62
|
+
<EntityContextMenu />
|
|
63
|
+
<HoverTooltip />
|
|
64
|
+
<BoxSelectionOverlay />
|
|
65
|
+
|
|
66
|
+
{/* Main Toolbar */}
|
|
67
|
+
<MainToolbar onShowShortcuts={shortcutsDialog.toggle} />
|
|
68
|
+
|
|
69
|
+
{/* Main Content Area - Desktop Layout */}
|
|
70
|
+
{!isMobile && (
|
|
71
|
+
<PanelGroup orientation="horizontal" className="flex-1 min-h-0">
|
|
72
|
+
{/* Left Panel - Hierarchy */}
|
|
73
|
+
<Panel
|
|
74
|
+
id="left-panel"
|
|
75
|
+
defaultSize={20}
|
|
76
|
+
minSize={10}
|
|
77
|
+
collapsible
|
|
78
|
+
collapsedSize={0}
|
|
79
|
+
>
|
|
80
|
+
<div className="h-full w-full overflow-hidden">
|
|
81
|
+
<HierarchyPanel />
|
|
82
|
+
</div>
|
|
83
|
+
</Panel>
|
|
84
|
+
|
|
85
|
+
<PanelResizeHandle className="w-1.5 bg-border hover:bg-primary/50 active:bg-primary/70 transition-colors cursor-col-resize" />
|
|
86
|
+
|
|
87
|
+
{/* Center - Viewport */}
|
|
88
|
+
<Panel id="viewport-panel" defaultSize={60} minSize={30}>
|
|
89
|
+
<div className="h-full w-full overflow-hidden">
|
|
90
|
+
<ViewportContainer />
|
|
91
|
+
</div>
|
|
92
|
+
</Panel>
|
|
93
|
+
|
|
94
|
+
<PanelResizeHandle className="w-1.5 bg-border hover:bg-primary/50 active:bg-primary/70 transition-colors cursor-col-resize" />
|
|
95
|
+
|
|
96
|
+
{/* Right Panel - Properties */}
|
|
97
|
+
<Panel
|
|
98
|
+
id="right-panel"
|
|
99
|
+
defaultSize={20}
|
|
100
|
+
minSize={10}
|
|
101
|
+
collapsible
|
|
102
|
+
collapsedSize={0}
|
|
103
|
+
>
|
|
104
|
+
<div className="h-full w-full overflow-hidden">
|
|
105
|
+
<PropertiesPanel />
|
|
106
|
+
</div>
|
|
107
|
+
</Panel>
|
|
108
|
+
</PanelGroup>
|
|
109
|
+
)}
|
|
110
|
+
|
|
111
|
+
{/* Main Content Area - Mobile Layout */}
|
|
112
|
+
{isMobile && (
|
|
113
|
+
<div className="flex-1 min-h-0 relative">
|
|
114
|
+
{/* Full-screen Viewport */}
|
|
115
|
+
<div className="h-full w-full">
|
|
116
|
+
<ViewportContainer />
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
{/* Mobile Bottom Sheet - Hierarchy */}
|
|
120
|
+
{!leftPanelCollapsed && (
|
|
121
|
+
<div className="absolute inset-x-0 bottom-0 h-[50vh] bg-background border-t rounded-t-xl shadow-xl z-40 animate-in slide-in-from-bottom">
|
|
122
|
+
<div className="flex items-center justify-between p-2 border-b">
|
|
123
|
+
<span className="font-medium text-sm">Hierarchy</span>
|
|
124
|
+
<button
|
|
125
|
+
className="p-1 hover:bg-muted rounded"
|
|
126
|
+
onClick={() => setLeftPanelCollapsed(true)}
|
|
127
|
+
>
|
|
128
|
+
<span className="sr-only">Close</span>
|
|
129
|
+
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
130
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
131
|
+
</svg>
|
|
132
|
+
</button>
|
|
133
|
+
</div>
|
|
134
|
+
<div className="h-[calc(50vh-48px)] overflow-auto">
|
|
135
|
+
<HierarchyPanel />
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
|
|
140
|
+
{/* Mobile Bottom Sheet - Properties */}
|
|
141
|
+
{!rightPanelCollapsed && (
|
|
142
|
+
<div className="absolute inset-x-0 bottom-0 h-[50vh] bg-background border-t rounded-t-xl shadow-xl z-40 animate-in slide-in-from-bottom">
|
|
143
|
+
<div className="flex items-center justify-between p-2 border-b">
|
|
144
|
+
<span className="font-medium text-sm">Properties</span>
|
|
145
|
+
<button
|
|
146
|
+
className="p-1 hover:bg-muted rounded"
|
|
147
|
+
onClick={() => setRightPanelCollapsed(true)}
|
|
148
|
+
>
|
|
149
|
+
<span className="sr-only">Close</span>
|
|
150
|
+
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
151
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
152
|
+
</svg>
|
|
153
|
+
</button>
|
|
154
|
+
</div>
|
|
155
|
+
<div className="h-[calc(50vh-48px)] overflow-auto">
|
|
156
|
+
<PropertiesPanel />
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
|
|
161
|
+
{/* Mobile Action Buttons */}
|
|
162
|
+
<div className="absolute bottom-4 left-4 right-4 flex justify-center gap-2 z-30">
|
|
163
|
+
<button
|
|
164
|
+
className="px-4 py-2 bg-primary text-primary-foreground rounded-full shadow-lg text-sm font-medium"
|
|
165
|
+
onClick={() => {
|
|
166
|
+
setRightPanelCollapsed(true);
|
|
167
|
+
setLeftPanelCollapsed(!leftPanelCollapsed);
|
|
168
|
+
}}
|
|
169
|
+
>
|
|
170
|
+
Hierarchy
|
|
171
|
+
</button>
|
|
172
|
+
<button
|
|
173
|
+
className="px-4 py-2 bg-primary text-primary-foreground rounded-full shadow-lg text-sm font-medium"
|
|
174
|
+
onClick={() => {
|
|
175
|
+
setLeftPanelCollapsed(true);
|
|
176
|
+
setRightPanelCollapsed(!rightPanelCollapsed);
|
|
177
|
+
}}
|
|
178
|
+
>
|
|
179
|
+
Properties
|
|
180
|
+
</button>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
|
|
185
|
+
{/* Status Bar */}
|
|
186
|
+
<StatusBar />
|
|
187
|
+
</div>
|
|
188
|
+
</TooltipProvider>
|
|
189
|
+
);
|
|
190
|
+
}
|