@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,607 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { toCreasedNormals } from 'three/addons/utils/BufferGeometryUtils.js';
|
|
3
|
+
import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js';
|
|
4
|
+
import { DUO_DIMENSIONS, type DuoButton } from '../core/duo';
|
|
5
|
+
|
|
6
|
+
// Body dimensions are published by Apple; radii and hardware details follow its product photographs.
|
|
7
|
+
const { width, height, depth } = DUO_DIMENSIONS;
|
|
8
|
+
const half = width / 2;
|
|
9
|
+
const pivot = (11.3 - depth * 2) / 2;
|
|
10
|
+
const innerWidth = 157.5;
|
|
11
|
+
const innerHeight = 110.8;
|
|
12
|
+
const bendWidth = 1.1;
|
|
13
|
+
const glassZ = 0.14;
|
|
14
|
+
|
|
15
|
+
// Nearby studio lights vary across a rail even with the parallel camera used for simulator input.
|
|
16
|
+
function polishSilver(material: THREE.MeshStandardMaterial, environment?: THREE.Texture) {
|
|
17
|
+
material.envMap = environment ?? null;
|
|
18
|
+
material.envMapIntensity = 1;
|
|
19
|
+
material.onBeforeCompile = (shader) => {
|
|
20
|
+
shader.vertexShader = `varying vec3 vDuoStudioPosition;\n${shader.vertexShader}`.replace(
|
|
21
|
+
'#include <worldpos_vertex>',
|
|
22
|
+
'#include <worldpos_vertex>\nvDuoStudioPosition = (modelMatrix * vec4(transformed, 1.0)).xyz;',
|
|
23
|
+
);
|
|
24
|
+
shader.fragmentShader = `
|
|
25
|
+
varying vec3 vDuoStudioPosition;
|
|
26
|
+
vec3 duoStudioReflection(vec3 direction) {
|
|
27
|
+
float radius = 240.0;
|
|
28
|
+
float along = dot(vDuoStudioPosition, direction);
|
|
29
|
+
float distance = -along + sqrt(max(0.0, along * along + radius * radius - dot(vDuoStudioPosition, vDuoStudioPosition)));
|
|
30
|
+
return normalize(vDuoStudioPosition + direction * distance);
|
|
31
|
+
}
|
|
32
|
+
${shader.fragmentShader}`.replace(
|
|
33
|
+
'#include <envmap_physical_pars_fragment>',
|
|
34
|
+
THREE.ShaderChunk.envmap_physical_pars_fragment.replace(
|
|
35
|
+
'envMapRotation * reflectVec',
|
|
36
|
+
'envMapRotation * duoStudioReflection(reflectVec)',
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
};
|
|
40
|
+
material.customProgramCacheKey = () => 'duo-polished-silver-v1';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function outline(w: number, h: number, lr: number, rr: number) {
|
|
44
|
+
const shape = new THREE.Shape();
|
|
45
|
+
const x = -w / 2,
|
|
46
|
+
y = -h / 2;
|
|
47
|
+
// Circular quarter-arcs retain the generous outside corners and tight hinge corners.
|
|
48
|
+
shape.moveTo(x + lr, y);
|
|
49
|
+
shape.lineTo(x + w - rr, y);
|
|
50
|
+
if (rr) shape.absarc(x + w - rr, y + rr, rr, -Math.PI / 2, 0, false);
|
|
51
|
+
shape.lineTo(x + w, y + h - rr);
|
|
52
|
+
if (rr) shape.absarc(x + w - rr, y + h - rr, rr, 0, Math.PI / 2, false);
|
|
53
|
+
shape.lineTo(x + lr, y + h);
|
|
54
|
+
if (lr) shape.absarc(x + lr, y + h - lr, lr, Math.PI / 2, Math.PI, false);
|
|
55
|
+
shape.lineTo(x, y + lr);
|
|
56
|
+
if (lr) shape.absarc(x + lr, y + lr, lr, Math.PI, Math.PI * 1.5, false);
|
|
57
|
+
return shape;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Independent of WebGL so fold geometry and touch occlusion can be tested directly. */
|
|
61
|
+
export function createDuoModel(
|
|
62
|
+
outerTexture: THREE.Texture,
|
|
63
|
+
innerTexture: THREE.Texture,
|
|
64
|
+
environment?: THREE.Texture,
|
|
65
|
+
) {
|
|
66
|
+
const device = new THREE.Group();
|
|
67
|
+
const left = new THREE.Group();
|
|
68
|
+
const right = new THREE.Group();
|
|
69
|
+
device.add(left, right);
|
|
70
|
+
const hitObjects: THREE.Object3D[] = [];
|
|
71
|
+
const buttons = new Map<DuoButton, THREE.Object3D>();
|
|
72
|
+
const buttonBases = new Map<DuoButton, THREE.Vector3>();
|
|
73
|
+
const buttonOffsets = new Map<DuoButton, number>();
|
|
74
|
+
let hoveredButton: DuoButton | undefined;
|
|
75
|
+
let pressedButton = false;
|
|
76
|
+
let disposed = false;
|
|
77
|
+
const buttonTargets: THREE.Object3D[] = [];
|
|
78
|
+
const resources: Array<{ dispose(): void }> = [];
|
|
79
|
+
const metal = new THREE.MeshPhysicalMaterial({
|
|
80
|
+
color: 0xe8ebee,
|
|
81
|
+
metalness: 1,
|
|
82
|
+
roughness: 0.055,
|
|
83
|
+
clearcoat: 1,
|
|
84
|
+
clearcoatRoughness: 0.055,
|
|
85
|
+
envMapIntensity: 1.15,
|
|
86
|
+
});
|
|
87
|
+
const ceramic = new THREE.MeshPhysicalMaterial({
|
|
88
|
+
color: 0xf3f1eb,
|
|
89
|
+
metalness: 0.04,
|
|
90
|
+
roughness: 0.2,
|
|
91
|
+
clearcoat: 1,
|
|
92
|
+
clearcoatRoughness: 0.08,
|
|
93
|
+
});
|
|
94
|
+
const hingeMetal = metal.clone();
|
|
95
|
+
hingeMetal.roughness = 0.085;
|
|
96
|
+
polishSilver(metal, environment);
|
|
97
|
+
polishSilver(hingeMetal, environment);
|
|
98
|
+
const gasket = new THREE.MeshBasicMaterial({ color: 0x080a0c });
|
|
99
|
+
const antenna = new THREE.MeshStandardMaterial({ color: 0xa4a5a0, roughness: 0.55 });
|
|
100
|
+
const glass = new THREE.MeshPhysicalMaterial({
|
|
101
|
+
color: 0x03060d,
|
|
102
|
+
envMap: environment ?? null,
|
|
103
|
+
envMapIntensity: 0.05,
|
|
104
|
+
metalness: 1,
|
|
105
|
+
roughness: 0.22,
|
|
106
|
+
});
|
|
107
|
+
const optic = new THREE.MeshPhysicalMaterial({
|
|
108
|
+
color: 0x102137,
|
|
109
|
+
envMap: environment ?? null,
|
|
110
|
+
envMapIntensity: 0.04,
|
|
111
|
+
metalness: 1,
|
|
112
|
+
roughness: 0.3,
|
|
113
|
+
});
|
|
114
|
+
const flash = new THREE.MeshStandardMaterial({ color: 0xefead4, roughness: 0.35 });
|
|
115
|
+
const logo = new THREE.MeshStandardMaterial({
|
|
116
|
+
color: 0xc0c0bc,
|
|
117
|
+
metalness: 0.6,
|
|
118
|
+
roughness: 0.25,
|
|
119
|
+
side: THREE.DoubleSide,
|
|
120
|
+
});
|
|
121
|
+
const outerMaterial = new THREE.MeshBasicMaterial({ map: outerTexture, toneMapped: false });
|
|
122
|
+
const innerMaterial = new THREE.MeshBasicMaterial({ map: innerTexture, toneMapped: false });
|
|
123
|
+
resources.push(
|
|
124
|
+
metal,
|
|
125
|
+
ceramic,
|
|
126
|
+
hingeMetal,
|
|
127
|
+
gasket,
|
|
128
|
+
antenna,
|
|
129
|
+
glass,
|
|
130
|
+
optic,
|
|
131
|
+
flash,
|
|
132
|
+
logo,
|
|
133
|
+
outerMaterial,
|
|
134
|
+
innerMaterial,
|
|
135
|
+
);
|
|
136
|
+
const mesh = <T extends THREE.BufferGeometry>(geometry: T, material: THREE.Material) => {
|
|
137
|
+
resources.push(geometry);
|
|
138
|
+
const result = new THREE.Mesh(geometry, material);
|
|
139
|
+
hitObjects.push(result);
|
|
140
|
+
return result;
|
|
141
|
+
};
|
|
142
|
+
const plate = (
|
|
143
|
+
w: number,
|
|
144
|
+
h: number,
|
|
145
|
+
d: number,
|
|
146
|
+
lr: number,
|
|
147
|
+
rr: number,
|
|
148
|
+
material: THREE.Material,
|
|
149
|
+
bevel = 0.12,
|
|
150
|
+
) =>
|
|
151
|
+
mesh(
|
|
152
|
+
toCreasedNormals(
|
|
153
|
+
new THREE.ExtrudeGeometry(outline(w, h, lr, rr), {
|
|
154
|
+
depth: d,
|
|
155
|
+
bevelEnabled: bevel > 0,
|
|
156
|
+
bevelSize: bevel,
|
|
157
|
+
bevelThickness: bevel,
|
|
158
|
+
bevelSegments: 3,
|
|
159
|
+
steps: 1,
|
|
160
|
+
curveSegments: 20,
|
|
161
|
+
}),
|
|
162
|
+
Math.PI / 3,
|
|
163
|
+
),
|
|
164
|
+
material,
|
|
165
|
+
);
|
|
166
|
+
const box = (w: number, h: number, d: number, radius: number, material: THREE.Material) =>
|
|
167
|
+
mesh(new RoundedBoxGeometry(w, h, d, 3, radius), material);
|
|
168
|
+
|
|
169
|
+
for (const [part, side] of [
|
|
170
|
+
[left, -1],
|
|
171
|
+
[right, 1],
|
|
172
|
+
] as const) {
|
|
173
|
+
const lr = side === -1 ? 8.6 : 0.55;
|
|
174
|
+
const rr = side === 1 ? 8.6 : 0.55;
|
|
175
|
+
const rail = plate(half - 0.3, height - 0.3, depth - 0.8, lr, rr, metal, 0.35);
|
|
176
|
+
rail.position.set((side * half) / 2, 0, -depth + 0.4);
|
|
177
|
+
part.add(rail);
|
|
178
|
+
const rear = plate(half - 1.25, height - 1.25, 0.2, lr - 0.3, rr - 0.3, ceramic);
|
|
179
|
+
rear.position.set((side * half) / 2, 0, -depth - 0.02);
|
|
180
|
+
part.add(rear);
|
|
181
|
+
const seal = plate(half - 0.8, height - 0.8, 0.1, lr - 0.2, rr - 0.2, gasket, 0.05);
|
|
182
|
+
seal.position.set((side * half) / 2, 0, -0.06);
|
|
183
|
+
part.add(seal);
|
|
184
|
+
// Antenna bands interrupt the polished side rails near each corner.
|
|
185
|
+
for (const y of [-height / 2 + 13, height / 2 - 13]) {
|
|
186
|
+
const band = box(0.22, 0.65, depth - 0.8, 0.08, antenna);
|
|
187
|
+
band.position.set(side * (half + 0.04), y, -depth / 2);
|
|
188
|
+
part.add(band);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const screen = (
|
|
193
|
+
part: THREE.Group,
|
|
194
|
+
x: number,
|
|
195
|
+
w: number,
|
|
196
|
+
h: number,
|
|
197
|
+
display: 'inner' | 'outer',
|
|
198
|
+
side = 0,
|
|
199
|
+
) => {
|
|
200
|
+
const shape =
|
|
201
|
+
display === 'outer' ?
|
|
202
|
+
outline(w, h, 0.8, 6.6)
|
|
203
|
+
: outline(w, h, side === 0 ? 5.3 : 0, side === 1 ? 5.3 : 0);
|
|
204
|
+
const geometry = new THREE.ShapeGeometry(shape, 24);
|
|
205
|
+
const positions = geometry.getAttribute('position');
|
|
206
|
+
const uv = geometry.getAttribute('uv');
|
|
207
|
+
for (let i = 0; i < positions.count; i++) {
|
|
208
|
+
const u = (positions.getX(i) + w / 2) / w;
|
|
209
|
+
const v = (positions.getY(i) + h / 2) / h;
|
|
210
|
+
if (display === 'inner') {
|
|
211
|
+
const physicalX = positions.getX(i) + x;
|
|
212
|
+
uv.setXY(i, 1 - v, (physicalX + innerWidth / 2) / innerWidth);
|
|
213
|
+
} else uv.setXY(i, u, v);
|
|
214
|
+
}
|
|
215
|
+
const panel = mesh(geometry, display === 'inner' ? innerMaterial : outerMaterial);
|
|
216
|
+
panel.name = display === 'outer' ? 'cover-display' : `inner-display-${side}`;
|
|
217
|
+
panel.position.set(x, 0, display === 'inner' ? glassZ : -depth - 0.25);
|
|
218
|
+
if (display === 'outer') panel.rotation.y = Math.PI;
|
|
219
|
+
panel.userData['display'] = display;
|
|
220
|
+
part.add(panel);
|
|
221
|
+
};
|
|
222
|
+
const innerHalf = innerWidth / 2 - bendWidth;
|
|
223
|
+
screen(left, -(innerWidth / 2 + bendWidth) / 2, innerHalf, innerHeight, 'inner', 0);
|
|
224
|
+
screen(right, (innerWidth / 2 + bendWidth) / 2, innerHalf, innerHeight, 'inner', 1);
|
|
225
|
+
// The cover's hinge edge is nearly square; the exposed edge follows the rounded titanium rail.
|
|
226
|
+
const coverSeal = plate(half - 0.85, height - 0.9, 0.08, 8.1, 0.3, gasket, 0.04);
|
|
227
|
+
coverSeal.position.set(-half / 2, 0, -depth - 0.15);
|
|
228
|
+
left.add(coverSeal);
|
|
229
|
+
screen(left, -half / 2, 77.1, 112.2, 'outer');
|
|
230
|
+
|
|
231
|
+
// A compact ceramic capsule carries two independently raised, concentric camera rings.
|
|
232
|
+
const cameraY = height / 2 - 15;
|
|
233
|
+
const island = plate(57, 20, 1.6, 10, 10, ceramic, 0.28);
|
|
234
|
+
island.position.set(half / 2 + 2, cameraY, -depth - 1.8);
|
|
235
|
+
right.add(island);
|
|
236
|
+
const disc = (radius: number, material: THREE.Material, x: number, y: number, z: number) => {
|
|
237
|
+
const result = mesh(new THREE.CircleGeometry(radius, 48), material);
|
|
238
|
+
result.rotation.y = Math.PI;
|
|
239
|
+
result.position.set(x, y, z);
|
|
240
|
+
right.add(result);
|
|
241
|
+
return result;
|
|
242
|
+
};
|
|
243
|
+
for (const x of [half / 2 + 19, half / 2 - 1]) {
|
|
244
|
+
const ring = mesh(new THREE.CylinderGeometry(8.05, 8.05, 1.7, 64), metal);
|
|
245
|
+
ring.rotation.x = Math.PI / 2;
|
|
246
|
+
ring.position.set(x, cameraY, -depth - 2.55);
|
|
247
|
+
right.add(ring);
|
|
248
|
+
disc(7.35, gasket, x, cameraY, -depth - 3.43);
|
|
249
|
+
disc(6.65, glass, x, cameraY, -depth - 3.46);
|
|
250
|
+
disc(2.5, optic, x, cameraY, -depth - 3.48);
|
|
251
|
+
disc(1.9, glass, x, cameraY, -depth - 3.5);
|
|
252
|
+
const reflection = disc(0.7, optic, x - 1.1, cameraY + 1.15, -depth - 3.52);
|
|
253
|
+
reflection.scale.y = 0.55;
|
|
254
|
+
}
|
|
255
|
+
disc(2.2, metal, half / 2 - 18, cameraY - 3, -depth - 2.11);
|
|
256
|
+
disc(1.8, flash, half / 2 - 18, cameraY - 3, -depth - 2.15);
|
|
257
|
+
disc(0.65, gasket, half / 2 - 18, cameraY + 3.2, -depth - 2.15);
|
|
258
|
+
|
|
259
|
+
// A subdued inlay sits beneath the ceramic surface, as on the rear product photographs.
|
|
260
|
+
const apple = new THREE.Shape();
|
|
261
|
+
apple.moveTo(0, 3.2);
|
|
262
|
+
apple.bezierCurveTo(-1.5, 4.2, -3.5, 4.3, -4.8, 2.6);
|
|
263
|
+
apple.bezierCurveTo(-6.4, 0.3, -4.4, -4.6, -2.5, -5.4);
|
|
264
|
+
apple.bezierCurveTo(-1.4, -5.9, -0.8, -5, 0, -5);
|
|
265
|
+
apple.bezierCurveTo(1, -5, 1.7, -5.9, 2.8, -5.3);
|
|
266
|
+
apple.bezierCurveTo(3.8, -4.5, 4.6, -3.1, 4.9, -2.1);
|
|
267
|
+
apple.bezierCurveTo(2.3, -1.2, 2.1, 1.5, 4.1, 2.8);
|
|
268
|
+
apple.bezierCurveTo(3, 4.3, 1.5, 4, 0, 3.2);
|
|
269
|
+
const leaf = new THREE.Shape();
|
|
270
|
+
leaf.moveTo(-0.2, 4);
|
|
271
|
+
leaf.bezierCurveTo(-0.3, 5.7, 1, 6.9, 2.5, 7.1);
|
|
272
|
+
leaf.bezierCurveTo(2.6, 5.6, 1.2, 4, -0.2, 4);
|
|
273
|
+
const inlay = mesh(new THREE.ShapeGeometry([apple, leaf], 20), logo);
|
|
274
|
+
inlay.rotation.y = Math.PI;
|
|
275
|
+
inlay.position.set(half / 2, -3, -depth - 0.16);
|
|
276
|
+
right.add(inlay);
|
|
277
|
+
|
|
278
|
+
const addButton = (name: DuoButton, w: number, h: number, x: number, y: number) => {
|
|
279
|
+
const material = metal.clone();
|
|
280
|
+
resources.push(material);
|
|
281
|
+
const button = box(w, h, 2.5, 0.27, material);
|
|
282
|
+
button.name = `button-${name}`;
|
|
283
|
+
button.userData['button'] = name;
|
|
284
|
+
button.position.set(x, y, -depth / 2);
|
|
285
|
+
right.add(button);
|
|
286
|
+
buttons.set(name, button);
|
|
287
|
+
buttonBases.set(name, button.position.clone());
|
|
288
|
+
// Extend away from the bezel so narrow hardware remains clickable without covering the glass.
|
|
289
|
+
const target = box(w < h ? 4.5 : w, w < h ? h : 4.5, depth, 0.1, material);
|
|
290
|
+
hitObjects.splice(hitObjects.indexOf(target), 1);
|
|
291
|
+
target.position.set(x + (w < h ? 2 : 0), y + (w < h ? 0 : 2), -depth / 2);
|
|
292
|
+
target.userData['button'] = name;
|
|
293
|
+
target.visible = false;
|
|
294
|
+
target.updateMatrix();
|
|
295
|
+
buttonTargets.push(target);
|
|
296
|
+
};
|
|
297
|
+
addButton('side', 0.7, 13, half + 0.15, 21);
|
|
298
|
+
addButton('volumeUp', 9.5, 0.65, half - 25, height / 2 + 0.12);
|
|
299
|
+
addButton('volumeDown', 9.5, 0.65, half - 37, height / 2 + 0.12);
|
|
300
|
+
const antennaWindow = box(0.35, 12, 2.5, 0.15, antenna);
|
|
301
|
+
antennaWindow.position.set(half - 0.08, -12, -depth / 2);
|
|
302
|
+
right.add(antennaWindow);
|
|
303
|
+
const segments = 32;
|
|
304
|
+
const strip = (material: THREE.Material, display = false) => {
|
|
305
|
+
const geometry = new THREE.BufferGeometry();
|
|
306
|
+
const positions = new Float32Array((segments + 1) * 2 * 3);
|
|
307
|
+
const uv = new Float32Array((segments + 1) * 2 * 2);
|
|
308
|
+
const indices: number[] = [];
|
|
309
|
+
for (let i = 0; i <= segments; i++) {
|
|
310
|
+
const v = (innerWidth / 2 - bendWidth + (2 * bendWidth * i) / segments) / innerWidth;
|
|
311
|
+
uv.set([1, v, 0, v], i * 4);
|
|
312
|
+
if (i < segments) {
|
|
313
|
+
const a = i * 2;
|
|
314
|
+
indices.push(a, a + 2, a + 1, a + 2, a + 3, a + 1);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
geometry.setAttribute(
|
|
318
|
+
'position',
|
|
319
|
+
new THREE.BufferAttribute(positions, 3).setUsage(THREE.DynamicDrawUsage),
|
|
320
|
+
);
|
|
321
|
+
geometry.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
|
|
322
|
+
geometry.setIndex(indices);
|
|
323
|
+
const result = mesh(geometry, material);
|
|
324
|
+
if (display) result.userData['display'] = 'inner';
|
|
325
|
+
device.add(result);
|
|
326
|
+
return geometry;
|
|
327
|
+
};
|
|
328
|
+
const bendSeal = strip(gasket);
|
|
329
|
+
const bend = strip(innerMaterial, true);
|
|
330
|
+
const spine = strip(hingeMetal);
|
|
331
|
+
const hingeCaps = [-1, 1].map((edge) => {
|
|
332
|
+
const geometry = new THREE.BufferGeometry();
|
|
333
|
+
geometry.setAttribute(
|
|
334
|
+
'position',
|
|
335
|
+
new THREE.BufferAttribute(new Float32Array((segments + 1) * 2 * 3), 3).setUsage(THREE.DynamicDrawUsage),
|
|
336
|
+
);
|
|
337
|
+
const indices: number[] = [];
|
|
338
|
+
for (let i = 0; i < segments; i++) {
|
|
339
|
+
const a = i * 2;
|
|
340
|
+
indices.push(
|
|
341
|
+
...(edge === 1 ? [a, a + 1, a + 2, a + 1, a + 3, a + 2] : [a, a + 2, a + 1, a + 1, a + 2, a + 3]),
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
geometry.setIndex(indices);
|
|
345
|
+
const cap = mesh(geometry, hingeMetal);
|
|
346
|
+
cap.name = `hinge-cap-${edge}`;
|
|
347
|
+
device.add(cap);
|
|
348
|
+
return { geometry, edge };
|
|
349
|
+
});
|
|
350
|
+
spine.setIndex(Array.from(spine.getIndex()!.array).reverse());
|
|
351
|
+
|
|
352
|
+
let previousAngle: number | undefined;
|
|
353
|
+
const setAngle = (degrees: number) => {
|
|
354
|
+
if (previousAngle === degrees) return;
|
|
355
|
+
previousAngle = degrees;
|
|
356
|
+
const radians = THREE.MathUtils.degToRad((180 - degrees) / 2);
|
|
357
|
+
const s = Math.sin(radians),
|
|
358
|
+
c = Math.cos(radians);
|
|
359
|
+
for (const [part, sign] of [
|
|
360
|
+
[left, 1],
|
|
361
|
+
[right, -1],
|
|
362
|
+
] as const) {
|
|
363
|
+
part.rotation.y = sign * radians;
|
|
364
|
+
part.position.set(-sign * pivot * s, 0, pivot * (1 - c));
|
|
365
|
+
}
|
|
366
|
+
const endX = bendWidth * c + (pivot - glassZ) * s;
|
|
367
|
+
const endZ = bendWidth * s + glassZ * c + pivot * (1 - c);
|
|
368
|
+
const handle = (2 * bendWidth) / 3;
|
|
369
|
+
const backX = 0.45 * c + (depth + pivot) * s;
|
|
370
|
+
const backZ = 0.45 * s - depth * c + pivot * (1 - c);
|
|
371
|
+
const spineZ = -depth * (1 - s) - 0.8 * s;
|
|
372
|
+
for (let i = 0; i <= segments; i++) {
|
|
373
|
+
const t = i / segments,
|
|
374
|
+
a = 1 - t;
|
|
375
|
+
const x =
|
|
376
|
+
a * a * a * -endX +
|
|
377
|
+
3 * a * a * t * (-endX + c * handle) +
|
|
378
|
+
3 * a * t * t * (endX - c * handle) +
|
|
379
|
+
t * t * t * endX;
|
|
380
|
+
const z = endZ - 3 * a * t * s * handle;
|
|
381
|
+
const edge = Math.PI * t;
|
|
382
|
+
for (let row = 0; row < 2; row++) {
|
|
383
|
+
bend.getAttribute('position').setXYZ(i * 2 + row, x, (row - 0.5) * innerHeight, z);
|
|
384
|
+
bendSeal.getAttribute('position').setXYZ(i * 2 + row, x, (row - 0.5) * (height - 0.9), z - 0.07);
|
|
385
|
+
spine
|
|
386
|
+
.getAttribute('position')
|
|
387
|
+
.setXYZ(
|
|
388
|
+
i * 2 + row,
|
|
389
|
+
-backX * Math.cos(edge),
|
|
390
|
+
(row - 0.5) * (height - 0.9),
|
|
391
|
+
backZ + (spineZ - backZ) * Math.sin(edge),
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
// Close the hinge ends between the flexible display seal and the titanium spine.
|
|
395
|
+
for (const { geometry, edge: capEdge } of hingeCaps) {
|
|
396
|
+
const positions = geometry.getAttribute('position');
|
|
397
|
+
positions.setXYZ(i * 2, x, capEdge * (height / 2 - 0.45), z - 0.07);
|
|
398
|
+
positions.setXYZ(
|
|
399
|
+
i * 2 + 1,
|
|
400
|
+
-backX * Math.cos(edge),
|
|
401
|
+
capEdge * (height / 2 - 0.45),
|
|
402
|
+
backZ + (spineZ - backZ) * Math.sin(edge),
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
for (const geometry of [bendSeal, bend, spine, ...hingeCaps.map((cap) => cap.geometry)]) {
|
|
407
|
+
geometry.getAttribute('position').needsUpdate = true;
|
|
408
|
+
geometry.computeVertexNormals();
|
|
409
|
+
geometry.computeBoundingSphere();
|
|
410
|
+
geometry.computeBoundingBox();
|
|
411
|
+
}
|
|
412
|
+
device.updateMatrixWorld(true);
|
|
413
|
+
};
|
|
414
|
+
setAngle(180);
|
|
415
|
+
return {
|
|
416
|
+
device,
|
|
417
|
+
hitObjects,
|
|
418
|
+
setAngle,
|
|
419
|
+
buttonAnchors: () =>
|
|
420
|
+
[...buttonBases].map(([button, base]) => ({
|
|
421
|
+
button,
|
|
422
|
+
point: base.clone().applyMatrix4(right.matrixWorld),
|
|
423
|
+
outward: base
|
|
424
|
+
.clone()
|
|
425
|
+
.add(button === 'side' ? new THREE.Vector3(10, 0, 0) : new THREE.Vector3(0, 10, 0))
|
|
426
|
+
.applyMatrix4(right.matrixWorld),
|
|
427
|
+
})),
|
|
428
|
+
getBounds: (bounds: THREE.Box3) => {
|
|
429
|
+
bounds.makeEmpty();
|
|
430
|
+
const objectBounds = new THREE.Box3();
|
|
431
|
+
for (const object of hitObjects) {
|
|
432
|
+
if (!(object instanceof THREE.Mesh) || object.userData['button']) continue;
|
|
433
|
+
if (!object.geometry.boundingBox) object.geometry.computeBoundingBox();
|
|
434
|
+
objectBounds.copy(object.geometry.boundingBox!).applyMatrix4(object.matrixWorld);
|
|
435
|
+
bounds.union(objectBounds);
|
|
436
|
+
}
|
|
437
|
+
return bounds;
|
|
438
|
+
},
|
|
439
|
+
pick: (ray: THREE.Raycaster) => {
|
|
440
|
+
const surface = ray.intersectObjects(hitObjects, false)[0];
|
|
441
|
+
if (surface?.object.userData['display'] || surface?.object.userData['button']) return surface;
|
|
442
|
+
for (const target of buttonTargets)
|
|
443
|
+
target.matrixWorld.multiplyMatrices(right.matrixWorld, target.matrix);
|
|
444
|
+
const hardware = ray.intersectObjects(buttonTargets, false)[0];
|
|
445
|
+
return hardware && (!surface || hardware.distance <= surface.distance) ? hardware : surface;
|
|
446
|
+
},
|
|
447
|
+
// Imported appearances replace the rigid body while live screens and the hinge remain under our control.
|
|
448
|
+
loadAppearance: async (url: string) => {
|
|
449
|
+
const { GLTFLoader } = await import('three/addons/loaders/GLTFLoader.js');
|
|
450
|
+
const { scene } = await new GLTFLoader().loadAsync(url);
|
|
451
|
+
const importedResources = new Set<{ dispose(): void }>();
|
|
452
|
+
scene.traverse((object) => {
|
|
453
|
+
if (!(object instanceof THREE.Mesh)) return;
|
|
454
|
+
importedResources.add(object.geometry);
|
|
455
|
+
for (const material of Array.isArray(object.material) ? object.material : [object.material]) {
|
|
456
|
+
importedResources.add(material);
|
|
457
|
+
if (
|
|
458
|
+
material instanceof THREE.MeshStandardMaterial &&
|
|
459
|
+
material.userData['finish'] === 'polished-silver'
|
|
460
|
+
)
|
|
461
|
+
polishSilver(material, environment);
|
|
462
|
+
for (const value of Object.values(material))
|
|
463
|
+
if (value instanceof THREE.Texture && value !== environment) importedResources.add(value);
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
if (disposed) {
|
|
467
|
+
importedResources.forEach((r) => r.dispose());
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const coverBody = scene.getObjectByName('folding-half');
|
|
471
|
+
const cameraBody = scene.getObjectByName('stationary-half');
|
|
472
|
+
const importedButtons = new Set<DuoButton>();
|
|
473
|
+
cameraBody?.traverse((object) => {
|
|
474
|
+
if (object instanceof THREE.Mesh && object.userData['button'])
|
|
475
|
+
importedButtons.add(object.userData['button']);
|
|
476
|
+
});
|
|
477
|
+
if (
|
|
478
|
+
!coverBody ||
|
|
479
|
+
!cameraBody ||
|
|
480
|
+
!['side', 'volumeUp', 'volumeDown'].every((name) => importedButtons.has(name as DuoButton))
|
|
481
|
+
) {
|
|
482
|
+
importedResources.forEach((r) => r.dispose());
|
|
483
|
+
throw new Error(
|
|
484
|
+
'Duo appearance requires both body halves and side, volumeUp, and volumeDown button meshes.',
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
resources.push(...importedResources);
|
|
488
|
+
// Match the imported cover aperture exactly so the live image cannot expose seams beneath its bezel.
|
|
489
|
+
const cover = coverBody.children.find(
|
|
490
|
+
(child) =>
|
|
491
|
+
child instanceof THREE.Mesh &&
|
|
492
|
+
(child.userData['sourceDisplay'] === 'outer' || child.material.name === 'cover-screen'),
|
|
493
|
+
) as THREE.Mesh | undefined;
|
|
494
|
+
if (cover) {
|
|
495
|
+
const panel = device.getObjectByName('cover-display') as THREE.Mesh;
|
|
496
|
+
const geometry = cover.geometry.clone().scale(10, 10, 10);
|
|
497
|
+
geometry.computeBoundingBox();
|
|
498
|
+
const bounds = geometry.boundingBox!;
|
|
499
|
+
const positions = geometry.getAttribute('position');
|
|
500
|
+
const uv = new THREE.BufferAttribute(new Float32Array(positions.count * 2), 2);
|
|
501
|
+
for (let i = 0; i < positions.count; i++) {
|
|
502
|
+
uv.setXY(
|
|
503
|
+
i,
|
|
504
|
+
(bounds.max.x - positions.getX(i)) / (bounds.max.x - bounds.min.x),
|
|
505
|
+
(positions.getY(i) - bounds.min.y) / (bounds.max.y - bounds.min.y),
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
geometry.setAttribute('uv', uv);
|
|
509
|
+
panel.geometry = geometry;
|
|
510
|
+
panel.position.copy(cover.position).multiplyScalar(10);
|
|
511
|
+
panel.rotation.copy(cover.rotation);
|
|
512
|
+
panel.scale.copy(cover.scale);
|
|
513
|
+
resources.push(geometry);
|
|
514
|
+
}
|
|
515
|
+
// Live display meshes and the flexible hinge stay under our input and fold control.
|
|
516
|
+
for (const part of [left, right]) {
|
|
517
|
+
for (const child of [...part.children]) {
|
|
518
|
+
if (child.userData['display']) continue;
|
|
519
|
+
part.remove(child);
|
|
520
|
+
const index = hitObjects.indexOf(child);
|
|
521
|
+
if (index !== -1) hitObjects.splice(index, 1);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
buttons.clear();
|
|
525
|
+
buttonTargets.length = 0;
|
|
526
|
+
buttonBases.clear();
|
|
527
|
+
buttonOffsets.clear();
|
|
528
|
+
for (const name of ['side', 'volumeUp', 'volumeDown'] as const) {
|
|
529
|
+
const group = new THREE.Group();
|
|
530
|
+
group.name = `button-${name}`;
|
|
531
|
+
buttons.set(name, group);
|
|
532
|
+
right.add(group);
|
|
533
|
+
}
|
|
534
|
+
for (const [source, target] of [
|
|
535
|
+
[coverBody, left],
|
|
536
|
+
[cameraBody, right],
|
|
537
|
+
] as const) {
|
|
538
|
+
for (const child of [...source.children]) {
|
|
539
|
+
if (!(child instanceof THREE.Mesh)) continue;
|
|
540
|
+
if (
|
|
541
|
+
child.userData['sourceDisplay'] ||
|
|
542
|
+
['inner-screen', 'cover-screen'].includes(child.material.name)
|
|
543
|
+
)
|
|
544
|
+
continue;
|
|
545
|
+
// GLB geometry is measured in centimeters; runtime display geometry uses millimeters.
|
|
546
|
+
child.geometry.scale(10, 10, 10);
|
|
547
|
+
child.position.multiplyScalar(10);
|
|
548
|
+
child.geometry.computeBoundingBox();
|
|
549
|
+
const buttonName = child.userData['button'] as DuoButton | undefined;
|
|
550
|
+
if (buttonName && buttons.has(buttonName)) {
|
|
551
|
+
buttons.get(buttonName)!.add(child);
|
|
552
|
+
} else target.add(child);
|
|
553
|
+
hitObjects.push(child);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
for (const [name, group] of buttons) {
|
|
557
|
+
const bounds = new THREE.Box3();
|
|
558
|
+
for (const child of group.children) {
|
|
559
|
+
const mesh = child as THREE.Mesh;
|
|
560
|
+
mesh.updateMatrix();
|
|
561
|
+
bounds.union(mesh.geometry.boundingBox!.clone().applyMatrix4(mesh.matrix));
|
|
562
|
+
}
|
|
563
|
+
const center = bounds.getCenter(new THREE.Vector3());
|
|
564
|
+
for (const child of group.children) child.position.sub(center);
|
|
565
|
+
group.position.copy(center);
|
|
566
|
+
buttonBases.set(name, center);
|
|
567
|
+
const size = bounds.getSize(new THREE.Vector3());
|
|
568
|
+
const target = box(name === 'side' ? 5 : size.x, name === 'side' ? size.y : 5, depth, 0.1, metal);
|
|
569
|
+
hitObjects.splice(hitObjects.indexOf(target), 1);
|
|
570
|
+
target.position
|
|
571
|
+
.copy(center)
|
|
572
|
+
.add(new THREE.Vector3(name === 'side' ? 2 : 0, name === 'side' ? 0 : 2, 0));
|
|
573
|
+
target.userData['button'] = name;
|
|
574
|
+
target.visible = false;
|
|
575
|
+
target.updateMatrix();
|
|
576
|
+
buttonTargets.push(target);
|
|
577
|
+
}
|
|
578
|
+
device.updateMatrixWorld(true);
|
|
579
|
+
},
|
|
580
|
+
highlightButton: (name?: DuoButton, pressed = false) => {
|
|
581
|
+
hoveredButton = name;
|
|
582
|
+
pressedButton = pressed;
|
|
583
|
+
},
|
|
584
|
+
animateButtons: (dt: number) => {
|
|
585
|
+
let moving = false;
|
|
586
|
+
for (const [name, button] of buttons) {
|
|
587
|
+
const base = buttonBases.get(name)!;
|
|
588
|
+
const goal =
|
|
589
|
+
name === hoveredButton ?
|
|
590
|
+
pressedButton ? 0
|
|
591
|
+
: 0.5
|
|
592
|
+
: 0.16;
|
|
593
|
+
let offset = THREE.MathUtils.damp(buttonOffsets.get(name) ?? 0.16, goal, 22, dt);
|
|
594
|
+
if (Math.abs(offset - goal) < 0.001) offset = goal;
|
|
595
|
+
else moving = true;
|
|
596
|
+
buttonOffsets.set(name, offset);
|
|
597
|
+
button.position.copy(base);
|
|
598
|
+
button.position[name === 'side' ? 'x' : 'y'] += offset;
|
|
599
|
+
}
|
|
600
|
+
return moving;
|
|
601
|
+
},
|
|
602
|
+
dispose: () => {
|
|
603
|
+
disposed = true;
|
|
604
|
+
resources.forEach((resource) => resource.dispose());
|
|
605
|
+
},
|
|
606
|
+
};
|
|
607
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** Capture two bounded stills per fold, without retaining a canvas or rendering loop. */
|
|
2
|
+
export function captureDuoFrame(video: HTMLVideoElement | null): HTMLCanvasElement | undefined {
|
|
3
|
+
if (!video || video.readyState < 2 || !video.videoWidth || !video.videoHeight) return;
|
|
4
|
+
const ratio = Math.min(1, 1024 / Math.max(video.videoWidth, video.videoHeight));
|
|
5
|
+
const canvas = document.createElement('canvas');
|
|
6
|
+
try {
|
|
7
|
+
const context = canvas.getContext('2d');
|
|
8
|
+
if (!context) return;
|
|
9
|
+
canvas.width = Math.round(video.videoWidth * ratio);
|
|
10
|
+
canvas.height = Math.round(video.videoHeight * ratio);
|
|
11
|
+
context.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
12
|
+
return canvas;
|
|
13
|
+
} catch {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Capture the first incoming frame without delaying for native fades or brightness changes. */
|
|
19
|
+
export function waitForDuoFrame(
|
|
20
|
+
video: HTMLVideoElement | null,
|
|
21
|
+
ready: (image: HTMLCanvasElement | undefined) => void,
|
|
22
|
+
) {
|
|
23
|
+
let stopped = false;
|
|
24
|
+
let callback: number | undefined;
|
|
25
|
+
const finish = (image: HTMLCanvasElement | undefined) => {
|
|
26
|
+
if (stopped) return;
|
|
27
|
+
stop();
|
|
28
|
+
ready(image);
|
|
29
|
+
};
|
|
30
|
+
const available = () => {
|
|
31
|
+
if (stopped) return;
|
|
32
|
+
const image = captureDuoFrame(video);
|
|
33
|
+
if (image) finish(image);
|
|
34
|
+
};
|
|
35
|
+
const frame = () => {
|
|
36
|
+
callback = undefined;
|
|
37
|
+
available();
|
|
38
|
+
if (!stopped) callback = video?.requestVideoFrameCallback?.(frame);
|
|
39
|
+
};
|
|
40
|
+
// Release the held image if the incoming stream never delivers a frame.
|
|
41
|
+
const fallback = setTimeout(() => finish(undefined), 2500);
|
|
42
|
+
callback = video?.requestVideoFrameCallback?.(frame);
|
|
43
|
+
video?.addEventListener('loadeddata', available);
|
|
44
|
+
if (!video?.requestVideoFrameCallback && video && video.readyState >= 2) available();
|
|
45
|
+
function stop() {
|
|
46
|
+
stopped = true;
|
|
47
|
+
clearTimeout(fallback);
|
|
48
|
+
if (callback !== undefined) video?.cancelVideoFrameCallback(callback);
|
|
49
|
+
video?.removeEventListener('loadeddata', available);
|
|
50
|
+
}
|
|
51
|
+
return stop;
|
|
52
|
+
}
|