@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,61 +0,0 @@
1
- import * as THREE from 'three';
2
- import type { DuoButton } from '../core/duo';
3
- import { hardwareIconSize } from './duo-flat-geometry';
4
-
5
- type Edge = 'top' | 'bottom' | 'left' | 'right';
6
- export type HardwareGuide = { button: DuoButton; edge: Edge; x: number; y: number };
7
-
8
- /** Keep button hints outside the silhouette as the device folds, rotates and changes size. */
9
- export function hardwareLayout(
10
- anchors: { button: DuoButton; point: THREE.Vector3; outward: THREE.Vector3 }[],
11
- bounds: THREE.Box3,
12
- camera: THREE.Camera,
13
- width: number,
14
- height: number,
15
- ) {
16
- const project = (point: THREE.Vector3) => {
17
- const p = point.clone().project(camera);
18
- return new THREE.Vector2(((p.x + 1) * width) / 2, ((1 - p.y) * height) / 2);
19
- };
20
- const rect = new THREE.Box2();
21
- for (const x of [bounds.min.x, bounds.max.x])
22
- for (const y of [bounds.min.y, bounds.max.y])
23
- for (const z of [bounds.min.z, bounds.max.z]) rect.expandByPoint(project(new THREE.Vector3(x, y, z)));
24
- const guides: HardwareGuide[] = anchors.map(({ button, point, outward }) => {
25
- const p = project(point);
26
- const d = project(outward).sub(p);
27
- const vertical = Math.abs(d.y) >= Math.abs(d.x);
28
- const edge: Edge =
29
- vertical ?
30
- d.y < 0 ?
31
- 'top'
32
- : 'bottom'
33
- : d.x < 0 ? 'left'
34
- : 'right';
35
- return {
36
- button,
37
- edge,
38
- x:
39
- vertical ? p.x
40
- : edge === 'left' ? rect.min.x - 28
41
- : rect.max.x + 28,
42
- y:
43
- !vertical ? p.y
44
- : edge === 'top' ? rect.min.y - 28
45
- : rect.max.y + 28,
46
- };
47
- });
48
- const volume = guides.filter((guide) => guide.button !== 'side');
49
- if (volume.length === 2 && volume[0]!.edge === volume[1]!.edge) {
50
- const axis = volume[0]!.edge === 'top' || volume[0]!.edge === 'bottom' ? 'x' : 'y';
51
- volume.sort((a, b) => a[axis] - b[axis]);
52
- if (volume[1]![axis] - volume[0]![axis] < 48) {
53
- const center = (volume[0]![axis] + volume[1]![axis]) / 2;
54
- volume[0]![axis] = center - 24;
55
- volume[1]![axis] = center + 24;
56
- }
57
- }
58
- return { guides, rect, iconSize: hardwareIconSize(rect) };
59
- }
60
-
61
- export { flatHoverEdge as hardwareHoverEdge } from './duo-flat-geometry';
@@ -1,243 +0,0 @@
1
- import { afterEach, describe, expect, it, vi } from 'vitest';
2
- import * as THREE from 'three';
3
- import { createDuoModel } from './duo-model';
4
- import { GLTFLoader, type GLTF } from 'three/addons/loaders/GLTFLoader.js';
5
- import { panelTouch } from '../core/duo';
6
-
7
- function rayAt(model: ReturnType<typeof createDuoModel>, name: string) {
8
- const panel = model.device.getObjectByName(name)!;
9
- const point = panel.localToWorld(new THREE.Vector3());
10
- const normal = new THREE.Vector3(0, 0, 1).transformDirection(panel.matrixWorld);
11
- return new THREE.Raycaster(point.clone().addScaledVector(normal, 80), normal.negate()).intersectObjects(
12
- model.hitObjects,
13
- false,
14
- )[0];
15
- }
16
-
17
- describe('Duo display geometry', () => {
18
- it.each([110, 180])('preserves native touch UVs on both inner leaves at %s°', (angle) => {
19
- const model = createDuoModel(new THREE.Texture(), new THREE.Texture());
20
- model.setAngle(angle);
21
- for (const side of [0, 1]) {
22
- const hit = rayAt(model, `inner-display-${side}`)!;
23
- expect(hit.object.userData['display']).toBe('inner');
24
- const point = panelTouch(hit.uv!.x, hit.uv!.y);
25
- expect(point.x).toBeCloseTo(0.5);
26
- expect(point.y).toBeCloseTo(side === 0 ? 0.7535 : 0.2465, 3);
27
- }
28
- model.dispose();
29
- });
30
- it('exposes the cover when folded and leaves the native image upright', () => {
31
- const model = createDuoModel(new THREE.Texture(), new THREE.Texture());
32
- model.setAngle(0);
33
- model.device.rotation.y = Math.PI / 2;
34
- model.device.updateMatrixWorld(true);
35
- const hit = rayAt(model, 'cover-display')!;
36
- expect(hit.object.name).toBe('cover-display');
37
- const point = panelTouch(hit.uv!.x, hit.uv!.y);
38
- expect(point.x).toBeCloseTo(0.5);
39
- expect(point.y).toBeCloseTo(0.5);
40
- model.dispose();
41
- });
42
- it.each([0, 45, 90, 110, 180])(
43
- 'has finite bounds and a continuous display across the fold at %s°',
44
- (angle) => {
45
- const model = createDuoModel(new THREE.Texture(), new THREE.Texture());
46
- model.setAngle(angle);
47
- const displays = model.hitObjects.filter((o) => o.userData['display'] === 'inner') as THREE.Mesh[];
48
- const bend = displays.find((o) => o.name === '')!;
49
- const bendPositions = bend.geometry.getAttribute('position');
50
- for (const [side, vertex] of [
51
- [0, 0],
52
- [1, bendPositions.count - 2],
53
- ]) {
54
- const panel = model.device.getObjectByName(`inner-display-${side}`) as THREE.Mesh;
55
- const vertices = panel.geometry.getAttribute('position');
56
- const edge = new THREE.Vector3().fromBufferAttribute(bendPositions, vertex!);
57
- const distances = Array.from({ length: vertices.count }, (_, i) =>
58
- panel.localToWorld(new THREE.Vector3().fromBufferAttribute(vertices, i)).distanceTo(edge),
59
- );
60
- expect(Math.min(...distances)).toBeLessThan(0.0001);
61
- }
62
- const size = new THREE.Box3().setFromObject(model.device).getSize(new THREE.Vector3());
63
- expect(size.toArray().every(Number.isFinite)).toBe(true);
64
- expect(Math.max(size.x, size.y, size.z)).toBeLessThan(166);
65
- model.dispose();
66
- },
67
- );
68
- it('occludes the inner screen from the back of an unfolded phone', () => {
69
- const model = createDuoModel(new THREE.Texture(), new THREE.Texture());
70
- const hit = new THREE.Raycaster(
71
- new THREE.Vector3(40, 0, -80),
72
- new THREE.Vector3(0, 0, 1),
73
- ).intersectObjects(model.hitObjects, false)[0]!;
74
- expect(hit.object.userData['display']).toBeUndefined();
75
- model.dispose();
76
- });
77
- });
78
-
79
- describe('Duo hardware hit targets', () => {
80
- it.each([0, 110, 180])('keeps all three hardware buttons clickable at %s°', (angle) => {
81
- const model = createDuoModel(new THREE.Texture(), new THREE.Texture());
82
- model.setAngle(angle);
83
- for (const name of ['side', 'volumeUp', 'volumeDown']) {
84
- const button = model.device.getObjectByName(`button-${name}`)!;
85
- const point = button.getWorldPosition(new THREE.Vector3());
86
- const normal = new THREE.Vector3(
87
- ...((name === 'side' ? [1, 0, 0] : [0, 1, 0]) as [number, number, number]),
88
- ).transformDirection(button.matrixWorld);
89
- const ray = new THREE.Raycaster(point.clone().addScaledVector(normal, 20), normal.negate());
90
- expect(model.pick(ray)?.object.userData['button']).toBe(name);
91
- }
92
- model.dispose();
93
- });
94
- it('does not steal screen touches near the side button', () => {
95
- const model = createDuoModel(new THREE.Texture(), new THREE.Texture());
96
- const ray = new THREE.Raycaster(new THREE.Vector3(76, 21, 80), new THREE.Vector3(0, 0, -1));
97
- expect(model.pick(ray)?.object.userData['display']).toBe('inner');
98
- model.dispose();
99
- });
100
- it('does not activate an occluded button through the chassis', () => {
101
- const model = createDuoModel(new THREE.Texture(), new THREE.Texture());
102
- const ray = new THREE.Raycaster(new THREE.Vector3(-100, 21, -2.6), new THREE.Vector3(1, 0, 0));
103
- expect(model.pick(ray)?.object.userData['button']).toBeUndefined();
104
- model.dispose();
105
- });
106
- });
107
-
108
- describe('Prepared Duo appearance', () => {
109
- afterEach(() => vi.restoreAllMocks());
110
-
111
- function appearance() {
112
- const scene = new THREE.Group();
113
- const left = new THREE.Group();
114
- left.name = 'folding-half';
115
- const right = new THREE.Group();
116
- right.name = 'stationary-half';
117
- scene.add(left, right);
118
- const material = new THREE.MeshStandardMaterial();
119
- for (const [name, x, y, w, h] of [
120
- ['side', 8.24, 2.1, 0.1, 1.8],
121
- ['volumeUp', 5.6, 5.89, 1.09, 0.1],
122
- ['volumeDown', 4.25, 5.89, 1.09, 0.1],
123
- ] as const) {
124
- const button = new THREE.Mesh(new THREE.BoxGeometry(w, h, 0.25).translate(x, y, -0.26), material);
125
- button.userData['button'] = name;
126
- right.add(button);
127
- }
128
- return scene;
129
- }
130
-
131
- it.each([0, 110, 180])('keeps button coordinates local when an appearance loads at %s°', async (angle) => {
132
- const scene = appearance();
133
- vi.spyOn(GLTFLoader.prototype, 'loadAsync').mockResolvedValue({ scene } as unknown as GLTF);
134
- const model = createDuoModel(new THREE.Texture(), new THREE.Texture());
135
- model.setAngle(angle);
136
- model.device.rotation.set(0.2, 1.1, 0.5);
137
- model.device.position.set(18, -12, -30);
138
- model.device.updateMatrixWorld(true);
139
- await model.loadAppearance('/approved-model.glb');
140
- expect(model.device.getObjectByName('button-side')!.position.x).toBeCloseTo(82.4);
141
- expect(model.device.getObjectByName('button-volumeUp')!.position.y).toBeCloseTo(58.9);
142
- expect(rayAt(model, angle === 0 ? 'cover-display' : 'inner-display-1')!.object.userData['display']).toBe(
143
- angle === 0 ? 'outer' : 'inner',
144
- );
145
- model.dispose();
146
- });
147
-
148
- it('keeps buttons visible and clickable from the closed front without detached controls', async () => {
149
- vi.spyOn(GLTFLoader.prototype, 'loadAsync').mockResolvedValue({ scene: appearance() } as unknown as GLTF);
150
- const model = createDuoModel(new THREE.Texture(), new THREE.Texture());
151
- await model.loadAppearance('/approved-model.glb');
152
- model.setAngle(0);
153
- model.device.rotation.y = Math.PI / 2;
154
- model.animateButtons(1);
155
- model.device.updateMatrixWorld(true);
156
- for (const name of ['side', 'volumeUp', 'volumeDown']) {
157
- const position = model.device.getObjectByName(`button-${name}`)!.getWorldPosition(new THREE.Vector3());
158
- const hit = model.pick(
159
- new THREE.Raycaster(new THREE.Vector3(position.x, position.y, 300), new THREE.Vector3(0, 0, -1)),
160
- );
161
- expect(hit?.object.userData['button']).toBe(name);
162
- }
163
- model.dispose();
164
- });
165
-
166
- it('extends the physical mesh on hover and retracts after release', () => {
167
- const model = createDuoModel(new THREE.Texture(), new THREE.Texture());
168
- const button = model.device.getObjectByName('button-volumeUp')!;
169
- model.animateButtons(1);
170
- const rest = button.position.y;
171
- model.highlightButton('volumeUp');
172
- model.animateButtons(1);
173
- expect(button.position.y - rest).toBeCloseTo(0.34);
174
- model.highlightButton('volumeUp', true);
175
- model.animateButtons(1);
176
- expect(button.position.y).toBeLessThan(rest);
177
- model.highlightButton();
178
- model.animateButtons(1);
179
- expect(button.position.y).toBeCloseTo(rest);
180
- model.dispose();
181
- });
182
-
183
- it('does not change camera framing when a button rises on hover', () => {
184
- const model = createDuoModel(new THREE.Texture(), new THREE.Texture());
185
- model.animateButtons(1);
186
- model.device.updateMatrixWorld(true);
187
- const before = model.getBounds(new THREE.Box3()).clone();
188
- model.highlightButton('volumeUp');
189
- model.animateButtons(1);
190
- model.device.updateMatrixWorld(true);
191
- expect(model.getBounds(new THREE.Box3()).equals(before)).toBe(true);
192
- model.dispose();
193
- });
194
-
195
- it.each([false, true])(
196
- 'fits live cover video to the imported aperture after material deduplication (%s)',
197
- async (deduplicated) => {
198
- const scene = appearance();
199
- const material = new THREE.MeshBasicMaterial();
200
- material.name = deduplicated ? 'inner-screen' : 'cover-screen';
201
- const geometry = new THREE.PlaneGeometry(7.739, 11.251).rotateY(Math.PI).translate(-4.104, 0, -0.524);
202
- const cover = new THREE.Mesh(geometry, material);
203
- if (deduplicated) cover.userData['sourceDisplay'] = 'outer';
204
- scene.getObjectByName('folding-half')!.add(cover);
205
- vi.spyOn(GLTFLoader.prototype, 'loadAsync').mockResolvedValue({ scene } as unknown as GLTF);
206
- const model = createDuoModel(new THREE.Texture(), new THREE.Texture());
207
- await model.loadAppearance('/approved-model.glb');
208
- model.setAngle(0);
209
- model.device.rotation.y = Math.PI / 2;
210
- model.device.updateMatrixWorld(true);
211
- const panel = model.device.getObjectByName('cover-display') as THREE.Mesh;
212
- expect(panel.geometry.boundingBox!.getSize(new THREE.Vector3()).x).toBeCloseTo(77.39);
213
- for (const [x, y] of [
214
- [-41.04, 0],
215
- [-70, 40],
216
- ]) {
217
- const world = panel.localToWorld(new THREE.Vector3(x, y, -5.24));
218
- const ray = new THREE.Raycaster(
219
- new THREE.Vector3(world.x, world.y, 300),
220
- new THREE.Vector3(0, 0, -1),
221
- );
222
- const hit = model.pick(ray)!;
223
- expect(hit.object.userData['display']).toBe('outer');
224
- expect(hit.uv!.x).toBeCloseTo((-2.345 - x!) / 77.39, 3);
225
- expect(panelTouch(hit.uv!.x, hit.uv!.y).y).toBeCloseTo((56.255 - y!) / 112.51, 3);
226
- }
227
- model.dispose();
228
- },
229
- );
230
-
231
- it('retains the functional frame if a replacement has no hardware metadata', async () => {
232
- const scene = appearance();
233
- scene.traverse((object) => {
234
- delete object.userData['button'];
235
- });
236
- vi.spyOn(GLTFLoader.prototype, 'loadAsync').mockResolvedValue({ scene } as unknown as GLTF);
237
- const model = createDuoModel(new THREE.Texture(), new THREE.Texture());
238
- await expect(model.loadAppearance('/invalid.glb')).rejects.toThrow('button meshes');
239
- expect(model.device.getObjectByName('button-side')).toBeDefined();
240
- expect(rayAt(model, 'inner-display-1')!.object.userData['display']).toBe('inner');
241
- model.dispose();
242
- });
243
- });