authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-24 22:14:36-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-24 22:14:36-07:00
log7b84431161a64c9f9e9b57637dec23474a1b4236
tree56066b7cc10da759a55908bb16af217d3197c7bb
parentdf9826f0806cc4874cd79f24a42a1e9e506030c2
signature Signed by PGP key B5690EEEBB952194

fix(web): size the Android fold model from the inner display (#13574)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

4 files changed, 154 insertions(+), 34 deletions(-)

packages/client-runtime/src/device/androidFoldScene.test.ts+21
......@@ -54,4 +54,25 @@ describe("Android fold scene", () => {
5454 scene.dispose();
5555 texture.dispose();
5656 });
57
58 it("shapes the inner display to the raw frame, portrait or landscape", () => {
59 const texture = new Texture();
60 for (const [width, height] of [
61 [2076, 2152],
62 [2208, 1840],
63 ] as const) {
64 const scene = createAndroidFoldScene(
65 texture,
66 phoneDisplayLayout(null, width, height),
67 180,
68 width / height,
69 );
70 const screen = scene.root.getObjectByName("continuous-inner-screen") as Mesh;
71 screen.geometry.computeBoundingBox();
72 const size = screen.geometry.boundingBox!.getSize(new Vector3());
73 expect(size.x / size.y).toBeCloseTo(width / height, 2);
74 scene.dispose();
75 }
76 texture.dispose();
77 });
5778});
packages/client-runtime/src/device/androidFoldScene.ts+43-18
......@@ -19,7 +19,6 @@ import {
1919} from "three";
2020import type { PhoneDisplayLayout } from "./phoneScene.ts";
2121
22const HALF_WIDTH = 1.04;
2322const HEIGHT = 2.2;
2423const DEPTH = 0.075;
2524const INSET = 0.026;
......@@ -28,10 +27,24 @@ const BEVEL = 0.008;
2827// The hinge axis sits just above the inner screens so closed halves meet face to face.
2928const PIVOT_Z = DEPTH / 2 + 0.005;
3029const SPINE_RADIUS = PIVOT_Z + DEPTH / 2 - 0.002;
30/** Width over height of the unfolded inner display until a live frame reports its own. */
31export const DEFAULT_FOLD_INNER_ASPECT = 2076 / 2152;
32/**
33 * Unfolded inner displays are near square in either orientation. Cover displays are
34 * phone shaped (about 0.4-0.5, or 2-2.6 when rotated), so they never retune the body.
35 */
36export const isFoldInnerAspect = (aspect: number) =>
37 Number.isFinite(aspect) && aspect > 0.75 && aspect < 1.5;
3138
32function panelPath(side: "left" | "right", inset: number, radius: number, hingeInset = 0) {
33 const left = side === "left" ? -HALF_WIDTH + inset : CREASE / 2 + hingeInset;
34 const right = side === "left" ? -CREASE / 2 - hingeInset : HALF_WIDTH - inset;
39function panelPath(
40 halfWidth: number,
41 side: "left" | "right",
42 inset: number,
43 radius: number,
44 hingeInset = 0,
45) {
46 const left = side === "left" ? -halfWidth + inset : CREASE / 2 + hingeInset;
47 const right = side === "left" ? -CREASE / 2 - hingeInset : halfWidth - inset;
3548 const bottom = -HEIGHT / 2 + inset;
3649 const top = HEIGHT / 2 - inset;
3750 const path = new Shape();
......@@ -70,16 +83,22 @@ function roundedRectPath(width: number, height: number, radius: number) {
7083 return path;
7184}
7285
73function coverPath() {
74 return roundedRectPath(HALF_WIDTH - INSET * 2 - 0.02, HEIGHT - INSET * 2 - 0.04, 0.07);
86function coverPath(halfWidth: number) {
87 return roundedRectPath(halfWidth - INSET * 2 - 0.02, HEIGHT - INSET * 2 - 0.04, 0.07);
7588}
7689
77/** A procedural book-style foldable: one fixed half, one half rotating around a shared hinge. */
90/**
91 * A procedural book-style foldable: one fixed half, one half rotating around a shared hinge.
92 * The inner display keeps the raw framebuffer's native aspect, portrait or landscape.
93 */
7894export function createAndroidFoldScene(
7995 texture: Texture,
8096 layout: PhoneDisplayLayout,
8197 initialAngle: number,
98 innerAspect = DEFAULT_FOLD_INNER_ASPECT,
8299) {
100 const screenHeight = HEIGHT - 2 * INSET;
101 const halfWidth = (innerAspect * screenHeight) / 2 + INSET;
83102 const root = new Group();
84103 const orientation = new Group();
85104 root.add(orientation);
......@@ -146,7 +165,7 @@ export function createAndroidFoldScene(
146165
147166 function half(group: Group, side: "left" | "right", back: MeshPhysicalMaterial) {
148167 const body = new Mesh(
149 new ExtrudeGeometry(panelPath(side, BEVEL, 0.1, BEVEL), {
168 new ExtrudeGeometry(panelPath(halfWidth, side, BEVEL, 0.1, BEVEL), {
150169 depth: DEPTH - BEVEL * 2,
151170 bevelEnabled: true,
152171 bevelSize: BEVEL,
......@@ -158,19 +177,25 @@ export function createAndroidFoldScene(
158177 );
159178 body.position.z = -DEPTH / 2 + BEVEL;
160179 group.add(body);
161 const frame = new Mesh(new ShapeGeometry(panelPath(side, 0.01, 0.095, 0.002), 12), bezel);
180 const frame = new Mesh(
181 new ShapeGeometry(panelPath(halfWidth, side, 0.01, 0.095, 0.002), 12),
182 bezel,
183 );
162184 frame.position.z = DEPTH / 2 + 0.001;
163185 group.add(frame);
164186 // A back-facing shape mirrors X, so it is drawn from the opposite side's outline.
165187 const rear = new Mesh(
166 new ShapeGeometry(panelPath(side === "left" ? "right" : "left", 0.01, 0.095, 0.002), 12),
188 new ShapeGeometry(
189 panelPath(halfWidth, side === "left" ? "right" : "left", 0.01, 0.095, 0.002),
190 12,
191 ),
167192 back,
168193 );
169194 rear.name = `${side}-back`;
170195 rear.rotation.y = Math.PI;
171196 rear.position.set(0, 0, -DEPTH / 2 - 0.001);
172197 group.add(rear);
173 const geometry = new ShapeGeometry(panelPath(side, INSET, 0.076));
198 const geometry = new ShapeGeometry(panelPath(halfWidth, side, INSET, 0.076));
174199 geometry.computeBoundingBox();
175200 const display = new Mesh(geometry, hitMaterial);
176201 display.name = `${side}-inner-screen`;
......@@ -183,8 +208,7 @@ export function createAndroidFoldScene(
183208 const innerRight = half(right, "right", backGlass);
184209 // One indexed surface keeps adjacent pixels joined at the crease. The
185210 // physical halves move separately underneath it.
186 const screenWidth = 2 * (HALF_WIDTH - INSET);
187 const screenHeight = HEIGHT - 2 * INSET;
211 const screenWidth = 2 * (halfWidth - INSET);
188212 const screenGeometry = new PlaneGeometry(screenWidth, screenHeight, 40, 48);
189213 const screenPositions = screenGeometry.getAttribute("position");
190214 const screenUvs = screenGeometry.getAttribute("uv");
......@@ -224,9 +248,9 @@ export function createAndroidFoldScene(
224248 spine.position.z = PIVOT_Z;
225249 orientation.add(spine);
226250
227 const cover = new Mesh(new ShapeGeometry(coverPath()), coverMaterial);
251 const cover = new Mesh(new ShapeGeometry(coverPath(halfWidth)), coverMaterial);
228252 cover.name = "cover-screen";
229 cover.position.set(-HALF_WIDTH / 2, 0, -DEPTH / 2 - 0.003);
253 cover.position.set(-halfWidth / 2, 0, -DEPTH / 2 - 0.003);
230254 cover.rotation.y = Math.PI;
231255 leftBody.add(cover);
232256
......@@ -236,7 +260,7 @@ export function createAndroidFoldScene(
236260 const islandWidth = 0.46;
237261 const islandHeight = 0.2;
238262 rearCamera.position.set(
239 HALF_WIDTH - 0.07 - islandWidth / 2,
263 halfWidth - 0.07 - islandWidth / 2,
240264 HEIGHT / 2 - 0.08 - islandHeight / 2,
241265 -DEPTH / 2 - 0.002,
242266 );
......@@ -284,7 +308,7 @@ export function createAndroidFoldScene(
284308 [0.2, 0.3],
285309 ] as const) {
286310 const key = new Mesh(new BoxGeometry(0.02, length, DEPTH * 0.45), frameMetal);
287 key.position.set(HALF_WIDTH + 0.008, y, 0);
311 key.position.set(halfWidth + 0.008, y, 0);
288312 right.add(key);
289313 }
290314
......@@ -349,8 +373,9 @@ export function createAndroidFoldScene(
349373 return {
350374 root,
351375 orientation,
352 width: HALF_WIDTH * 2,
376 width: halfWidth * 2,
353377 height: HEIGHT,
378 innerAspect,
354379 setAngle,
355380 setDisplay,
356381 screenPoint(x: number, y: number, camera: Camera, captured = false) {
packages/client-runtime/src/device/phoneViewer.test.ts+57-1
......@@ -80,7 +80,16 @@ vi.mock("./modelScene.ts", async () => {
8080 };
8181});
8282
83import { BoxGeometry, Group, Mesh, MeshBasicMaterial, PlaneGeometry, Quaternion } from "three";
83import {
84 Box3,
85 BoxGeometry,
86 Group,
87 Mesh,
88 MeshBasicMaterial,
89 PlaneGeometry,
90 Quaternion,
91 Vector3,
92} from "three";
8493import { disposeDeviceModel } from "./modelScene.ts";
8594import { createPhoneViewer } from "./phoneViewer.ts";
8695import {
......@@ -259,6 +268,53 @@ it("animates the Android hinge on the same scene through an encoder resize", ()
259268 viewer.dispose();
260269});
261270
271it("resizes the fold body for a landscape inner display and keeps it through the cover frame", () => {
272 const openProfile = resolveDeviceShape({ platform: "android", portraitAspect: 0.83 });
273 const { viewer, draw, source, state } = fixture(openProfile);
274 viewer.setFoldAngle(180);
275 draw(0);
276 const portraitWidth = new Box3()
277 .setFromObject(state.frames.at(-1)!.phone!)
278 .getSize(new Vector3()).x;
279 source.width = 2208;
280 source.height = 1840;
281 viewer.setScreen({ width: 2208, height: 1840, orientation: "portrait" });
282 viewer.frameUpdated();
283 draw(10);
284 const landscape = state.frames.at(-1)!.phone!;
285 const landscapeWidth = new Box3().setFromObject(landscape).getSize(new Vector3()).x;
286 expect(landscapeWidth / portraitWidth).toBeGreaterThan(1.15);
287 source.width = 1080;
288 source.height = 2092;
289 viewer.setScreen({ width: 1080, height: 2092, orientation: "portrait" }, ANDROID_PHONE_SHAPE);
290 viewer.frameUpdated();
291 draw(20);
292 expect(state.frames.at(-1)!.phone).toBe(landscape);
293 expect(gpu.instances).toHaveLength(1);
294 viewer.dispose();
295});
296
297it("keeps the fold body through a rotated cover frame and learns the inner shape before fold mode", () => {
298 const { viewer, draw, source, state } = fixture(ANDROID_PHONE_SHAPE);
299 source.width = 2208;
300 source.height = 1840;
301 viewer.setScreen({ width: 2208, height: 1840, orientation: "portrait" });
302 viewer.frameUpdated();
303 draw(0);
304 viewer.setFoldAngle(180);
305 draw(10);
306 const landscape = state.frames.at(-1)!.phone!;
307 const width = new Box3().setFromObject(landscape).getSize(new Vector3()).x;
308 expect(width / new Box3().setFromObject(landscape).getSize(new Vector3()).y).toBeGreaterThan(1.1);
309 source.width = 2092;
310 source.height = 1080;
311 viewer.setScreen({ width: 2092, height: 1080, orientation: "landscape_left" });
312 viewer.frameUpdated();
313 draw(20);
314 expect(state.frames.at(-1)!.phone).toBe(landscape);
315 viewer.dispose();
316});
317
262318it("retargets an unfinished hinge turn from its visible angle", () => {
263319 const { viewer, draw, state } = fixture(ANDROID_PHONE_SHAPE);
264320 viewer.setFoldAngle(180);
packages/client-runtime/src/device/phoneViewer.ts+33-15
......@@ -19,7 +19,11 @@ import {
1919} from "./model.ts";
2020import { createImportedPhoneScene, loadDeviceModel } from "./modelScene.ts";
2121import { createPhoneScene, phoneDisplayLayout } from "./phoneScene.ts";
22import { createAndroidFoldScene } from "./androidFoldScene.ts";
22import {
23 createAndroidFoldScene,
24 DEFAULT_FOLD_INNER_ASPECT,
25 isFoldInnerAspect,
26} from "./androidFoldScene.ts";
2327import { createRenderScheduler } from "./renderScheduler.ts";
2428import { createDeviceMotion } from "./deviceMotion.ts";
2529import { createDeviceFraming } from "./deviceFraming.ts";
......@@ -102,9 +106,21 @@ export function createPhoneViewer(options: {
102106 let accessory: Awaited<ReturnType<typeof loadDeviceModel>> | null = null;
103107 let accessoryBounds: Box3 | null = null;
104108 let foldTurn: { from: number; to: number; startedAt: number } | null = null;
109 // The inner display's raw width over height. Cover frames leave the last unfolded shape.
110 const rawAspect = () => options.source.width / options.source.height;
111 let foldAspect = isFoldInnerAspect(rawAspect()) ? rawAspect() : DEFAULT_FOLD_INNER_ASPECT;
112 const createFoldScene = (angle: number, displayLayout = layout) =>
113 createAndroidFoldScene(texture, displayLayout, angle, foldAspect);
114 /** The hinge angle currently on screen, including an unfinished turn. */
115 const visibleFoldAngle = (fallback: number) => {
116 if (!foldTurn) return fallback;
117 const progress = Math.min(1, (performance.now() - foldTurn.startedAt) / ANDROID_FOLD_TURN_MS);
118 const eased = progress * progress * (3 - 2 * progress);
119 return foldTurn.from + (foldTurn.to - foldTurn.from) * eased;
120 };
105121 let phone: ReturnType<typeof createPhoneScene> | ReturnType<typeof createAndroidFoldScene> =
106122 foldAngle !== null && profile.id.startsWith("android")
107 ? createAndroidFoldScene(texture, layout, foldAngle)
123 ? createFoldScene(foldAngle)
108124 : createPhoneScene(texture, layout, profile);
109125 scene.add(phone.root);
110126 let disposed = false;
......@@ -222,7 +238,18 @@ export function createPhoneViewer(options: {
222238 phone.setDisplay(texture, next);
223239 previous.dispose();
224240 }
225 if (
241 // Learn the inner display shape from any unfolded frame, including before fold mode.
242 const frameAspect = rawAspect();
243 const innerChanged = isFoldInnerAspect(frameAspect) && frameAspect !== foldAspect;
244 if (innerChanged) foldAspect = frameAspect;
245 if (!imported && "setAngle" in phone && innerChanged) {
246 // A new inner display shape resizes the body; the hinge keeps its visible angle.
247 const angle = visibleFoldAngle(foldAngle ?? 180);
248 scene.remove(phone.root);
249 phone.dispose();
250 phone = createFoldScene(angle, next);
251 scene.add(phone.root);
252 } else if (
226253 !imported &&
227254 !("setAngle" in phone) &&
228255 (nextProfile !== profile || next.aspect !== layout.aspect)
......@@ -267,7 +294,7 @@ export function createPhoneViewer(options: {
267294 : model
268295 ? createImportedPhoneScene(model.asset, texture, layout)
269296 : foldAngle !== null && profile.id.startsWith("android")
270 ? createAndroidFoldScene(texture, layout, foldAngle)
297 ? createFoldScene(foldAngle)
271298 : createPhoneScene(texture, layout, profile);
272299 foldTurn = null;
273300 scene.remove(phone.root);
......@@ -333,10 +360,7 @@ export function createPhoneViewer(options: {
333360 if (next === null || !("setAngle" in phone)) {
334361 scene.remove(phone.root);
335362 phone.dispose();
336 phone =
337 next === null
338 ? createPhoneScene(texture, layout, profile)
339 : createAndroidFoldScene(texture, layout, next);
363 phone = next === null ? createPhoneScene(texture, layout, profile) : createFoldScene(next);
340364 scene.add(phone.root);
341365 orientationTurn = null;
342366 orientationAngle = next === null ? layout.rotation : 0;
......@@ -344,13 +368,7 @@ export function createPhoneViewer(options: {
344368 applyPose();
345369 fit(true);
346370 } else {
347 const progress = foldTurn
348 ? Math.min(1, (performance.now() - foldTurn.startedAt) / ANDROID_FOLD_TURN_MS)
349 : 1;
350 const eased = progress * progress * (3 - 2 * progress);
351 const from = foldTurn
352 ? foldTurn.from + (foldTurn.to - foldTurn.from) * eased
353 : (previous ?? next);
371 const from = visibleFoldAngle(previous ?? next);
354372 if (reducedMotion()) {
355373 foldTurn = null;
356374 phone.setAngle(next);