@limrun/ui 0.17.0-rc.1 → 0.17.0-rc.2

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.
Files changed (83) hide show
  1. package/README.md +18 -3
  2. package/dist/{GLTFLoader-DW6wpvyR.mjs → GLTFLoader-Cami_vza.mjs} +1 -1
  3. package/dist/{GLTFLoader-BKnsBFsj.js → GLTFLoader-DeCLxW_q.js} +1 -1
  4. package/dist/components/duo-fold-motion.d.ts +1 -1
  5. package/dist/components/duo-loading.d.ts +8 -0
  6. package/dist/components/duo-view.d.ts +6 -1
  7. package/dist/{duo-frame-BzyYljix.mjs → duo-frame-Cmw4p2uN.mjs} +414 -414
  8. package/dist/{duo-frame-DjfA4gG-.js → duo-frame-iMtrvx_a.js} +18 -18
  9. package/dist/duo-view-BAh-w2Kz.js +1 -0
  10. package/dist/duo-view-D9Ae00rj.mjs +512 -0
  11. package/dist/index.cjs +1 -1
  12. package/dist/index.css +1 -1
  13. package/dist/index.js +2 -2
  14. package/dist/lite.cjs +1 -1
  15. package/dist/lite.js +1 -1
  16. package/dist/remote-control-B2jZpf3c.js +1 -0
  17. package/dist/remote-control-DiddxS8l.mjs +2883 -0
  18. package/package.json +5 -1
  19. package/dist/demo.d.ts +0 -1
  20. package/dist/duo-view-COZg8ieG.js +0 -1
  21. package/dist/duo-view-o1yxIzxu.mjs +0 -654
  22. package/dist/remote-control-CQjSEVDF.mjs +0 -2673
  23. package/dist/remote-control-DVRXfCR2.js +0 -1
  24. package/index.html +0 -199
  25. package/src/assets/Apple_logo_white.svg +0 -4
  26. package/src/assets/android_boot.gif +0 -0
  27. package/src/assets/android_boot.webp +0 -0
  28. package/src/assets/iphone16pro_black.webp +0 -0
  29. package/src/assets/iphone16pro_black_bg.webp +0 -0
  30. package/src/assets/iphone16pro_black_landscape.webp +0 -0
  31. package/src/assets/iphone16pro_black_landscape_bg.webp +0 -0
  32. package/src/assets/pixel9_black.webp +0 -0
  33. package/src/assets/pixel9_black_landscape.webp +0 -0
  34. package/src/assets/pixel_tablet_landscape.webp +0 -0
  35. package/src/assets/pixel_tablet_portrait.webp +0 -0
  36. package/src/components/device-install/index.ts +0 -1
  37. package/src/components/duo-button-hinge.test.tsx +0 -138
  38. package/src/components/duo-button-hinge.ts +0 -75
  39. package/src/components/duo-controls.tsx +0 -142
  40. package/src/components/duo-flat-frame.tsx +0 -145
  41. package/src/components/duo-flat-geometry.test.ts +0 -63
  42. package/src/components/duo-flat-geometry.ts +0 -97
  43. package/src/components/duo-fold-motion.test.tsx +0 -245
  44. package/src/components/duo-fold-motion.tsx +0 -192
  45. package/src/components/duo-fold-renderer.test.ts +0 -29
  46. package/src/components/duo-fold-renderer.ts +0 -397
  47. package/src/components/duo-fold-timing.test.ts +0 -44
  48. package/src/components/duo-fold-timing.ts +0 -9
  49. package/src/components/duo-frame.tsx +0 -579
  50. package/src/components/duo-hardware.test.ts +0 -61
  51. package/src/components/duo-hardware.ts +0 -61
  52. package/src/components/duo-model.test.ts +0 -243
  53. package/src/components/duo-model.ts +0 -607
  54. package/src/components/duo-screen-handoff.ts +0 -52
  55. package/src/components/duo-view.test.tsx +0 -104
  56. package/src/components/duo-view.tsx +0 -315
  57. package/src/components/inspect-overlay.css +0 -223
  58. package/src/components/inspect-overlay.tsx +0 -437
  59. package/src/components/remote-control-framed.tsx +0 -14
  60. package/src/components/remote-control.css +0 -441
  61. package/src/components/remote-control.test.ts +0 -34
  62. package/src/components/remote-control.tsx +0 -3989
  63. package/src/core/ax-fetcher.test.ts +0 -418
  64. package/src/core/ax-fetcher.ts +0 -387
  65. package/src/core/ax-tree.test.ts +0 -491
  66. package/src/core/ax-tree.ts +0 -416
  67. package/src/core/camera.test.ts +0 -155
  68. package/src/core/camera.ts +0 -133
  69. package/src/core/constants.ts +0 -344
  70. package/src/core/duo.test.ts +0 -142
  71. package/src/core/duo.ts +0 -156
  72. package/src/core/webrtc-messages.ts +0 -148
  73. package/src/demo.tsx +0 -283
  74. package/src/frame-assets.ts +0 -22
  75. package/src/hooks/index.ts +0 -1
  76. package/src/index.ts +0 -29
  77. package/src/lite.test.ts +0 -30
  78. package/src/lite.ts +0 -30
  79. package/src/vite-env.d.ts +0 -1
  80. package/tsconfig.json +0 -26
  81. package/tsconfig.node.json +0 -26
  82. package/vite.config.ts +0 -35
  83. package/vitest.config.ts +0 -24
@@ -1,607 +0,0 @@
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
- }
@@ -1,52 +0,0 @@
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
- }