@limrun/ui 0.16.0 → 0.17.0-rc.1
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/README.md +49 -0
- package/dist/GLTFLoader-BKnsBFsj.js +1 -0
- package/dist/GLTFLoader-DW6wpvyR.mjs +1679 -0
- package/dist/components/duo-button-hinge.d.ts +16 -0
- package/dist/components/duo-controls.d.ts +21 -0
- package/dist/components/duo-flat-frame.d.ts +7 -0
- package/dist/components/duo-flat-geometry.d.ts +52 -0
- package/dist/components/duo-fold-motion.d.ts +27 -0
- package/dist/components/duo-fold-renderer.d.ts +19 -0
- package/dist/components/duo-fold-timing.d.ts +1 -0
- package/dist/components/duo-frame.d.ts +16 -0
- package/dist/components/duo-hardware.d.ts +20 -0
- package/dist/components/duo-model.d.ts +19 -0
- package/dist/components/duo-screen-handoff.d.ts +4 -0
- package/dist/components/duo-view.d.ts +4 -0
- package/dist/components/remote-control.d.ts +5 -1
- package/dist/core/duo.d.ts +63 -0
- package/dist/duo-fold-renderer-BXejNHcY.mjs +257 -0
- package/dist/duo-fold-renderer-DdltAnF7.js +53 -0
- package/dist/duo-frame-BzyYljix.mjs +26767 -0
- package/dist/duo-frame-DjfA4gG-.js +4059 -0
- package/dist/duo-view-COZg8ieG.js +1 -0
- package/dist/duo-view-o1yxIzxu.mjs +654 -0
- package/dist/index.cjs +1 -1
- package/dist/index.css +1 -1
- package/dist/index.js +2 -2
- package/dist/lite.cjs +1 -1
- package/dist/lite.js +1 -1
- package/dist/remote-control-CQjSEVDF.mjs +2673 -0
- package/dist/remote-control-DVRXfCR2.js +1 -0
- package/package.json +4 -2
- package/src/components/duo-button-hinge.test.tsx +138 -0
- package/src/components/duo-button-hinge.ts +75 -0
- package/src/components/duo-controls.tsx +142 -0
- package/src/components/duo-flat-frame.tsx +145 -0
- package/src/components/duo-flat-geometry.test.ts +63 -0
- package/src/components/duo-flat-geometry.ts +97 -0
- package/src/components/duo-fold-motion.test.tsx +245 -0
- package/src/components/duo-fold-motion.tsx +192 -0
- package/src/components/duo-fold-renderer.test.ts +29 -0
- package/src/components/duo-fold-renderer.ts +397 -0
- package/src/components/duo-fold-timing.test.ts +44 -0
- package/src/components/duo-fold-timing.ts +9 -0
- package/src/components/duo-frame.tsx +579 -0
- package/src/components/duo-hardware.test.ts +61 -0
- package/src/components/duo-hardware.ts +61 -0
- package/src/components/duo-model.test.ts +243 -0
- package/src/components/duo-model.ts +607 -0
- package/src/components/duo-screen-handoff.ts +52 -0
- package/src/components/duo-view.test.tsx +104 -0
- package/src/components/duo-view.tsx +315 -0
- package/src/components/remote-control.css +310 -0
- package/src/components/remote-control.tsx +150 -14
- package/src/core/duo.test.ts +142 -0
- package/src/core/duo.ts +156 -0
- package/src/demo.tsx +14 -0
- package/dist/remote-control-BRgbD7yo.mjs +0 -2516
- package/dist/remote-control-VISrP1zI.js +0 -1
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import * as THREE from 'three';
|
|
3
|
+
import { createDuoModel } from './duo-model';
|
|
4
|
+
import { duoFoldAngle } from './duo-fold-timing';
|
|
5
|
+
import { hardwareLayout, hardwareHoverEdge } from './duo-hardware';
|
|
6
|
+
import { DuoControls, DuoHardwareIcon } from './duo-controls';
|
|
7
|
+
import { useDuoButtonHinge } from './duo-button-hinge';
|
|
8
|
+
import {
|
|
9
|
+
panelTouch,
|
|
10
|
+
duoPointerMode,
|
|
11
|
+
DuoButtonSender,
|
|
12
|
+
DUO_BUTTONS,
|
|
13
|
+
type DuoButton,
|
|
14
|
+
type DuoState,
|
|
15
|
+
type DuoOrientation,
|
|
16
|
+
} from '../core/duo';
|
|
17
|
+
|
|
18
|
+
export interface DuoFrameProps {
|
|
19
|
+
modelUrl?: string;
|
|
20
|
+
outer: HTMLVideoElement | null;
|
|
21
|
+
inner: HTMLVideoElement | null;
|
|
22
|
+
state: DuoState;
|
|
23
|
+
setAngle: (degrees: number) => Promise<void>;
|
|
24
|
+
setOrientation: (orientation: DuoOrientation) => Promise<void>;
|
|
25
|
+
button: (button: DuoButton, down: boolean) => Promise<void>;
|
|
26
|
+
touch: (action: number, screenId: number, x: number, y: number) => void;
|
|
27
|
+
onKeyDown: React.KeyboardEventHandler<HTMLDivElement>;
|
|
28
|
+
onKeyUp: React.KeyboardEventHandler<HTMLDivElement>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type View = 'front' | 'book' | 'table' | 'back';
|
|
32
|
+
|
|
33
|
+
/** A procedural titanium frame. Each glass panel carries its own native video texture. */
|
|
34
|
+
export default function DuoFrame(props: DuoFrameProps) {
|
|
35
|
+
const host = useRef<HTMLDivElement>(null);
|
|
36
|
+
const hardwareNodes = useRef(new Map<DuoButton, HTMLButtonElement>());
|
|
37
|
+
const latest = useRef(props);
|
|
38
|
+
latest.current = props;
|
|
39
|
+
const [error, setError] = useState<string>();
|
|
40
|
+
const { angle, changeAngle, interacting, motion, target, fold, cancelFold } = useDuoButtonHinge(
|
|
41
|
+
props.state.angleDegrees,
|
|
42
|
+
props.setAngle,
|
|
43
|
+
setError,
|
|
44
|
+
);
|
|
45
|
+
const angleRef = useRef(angle);
|
|
46
|
+
angleRef.current = angle;
|
|
47
|
+
const [view, setView] = useState<View>('front');
|
|
48
|
+
const viewRevision = useRef(0);
|
|
49
|
+
const viewRef = useRef(view);
|
|
50
|
+
viewRef.current = view;
|
|
51
|
+
const [positionLocked, setPositionLocked] = useState(true);
|
|
52
|
+
const lockedRef = useRef(positionLocked);
|
|
53
|
+
lockedRef.current = positionLocked;
|
|
54
|
+
const invalidateFrame = useRef<() => void>(() => undefined);
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
invalidateFrame.current();
|
|
58
|
+
}, [angle, target, view, positionLocked, props.state.orientation]);
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
const container = host.current;
|
|
62
|
+
if (!container || !props.outer || !props.inner) return;
|
|
63
|
+
let renderer: THREE.WebGLRenderer;
|
|
64
|
+
try {
|
|
65
|
+
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
|
66
|
+
} catch {
|
|
67
|
+
setError('3D rendering is unavailable in this browser. Enable WebGL to use the folding frame.');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
|
|
71
|
+
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
72
|
+
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
73
|
+
renderer.toneMappingExposure = 1;
|
|
74
|
+
renderer.setClearColor(0x000000, 0);
|
|
75
|
+
container.appendChild(renderer.domElement);
|
|
76
|
+
const scene = new THREE.Scene();
|
|
77
|
+
const camera = new THREE.OrthographicCamera(-150, 150, 150, -150, 1, 2000);
|
|
78
|
+
let aspect = 1;
|
|
79
|
+
camera.position.set(0, 0, 340);
|
|
80
|
+
const root = new THREE.Group();
|
|
81
|
+
// Continuous light-to-dark studio sweeps give broad metal faces reflections at every viewing angle.
|
|
82
|
+
const studioWidth = 512;
|
|
83
|
+
const studioHeight = 256;
|
|
84
|
+
const studioPixels = new Float32Array(studioWidth * studioHeight * 4);
|
|
85
|
+
for (let y = 0; y < studioHeight; y++) {
|
|
86
|
+
const latitude = ((y + 0.5) / studioHeight - 0.5) * Math.PI;
|
|
87
|
+
for (let x = 0; x < studioWidth; x++) {
|
|
88
|
+
const longitude = ((x + 0.5) / studioWidth) * Math.PI * 2;
|
|
89
|
+
const sweep = Math.pow(0.5 + 0.5 * Math.sin(4 * longitude + 1.8 * Math.sin(latitude)), 3);
|
|
90
|
+
const fill = Math.pow(0.5 + 0.5 * Math.cos(2 * longitude - 3 * latitude), 3);
|
|
91
|
+
const light = 0.12 + 3.2 * sweep + 0.8 * fill;
|
|
92
|
+
const offset = (y * studioWidth + x) * 4;
|
|
93
|
+
studioPixels.set([light, light, light, 1], offset);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const studio = new THREE.DataTexture(
|
|
97
|
+
studioPixels,
|
|
98
|
+
studioWidth,
|
|
99
|
+
studioHeight,
|
|
100
|
+
THREE.RGBAFormat,
|
|
101
|
+
THREE.FloatType,
|
|
102
|
+
);
|
|
103
|
+
studio.mapping = THREE.EquirectangularReflectionMapping;
|
|
104
|
+
studio.needsUpdate = true;
|
|
105
|
+
const pmrem = new THREE.PMREMGenerator(renderer);
|
|
106
|
+
const environmentMap = pmrem.fromEquirectangular(studio);
|
|
107
|
+
scene.environment = environmentMap.texture;
|
|
108
|
+
scene.environmentIntensity = 0.4;
|
|
109
|
+
studio.dispose();
|
|
110
|
+
pmrem.dispose();
|
|
111
|
+
scene.add(new THREE.HemisphereLight(0xffffff, 0xc1c8d1, 1.1));
|
|
112
|
+
const key = new THREE.DirectionalLight(0xffffff, 2.4);
|
|
113
|
+
key.position.set(-100, 180, 260);
|
|
114
|
+
scene.add(key);
|
|
115
|
+
const rim = new THREE.DirectionalLight(0xe4edff, 0.8);
|
|
116
|
+
rim.position.set(200, -40, -200);
|
|
117
|
+
scene.add(rim);
|
|
118
|
+
const outerTexture = new THREE.VideoTexture(props.outer);
|
|
119
|
+
const innerTexture = new THREE.VideoTexture(props.inner);
|
|
120
|
+
for (const texture of [outerTexture, innerTexture]) {
|
|
121
|
+
texture.colorSpace = THREE.SRGBColorSpace;
|
|
122
|
+
texture.minFilter = THREE.LinearFilter;
|
|
123
|
+
texture.magFilter = THREE.LinearFilter;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const model = createDuoModel(outerTexture, innerTexture, environmentMap.texture);
|
|
127
|
+
const { device } = model;
|
|
128
|
+
if (props.modelUrl)
|
|
129
|
+
void model
|
|
130
|
+
.loadAppearance(props.modelUrl)
|
|
131
|
+
.then(() => invalidate())
|
|
132
|
+
.catch((reason) => setError(String(reason)));
|
|
133
|
+
const buttons = new DuoButtonSender(
|
|
134
|
+
(button, down) => latest.current.button(button, down),
|
|
135
|
+
(reason) => setError(String(reason)),
|
|
136
|
+
);
|
|
137
|
+
root.add(device);
|
|
138
|
+
scene.add(root);
|
|
139
|
+
|
|
140
|
+
const ray = new THREE.Raycaster();
|
|
141
|
+
const pointer = new THREE.Vector2();
|
|
142
|
+
let drag:
|
|
143
|
+
| {
|
|
144
|
+
pointer: number;
|
|
145
|
+
button?: DuoButton;
|
|
146
|
+
screenId?: number;
|
|
147
|
+
x: number;
|
|
148
|
+
y: number;
|
|
149
|
+
clientX: number;
|
|
150
|
+
clientY: number;
|
|
151
|
+
}
|
|
152
|
+
| undefined;
|
|
153
|
+
let yaw = 0,
|
|
154
|
+
pitch = 0;
|
|
155
|
+
let previousViewRevision = viewRevision.current;
|
|
156
|
+
let renderedAngle = angleRef.current;
|
|
157
|
+
let foldFrom = renderedAngle;
|
|
158
|
+
let foldTarget = renderedAngle;
|
|
159
|
+
let foldStartedAt = 0;
|
|
160
|
+
let foldDragging = false;
|
|
161
|
+
let renderedYaw = 0;
|
|
162
|
+
let animation = 0;
|
|
163
|
+
let visible = true;
|
|
164
|
+
let disposed = false;
|
|
165
|
+
const invalidate = () => {
|
|
166
|
+
if (!animation && visible && !document.hidden && !disposed) animation = requestAnimationFrame(animate);
|
|
167
|
+
};
|
|
168
|
+
invalidateFrame.current = invalidate;
|
|
169
|
+
let lastTime = 0;
|
|
170
|
+
let layout: ReturnType<typeof hardwareLayout> | undefined;
|
|
171
|
+
let hoveredEdge: ReturnType<typeof hardwareHoverEdge>;
|
|
172
|
+
const iconButton = (target: EventTarget | null) =>
|
|
173
|
+
target instanceof Element ? target.closest<HTMLButtonElement>('[data-duo-button]') : null;
|
|
174
|
+
const showEdge = (edge: typeof hoveredEdge) => {
|
|
175
|
+
hoveredEdge = edge;
|
|
176
|
+
for (const guide of layout?.guides ?? []) {
|
|
177
|
+
const element = hardwareNodes.current.get(guide.button);
|
|
178
|
+
if (element) element.dataset.visible = String(guide.edge === edge);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
const pick = (event: PointerEvent) => {
|
|
182
|
+
const icon = iconButton(event.target);
|
|
183
|
+
if (icon) return { button: icon.dataset.duoButton as DuoButton };
|
|
184
|
+
const rect = renderer.domElement.getBoundingClientRect();
|
|
185
|
+
pointer.set(
|
|
186
|
+
((event.clientX - rect.left) / rect.width) * 2 - 1,
|
|
187
|
+
1 - ((event.clientY - rect.top) / rect.height) * 2,
|
|
188
|
+
);
|
|
189
|
+
ray.setFromCamera(pointer, camera);
|
|
190
|
+
const intersection = model.pick(ray);
|
|
191
|
+
const button = intersection?.object.userData['button'] as DuoButton | undefined;
|
|
192
|
+
if (button) return { button };
|
|
193
|
+
if (!intersection?.object.userData['display']) return undefined;
|
|
194
|
+
if (!intersection.uv) return undefined;
|
|
195
|
+
const display = latest.current.state.displays.find(
|
|
196
|
+
(d) => d.id === intersection.object.userData['display'],
|
|
197
|
+
);
|
|
198
|
+
if (!display) return undefined;
|
|
199
|
+
return { screenId: display.screenId, ...panelTouch(intersection.uv.x, intersection.uv.y) };
|
|
200
|
+
};
|
|
201
|
+
const down = (event: PointerEvent) => {
|
|
202
|
+
invalidate();
|
|
203
|
+
if (drag || event.button !== 0) return;
|
|
204
|
+
if (!iconButton(event.target)) container.focus({ preventScroll: true });
|
|
205
|
+
const picked = pick(event);
|
|
206
|
+
const hardware = picked && 'button' in picked ? picked.button : undefined;
|
|
207
|
+
const mode = duoPointerMode(lockedRef.current, !!picked && !hardware, event.altKey);
|
|
208
|
+
if (mode === 'ignore' && !hardware) return;
|
|
209
|
+
const hit = mode === 'touch' && picked && 'screenId' in picked ? picked : undefined;
|
|
210
|
+
drag = {
|
|
211
|
+
pointer: event.pointerId,
|
|
212
|
+
button: hardware,
|
|
213
|
+
...hit,
|
|
214
|
+
x: hit?.x ?? 0,
|
|
215
|
+
y: hit?.y ?? 0,
|
|
216
|
+
clientX: event.clientX,
|
|
217
|
+
clientY: event.clientY,
|
|
218
|
+
};
|
|
219
|
+
container.setPointerCapture(event.pointerId);
|
|
220
|
+
if (hardware) {
|
|
221
|
+
setError(undefined);
|
|
222
|
+
buttons.press(hardware);
|
|
223
|
+
model.highlightButton(hardware, true);
|
|
224
|
+
} else if (hit) latest.current.touch(0, hit.screenId, hit.x, hit.y);
|
|
225
|
+
};
|
|
226
|
+
const move = (event: PointerEvent) => {
|
|
227
|
+
invalidate();
|
|
228
|
+
if (!drag) {
|
|
229
|
+
const hit = pick(event);
|
|
230
|
+
const button = hit && 'button' in hit ? hit.button : undefined;
|
|
231
|
+
const rect = container.getBoundingClientRect();
|
|
232
|
+
showEdge(
|
|
233
|
+
button ?
|
|
234
|
+
layout?.guides.find((guide) => guide.button === button)?.edge
|
|
235
|
+
: layout && hardwareHoverEdge(event.clientX - rect.left, event.clientY - rect.top, layout.rect),
|
|
236
|
+
);
|
|
237
|
+
renderer.domElement.style.cursor = button ? 'pointer' : '';
|
|
238
|
+
renderer.domElement.title = button ? DUO_BUTTONS[button] : '';
|
|
239
|
+
model.highlightButton(button);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (drag.pointer !== event.pointerId || drag.button) return;
|
|
243
|
+
if (drag.screenId) {
|
|
244
|
+
const hit = pick(event);
|
|
245
|
+
if (hit && 'screenId' in hit && hit.screenId === drag.screenId) {
|
|
246
|
+
drag.x = hit.x;
|
|
247
|
+
drag.y = hit.y;
|
|
248
|
+
latest.current.touch(2, hit.screenId, hit.x, hit.y);
|
|
249
|
+
}
|
|
250
|
+
} else if (!lockedRef.current) {
|
|
251
|
+
yaw += (event.clientX - drag.clientX) * 0.008;
|
|
252
|
+
pitch = THREE.MathUtils.clamp(pitch + (event.clientY - drag.clientY) * 0.008, -1.4, 1.4);
|
|
253
|
+
}
|
|
254
|
+
drag.clientX = event.clientX;
|
|
255
|
+
drag.clientY = event.clientY;
|
|
256
|
+
};
|
|
257
|
+
const up = (event?: PointerEvent) => {
|
|
258
|
+
invalidate();
|
|
259
|
+
if (!drag || (event && drag.pointer !== event.pointerId)) return;
|
|
260
|
+
if (drag.button) {
|
|
261
|
+
buttons.release();
|
|
262
|
+
model.highlightButton();
|
|
263
|
+
} else if (drag.screenId) latest.current.touch(1, drag.screenId, drag.x, drag.y);
|
|
264
|
+
drag = undefined;
|
|
265
|
+
if (event) move(event);
|
|
266
|
+
};
|
|
267
|
+
const leave = () => {
|
|
268
|
+
invalidate();
|
|
269
|
+
if (!drag) {
|
|
270
|
+
model.highlightButton();
|
|
271
|
+
showEdge(undefined);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
const keyDown = (event: KeyboardEvent) => {
|
|
275
|
+
invalidate();
|
|
276
|
+
const icon = iconButton(event.target);
|
|
277
|
+
if (!icon) return;
|
|
278
|
+
event.stopPropagation();
|
|
279
|
+
if (event.code !== 'Space' && event.code !== 'Enter') return;
|
|
280
|
+
event.preventDefault();
|
|
281
|
+
if (event.repeat) return;
|
|
282
|
+
const button = icon.dataset.duoButton as DuoButton;
|
|
283
|
+
buttons.press(button);
|
|
284
|
+
model.highlightButton(button, true);
|
|
285
|
+
icon.dataset.pressed = 'true';
|
|
286
|
+
};
|
|
287
|
+
const keyUp = (event: KeyboardEvent) => {
|
|
288
|
+
invalidate();
|
|
289
|
+
const icon = iconButton(event.target);
|
|
290
|
+
if (!icon) return;
|
|
291
|
+
event.stopPropagation();
|
|
292
|
+
if (event.code !== 'Space' && event.code !== 'Enter') return;
|
|
293
|
+
event.preventDefault();
|
|
294
|
+
buttons.release();
|
|
295
|
+
model.highlightButton(icon.dataset.duoButton as DuoButton);
|
|
296
|
+
icon.dataset.pressed = 'false';
|
|
297
|
+
};
|
|
298
|
+
const focus = (event: FocusEvent) => {
|
|
299
|
+
const icon = iconButton(event.target);
|
|
300
|
+
if (icon) showEdge(layout?.guides.find((guide) => guide.button === icon.dataset.duoButton)?.edge);
|
|
301
|
+
};
|
|
302
|
+
const blur = () => {
|
|
303
|
+
invalidate();
|
|
304
|
+
up();
|
|
305
|
+
buttons.release();
|
|
306
|
+
model.highlightButton();
|
|
307
|
+
showEdge(undefined);
|
|
308
|
+
hardwareNodes.current.forEach((element) => {
|
|
309
|
+
element.dataset.pressed = 'false';
|
|
310
|
+
});
|
|
311
|
+
};
|
|
312
|
+
const focusOut = (event: FocusEvent) => {
|
|
313
|
+
if (!container.contains(event.relatedTarget as Node | null)) blur();
|
|
314
|
+
else if (iconButton(event.target)) {
|
|
315
|
+
buttons.release();
|
|
316
|
+
model.highlightButton();
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
container.addEventListener('pointerleave', leave);
|
|
320
|
+
container.addEventListener('pointerdown', down);
|
|
321
|
+
container.addEventListener('pointermove', move);
|
|
322
|
+
container.addEventListener('pointerup', up);
|
|
323
|
+
container.addEventListener('pointercancel', up);
|
|
324
|
+
container.addEventListener('lostpointercapture', up);
|
|
325
|
+
container.addEventListener('keydown', keyDown);
|
|
326
|
+
container.addEventListener('keyup', keyUp);
|
|
327
|
+
container.addEventListener('focusin', focus);
|
|
328
|
+
container.addEventListener('focusout', focusOut);
|
|
329
|
+
window.addEventListener('blur', blur);
|
|
330
|
+
const resize = () => {
|
|
331
|
+
const w = container.clientWidth,
|
|
332
|
+
h = container.clientHeight;
|
|
333
|
+
if (!w || !h) return;
|
|
334
|
+
aspect = w / h;
|
|
335
|
+
|
|
336
|
+
camera.updateProjectionMatrix();
|
|
337
|
+
renderer.setSize(w, h, false);
|
|
338
|
+
invalidate();
|
|
339
|
+
};
|
|
340
|
+
const observer = new ResizeObserver(resize);
|
|
341
|
+
observer.observe(container);
|
|
342
|
+
const bounds = new THREE.Box3();
|
|
343
|
+
const center = new THREE.Vector3();
|
|
344
|
+
const extent = new THREE.Vector3();
|
|
345
|
+
const animate = (time: number) => {
|
|
346
|
+
animation = 0;
|
|
347
|
+
if (disposed || !visible || document.hidden) return;
|
|
348
|
+
const dt = Math.min((time - lastTime) / 1000, 0.05);
|
|
349
|
+
lastTime = time;
|
|
350
|
+
if (viewRevision.current !== previousViewRevision) {
|
|
351
|
+
yaw = 0;
|
|
352
|
+
pitch = 0;
|
|
353
|
+
previousViewRevision = viewRevision.current;
|
|
354
|
+
}
|
|
355
|
+
if (foldTarget !== angleRef.current) {
|
|
356
|
+
foldFrom = renderedAngle;
|
|
357
|
+
foldTarget = angleRef.current;
|
|
358
|
+
foldStartedAt = time;
|
|
359
|
+
foldDragging = interacting.current;
|
|
360
|
+
}
|
|
361
|
+
const buttonFold = motion.current;
|
|
362
|
+
renderedAngle =
|
|
363
|
+
buttonFold ? duoFoldAngle(buttonFold.from, buttonFold.target, (time - buttonFold.startedAt) / 1000)
|
|
364
|
+
: foldDragging ? THREE.MathUtils.damp(renderedAngle, foldTarget, 18, dt)
|
|
365
|
+
: duoFoldAngle(foldFrom, foldTarget, (time - foldStartedAt) / 1000);
|
|
366
|
+
if (Math.abs(renderedAngle - angleRef.current) < 0.001) renderedAngle = angleRef.current;
|
|
367
|
+
model.setAngle(renderedAngle);
|
|
368
|
+
// Closed view presents the cover; intermediate poses retain depth and true occlusion.
|
|
369
|
+
const closed = 1 - THREE.MathUtils.smoothstep(renderedAngle, 15, 110);
|
|
370
|
+
const baseYaw = (closed * Math.PI) / 2 + (viewRef.current === 'back' ? Math.PI : 0);
|
|
371
|
+
renderedYaw = THREE.MathUtils.damp(renderedYaw, baseYaw, 12, dt);
|
|
372
|
+
if (Math.abs(renderedYaw - baseYaw) < 0.001) renderedYaw = baseYaw;
|
|
373
|
+
// Camera orbit stays independent of the native device orientation.
|
|
374
|
+
root.rotation.set(
|
|
375
|
+
pitch +
|
|
376
|
+
(viewRef.current === 'table' ? -0.35
|
|
377
|
+
: viewRef.current === 'book' ? 0.2
|
|
378
|
+
: 0),
|
|
379
|
+
yaw + (viewRef.current === 'book' ? -0.2 : 0),
|
|
380
|
+
0,
|
|
381
|
+
);
|
|
382
|
+
// Apply screen rotation after turning the device over so the cover remains upright.
|
|
383
|
+
device.rotation.set(
|
|
384
|
+
0,
|
|
385
|
+
renderedYaw,
|
|
386
|
+
{ portrait: 0, pud: Math.PI, 'landscape-left': Math.PI / 2, 'landscape-right': -Math.PI / 2 }[
|
|
387
|
+
latest.current.state.orientation
|
|
388
|
+
] ?? 0,
|
|
389
|
+
'ZYX',
|
|
390
|
+
);
|
|
391
|
+
root.position.set(0, 0, 0);
|
|
392
|
+
root.updateMatrixWorld(true);
|
|
393
|
+
model.getBounds(bounds).getCenter(center);
|
|
394
|
+
bounds.getSize(extent);
|
|
395
|
+
root.position.copy(center).negate();
|
|
396
|
+
// Parallel projection keeps the far leaf's buttons visible when the device is closed.
|
|
397
|
+
const width = container.clientWidth;
|
|
398
|
+
const height = container.clientHeight;
|
|
399
|
+
const usableWidth = Math.max(0.4, 1 - 108 / width);
|
|
400
|
+
const usableHeight = Math.min(0.8, Math.max(0.35, 1 - 108 / height));
|
|
401
|
+
const halfHeight = Math.max(extent.y / (2 * usableHeight), extent.x / (2 * aspect * usableWidth));
|
|
402
|
+
camera.top = THREE.MathUtils.damp(camera.top, halfHeight, 14, dt);
|
|
403
|
+
if (Math.abs(camera.top - halfHeight) < 0.001) camera.top = halfHeight;
|
|
404
|
+
camera.bottom = -camera.top;
|
|
405
|
+
camera.left = -camera.top * aspect;
|
|
406
|
+
camera.right = camera.top * aspect;
|
|
407
|
+
camera.updateProjectionMatrix();
|
|
408
|
+
|
|
409
|
+
const buttonsMoving = model.animateButtons(dt);
|
|
410
|
+
renderer.render(scene, camera);
|
|
411
|
+
model.getBounds(bounds);
|
|
412
|
+
layout = hardwareLayout(model.buttonAnchors(), bounds, camera, width, height);
|
|
413
|
+
for (const guide of layout.guides) {
|
|
414
|
+
const element = hardwareNodes.current.get(guide.button);
|
|
415
|
+
if (!element) continue;
|
|
416
|
+
element.style.left = `${guide.x}px`;
|
|
417
|
+
element.style.top = `${guide.y}px`;
|
|
418
|
+
element.style.setProperty('--duo-hardware-icon-size', `${layout.iconSize}px`);
|
|
419
|
+
element.dataset.visible = String(guide.edge === hoveredEdge);
|
|
420
|
+
}
|
|
421
|
+
if (
|
|
422
|
+
motion.current ||
|
|
423
|
+
renderedAngle !== angleRef.current ||
|
|
424
|
+
renderedYaw !== baseYaw ||
|
|
425
|
+
camera.top !== halfHeight ||
|
|
426
|
+
buttonsMoving
|
|
427
|
+
)
|
|
428
|
+
invalidate();
|
|
429
|
+
};
|
|
430
|
+
const videoCallbacks = new Map<HTMLVideoElement, number>();
|
|
431
|
+
for (const video of [props.outer, props.inner]) {
|
|
432
|
+
const frame = () => {
|
|
433
|
+
if (disposed) return;
|
|
434
|
+
invalidate();
|
|
435
|
+
videoCallbacks.set(video, video.requestVideoFrameCallback(frame));
|
|
436
|
+
};
|
|
437
|
+
if ('requestVideoFrameCallback' in video)
|
|
438
|
+
videoCallbacks.set(video, video.requestVideoFrameCallback(frame));
|
|
439
|
+
}
|
|
440
|
+
const fallback =
|
|
441
|
+
!('requestVideoFrameCallback' in props.outer) ? window.setInterval(invalidate, 1000 / 30) : undefined;
|
|
442
|
+
const intersection = new IntersectionObserver(([entry]) => {
|
|
443
|
+
visible = !!entry?.isIntersecting;
|
|
444
|
+
invalidate();
|
|
445
|
+
});
|
|
446
|
+
intersection.observe(container);
|
|
447
|
+
document.addEventListener('visibilitychange', invalidate);
|
|
448
|
+
resize();
|
|
449
|
+
return () => {
|
|
450
|
+
disposed = true;
|
|
451
|
+
invalidateFrame.current = () => undefined;
|
|
452
|
+
intersection.disconnect();
|
|
453
|
+
document.removeEventListener('visibilitychange', invalidate);
|
|
454
|
+
for (const [video, id] of videoCallbacks) video.cancelVideoFrameCallback(id);
|
|
455
|
+
clearInterval(fallback);
|
|
456
|
+
up();
|
|
457
|
+
buttons.release();
|
|
458
|
+
cancelAnimationFrame(animation);
|
|
459
|
+
observer.disconnect();
|
|
460
|
+
window.removeEventListener('blur', blur);
|
|
461
|
+
container.removeEventListener('pointerleave', leave);
|
|
462
|
+
container.removeEventListener('pointerdown', down);
|
|
463
|
+
container.removeEventListener('pointermove', move);
|
|
464
|
+
container.removeEventListener('pointerup', up);
|
|
465
|
+
container.removeEventListener('pointercancel', up);
|
|
466
|
+
container.removeEventListener('lostpointercapture', up);
|
|
467
|
+
container.removeEventListener('keydown', keyDown);
|
|
468
|
+
container.removeEventListener('keyup', keyUp);
|
|
469
|
+
container.removeEventListener('focusin', focus);
|
|
470
|
+
container.removeEventListener('focusout', focusOut);
|
|
471
|
+
renderer.dispose();
|
|
472
|
+
renderer.forceContextLoss();
|
|
473
|
+
model.dispose();
|
|
474
|
+
outerTexture.dispose();
|
|
475
|
+
innerTexture.dispose();
|
|
476
|
+
environmentMap.dispose();
|
|
477
|
+
renderer.domElement.remove();
|
|
478
|
+
};
|
|
479
|
+
}, [props.outer, props.inner, props.modelUrl]);
|
|
480
|
+
|
|
481
|
+
return (
|
|
482
|
+
<div className="rc-duo">
|
|
483
|
+
<div
|
|
484
|
+
ref={host}
|
|
485
|
+
className="rc-duo-stage"
|
|
486
|
+
data-position-locked={positionLocked}
|
|
487
|
+
tabIndex={0}
|
|
488
|
+
role="application"
|
|
489
|
+
aria-label="iPhone Duo simulator. Touch the screen to control iOS. Unlock position to rotate the view."
|
|
490
|
+
onKeyDown={props.onKeyDown}
|
|
491
|
+
onKeyUp={props.onKeyUp}
|
|
492
|
+
>
|
|
493
|
+
{(Object.keys(DUO_BUTTONS) as DuoButton[]).map((button) => (
|
|
494
|
+
<button
|
|
495
|
+
key={button}
|
|
496
|
+
ref={(element) => {
|
|
497
|
+
if (element) hardwareNodes.current.set(button, element);
|
|
498
|
+
else hardwareNodes.current.delete(button);
|
|
499
|
+
}}
|
|
500
|
+
type="button"
|
|
501
|
+
className="rc-duo-hardware"
|
|
502
|
+
data-duo-button={button}
|
|
503
|
+
data-visible="false"
|
|
504
|
+
aria-label={DUO_BUTTONS[button]}
|
|
505
|
+
title={DUO_BUTTONS[button]}
|
|
506
|
+
>
|
|
507
|
+
<DuoHardwareIcon button={button} />
|
|
508
|
+
</button>
|
|
509
|
+
))}
|
|
510
|
+
</div>
|
|
511
|
+
<DuoControls
|
|
512
|
+
angle={angle}
|
|
513
|
+
onFold={fold}
|
|
514
|
+
foldTarget={target}
|
|
515
|
+
onHingeDrag={cancelFold}
|
|
516
|
+
changeAngle={changeAngle}
|
|
517
|
+
interacting={interacting}
|
|
518
|
+
rotate={() => {
|
|
519
|
+
void props
|
|
520
|
+
.setOrientation(props.state.orientation === 'portrait' ? 'landscape-left' : 'portrait')
|
|
521
|
+
.catch((reason) => setError(String(reason)));
|
|
522
|
+
}}
|
|
523
|
+
>
|
|
524
|
+
{(['front', 'book', 'table', 'back'] as const).map((value) => (
|
|
525
|
+
<button
|
|
526
|
+
key={value}
|
|
527
|
+
aria-pressed={view === value}
|
|
528
|
+
onClick={() => {
|
|
529
|
+
viewRevision.current++;
|
|
530
|
+
setView(value);
|
|
531
|
+
invalidateFrame.current();
|
|
532
|
+
if (value === 'book') {
|
|
533
|
+
void props
|
|
534
|
+
.setOrientation('portrait')
|
|
535
|
+
.then(() => changeAngle(110))
|
|
536
|
+
.catch((reason) => setError(String(reason)));
|
|
537
|
+
}
|
|
538
|
+
if (value === 'table') {
|
|
539
|
+
void props
|
|
540
|
+
.setOrientation('landscape-left')
|
|
541
|
+
.then(() => changeAngle(110))
|
|
542
|
+
.catch((reason) => setError(String(reason)));
|
|
543
|
+
}
|
|
544
|
+
}}
|
|
545
|
+
>
|
|
546
|
+
{value === 'table' ? 'Laptop view' : value[0]!.toUpperCase() + value.slice(1)}
|
|
547
|
+
</button>
|
|
548
|
+
))}
|
|
549
|
+
<button
|
|
550
|
+
className="rc-duo-lock"
|
|
551
|
+
aria-label="Lock position"
|
|
552
|
+
aria-pressed={positionLocked}
|
|
553
|
+
title={
|
|
554
|
+
positionLocked ?
|
|
555
|
+
'Position is locked. Screen touches still control iOS.'
|
|
556
|
+
: 'Drag the frame or background to rotate. Alt-drag rotates from the screen.'
|
|
557
|
+
}
|
|
558
|
+
onClick={() => setPositionLocked(!positionLocked)}
|
|
559
|
+
>
|
|
560
|
+
<svg width="14" height="14" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
|
561
|
+
<rect x="4" y="9" width="12" height="9" rx="2" stroke="currentColor" strokeWidth="1.6" />
|
|
562
|
+
<path
|
|
563
|
+
d={positionLocked ? 'M6 9V6a4 4 0 0 1 8 0v3' : 'M6 9V6a4 4 0 0 1 8 0'}
|
|
564
|
+
stroke="currentColor"
|
|
565
|
+
strokeWidth="1.6"
|
|
566
|
+
strokeLinecap="round"
|
|
567
|
+
/>
|
|
568
|
+
</svg>
|
|
569
|
+
{positionLocked ? 'Position locked' : 'Lock position'}
|
|
570
|
+
</button>
|
|
571
|
+
</DuoControls>
|
|
572
|
+
{error && (
|
|
573
|
+
<div role="alert" className="rc-duo-error">
|
|
574
|
+
{error}
|
|
575
|
+
</div>
|
|
576
|
+
)}
|
|
577
|
+
</div>
|
|
578
|
+
);
|
|
579
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import * as THREE from 'three';
|
|
3
|
+
import { createDuoModel } from './duo-model';
|
|
4
|
+
import { hardwareHoverEdge, hardwareLayout } from './duo-hardware';
|
|
5
|
+
import { hardwareIconSize } from './duo-flat-geometry';
|
|
6
|
+
|
|
7
|
+
describe('Duo edge hints', () => {
|
|
8
|
+
it.each([0, 110, 180])('keeps icon targets outside the screen at %s degrees', (angle) => {
|
|
9
|
+
const model = createDuoModel(new THREE.Texture(), new THREE.Texture());
|
|
10
|
+
model.setAngle(angle);
|
|
11
|
+
model.device.rotation.y = angle === 0 ? Math.PI / 2 : 0;
|
|
12
|
+
model.device.updateMatrixWorld(true);
|
|
13
|
+
const camera = new THREE.OrthographicCamera(-120, 120, 120, -120, 1, 1000);
|
|
14
|
+
camera.position.z = 340;
|
|
15
|
+
camera.updateMatrixWorld(true);
|
|
16
|
+
const { guides, rect, iconSize } = hardwareLayout(
|
|
17
|
+
model.buttonAnchors(),
|
|
18
|
+
model.getBounds(new THREE.Box3()),
|
|
19
|
+
camera,
|
|
20
|
+
500,
|
|
21
|
+
500,
|
|
22
|
+
);
|
|
23
|
+
expect(guides).toHaveLength(3);
|
|
24
|
+
expect(iconSize).toBe(hardwareIconSize(rect));
|
|
25
|
+
for (const guide of guides) {
|
|
26
|
+
expect(rect.containsPoint(new THREE.Vector2(guide.x, guide.y))).toBe(false);
|
|
27
|
+
expect(hardwareHoverEdge(guide.x, guide.y, rect)).toBe(guide.edge);
|
|
28
|
+
}
|
|
29
|
+
expect(guides.filter((g) => g.button !== 'side').every((g) => g.edge === 'top')).toBe(true);
|
|
30
|
+
const volume = guides.filter((guide) => guide.button !== 'side');
|
|
31
|
+
expect(Math.abs(volume[0]!.x - volume[1]!.x)).toBeGreaterThanOrEqual(48);
|
|
32
|
+
model.dispose();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('keeps the edge visible while crossing from the device to its icon', () => {
|
|
36
|
+
const rect = new THREE.Box2(new THREE.Vector2(100, 100), new THREE.Vector2(400, 600));
|
|
37
|
+
for (const y of [104, 100, 90, 80, 72]) expect(hardwareHoverEdge(250, y, rect)).toBe('top');
|
|
38
|
+
expect(hardwareHoverEdge(250, 350, rect)).toBeUndefined();
|
|
39
|
+
expect(hardwareHoverEdge(250, 40, rect)).toBeUndefined();
|
|
40
|
+
expect(hardwareHoverEdge(428, 250, rect)).toBe('right');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('moves hints with native screen orientation', () => {
|
|
44
|
+
const model = createDuoModel(new THREE.Texture(), new THREE.Texture());
|
|
45
|
+
model.device.rotation.z = Math.PI / 2;
|
|
46
|
+
model.device.updateMatrixWorld(true);
|
|
47
|
+
const camera = new THREE.OrthographicCamera(-120, 120, 120, -120, 1, 1000);
|
|
48
|
+
camera.position.z = 340;
|
|
49
|
+
camera.updateMatrixWorld(true);
|
|
50
|
+
const { guides } = hardwareLayout(
|
|
51
|
+
model.buttonAnchors(),
|
|
52
|
+
model.getBounds(new THREE.Box3()),
|
|
53
|
+
camera,
|
|
54
|
+
500,
|
|
55
|
+
500,
|
|
56
|
+
);
|
|
57
|
+
expect(guides.filter((g) => g.button !== 'side').every((g) => g.edge === 'left')).toBe(true);
|
|
58
|
+
expect(guides.find((g) => g.button === 'side')?.edge).toBe('top');
|
|
59
|
+
model.dispose();
|
|
60
|
+
});
|
|
61
|
+
});
|