| author | |
| committer | |
| log | 7b84431161a64c9f9e9b57637dec23474a1b4236 |
| tree | 56066b7cc10da759a55908bb16af217d3197c7bb |
| parent | df9826f0806cc4874cd79f24a42a1e9e506030c2 |
| signature | Signed by PGP key B5690EEEBB952194 |
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", () => { |
| 54 | 54 | scene.dispose(); |
| 55 | 55 | texture.dispose(); |
| 56 | 56 | }); |
| 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 | }); | |
| 57 | 78 | }); |
packages/client-runtime/src/device/androidFoldScene.ts+43-18| ... | ... | @@ -19,7 +19,6 @@ import { |
| 19 | 19 | } from "three"; |
| 20 | 20 | import type { PhoneDisplayLayout } from "./phoneScene.ts"; |
| 21 | 21 | |
| 22 | const HALF_WIDTH = 1.04; | |
| 23 | 22 | const HEIGHT = 2.2; |
| 24 | 23 | const DEPTH = 0.075; |
| 25 | 24 | const INSET = 0.026; |
| ... | ... | @@ -28,10 +27,24 @@ const BEVEL = 0.008; |
| 28 | 27 | // The hinge axis sits just above the inner screens so closed halves meet face to face. |
| 29 | 28 | const PIVOT_Z = DEPTH / 2 + 0.005; |
| 30 | 29 | const SPINE_RADIUS = PIVOT_Z + DEPTH / 2 - 0.002; |
| 30 | /** Width over height of the unfolded inner display until a live frame reports its own. */ | |
| 31 | export 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 | */ | |
| 36 | export const isFoldInnerAspect = (aspect: number) => | |
| 37 | Number.isFinite(aspect) && aspect > 0.75 && aspect < 1.5; | |
| 31 | 38 | |
| 32 | function 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; | |
| 39 | function 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; | |
| 35 | 48 | const bottom = -HEIGHT / 2 + inset; |
| 36 | 49 | const top = HEIGHT / 2 - inset; |
| 37 | 50 | const path = new Shape(); |
| ... | ... | @@ -70,16 +83,22 @@ function roundedRectPath(width: number, height: number, radius: number) { |
| 70 | 83 | return path; |
| 71 | 84 | } |
| 72 | 85 | |
| 73 | function coverPath() { | |
| 74 | return roundedRectPath(HALF_WIDTH - INSET * 2 - 0.02, HEIGHT - INSET * 2 - 0.04, 0.07); | |
| 86 | function coverPath(halfWidth: number) { | |
| 87 | return roundedRectPath(halfWidth - INSET * 2 - 0.02, HEIGHT - INSET * 2 - 0.04, 0.07); | |
| 75 | 88 | } |
| 76 | 89 | |
| 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 | */ | |
| 78 | 94 | export function createAndroidFoldScene( |
| 79 | 95 | texture: Texture, |
| 80 | 96 | layout: PhoneDisplayLayout, |
| 81 | 97 | initialAngle: number, |
| 98 | innerAspect = DEFAULT_FOLD_INNER_ASPECT, | |
| 82 | 99 | ) { |
| 100 | const screenHeight = HEIGHT - 2 * INSET; | |
| 101 | const halfWidth = (innerAspect * screenHeight) / 2 + INSET; | |
| 83 | 102 | const root = new Group(); |
| 84 | 103 | const orientation = new Group(); |
| 85 | 104 | root.add(orientation); |
| ... | ... | @@ -146,7 +165,7 @@ export function createAndroidFoldScene( |
| 146 | 165 | |
| 147 | 166 | function half(group: Group, side: "left" | "right", back: MeshPhysicalMaterial) { |
| 148 | 167 | const body = new Mesh( |
| 149 | new ExtrudeGeometry(panelPath(side, BEVEL, 0.1, BEVEL), { | |
| 168 | new ExtrudeGeometry(panelPath(halfWidth, side, BEVEL, 0.1, BEVEL), { | |
| 150 | 169 | depth: DEPTH - BEVEL * 2, |
| 151 | 170 | bevelEnabled: true, |
| 152 | 171 | bevelSize: BEVEL, |
| ... | ... | @@ -158,19 +177,25 @@ export function createAndroidFoldScene( |
| 158 | 177 | ); |
| 159 | 178 | body.position.z = -DEPTH / 2 + BEVEL; |
| 160 | 179 | 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 | ); | |
| 162 | 184 | frame.position.z = DEPTH / 2 + 0.001; |
| 163 | 185 | group.add(frame); |
| 164 | 186 | // A back-facing shape mirrors X, so it is drawn from the opposite side's outline. |
| 165 | 187 | 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 | ), | |
| 167 | 192 | back, |
| 168 | 193 | ); |
| 169 | 194 | rear.name = `${side}-back`; |
| 170 | 195 | rear.rotation.y = Math.PI; |
| 171 | 196 | rear.position.set(0, 0, -DEPTH / 2 - 0.001); |
| 172 | 197 | group.add(rear); |
| 173 | const geometry = new ShapeGeometry(panelPath(side, INSET, 0.076)); | |
| 198 | const geometry = new ShapeGeometry(panelPath(halfWidth, side, INSET, 0.076)); | |
| 174 | 199 | geometry.computeBoundingBox(); |
| 175 | 200 | const display = new Mesh(geometry, hitMaterial); |
| 176 | 201 | display.name = `${side}-inner-screen`; |
| ... | ... | @@ -183,8 +208,7 @@ export function createAndroidFoldScene( |
| 183 | 208 | const innerRight = half(right, "right", backGlass); |
| 184 | 209 | // One indexed surface keeps adjacent pixels joined at the crease. The |
| 185 | 210 | // 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); | |
| 188 | 212 | const screenGeometry = new PlaneGeometry(screenWidth, screenHeight, 40, 48); |
| 189 | 213 | const screenPositions = screenGeometry.getAttribute("position"); |
| 190 | 214 | const screenUvs = screenGeometry.getAttribute("uv"); |
| ... | ... | @@ -224,9 +248,9 @@ export function createAndroidFoldScene( |
| 224 | 248 | spine.position.z = PIVOT_Z; |
| 225 | 249 | orientation.add(spine); |
| 226 | 250 | |
| 227 | const cover = new Mesh(new ShapeGeometry(coverPath()), coverMaterial); | |
| 251 | const cover = new Mesh(new ShapeGeometry(coverPath(halfWidth)), coverMaterial); | |
| 228 | 252 | 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); | |
| 230 | 254 | cover.rotation.y = Math.PI; |
| 231 | 255 | leftBody.add(cover); |
| 232 | 256 | |
| ... | ... | @@ -236,7 +260,7 @@ export function createAndroidFoldScene( |
| 236 | 260 | const islandWidth = 0.46; |
| 237 | 261 | const islandHeight = 0.2; |
| 238 | 262 | rearCamera.position.set( |
| 239 | HALF_WIDTH - 0.07 - islandWidth / 2, | |
| 263 | halfWidth - 0.07 - islandWidth / 2, | |
| 240 | 264 | HEIGHT / 2 - 0.08 - islandHeight / 2, |
| 241 | 265 | -DEPTH / 2 - 0.002, |
| 242 | 266 | ); |
| ... | ... | @@ -284,7 +308,7 @@ export function createAndroidFoldScene( |
| 284 | 308 | [0.2, 0.3], |
| 285 | 309 | ] as const) { |
| 286 | 310 | 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); | |
| 288 | 312 | right.add(key); |
| 289 | 313 | } |
| 290 | 314 | |
| ... | ... | @@ -349,8 +373,9 @@ export function createAndroidFoldScene( |
| 349 | 373 | return { |
| 350 | 374 | root, |
| 351 | 375 | orientation, |
| 352 | width: HALF_WIDTH * 2, | |
| 376 | width: halfWidth * 2, | |
| 353 | 377 | height: HEIGHT, |
| 378 | innerAspect, | |
| 354 | 379 | setAngle, |
| 355 | 380 | setDisplay, |
| 356 | 381 | 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 () => { |
| 80 | 80 | }; |
| 81 | 81 | }); |
| 82 | 82 | |
| 83 | import { BoxGeometry, Group, Mesh, MeshBasicMaterial, PlaneGeometry, Quaternion } from "three"; | |
| 83 | import { | |
| 84 | Box3, | |
| 85 | BoxGeometry, | |
| 86 | Group, | |
| 87 | Mesh, | |
| 88 | MeshBasicMaterial, | |
| 89 | PlaneGeometry, | |
| 90 | Quaternion, | |
| 91 | Vector3, | |
| 92 | } from "three"; | |
| 84 | 93 | import { disposeDeviceModel } from "./modelScene.ts"; |
| 85 | 94 | import { createPhoneViewer } from "./phoneViewer.ts"; |
| 86 | 95 | import { |
| ... | ... | @@ -259,6 +268,53 @@ it("animates the Android hinge on the same scene through an encoder resize", () |
| 259 | 268 | viewer.dispose(); |
| 260 | 269 | }); |
| 261 | 270 | |
| 271 | it("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 | ||
| 297 | it("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 | ||
| 262 | 318 | it("retargets an unfinished hinge turn from its visible angle", () => { |
| 263 | 319 | const { viewer, draw, state } = fixture(ANDROID_PHONE_SHAPE); |
| 264 | 320 | viewer.setFoldAngle(180); |
packages/client-runtime/src/device/phoneViewer.ts+33-15| ... | ... | @@ -19,7 +19,11 @@ import { |
| 19 | 19 | } from "./model.ts"; |
| 20 | 20 | import { createImportedPhoneScene, loadDeviceModel } from "./modelScene.ts"; |
| 21 | 21 | import { createPhoneScene, phoneDisplayLayout } from "./phoneScene.ts"; |
| 22 | import { createAndroidFoldScene } from "./androidFoldScene.ts"; | |
| 22 | import { | |
| 23 | createAndroidFoldScene, | |
| 24 | DEFAULT_FOLD_INNER_ASPECT, | |
| 25 | isFoldInnerAspect, | |
| 26 | } from "./androidFoldScene.ts"; | |
| 23 | 27 | import { createRenderScheduler } from "./renderScheduler.ts"; |
| 24 | 28 | import { createDeviceMotion } from "./deviceMotion.ts"; |
| 25 | 29 | import { createDeviceFraming } from "./deviceFraming.ts"; |
| ... | ... | @@ -102,9 +106,21 @@ export function createPhoneViewer(options: { |
| 102 | 106 | let accessory: Awaited<ReturnType<typeof loadDeviceModel>> | null = null; |
| 103 | 107 | let accessoryBounds: Box3 | null = null; |
| 104 | 108 | 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 | }; | |
| 105 | 121 | let phone: ReturnType<typeof createPhoneScene> | ReturnType<typeof createAndroidFoldScene> = |
| 106 | 122 | foldAngle !== null && profile.id.startsWith("android") |
| 107 | ? createAndroidFoldScene(texture, layout, foldAngle) | |
| 123 | ? createFoldScene(foldAngle) | |
| 108 | 124 | : createPhoneScene(texture, layout, profile); |
| 109 | 125 | scene.add(phone.root); |
| 110 | 126 | let disposed = false; |
| ... | ... | @@ -222,7 +238,18 @@ export function createPhoneViewer(options: { |
| 222 | 238 | phone.setDisplay(texture, next); |
| 223 | 239 | previous.dispose(); |
| 224 | 240 | } |
| 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 ( | |
| 226 | 253 | !imported && |
| 227 | 254 | !("setAngle" in phone) && |
| 228 | 255 | (nextProfile !== profile || next.aspect !== layout.aspect) |
| ... | ... | @@ -267,7 +294,7 @@ export function createPhoneViewer(options: { |
| 267 | 294 | : model |
| 268 | 295 | ? createImportedPhoneScene(model.asset, texture, layout) |
| 269 | 296 | : foldAngle !== null && profile.id.startsWith("android") |
| 270 | ? createAndroidFoldScene(texture, layout, foldAngle) | |
| 297 | ? createFoldScene(foldAngle) | |
| 271 | 298 | : createPhoneScene(texture, layout, profile); |
| 272 | 299 | foldTurn = null; |
| 273 | 300 | scene.remove(phone.root); |
| ... | ... | @@ -333,10 +360,7 @@ export function createPhoneViewer(options: { |
| 333 | 360 | if (next === null || !("setAngle" in phone)) { |
| 334 | 361 | scene.remove(phone.root); |
| 335 | 362 | 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); | |
| 340 | 364 | scene.add(phone.root); |
| 341 | 365 | orientationTurn = null; |
| 342 | 366 | orientationAngle = next === null ? layout.rotation : 0; |
| ... | ... | @@ -344,13 +368,7 @@ export function createPhoneViewer(options: { |
| 344 | 368 | applyPose(); |
| 345 | 369 | fit(true); |
| 346 | 370 | } 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); | |
| 354 | 372 | if (reducedMotion()) { |
| 355 | 373 | foldTurn = null; |
| 356 | 374 | phone.setAngle(next); |