@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.
- package/README.md +18 -3
- package/dist/{GLTFLoader-DW6wpvyR.mjs → GLTFLoader-Cami_vza.mjs} +1 -1
- package/dist/{GLTFLoader-BKnsBFsj.js → GLTFLoader-DeCLxW_q.js} +1 -1
- package/dist/components/duo-fold-motion.d.ts +1 -1
- package/dist/components/duo-loading.d.ts +8 -0
- package/dist/components/duo-view.d.ts +6 -1
- package/dist/{duo-frame-BzyYljix.mjs → duo-frame-Cmw4p2uN.mjs} +414 -414
- package/dist/{duo-frame-DjfA4gG-.js → duo-frame-iMtrvx_a.js} +18 -18
- package/dist/duo-view-BAh-w2Kz.js +1 -0
- package/dist/duo-view-D9Ae00rj.mjs +512 -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-B2jZpf3c.js +1 -0
- package/dist/remote-control-DiddxS8l.mjs +2883 -0
- package/package.json +5 -1
- package/dist/demo.d.ts +0 -1
- package/dist/duo-view-COZg8ieG.js +0 -1
- package/dist/duo-view-o1yxIzxu.mjs +0 -654
- package/dist/remote-control-CQjSEVDF.mjs +0 -2673
- package/dist/remote-control-DVRXfCR2.js +0 -1
- package/index.html +0 -199
- package/src/assets/Apple_logo_white.svg +0 -4
- package/src/assets/android_boot.gif +0 -0
- package/src/assets/android_boot.webp +0 -0
- package/src/assets/iphone16pro_black.webp +0 -0
- package/src/assets/iphone16pro_black_bg.webp +0 -0
- package/src/assets/iphone16pro_black_landscape.webp +0 -0
- package/src/assets/iphone16pro_black_landscape_bg.webp +0 -0
- package/src/assets/pixel9_black.webp +0 -0
- package/src/assets/pixel9_black_landscape.webp +0 -0
- package/src/assets/pixel_tablet_landscape.webp +0 -0
- package/src/assets/pixel_tablet_portrait.webp +0 -0
- package/src/components/device-install/index.ts +0 -1
- package/src/components/duo-button-hinge.test.tsx +0 -138
- package/src/components/duo-button-hinge.ts +0 -75
- package/src/components/duo-controls.tsx +0 -142
- package/src/components/duo-flat-frame.tsx +0 -145
- package/src/components/duo-flat-geometry.test.ts +0 -63
- package/src/components/duo-flat-geometry.ts +0 -97
- package/src/components/duo-fold-motion.test.tsx +0 -245
- package/src/components/duo-fold-motion.tsx +0 -192
- package/src/components/duo-fold-renderer.test.ts +0 -29
- package/src/components/duo-fold-renderer.ts +0 -397
- package/src/components/duo-fold-timing.test.ts +0 -44
- package/src/components/duo-fold-timing.ts +0 -9
- package/src/components/duo-frame.tsx +0 -579
- package/src/components/duo-hardware.test.ts +0 -61
- package/src/components/duo-hardware.ts +0 -61
- package/src/components/duo-model.test.ts +0 -243
- package/src/components/duo-model.ts +0 -607
- package/src/components/duo-screen-handoff.ts +0 -52
- package/src/components/duo-view.test.tsx +0 -104
- package/src/components/duo-view.tsx +0 -315
- package/src/components/inspect-overlay.css +0 -223
- package/src/components/inspect-overlay.tsx +0 -437
- package/src/components/remote-control-framed.tsx +0 -14
- package/src/components/remote-control.css +0 -441
- package/src/components/remote-control.test.ts +0 -34
- package/src/components/remote-control.tsx +0 -3989
- package/src/core/ax-fetcher.test.ts +0 -418
- package/src/core/ax-fetcher.ts +0 -387
- package/src/core/ax-tree.test.ts +0 -491
- package/src/core/ax-tree.ts +0 -416
- package/src/core/camera.test.ts +0 -155
- package/src/core/camera.ts +0 -133
- package/src/core/constants.ts +0 -344
- package/src/core/duo.test.ts +0 -142
- package/src/core/duo.ts +0 -156
- package/src/core/webrtc-messages.ts +0 -148
- package/src/demo.tsx +0 -283
- package/src/frame-assets.ts +0 -22
- package/src/hooks/index.ts +0 -1
- package/src/index.ts +0 -29
- package/src/lite.test.ts +0 -30
- package/src/lite.ts +0 -30
- package/src/vite-env.d.ts +0 -1
- package/tsconfig.json +0 -26
- package/tsconfig.node.json +0 -26
- package/vite.config.ts +0 -35
- package/vitest.config.ts +0 -24
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
-
import { DuoFlatFrame } from './duo-flat-frame';
|
|
3
|
-
import { captureDuoFrame, waitForDuoFrame } from './duo-screen-handoff';
|
|
4
|
-
import { flatFrameGeometry } from './duo-flat-geometry';
|
|
5
|
-
import type { DuoOrientation } from '../core/duo';
|
|
6
|
-
|
|
7
|
-
type Frame = ReturnType<typeof flatFrameGeometry>;
|
|
8
|
-
type Pose = { inner: boolean; frame: Frame };
|
|
9
|
-
export type FoldMotion = {
|
|
10
|
-
from: Pose;
|
|
11
|
-
to: Pose;
|
|
12
|
-
before: HTMLCanvasElement;
|
|
13
|
-
after?: HTMLCanvasElement;
|
|
14
|
-
ready: boolean;
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
/** Animate confirmed display changes; resizing, rotation and reduced motion cancel the transition. */
|
|
18
|
-
export function useDuoFoldMotion(
|
|
19
|
-
video: React.RefObject<HTMLVideoElement | null>,
|
|
20
|
-
inner: boolean,
|
|
21
|
-
frame: Frame,
|
|
22
|
-
orientation?: DuoOrientation,
|
|
23
|
-
) {
|
|
24
|
-
const previous = useRef<Pose | undefined>(undefined);
|
|
25
|
-
const previousOrientation = useRef(orientation);
|
|
26
|
-
const current = useRef<Pose>({ inner, frame });
|
|
27
|
-
current.current = { inner, frame };
|
|
28
|
-
const [motion, setMotion] = useState<FoldMotion>();
|
|
29
|
-
const prepared = useRef<FoldMotion | undefined>(undefined);
|
|
30
|
-
const commandTimeout = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
31
|
-
const finish = useCallback(() => {
|
|
32
|
-
clearTimeout(commandTimeout.current);
|
|
33
|
-
prepared.current = undefined;
|
|
34
|
-
setMotion(undefined);
|
|
35
|
-
}, []);
|
|
36
|
-
const prepare = (targetInner: boolean) => {
|
|
37
|
-
if (targetInner === inner) {
|
|
38
|
-
finish();
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return;
|
|
42
|
-
if (prepared.current) return;
|
|
43
|
-
const before = captureDuoFrame(video.current);
|
|
44
|
-
if (!before) return;
|
|
45
|
-
const from = current.current;
|
|
46
|
-
prepared.current = { from, to: from, before, ready: false };
|
|
47
|
-
setMotion(prepared.current);
|
|
48
|
-
commandTimeout.current = setTimeout(finish, 5000);
|
|
49
|
-
};
|
|
50
|
-
useEffect(() => () => clearTimeout(commandTimeout.current), []);
|
|
51
|
-
useLayoutEffect(() => {
|
|
52
|
-
const from = previous.current;
|
|
53
|
-
const to = { inner, frame };
|
|
54
|
-
previous.current = to;
|
|
55
|
-
const rotated = previousOrientation.current !== orientation;
|
|
56
|
-
previousOrientation.current = orientation;
|
|
57
|
-
if (!from) return;
|
|
58
|
-
const resized =
|
|
59
|
-
Math.abs(from.frame.rect.min.x + from.frame.rect.max.x - frame.rect.min.x - frame.rect.max.x) > 0.5 ||
|
|
60
|
-
Math.abs(from.frame.rect.min.y + from.frame.rect.max.y - frame.rect.min.y - frame.rect.max.y) > 0.5;
|
|
61
|
-
if (resized || rotated) {
|
|
62
|
-
finish();
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
if (from.inner === inner) {
|
|
66
|
-
// Display metadata can arrive mid-animation; it does not rotate the physical device.
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
setMotion(undefined);
|
|
70
|
-
if (!from.frame.scale || !frame.scale) return;
|
|
71
|
-
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return;
|
|
72
|
-
const before = prepared.current?.before ?? captureDuoFrame(video.current);
|
|
73
|
-
const origin = prepared.current?.from ?? from;
|
|
74
|
-
clearTimeout(commandTimeout.current);
|
|
75
|
-
prepared.current = undefined;
|
|
76
|
-
if (before) {
|
|
77
|
-
// Preserve the device's pose while iOS updates the incoming display's orientation.
|
|
78
|
-
to.frame = flatFrameGeometry(
|
|
79
|
-
inner,
|
|
80
|
-
(origin.frame.turns + (inner ? 1 : 0)) % 4,
|
|
81
|
-
frame.rect.min.x + frame.rect.max.x,
|
|
82
|
-
frame.rect.min.y + frame.rect.max.y,
|
|
83
|
-
);
|
|
84
|
-
setMotion({ from: origin, to, before, ready: false });
|
|
85
|
-
}
|
|
86
|
-
}, [inner, frame.scale, frame.turns, frame.rect.min.x, frame.rect.min.y, orientation]);
|
|
87
|
-
|
|
88
|
-
useEffect(() => {
|
|
89
|
-
if (!motion || motion.from.inner === inner) return;
|
|
90
|
-
const stop = waitForDuoFrame(video.current, (after) => {
|
|
91
|
-
if (after) setMotion((active) => active && { ...active, after, ready: true });
|
|
92
|
-
else finish();
|
|
93
|
-
});
|
|
94
|
-
const preference = window.matchMedia?.('(prefers-reduced-motion: reduce)');
|
|
95
|
-
const reduce = () => {
|
|
96
|
-
if (preference?.matches) setMotion(undefined);
|
|
97
|
-
};
|
|
98
|
-
preference?.addEventListener('change', reduce);
|
|
99
|
-
return () => {
|
|
100
|
-
stop();
|
|
101
|
-
preference?.removeEventListener('change', reduce);
|
|
102
|
-
};
|
|
103
|
-
}, [motion?.from, inner]);
|
|
104
|
-
return { motion, finish, prepare };
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function Still({ pose, image, scale }: { pose: Pose; image?: HTMLCanvasElement; scale: number }) {
|
|
108
|
-
const { bodyWidth, bodyHeight, screenWidth, screenHeight } = pose.frame;
|
|
109
|
-
const width = screenWidth * scale;
|
|
110
|
-
const height = screenHeight * scale;
|
|
111
|
-
return (
|
|
112
|
-
<div className="rc-duo-fold-still" style={{ width: bodyWidth * scale, height: bodyHeight * scale }}>
|
|
113
|
-
<DuoFlatFrame inner={pose.inner} width={bodyWidth} height={bodyHeight} />
|
|
114
|
-
<div
|
|
115
|
-
className="rc-duo-flat-screen"
|
|
116
|
-
style={{
|
|
117
|
-
width,
|
|
118
|
-
height,
|
|
119
|
-
left: ((bodyWidth - screenWidth) * scale) / 2,
|
|
120
|
-
top: ((bodyHeight - screenHeight) * scale) / 2,
|
|
121
|
-
borderRadius:
|
|
122
|
-
pose.inner ? 5.3 * scale : [0.8, 6.6, 6.6, 0.8].map((r) => r * scale + 'px').join(' '),
|
|
123
|
-
}}
|
|
124
|
-
>
|
|
125
|
-
{image && (
|
|
126
|
-
<canvas
|
|
127
|
-
ref={(element) => {
|
|
128
|
-
if (element) {
|
|
129
|
-
element.width = image.width;
|
|
130
|
-
element.height = image.height;
|
|
131
|
-
element.getContext('2d')?.drawImage(image, 0, 0);
|
|
132
|
-
}
|
|
133
|
-
}}
|
|
134
|
-
style={{
|
|
135
|
-
width: pose.inner ? height : width,
|
|
136
|
-
height: pose.inner ? width : height,
|
|
137
|
-
transform: `translate(-50%, -50%) rotate(${pose.inner ? 90 : 0}deg)`,
|
|
138
|
-
}}
|
|
139
|
-
/>
|
|
140
|
-
)}
|
|
141
|
-
</div>
|
|
142
|
-
</div>
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** Load the tiny folding renderer only for a confirmed display change. */
|
|
147
|
-
export function DuoFoldMotion({ motion, onFinish }: { motion: FoldMotion; onFinish: () => void }) {
|
|
148
|
-
const canvas = useRef<HTMLCanvasElement>(null);
|
|
149
|
-
const [rendering, setRendering] = useState(false);
|
|
150
|
-
useEffect(() => {
|
|
151
|
-
setRendering(false);
|
|
152
|
-
if (!motion.ready) return;
|
|
153
|
-
let cancelled = false;
|
|
154
|
-
let dispose: (() => void) | undefined;
|
|
155
|
-
void import('./duo-fold-renderer')
|
|
156
|
-
.then(({ renderFold }) => {
|
|
157
|
-
if (cancelled || !canvas.current) return;
|
|
158
|
-
dispose = renderFold(canvas.current, motion, onFinish);
|
|
159
|
-
setRendering(true);
|
|
160
|
-
})
|
|
161
|
-
.catch(() => {
|
|
162
|
-
if (!cancelled) onFinish();
|
|
163
|
-
});
|
|
164
|
-
return () => {
|
|
165
|
-
cancelled = true;
|
|
166
|
-
dispose?.();
|
|
167
|
-
};
|
|
168
|
-
}, [motion.ready, motion.from, onFinish]);
|
|
169
|
-
return (
|
|
170
|
-
<>
|
|
171
|
-
{!rendering && (
|
|
172
|
-
<div
|
|
173
|
-
className="rc-duo-fold-hold"
|
|
174
|
-
aria-hidden="true"
|
|
175
|
-
style={{
|
|
176
|
-
width: motion.from.frame.bodyWidth * motion.from.frame.scale,
|
|
177
|
-
height: motion.from.frame.bodyHeight * motion.from.frame.scale,
|
|
178
|
-
transform: `translate(-50%, -50%) rotate(${motion.from.frame.turns * 90}deg)`,
|
|
179
|
-
}}
|
|
180
|
-
>
|
|
181
|
-
<Still pose={motion.from} image={motion.before} scale={motion.from.frame.scale} />
|
|
182
|
-
</div>
|
|
183
|
-
)}
|
|
184
|
-
<canvas
|
|
185
|
-
ref={canvas}
|
|
186
|
-
className="rc-duo-fold-canvas"
|
|
187
|
-
aria-hidden="true"
|
|
188
|
-
style={{ visibility: rendering ? 'visible' : 'hidden' }}
|
|
189
|
-
/>
|
|
190
|
-
</>
|
|
191
|
-
);
|
|
192
|
-
}
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { expect, it } from 'vitest';
|
|
2
|
-
import { foldMeshes, foldPose } from './duo-fold-renderer';
|
|
3
|
-
it('keeps the folding model under 2000 triangles and uses bounded screen UVs', () => {
|
|
4
|
-
const meshes = foldMeshes();
|
|
5
|
-
expect(meshes.reduce((n, m) => n + m.vertices.length / 24, 0)).toBeLessThan(2000);
|
|
6
|
-
expect(meshes).toHaveLength(15);
|
|
7
|
-
for (const mesh of meshes) {
|
|
8
|
-
expect([...mesh.vertices].every(Number.isFinite)).toBe(true);
|
|
9
|
-
if (mesh.kind === 'inner' || mesh.kind === 'outer')
|
|
10
|
-
for (let i = 0; i < mesh.vertices.length; i += 8) {
|
|
11
|
-
expect(mesh.vertices[i + 6]).toBeGreaterThanOrEqual(0);
|
|
12
|
-
expect(mesh.vertices[i + 6]).toBeLessThanOrEqual(1);
|
|
13
|
-
expect(mesh.vertices[i + 7]).toBeGreaterThanOrEqual(0);
|
|
14
|
-
expect(mesh.vertices[i + 7]).toBeLessThanOrEqual(1);
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
});
|
|
18
|
-
it('keeps the hinge attached, closes fully and settles both leaves flat', () => {
|
|
19
|
-
expect(foldPose(0)).toEqual({ left: Math.PI, right: 0, shift: -41.15 });
|
|
20
|
-
expect(foldPose(1)).toEqual({ left: 0, right: 0, shift: 0 });
|
|
21
|
-
expect(foldPose(0.48).left).toBeGreaterThan(0);
|
|
22
|
-
expect(foldPose(0.48).right).toBeLessThan(0);
|
|
23
|
-
let previous = Math.PI;
|
|
24
|
-
for (let i = 0; i <= 100; i++) {
|
|
25
|
-
const pose = foldPose(i / 100);
|
|
26
|
-
expect(pose.left).toBeLessThanOrEqual(previous);
|
|
27
|
-
previous = pose.left;
|
|
28
|
-
}
|
|
29
|
-
});
|
|
@@ -1,397 +0,0 @@
|
|
|
1
|
-
/** A small WebGL model used only while the otherwise-flat device folds. */
|
|
2
|
-
import type { FoldMotion } from './duo-fold-motion';
|
|
3
|
-
|
|
4
|
-
export const FOLD_DURATION = 1200;
|
|
5
|
-
const HALF = 82.3;
|
|
6
|
-
const HEIGHT = 117.8;
|
|
7
|
-
const DEPTH = 5.2;
|
|
8
|
-
const PIVOT = 0.6;
|
|
9
|
-
type Kind = 'metal' | 'seal' | 'inner' | 'outer' | 'back';
|
|
10
|
-
export type FoldMesh = { side: -1 | 0 | 1; kind: Kind; vertices: Float32Array };
|
|
11
|
-
type Point = { x: number; y: number; nx: number; ny: number };
|
|
12
|
-
|
|
13
|
-
function perimeter(w: number, h: number, left: number, right: number, inset = 0): Point[] {
|
|
14
|
-
const points: Point[] = [];
|
|
15
|
-
for (const [cx, cy, r, start] of [
|
|
16
|
-
[w / 2 - right, h / 2 - right, right, 0],
|
|
17
|
-
[-w / 2 + left, h / 2 - left, left, Math.PI / 2],
|
|
18
|
-
[-w / 2 + left, -h / 2 + left, left, Math.PI],
|
|
19
|
-
[w / 2 - right, -h / 2 + right, right, Math.PI * 1.5],
|
|
20
|
-
]) {
|
|
21
|
-
for (let j = 0; j <= 8; j++) {
|
|
22
|
-
const a = start! + (j * Math.PI) / 16;
|
|
23
|
-
const nx = Math.cos(a),
|
|
24
|
-
ny = Math.sin(a);
|
|
25
|
-
points.push({ x: cx! + (r! - inset) * nx, y: cy! + (r! - inset) * ny, nx, ny });
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
return points;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Low-segment beveled rails, three caps, glass seals and three screen polygons. */
|
|
32
|
-
export function foldMeshes(): FoldMesh[] {
|
|
33
|
-
const meshes: FoldMesh[] = [];
|
|
34
|
-
const add = (side: -1 | 0 | 1, kind: Kind, vertices: number[]) =>
|
|
35
|
-
meshes.push({ side, kind, vertices: new Float32Array(vertices) });
|
|
36
|
-
const plate = (
|
|
37
|
-
side: -1 | 1,
|
|
38
|
-
kind: Kind,
|
|
39
|
-
cx: number,
|
|
40
|
-
cy: number,
|
|
41
|
-
w: number,
|
|
42
|
-
h: number,
|
|
43
|
-
front: number,
|
|
44
|
-
depth: number,
|
|
45
|
-
left: number,
|
|
46
|
-
right: number,
|
|
47
|
-
) => {
|
|
48
|
-
const vertices: number[] = [];
|
|
49
|
-
const append = (p: Point, z: number, nz: number, radial: number) =>
|
|
50
|
-
vertices.push(p.x + cx, p.y + cy, z, p.nx * radial, p.ny * radial, nz, 0, 0);
|
|
51
|
-
const bevel = Math.min(0.32, depth / 3, left / 2, right / 2);
|
|
52
|
-
const rings = [
|
|
53
|
-
[bevel, front, 0.707, 0.707],
|
|
54
|
-
[0, front - bevel, 0, 1],
|
|
55
|
-
[0, front - depth + bevel, 0, 1],
|
|
56
|
-
[bevel, front - depth, -0.707, 0.707],
|
|
57
|
-
];
|
|
58
|
-
for (let k = 0; k < rings.length - 1; k++) {
|
|
59
|
-
const a = rings[k]!,
|
|
60
|
-
b = rings[k + 1]!;
|
|
61
|
-
const pa = perimeter(w, h, left, right, a[0]);
|
|
62
|
-
const pb = perimeter(w, h, left, right, b[0]);
|
|
63
|
-
for (let i = 0; i < pa.length; i++) {
|
|
64
|
-
const n = (i + 1) % pa.length;
|
|
65
|
-
for (const [p, ring] of [
|
|
66
|
-
[pa[i]!, a],
|
|
67
|
-
[pb[i]!, b],
|
|
68
|
-
[pa[n]!, a],
|
|
69
|
-
[pa[n]!, a],
|
|
70
|
-
[pb[i]!, b],
|
|
71
|
-
[pb[n]!, b],
|
|
72
|
-
] as const)
|
|
73
|
-
append(p, ring[1]!, ring[2]!, ring[3]!);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
const rim = perimeter(w, h, left, right, bevel);
|
|
77
|
-
for (const [z, nz] of [
|
|
78
|
-
[front, 1],
|
|
79
|
-
[front - depth, -1],
|
|
80
|
-
]) {
|
|
81
|
-
for (let i = 0; i < rim.length; i++) {
|
|
82
|
-
vertices.push(cx, cy, z!, 0, 0, nz!, 0, 0);
|
|
83
|
-
append(rim[i]!, z!, nz!, 0);
|
|
84
|
-
append(rim[(i + 1) % rim.length]!, z!, nz!, 0);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
add(side, kind, vertices);
|
|
88
|
-
};
|
|
89
|
-
const face = (
|
|
90
|
-
side: -1 | 1,
|
|
91
|
-
kind: Kind,
|
|
92
|
-
cx: number,
|
|
93
|
-
w: number,
|
|
94
|
-
h: number,
|
|
95
|
-
z: number,
|
|
96
|
-
left: number,
|
|
97
|
-
right: number,
|
|
98
|
-
) => {
|
|
99
|
-
const vertices: number[] = [];
|
|
100
|
-
const rim = perimeter(w, h, left, right);
|
|
101
|
-
const append = (x: number, y: number) => {
|
|
102
|
-
const px = x + cx;
|
|
103
|
-
let u = 0,
|
|
104
|
-
v = 0;
|
|
105
|
-
if (kind === 'inner') {
|
|
106
|
-
u = 0.5 - y / 110.8;
|
|
107
|
-
v = 0.5 + px / 157.5;
|
|
108
|
-
}
|
|
109
|
-
if (kind === 'outer') {
|
|
110
|
-
u = 0.5 - x / w;
|
|
111
|
-
v = 0.5 + y / h;
|
|
112
|
-
}
|
|
113
|
-
vertices.push(px, y, z, 0, 0, kind === 'outer' || kind === 'back' ? -1 : 1, u, v);
|
|
114
|
-
};
|
|
115
|
-
for (let i = 0; i < rim.length; i++) {
|
|
116
|
-
append(0, 0);
|
|
117
|
-
append(rim[i]!.x, rim[i]!.y);
|
|
118
|
-
append(rim[(i + 1) % rim.length]!.x, rim[(i + 1) % rim.length]!.y);
|
|
119
|
-
}
|
|
120
|
-
add(side, kind, vertices);
|
|
121
|
-
};
|
|
122
|
-
for (const side of [-1, 1] as const) {
|
|
123
|
-
const left = side === -1 ? 8.4 : 1.25;
|
|
124
|
-
const right = side === 1 ? 8.4 : 1.25;
|
|
125
|
-
plate(side, 'metal', (side * HALF) / 2, 0, HALF, HEIGHT, 0, DEPTH, left, right);
|
|
126
|
-
face(
|
|
127
|
-
side,
|
|
128
|
-
'seal',
|
|
129
|
-
(side * HALF) / 2,
|
|
130
|
-
HALF - 2.24,
|
|
131
|
-
HEIGHT - 2.24,
|
|
132
|
-
0.12,
|
|
133
|
-
side === -1 ? 7.15 : 0.2,
|
|
134
|
-
side === 1 ? 7.15 : 0.2,
|
|
135
|
-
);
|
|
136
|
-
face(
|
|
137
|
-
side,
|
|
138
|
-
'inner',
|
|
139
|
-
side * (157.5 / 4 + 0.55),
|
|
140
|
-
157.5 / 2 - 1.1,
|
|
141
|
-
110.8,
|
|
142
|
-
0.24,
|
|
143
|
-
side === -1 ? 5.3 : 0,
|
|
144
|
-
side === 1 ? 5.3 : 0,
|
|
145
|
-
);
|
|
146
|
-
}
|
|
147
|
-
for (const [kind, h, z] of [
|
|
148
|
-
['metal', HEIGHT, -0.12],
|
|
149
|
-
['seal', HEIGHT - 2.24, 0.12],
|
|
150
|
-
['inner', 110.8, 0.24],
|
|
151
|
-
] as const) {
|
|
152
|
-
const hinge: number[] = [];
|
|
153
|
-
const point = (x: number, y: number) => hinge.push(x, y, z, 0, 0, 1, 0.5 - y / 110.8, 0.5 + x / 157.5);
|
|
154
|
-
for (let i = 0; i < 6; i++) {
|
|
155
|
-
const a = -1.1 + (i * 2.2) / 6,
|
|
156
|
-
b = a + 2.2 / 6;
|
|
157
|
-
for (const [x, y] of [
|
|
158
|
-
[a, -h / 2],
|
|
159
|
-
[b, -h / 2],
|
|
160
|
-
[a, h / 2],
|
|
161
|
-
[a, h / 2],
|
|
162
|
-
[b, -h / 2],
|
|
163
|
-
[b, h / 2],
|
|
164
|
-
])
|
|
165
|
-
point(x!, y!);
|
|
166
|
-
}
|
|
167
|
-
add(0, kind, hinge);
|
|
168
|
-
}
|
|
169
|
-
face(-1, 'seal', -HALF / 2, HALF - 2.24, HEIGHT - 2.24, -DEPTH - 0.12, 7.15, 0.65);
|
|
170
|
-
face(-1, 'outer', -HALF / 2, 77.1, 112.2, -DEPTH - 0.24, 6.6, 0.8);
|
|
171
|
-
face(1, 'back', HALF / 2, HALF - 1, HEIGHT - 1, -DEPTH - 0.12, 0.8, 8);
|
|
172
|
-
for (const x of [HALF - 37, HALF - 25])
|
|
173
|
-
plate(1, 'metal', x, HEIGHT / 2 + 0.4, 9.5, 0.8, -1.4, 2, 0.35, 0.35);
|
|
174
|
-
plate(1, 'metal', HALF + 0.4, 21, 0.8, 13, -1.4, 2, 0.35, 0.35);
|
|
175
|
-
return meshes;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** Both leaves turn toward the viewer before settling flat, following the reference's shallow V. */
|
|
179
|
-
export function foldPose(progress: number) {
|
|
180
|
-
const keys = [
|
|
181
|
-
[0, 180, 0, -0.5],
|
|
182
|
-
[0.12, 176, -4, -0.46],
|
|
183
|
-
[0.24, 150, -8, -0.4],
|
|
184
|
-
[0.35, 90, -12, -0.32],
|
|
185
|
-
[0.48, 48, -22, -0.14],
|
|
186
|
-
[0.62, 30, -20, -0.04],
|
|
187
|
-
[0.8, 14, -10, 0],
|
|
188
|
-
[1, 0, 0, 0],
|
|
189
|
-
];
|
|
190
|
-
const p = Math.max(0, Math.min(1, progress));
|
|
191
|
-
const i = Math.max(0, keys.findIndex((key) => key[0]! >= p) - 1);
|
|
192
|
-
const a = keys[i]!,
|
|
193
|
-
b = keys[i + 1]!;
|
|
194
|
-
const t = Math.max(0, Math.min(1, (p - a[0]!) / (b[0]! - a[0]!)));
|
|
195
|
-
const blend = (n: number) => a[n]! + (b[n]! - a[n]!) * t;
|
|
196
|
-
return { left: (blend(1) * Math.PI) / 180, right: (blend(2) * Math.PI) / 180, shift: blend(3) * HALF };
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
const vertexShader = `
|
|
200
|
-
attribute vec3 position;
|
|
201
|
-
attribute vec3 normal;
|
|
202
|
-
attribute vec2 uv;
|
|
203
|
-
uniform vec2 viewport;
|
|
204
|
-
uniform float scale;
|
|
205
|
-
uniform vec2 leaf;
|
|
206
|
-
uniform vec2 roll;
|
|
207
|
-
uniform vec4 hinge;
|
|
208
|
-
uniform int bend;
|
|
209
|
-
uniform float shift;
|
|
210
|
-
uniform float level;
|
|
211
|
-
varying vec3 worldPosition;
|
|
212
|
-
varying vec3 worldNormal;
|
|
213
|
-
varying vec2 texcoord;
|
|
214
|
-
void main() {
|
|
215
|
-
vec3 p = position - vec3(0.,0.,${PIVOT});
|
|
216
|
-
p = vec3(leaf.x*p.x + leaf.y*p.z, p.y, -leaf.y*p.x + leaf.x*p.z);
|
|
217
|
-
if (bend==1) {
|
|
218
|
-
vec2 a=vec2(-1.1*hinge.x+(position.z-${PIVOT})*hinge.y,1.1*hinge.y+(position.z-${PIVOT})*hinge.x);
|
|
219
|
-
vec2 b=vec2(1.1*hinge.z+(position.z-${PIVOT})*hinge.w,-1.1*hinge.w+(position.z-${PIVOT})*hinge.z);
|
|
220
|
-
p.xz=mix(a,b,(position.x+1.1)/2.2);
|
|
221
|
-
}
|
|
222
|
-
p += vec3(shift,0.,${PIVOT});
|
|
223
|
-
vec3 n = vec3(leaf.x*normal.x + leaf.y*normal.z, normal.y, -leaf.y*normal.x + leaf.x*normal.z);
|
|
224
|
-
p.xy = vec2(roll.x*p.x-roll.y*p.y, roll.y*p.x+roll.x*p.y);
|
|
225
|
-
n.xy = vec2(roll.x*n.x-roll.y*n.y, roll.y*n.x+roll.x*n.y);
|
|
226
|
-
p.z -= level;
|
|
227
|
-
worldPosition=p; worldNormal=n; texcoord=uv;
|
|
228
|
-
float w = 1. - p.z/600.;
|
|
229
|
-
gl_Position=vec4(p.xy*scale*2./viewport, -p.z/180., w);
|
|
230
|
-
}`;
|
|
231
|
-
const fragmentShader = `
|
|
232
|
-
precision highp float;
|
|
233
|
-
uniform int kind;
|
|
234
|
-
uniform sampler2D screen;
|
|
235
|
-
varying vec3 worldPosition;
|
|
236
|
-
varying vec3 worldNormal;
|
|
237
|
-
varying vec2 texcoord;
|
|
238
|
-
void main() {
|
|
239
|
-
if (kind==2) { gl_FragColor=texture2D(screen,texcoord); return; }
|
|
240
|
-
vec3 n=normalize(worldNormal);
|
|
241
|
-
vec3 eye=normalize(vec3(0.,0.,600.)-worldPosition);
|
|
242
|
-
vec3 r=normalize(reflect(-eye,n)+worldPosition/190.);
|
|
243
|
-
float longitude=atan(r.z,r.x);
|
|
244
|
-
float latitude=asin(clamp(r.y,-1.,1.));
|
|
245
|
-
float strip=pow(.5+.5*sin(4.*longitude+1.8*sin(latitude)),12.);
|
|
246
|
-
float fill=pow(.5+.5*cos(2.*longitude-3.*latitude),5.);
|
|
247
|
-
float highlight=pow(max(0.,dot(n,normalize(vec3(-.4,.65,1.)))),90.);
|
|
248
|
-
vec3 silver=vec3(.30,.32,.34)+vec3(.85,.86,.87)*strip+vec3(.48)*fill+vec3(.7)*highlight;
|
|
249
|
-
if(kind==1) silver=vec3(.022,.026,.030)+silver*.035;
|
|
250
|
-
if(kind==3) silver=vec3(.93,.93,.91)*(0.88+.12*max(n.z,0.));
|
|
251
|
-
gl_FragColor=vec4(clamp(silver,0.,1.),1.);
|
|
252
|
-
}`;
|
|
253
|
-
|
|
254
|
-
/** The renderer owns its context, buffers and textures, and releases all of them after one fold. */
|
|
255
|
-
export function renderFold(canvas: HTMLCanvasElement, motion: FoldMotion, finished: () => void) {
|
|
256
|
-
const gl = canvas.getContext('webgl', { alpha: true, antialias: true, powerPreference: 'low-power' });
|
|
257
|
-
if (!gl) throw new Error('Fold animation WebGL unavailable');
|
|
258
|
-
const shaders: WebGLShader[] = [];
|
|
259
|
-
const buffers: WebGLBuffer[] = [];
|
|
260
|
-
const textures: WebGLTexture[] = [];
|
|
261
|
-
let program: WebGLProgram | null = null;
|
|
262
|
-
let animation = 0;
|
|
263
|
-
let disposed = false;
|
|
264
|
-
const dispose = () => {
|
|
265
|
-
if (disposed) return;
|
|
266
|
-
disposed = true;
|
|
267
|
-
cancelAnimationFrame(animation);
|
|
268
|
-
for (const resource of buffers) gl.deleteBuffer(resource);
|
|
269
|
-
for (const resource of textures) gl.deleteTexture(resource);
|
|
270
|
-
for (const resource of shaders) gl.deleteShader(resource);
|
|
271
|
-
if (program) gl.deleteProgram(program);
|
|
272
|
-
gl.getExtension('WEBGL_lose_context')?.loseContext();
|
|
273
|
-
};
|
|
274
|
-
try {
|
|
275
|
-
for (const [type, source] of [
|
|
276
|
-
[gl.VERTEX_SHADER, vertexShader],
|
|
277
|
-
[gl.FRAGMENT_SHADER, fragmentShader],
|
|
278
|
-
] as const) {
|
|
279
|
-
const shader = gl.createShader(type)!;
|
|
280
|
-
shaders.push(shader);
|
|
281
|
-
gl.shaderSource(shader, source);
|
|
282
|
-
gl.compileShader(shader);
|
|
283
|
-
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS))
|
|
284
|
-
throw new Error('Fold shader compilation failed');
|
|
285
|
-
}
|
|
286
|
-
program = gl.createProgram()!;
|
|
287
|
-
for (const shader of shaders) gl.attachShader(program, shader);
|
|
288
|
-
gl.linkProgram(program);
|
|
289
|
-
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error('Fold shader link failed');
|
|
290
|
-
gl.useProgram(program);
|
|
291
|
-
const location = (name: string) => gl.getUniformLocation(program!, name);
|
|
292
|
-
const uniforms = Object.fromEntries(
|
|
293
|
-
['viewport', 'scale', 'leaf', 'roll', 'shift', 'level', 'kind', 'screen', 'hinge', 'bend'].map((n) => [
|
|
294
|
-
n,
|
|
295
|
-
location(n),
|
|
296
|
-
]),
|
|
297
|
-
);
|
|
298
|
-
const opening = motion.to.inner;
|
|
299
|
-
const opened = opening ? motion.to : motion.from;
|
|
300
|
-
const closed = opening ? motion.from : motion.to;
|
|
301
|
-
const width = opened.frame.rect.min.x + opened.frame.rect.max.x;
|
|
302
|
-
const height = opened.frame.rect.min.y + opened.frame.rect.max.y;
|
|
303
|
-
const ratio = Math.min(window.devicePixelRatio || 1, 1.5, 1280 / Math.max(width, height));
|
|
304
|
-
canvas.width = Math.round(width * ratio);
|
|
305
|
-
canvas.height = Math.round(height * ratio);
|
|
306
|
-
gl.viewport(0, 0, canvas.width, canvas.height);
|
|
307
|
-
gl.uniform2f(uniforms['viewport']!, width, height);
|
|
308
|
-
gl.uniform1i(uniforms['screen']!, 0);
|
|
309
|
-
gl.enable(gl.DEPTH_TEST);
|
|
310
|
-
gl.clearColor(0, 0, 0, 0);
|
|
311
|
-
const images = {
|
|
312
|
-
inner: opening ? motion.after! : motion.before,
|
|
313
|
-
outer: opening ? motion.before : motion.after!,
|
|
314
|
-
};
|
|
315
|
-
const maps = {} as Record<'inner' | 'outer', WebGLTexture>;
|
|
316
|
-
for (const kind of ['inner', 'outer'] as const) {
|
|
317
|
-
const texture = gl.createTexture()!;
|
|
318
|
-
textures.push(texture);
|
|
319
|
-
maps[kind] = texture;
|
|
320
|
-
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
321
|
-
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1);
|
|
322
|
-
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, images[kind]);
|
|
323
|
-
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
324
|
-
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
325
|
-
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
326
|
-
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
327
|
-
}
|
|
328
|
-
const attributes = ['position', 'normal', 'uv'].map((name) => gl.getAttribLocation(program!, name));
|
|
329
|
-
const meshes = foldMeshes().map((mesh) => {
|
|
330
|
-
const buffer = gl.createBuffer()!;
|
|
331
|
-
buffers.push(buffer);
|
|
332
|
-
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
|
333
|
-
gl.bufferData(gl.ARRAY_BUFFER, mesh.vertices, gl.STATIC_DRAW);
|
|
334
|
-
return { ...mesh, buffer };
|
|
335
|
-
});
|
|
336
|
-
const start = performance.now();
|
|
337
|
-
const rollFrom = (closed.frame.turns * -Math.PI) / 2;
|
|
338
|
-
const rollDelta =
|
|
339
|
-
(((opened.frame.turns * -Math.PI) / 2 - rollFrom + Math.PI * 3) % (Math.PI * 2)) - Math.PI;
|
|
340
|
-
let lastRender = -Infinity;
|
|
341
|
-
const render = (time: number) => {
|
|
342
|
-
if (disposed) return;
|
|
343
|
-
if (time - lastRender < 1000 / 60 - 0.5 && time - start < FOLD_DURATION) {
|
|
344
|
-
animation = requestAnimationFrame(render);
|
|
345
|
-
return;
|
|
346
|
-
}
|
|
347
|
-
lastRender = time;
|
|
348
|
-
const elapsed = Math.min(1, (time - start) / FOLD_DURATION);
|
|
349
|
-
const p = opening ? elapsed : 1 - elapsed;
|
|
350
|
-
const pose = foldPose(p);
|
|
351
|
-
const fit = p * p * (3 - 2 * p);
|
|
352
|
-
const roll = rollFrom + rollDelta * fit;
|
|
353
|
-
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
|
354
|
-
gl.uniform1f(uniforms['scale']!, closed.frame.scale + (opened.frame.scale - closed.frame.scale) * fit);
|
|
355
|
-
gl.uniform2f(uniforms['roll']!, Math.cos(roll), Math.sin(roll));
|
|
356
|
-
gl.uniform1f(uniforms['shift']!, pose.shift);
|
|
357
|
-
gl.uniform1f(uniforms['level']!, (DEPTH + PIVOT * 2 + 0.24) * (1 - fit));
|
|
358
|
-
gl.uniform4f(
|
|
359
|
-
uniforms['hinge']!,
|
|
360
|
-
Math.cos(pose.left),
|
|
361
|
-
Math.sin(pose.left),
|
|
362
|
-
Math.cos(pose.right),
|
|
363
|
-
Math.sin(pose.right),
|
|
364
|
-
);
|
|
365
|
-
for (const mesh of meshes) {
|
|
366
|
-
gl.uniform1i(uniforms['bend']!, mesh.side === 0 ? 1 : 0);
|
|
367
|
-
const angle = mesh.side === -1 ? pose.left : pose.right;
|
|
368
|
-
gl.uniform2f(uniforms['leaf']!, Math.cos(angle), Math.sin(angle));
|
|
369
|
-
const textured = mesh.kind === 'inner' || mesh.kind === 'outer';
|
|
370
|
-
gl.uniform1i(
|
|
371
|
-
uniforms['kind']!,
|
|
372
|
-
textured ? 2
|
|
373
|
-
: mesh.kind === 'metal' ? 0
|
|
374
|
-
: mesh.kind === 'seal' ? 1
|
|
375
|
-
: 3,
|
|
376
|
-
);
|
|
377
|
-
if (textured) gl.bindTexture(gl.TEXTURE_2D, maps[mesh.kind as 'inner' | 'outer']);
|
|
378
|
-
gl.bindBuffer(gl.ARRAY_BUFFER, mesh.buffer);
|
|
379
|
-
for (let i = 0; i < attributes.length; i++) {
|
|
380
|
-
gl.enableVertexAttribArray(attributes[i]!);
|
|
381
|
-
gl.vertexAttribPointer(attributes[i]!, i === 2 ? 2 : 3, gl.FLOAT, false, 32, i === 2 ? 24 : i * 12);
|
|
382
|
-
}
|
|
383
|
-
gl.drawArrays(gl.TRIANGLES, 0, mesh.vertices.length / 8);
|
|
384
|
-
}
|
|
385
|
-
if (elapsed < 1) animation = requestAnimationFrame(render);
|
|
386
|
-
else {
|
|
387
|
-
dispose();
|
|
388
|
-
finished();
|
|
389
|
-
}
|
|
390
|
-
};
|
|
391
|
-
render(start);
|
|
392
|
-
return dispose;
|
|
393
|
-
} catch (error) {
|
|
394
|
-
dispose();
|
|
395
|
-
throw error;
|
|
396
|
-
}
|
|
397
|
-
}
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { expect, it } from 'vitest';
|
|
2
|
-
import { duoFoldAngle } from './duo-fold-timing';
|
|
3
|
-
|
|
4
|
-
it('uses the requested 4×, 1× and 2× speed segments over 2.5 seconds', () => {
|
|
5
|
-
for (const [from, target] of [
|
|
6
|
-
[0, 180],
|
|
7
|
-
[180, 0],
|
|
8
|
-
[110, 0],
|
|
9
|
-
] as const) {
|
|
10
|
-
const progress = (time: number) => (duoFoldAngle(from, target, time) - from) / (target - from);
|
|
11
|
-
expect(progress(0)).toBeCloseTo(0, 12);
|
|
12
|
-
expect(progress(0.625)).toBeCloseTo(0.5, 12);
|
|
13
|
-
expect(progress(1.25)).toBeCloseTo(0.625, 12);
|
|
14
|
-
expect(progress(1.875)).toBeCloseTo(0.75, 12);
|
|
15
|
-
expect(progress(2.499)).toBeLessThan(1);
|
|
16
|
-
expect(progress(2.5)).toBe(1);
|
|
17
|
-
expect(progress(3)).toBe(1);
|
|
18
|
-
for (let step = 1; step <= 20; step++) {
|
|
19
|
-
const delta = progress(step / 8) - progress((step - 1) / 8);
|
|
20
|
-
expect(delta).toBeCloseTo(
|
|
21
|
-
step <= 5 ? 0.1
|
|
22
|
-
: step <= 15 ? 0.025
|
|
23
|
-
: 0.05,
|
|
24
|
-
12,
|
|
25
|
-
);
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it('starts at the current pose, moves monotonically and settles at the target', () => {
|
|
31
|
-
for (const fps of [30, 60, 120]) {
|
|
32
|
-
let previous = 180;
|
|
33
|
-
for (let frame = 0; frame <= fps * 2.5; frame++) {
|
|
34
|
-
const angle = duoFoldAngle(180, 0, frame / fps);
|
|
35
|
-
expect(angle).toBeLessThanOrEqual(previous);
|
|
36
|
-
expect(angle).toBeGreaterThanOrEqual(0);
|
|
37
|
-
previous = angle;
|
|
38
|
-
}
|
|
39
|
-
expect(previous).toBe(0);
|
|
40
|
-
}
|
|
41
|
-
const reversedFrom = duoFoldAngle(180, 0, 0.2);
|
|
42
|
-
expect(duoFoldAngle(reversedFrom, 180, 0)).toBeCloseTo(reversedFrom, 12);
|
|
43
|
-
expect(duoFoldAngle(reversedFrom, 180, 2.5)).toBe(180);
|
|
44
|
-
});
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
// A 2.5-second fold uses 4× speed for 0.625s, 1× for 1.25s, then 2× for 0.625s.
|
|
2
|
-
export function duoFoldAngle(from: number, target: number, seconds: number): number {
|
|
3
|
-
const time = Math.min(2.5, Math.max(0, seconds));
|
|
4
|
-
const progress =
|
|
5
|
-
time <= 0.625 ? time * 0.8
|
|
6
|
-
: time <= 1.875 ? 0.5 + (time - 0.625) * 0.2
|
|
7
|
-
: 0.75 + (time - 1.875) * 0.4;
|
|
8
|
-
return from + (target - from) * progress;
|
|
9
|
-
}
|