Repository navigation
Expand file tree
/
Copy pathViewportOverlay.jsx
More file actions
347 lines (319 loc) · 12.6 KB
/
Copy pathViewportOverlay.jsx
File metadata and controls
347 lines (319 loc) · 12.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
// Viewport top-right toolbar.
// [ Zoom slider ] [ Overlays ▾ ] [ Window | Volume ] [ 2D | 3D ]
//
// Window/Volume picks the scene type — flipping it kicks off a fresh seed
// for the new mode (with a confirmation dialog when the current scene has
// unsaved edits). 2D/3D is the camera toggle for window mode; in volume
// mode it greys out because the volumetric stage is always 3D.
//
// HDRI lives in Scene → Viewport (inspector). Pan-mode is gone — in 3D
// preview, left-drag rotates by default (matches Blender / Maya / C4D).
import { useState, useRef, useEffect } from 'react'
import { useStore } from '../store'
import { GridIcon, WindowIcon, VolumeIcon, ChevronDown } from './icons'
// ---- Zoom slider ------------------------------------------------------
function ZoomSlider() {
const zoomDistance = useStore((s) => s.zoomDistance)
const setZoomDistance = useStore((s) => s.setZoomDistance)
// Metres-scale slider: 0.3m–4m range covers "tip of window" to
// "across the room". 1.4m is the natural framing for a 1.2m window.
const DEFAULT_DIST = 1.4
const RESET_DIST = DEFAULT_DIST
const pct = Math.round((DEFAULT_DIST / Math.max(0.05, zoomDistance)) * 100)
return (
<div
className="flex items-center gap-1.5 rounded px-2"
style={{
height: 28,
border: '1px solid #2e2e2e',
background: 'rgba(21, 21, 21, 0.88)',
backdropFilter: 'blur(8px)'
}}
>
<span className="text-[11px] font-medium text-white w-9 text-right tabular-nums">{pct}%</span>
<input
type="range"
min={0.3}
max={5}
step={0.02}
value={zoomDistance}
onChange={(e) => setZoomDistance(parseFloat(e.target.value))}
onDoubleClick={() => setZoomDistance(RESET_DIST)}
className="w-24 accent-accent cursor-pointer"
title="Zoom — double-click to reset"
/>
</div>
)
}
// ---- Overlays dropdown ------------------------------------------------
// Mirrors Blender's viewport-overlay popover. Toggles render-time guides
// without cluttering the toolbar with one button per option.
function OverlaysDropdown() {
const [open, setOpen] = useState(false)
const ref = useRef(null)
const sceneMode = useStore((s) => s.scene.sceneMode)
const showDemoScene = useStore((s) => s.scene.showDemoScene)
const gridAxes = useStore((s) => s.gridAxes)
const showAxes = useStore((s) => s.showAxes)
const showSceneInfo = useStore((s) => s.showSceneInfo)
const preview3D = useStore((s) => s.scene.preview3D)
const toggleGridAxis = useStore((s) => s.toggleGridAxis)
const toggleAxes = useStore((s) => s.toggleAxes)
const toggleSceneInfo = useStore((s) => s.toggleSceneInfo)
const toggleDemoScene = useStore((s) => s.toggleDemoScene)
// Volume mode is always 3D — grids X / Y / axes apply unconditionally.
// Window mode honours the user's 2D / 3D toggle.
const isVolume = sceneMode === 'volume'
const orbitActive = isVolume || preview3D
useEffect(() => {
const onDoc = (e) => { if (!ref.current?.contains(e.target)) setOpen(false) }
document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc)
}, [])
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen((o) => !o)}
title="Viewport overlays"
className="vp-btn"
style={{ width: 'auto', paddingLeft: 8, paddingRight: 6, gap: 4 }}
>
<GridIcon />
<ChevronDown size={9} />
</button>
{open && (
<div
className="popover absolute right-0 top-full mt-1 rounded z-50 py-2 px-2.5 w-52"
onMouseDown={(e) => e.stopPropagation()}
>
{/*
Demo Scene applies to both window and volume modes — window
mode renders inside the same studio so a "hide the room"
toggle belongs here too. Previously it only surfaced in
volume mode, leaving window users with no way to opt out.
*/}
<div className="text-[9px] text-textMute uppercase tracking-wider mb-2 px-1">Studio</div>
<OverlayRow label="Demo Scene" on={showDemoScene} toggle={toggleDemoScene} />
<div className="border-b border-border my-2" />
<div className="text-[9px] text-textMute uppercase tracking-wider mb-2 px-1">Grid</div>
<OverlayRow label="X (side)" on={gridAxes.x} toggle={() => toggleGridAxis('x')} disabled={!orbitActive} disabledHint="3D only" />
<OverlayRow label="Y (floor)" on={gridAxes.y} toggle={() => toggleGridAxis('y')} disabled={!orbitActive} disabledHint="3D only" />
<OverlayRow label="Z (back)" on={gridAxes.z} toggle={() => toggleGridAxis('z')} />
<div className="text-[9px] text-textMute uppercase tracking-wider mt-3 mb-2 px-1">Guides</div>
<OverlayRow label="Axes Gizmo" on={showAxes} toggle={toggleAxes} disabled={!orbitActive} disabledHint="3D only" />
<OverlayRow label="Scene Info" on={showSceneInfo} toggle={toggleSceneInfo} />
</div>
)}
</div>
)
}
function OverlayRow({ label, on, toggle, disabled = false, disabledHint }) {
return (
<button
onClick={disabled ? undefined : toggle}
disabled={disabled}
title={disabled ? disabledHint : undefined}
className={`flex items-center gap-2 w-full px-1 py-1 text-[11px] rounded transition-colors ${
disabled
? 'text-textMute cursor-not-allowed opacity-50'
: 'text-text hover:bg-hover'
}`}
>
<span
className={`w-3.5 h-3.5 rounded border flex items-center justify-center text-[8px] ${
on ? 'bg-accent border-accent text-white' : 'bg-surface3 border-border text-transparent'
}`}
>{'✓'}</span>
<span className="flex-1 text-left">{label}</span>
{disabled && disabledHint && (
<span className="text-[8px] text-textMute uppercase tracking-wider">{disabledHint}</span>
)}
</button>
)
}
// ---- Confirm-reset dialog --------------------------------------------
//
// Modal shown when the user flips Window/Volume on a scene that has been
// edited since the last template/seed. Switching modes seeds a fresh
// scene for the target mode — destructive in the sense that the current
// item tree is replaced. ⌘Z still recovers the prior scene afterwards.
function SwitchModeDialog({ targetMode, onCancel, onConfirm }) {
return (
<div
className="fixed inset-0 z-[150] flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(6px)' }}
onMouseDown={(e) => { if (e.target === e.currentTarget) onCancel() }}
>
<div className="bg-surface border border-border rounded-xl shadow-2xl w-[420px] max-w-[92vw] overflow-hidden">
<div className="px-5 py-4 border-b border-border">
<div className="text-[13px] font-semibold text-text">
Switch to {targetMode === 'volume' ? 'Volume' : 'Window'} mode?
</div>
<div className="text-[11px] text-textMute mt-1 leading-relaxed">
Switching modes replaces your current scene with a fresh
{targetMode === 'volume' ? ' volumetric stage' : ' window'} seed.
You can recover this scene with <kbd className="kbd">⌘Z</kbd> after the switch.
</div>
</div>
<div className="px-5 py-3 flex justify-end gap-2">
<button
onClick={onCancel}
className="btn"
>Stay in {targetMode === 'volume' ? 'Window' : 'Volume'}</button>
<button
onClick={onConfirm}
className="btn btn-primary"
>Switch & Reset</button>
</div>
</div>
</div>
)
}
// ---- Scene mode toggle (Window / Volume) -----------------------------
function SceneModeToggle() {
const sceneMode = useStore((s) => s.scene.sceneMode)
const sceneIsDirty = useStore((s) => s.sceneIsDirty)
const switchSceneMode = useStore((s) => s.switchSceneMode)
const [pending, setPending] = useState(null)
const onPick = (mode) => {
if (mode === sceneMode) return
// Pristine scenes (just-seeded / just-template-applied / fresh launch)
// skip the warning — there's nothing for the user to lose.
if (!sceneIsDirty) {
switchSceneMode(mode)
return
}
setPending(mode)
}
const onConfirm = () => {
if (pending) switchSceneMode(pending)
setPending(null)
}
return (
<>
<div
className="segmented"
style={{
height: 28,
background: 'rgba(21, 21, 21, 0.88)',
backdropFilter: 'blur(8px)'
}}
>
<button
className={sceneMode === 'window' ? 'active' : ''}
onClick={() => onPick('window')}
title="Flat 2D scene that floats in space"
style={{ minWidth: 64, gap: 4 }}
>
<WindowIcon size={12} /> Window
</button>
<button
className={sceneMode === 'volume' ? 'active' : ''}
onClick={() => onPick('volume')}
title="Bounded 3D scene with depth"
style={{ minWidth: 64, gap: 4 }}
>
<VolumeIcon size={12} /> Volume
</button>
</div>
{pending && (
<SwitchModeDialog
targetMode={pending}
onCancel={() => setPending(null)}
onConfirm={onConfirm}
/>
)}
</>
)
}
// ---- VR View button --------------------------------------------------
//
// Replaces the old 2D/3D segmented toggle. Window mode now renders
// exclusively in the wearer's VR view (`preview3D` is forced on), so
// the button's sole job in either mode is to snap the orbit camera
// back to the wearer's default eye-line pose — the visionOS analogue
// of "press 0 in Blender for camera view".
function VRViewButton() {
const sceneMode = useStore((s) => s.scene.sceneMode)
const preview3D = useStore((s) => s.scene.preview3D)
const updateScene = useStore((s) => s.updateScene)
const isVolume = sceneMode === 'volume'
const onClick = () => {
if (!isVolume && !preview3D) {
// Safety net — if anything ever flipped preview3D off, ensure
// we're back in the 3D framing before snapping the camera.
updateScene({ preview3D: true })
}
requestAnimationFrame(() => {
window.dispatchEvent(new CustomEvent('snap-camera-to-default'))
})
}
return (
<button
onClick={onClick}
title="Snap to the wearer's default eye-line pose"
className="vp-btn"
style={{
width: 'auto',
height: 28,
paddingLeft: 12, paddingRight: 12,
whiteSpace: 'nowrap',
border: '1px solid #2e2e2e',
background: 'rgba(21, 21, 21, 0.88)',
backdropFilter: 'blur(8px)',
color: '#cfcfd1',
fontSize: 11
}}
>
VR View
</button>
)
}
function CameraEntityViewButton() {
const items = useStore((s) => s.items)
const selectedId = useStore((s) => s.selectedId)
const sceneMode = useStore((s) => s.scene.sceneMode)
const preview3D = useStore((s) => s.scene.preview3D)
const isOrbit = sceneMode === 'volume' || preview3D
// Prefer a selected camera entity, otherwise fall back to the first
// camera entity in the document.
const selected = items.find((it) => it.id === selectedId)
const target = (selected && selected.type === 'entity' && selected.entityKind === 'camera')
? selected
: items.find((it) => it.type === 'entity' && it.entityKind === 'camera')
if (!isOrbit || !target) return null
return (
<button
onClick={() => window.dispatchEvent(new CustomEvent('snap-camera-to-entity', { detail: { entityId: target.id } }))}
title={`Snap orbit camera to ${target.name || 'camera'}`}
className="vp-btn"
style={{
height: 28,
paddingLeft: 10, paddingRight: 10, gap: 6,
border: '1px solid #2e2e2e',
background: 'rgba(21, 21, 21, 0.88)',
backdropFilter: 'blur(8px)',
color: '#cfcfd1',
fontSize: 11
}}
>
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="5" width="9" height="7" rx="1" />
<path d="M11 7l3-2v6l-3-2z" />
</svg>
Camera Entity
</button>
)
}
// ---- Top-level overlay -------------------------------------------------
export default function ViewportOverlay() {
return (
<div className="absolute top-3 right-3 flex items-center gap-1.5 z-10 pointer-events-auto">
<VRViewButton />
<CameraEntityViewButton />
<ZoomSlider />
<OverlaysDropdown />
<SceneModeToggle />
</div>
)
}