| 1 | import type { PerspectiveCamera, Scene, Object3D } from "three"; |
| 2 | import { afterEach, expect, it, vi } from "vite-plus/test"; |
| 3 | |
| 4 | const gpu = vi.hoisted(() => ({ |
| 5 | instances: [] as { |
| 6 | blank: boolean; |
| 7 | allocations: number; |
| 8 | disposed: boolean; |
| 9 | size: { width: number; height: number; pixelRatio: number }; |
| 10 | frames: { |
| 11 | scene: Scene; |
| 12 | phone: Object3D | undefined; |
| 13 | rotation: Quaternion | undefined; |
| 14 | displayAngle: number | undefined; |
| 15 | yaw: number | undefined; |
| 16 | cameraZ: number; |
| 17 | }[]; |
| 18 | }[], |
| 19 | })); |
| 20 | |
| 21 | vi.mock("three", async () => { |
| 22 | const actual = await vi.importActual<typeof import("three")>("three"); |
| 23 | return { |
| 24 | ...actual, |
| 25 | WebGLRenderer: class { |
| 26 | outputColorSpace = ""; |
| 27 | state: (typeof gpu.instances)[number] = { |
| 28 | blank: true, |
| 29 | allocations: 0, |
| 30 | disposed: false, |
| 31 | size: { width: 0, height: 0, pixelRatio: 1 }, |
| 32 | frames: [], |
| 33 | }; |
| 34 | constructor() { |
| 35 | gpu.instances.push(this.state); |
| 36 | } |
| 37 | setDrawingBufferSize(width: number, height: number, pixelRatio: number) { |
| 38 | this.state.size = { width, height, pixelRatio }; |
| 39 | this.state.allocations++; |
| 40 | this.state.blank = true; |
| 41 | } |
| 42 | setSize(width: number, height: number) { |
| 43 | this.setDrawingBufferSize(width, height, this.state.size.pixelRatio); |
| 44 | } |
| 45 | setPixelRatio(pixelRatio: number) { |
| 46 | this.setDrawingBufferSize(this.state.size.width, this.state.size.height, pixelRatio); |
| 47 | } |
| 48 | render(scene: Scene, camera: PerspectiveCamera) { |
| 49 | const phone = scene.children.find((child) => child.type === "Group"); |
| 50 | this.state.frames.push({ |
| 51 | scene, |
| 52 | phone, |
| 53 | rotation: phone?.quaternion.clone(), |
| 54 | displayAngle: phone?.children[0]?.rotation.z, |
| 55 | yaw: phone?.rotation.y, |
| 56 | cameraZ: camera.position.z, |
| 57 | }); |
| 58 | this.state.blank = false; |
| 59 | } |
| 60 | dispose() { |
| 61 | this.state.disposed = true; |
| 62 | } |
| 63 | forceContextLoss() {} |
| 64 | }, |
| 65 | }; |
| 66 | }); |
| 67 | |
| 68 | const models = vi.hoisted(() => ({ |
| 69 | pending: [] as { |
| 70 | signal: AbortSignal; |
| 71 | resolve: (model: { asset: import("three").Group; dispose: () => void }) => void; |
| 72 | }[], |
| 73 | })); |
| 74 | vi.mock("./modelScene.ts", async () => { |
| 75 | const actual = await vi.importActual<typeof import("./modelScene.ts")>("./modelScene.ts"); |
| 76 | return { |
| 77 | ...actual, |
| 78 | loadDeviceModel: (_source: unknown, signal: AbortSignal) => |
| 79 | new Promise((resolve) => models.pending.push({ signal, resolve })), |
| 80 | }; |
| 81 | }); |
| 82 | |
| 83 | import { |
| 84 | Box3, |
| 85 | BoxGeometry, |
| 86 | Group, |
| 87 | Mesh, |
| 88 | MeshBasicMaterial, |
| 89 | PlaneGeometry, |
| 90 | Quaternion, |
| 91 | Vector3, |
| 92 | } from "three"; |
| 93 | import { disposeDeviceModel } from "./modelScene.ts"; |
| 94 | import { createPhoneViewer } from "./phoneViewer.ts"; |
| 95 | import { |
| 96 | ANDROID_PHONE_SHAPE, |
| 97 | IOS_TABLET_SHAPE, |
| 98 | resolveDeviceShape, |
| 99 | type DeviceShapeProfile, |
| 100 | } from "./shapeProfile.ts"; |
| 101 | |
| 102 | afterEach(() => { |
| 103 | vi.restoreAllMocks(); |
| 104 | vi.unstubAllGlobals(); |
| 105 | gpu.instances.length = 0; |
| 106 | models.pending.length = 0; |
| 107 | }); |
| 108 | |
| 109 | function fixture(profile?: DeviceShapeProfile) { |
| 110 | const pending = new Map<number, FrameRequestCallback>(); |
| 111 | let id = 0; |
| 112 | let now = 0; |
| 113 | vi.spyOn(performance, "now").mockImplementation(() => now); |
| 114 | vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { |
| 115 | pending.set(++id, callback); |
| 116 | return id; |
| 117 | }); |
| 118 | vi.stubGlobal("cancelAnimationFrame", (id: number) => pending.delete(id)); |
| 119 | const canvas = Object.assign(new EventTarget(), { width: 0, height: 0 }) as HTMLCanvasElement; |
| 120 | const source = { width: 1206, height: 2622 } as HTMLCanvasElement; |
| 121 | const onUnavailable = vi.fn(); |
| 122 | const onFramingAspect = vi.fn(); |
| 123 | const viewer = createPhoneViewer({ |
| 124 | canvas, |
| 125 | source, |
| 126 | onUnavailable, |
| 127 | onFramingAspect, |
| 128 | ...(profile ? { profile } : {}), |
| 129 | }); |
| 130 | const draw = (time = now) => { |
| 131 | now = time; |
| 132 | const callbacks = [...pending.values()]; |
| 133 | pending.clear(); |
| 134 | callbacks.forEach((callback) => callback(0)); |
| 135 | }; |
| 136 | viewer.resize(400, 700, 2); |
| 137 | draw(); |
| 138 | return { |
| 139 | viewer, |
| 140 | draw, |
| 141 | pending, |
| 142 | source, |
| 143 | onUnavailable, |
| 144 | onFramingAspect, |
| 145 | state: gpu.instances[0]!, |
| 146 | }; |
| 147 | } |
| 148 | |
| 149 | it("retains the drawn phone until resize and redraw commit together, without replacing scene or pose", () => { |
| 150 | const { viewer, draw, pending, source, state, onUnavailable } = fixture(); |
| 151 | viewer.orbit(0.08, 0.04); |
| 152 | draw(); |
| 153 | const previous = state.frames.at(-1)!; |
| 154 | const allocations = state.allocations; |
| 155 | viewer.resize(500, 700, 2); |
| 156 | viewer.resize(450, 700, 2); |
| 157 | viewer.frameUpdated(); |
| 158 | // ResizeObserver runs after rAF. A cleared buffer here would reach the browser's next paint. |
| 159 | expect(state.blank).toBe(false); |
| 160 | expect(state.allocations).toBe(allocations); |
| 161 | expect(pending.size).toBe(1); |
| 162 | draw(); |
| 163 | expect(state.size).toEqual({ width: 450, height: 700, pixelRatio: 2 }); |
| 164 | expect(state.blank).toBe(false); |
| 165 | expect(state.allocations).toBe(allocations + 1); |
| 166 | expect(state.frames.at(-1)?.scene).toBe(previous.scene); |
| 167 | expect(state.frames.at(-1)?.phone).toBe(previous.phone); |
| 168 | expect(state.frames.at(-1)?.yaw).toBe(previous.yaw); |
| 169 | viewer.resize(400, 700, 2); |
| 170 | draw(); |
| 171 | expect(state.frames.at(-1)?.cameraZ).toBeCloseTo(previous.cameraZ); |
| 172 | expect(source).toMatchObject({ width: 1206, height: 2622 }); |
| 173 | expect(gpu.instances).toHaveLength(1); |
| 174 | expect(onUnavailable).not.toHaveBeenCalled(); |
| 175 | viewer.dispose(); |
| 176 | }); |
| 177 | |
| 178 | it("ignores redundant and invalid sizes and combines a DPR change into one allocation and draw", () => { |
| 179 | const { viewer, draw, pending, state } = fixture(); |
| 180 | const allocations = state.allocations; |
| 181 | viewer.resize(400, 700, 2); |
| 182 | viewer.resize(400, 700, 5); |
| 183 | viewer.resize(0, 700, 2); |
| 184 | viewer.resize(NaN, 700, 2); |
| 185 | expect(pending.size).toBe(0); |
| 186 | viewer.resize(400, 700, 1); |
| 187 | expect(state.blank).toBe(false); |
| 188 | draw(); |
| 189 | expect(state.size.pixelRatio).toBe(1); |
| 190 | expect(state.allocations).toBe(allocations + 1); |
| 191 | viewer.resize(450, 700, 1); |
| 192 | viewer.dispose(); |
| 193 | draw(); |
| 194 | expect(state.allocations).toBe(allocations + 1); |
| 195 | expect(state.disposed).toBe(true); |
| 196 | }); |
| 197 | |
| 198 | it("changes device shape without replacing the renderer, decoded source or pose", () => { |
| 199 | const { viewer, draw, source, state } = fixture(); |
| 200 | viewer.orbit(0.1, 0.05); |
| 201 | draw(); |
| 202 | const previous = state.frames.at(-1)!; |
| 203 | const allocations = state.allocations; |
| 204 | viewer.setScreen(null, IOS_TABLET_SHAPE); |
| 205 | draw(); |
| 206 | expect(state.frames.at(-1)?.phone).not.toBe(previous.phone); |
| 207 | expect(state.frames.at(-1)?.yaw).toBe(previous.yaw); |
| 208 | expect(state.frames.at(-1)?.scene).toBe(previous.scene); |
| 209 | expect(state.allocations).toBe(allocations); |
| 210 | expect(source.width).toBe(1206); |
| 211 | expect(gpu.instances).toHaveLength(1); |
| 212 | const tablet = state.frames.at(-1)?.phone; |
| 213 | viewer.frameUpdated(); |
| 214 | draw(); |
| 215 | expect(state.frames.at(-1)?.phone).toBe(tablet); |
| 216 | viewer.dispose(); |
| 217 | }); |
| 218 | |
| 219 | it("keeps the Android viewer while the resized framebuffer turns between fold postures", () => { |
| 220 | const openProfile = resolveDeviceShape({ platform: "android", portraitAspect: 0.96 }); |
| 221 | const { viewer, draw, source, state } = fixture(openProfile); |
| 222 | const scene = state.frames.at(-1)!.scene; |
| 223 | source.width = 2076; |
| 224 | source.height = 2152; |
| 225 | viewer.setScreen({ width: 2076, height: 2152, orientation: "landscape_left" }); |
| 226 | viewer.frameUpdated(); |
| 227 | draw(0); |
| 228 | expect(state.frames.at(-1)!.displayAngle).toBeCloseTo(0); |
| 229 | draw(225); |
| 230 | expect(state.frames.at(-1)!.displayAngle).toBeCloseTo(-Math.PI / 4); |
| 231 | draw(450); |
| 232 | expect(state.frames.at(-1)!.displayAngle).toBeCloseTo(-Math.PI / 2); |
| 233 | expect(state.frames.at(-1)!.scene).toBe(scene); |
| 234 | expect(gpu.instances).toHaveLength(1); |
| 235 | |
| 236 | source.width = 1080; |
| 237 | source.height = 2424; |
| 238 | viewer.setScreen({ width: 1080, height: 2424, orientation: "portrait" }, ANDROID_PHONE_SHAPE); |
| 239 | viewer.frameUpdated(); |
| 240 | draw(450); |
| 241 | expect(state.frames.at(-1)!.displayAngle).toBeCloseTo(-Math.PI / 2); |
| 242 | draw(675); |
| 243 | expect(state.frames.at(-1)!.displayAngle).toBeCloseTo(-Math.PI / 4); |
| 244 | draw(900); |
| 245 | expect(state.frames.at(-1)!.displayAngle).toBeCloseTo(0); |
| 246 | expect(state.frames.at(-1)!.scene).toBe(scene); |
| 247 | viewer.dispose(); |
| 248 | }); |
| 249 | |
| 250 | it("animates the Android hinge on the same scene through an encoder resize", () => { |
| 251 | const openProfile = resolveDeviceShape({ platform: "android", portraitAspect: 0.96 }); |
| 252 | const { viewer, draw, source, state } = fixture(openProfile); |
| 253 | viewer.setFoldAngle(180); |
| 254 | draw(0); |
| 255 | const shell = state.frames.at(-1)!.phone!; |
| 256 | const moving = shell.children[0]!.children[0]!; |
| 257 | viewer.setFoldAngle(0); |
| 258 | draw(425); |
| 259 | expect(moving.rotation.y).toBeCloseTo(Math.PI / 2); |
| 260 | source.width = 1080; |
| 261 | source.height = 2424; |
| 262 | viewer.setScreen({ width: 1080, height: 2424, orientation: "portrait" }, ANDROID_PHONE_SHAPE); |
| 263 | viewer.frameUpdated(); |
| 264 | draw(850); |
| 265 | expect(moving.rotation.y).toBeCloseTo(Math.PI); |
| 266 | expect(state.frames.at(-1)!.phone).toBe(shell); |
| 267 | expect(gpu.instances).toHaveLength(1); |
| 268 | viewer.dispose(); |
| 269 | }); |
| 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 | |
| 318 | it("retargets an unfinished hinge turn from its visible angle", () => { |
| 319 | const { viewer, draw, state } = fixture(ANDROID_PHONE_SHAPE); |
| 320 | viewer.setFoldAngle(180); |
| 321 | draw(0); |
| 322 | const moving = state.frames.at(-1)!.phone!.children[0]!.children[0]!; |
| 323 | viewer.setFoldAngle(0); |
| 324 | draw(200); |
| 325 | const visibleAngle = moving.rotation.y; |
| 326 | viewer.setFoldAngle(180); |
| 327 | draw(200); |
| 328 | expect(moving.rotation.y).toBeCloseTo(visibleAngle); |
| 329 | draw(1050); |
| 330 | expect(moving.rotation.y).toBeCloseTo(0); |
| 331 | viewer.dispose(); |
| 332 | }); |
| 333 | |
| 334 | it("stops a hinge turn when a loaded model replaces the fold scene", async () => { |
| 335 | const { viewer, draw, pending } = fixture(ANDROID_PHONE_SHAPE); |
| 336 | viewer.setFoldAngle(180); |
| 337 | draw(0); |
| 338 | viewer.setFoldAngle(0); |
| 339 | viewer.setModel({ id: "iphone-18-pro", url: "/fold.glb" }); |
| 340 | const asset = new Group(); |
| 341 | const body = new Mesh(new BoxGeometry(1, 2, 0.1), new MeshBasicMaterial()); |
| 342 | const display = new Mesh(new PlaneGeometry(0.9, 1.9), new MeshBasicMaterial()); |
| 343 | display.name = "device-screen"; |
| 344 | asset.add(body, display); |
| 345 | models.pending[0]!.resolve({ asset, dispose: () => disposeDeviceModel(asset) }); |
| 346 | await Promise.resolve(); |
| 347 | draw(200); |
| 348 | draw(1050); |
| 349 | expect(pending.size).toBe(0); |
| 350 | viewer.dispose(); |
| 351 | }); |
| 352 | |
| 353 | it("keeps a loaded model when the fold angle changes and releases it once", async () => { |
| 354 | const { viewer, draw, state } = fixture(ANDROID_PHONE_SHAPE); |
| 355 | viewer.setModel({ id: "iphone-18-pro", url: "/pro.glb" }); |
| 356 | const asset = new Group(); |
| 357 | const body = new Mesh(new BoxGeometry(1.15, 2.3, 0.1), new MeshBasicMaterial()); |
| 358 | body.position.z = -0.02; |
| 359 | const display = new Mesh(new PlaneGeometry(1, 2.2), new MeshBasicMaterial()); |
| 360 | display.geometry.translate(0, 0, 0.043); |
| 361 | display.name = "device-screen"; |
| 362 | asset.add(body, display); |
| 363 | const dispose = vi.fn(() => disposeDeviceModel(asset)); |
| 364 | models.pending[0]!.resolve({ asset, dispose }); |
| 365 | await Promise.resolve(); |
| 366 | draw(); |
| 367 | const loaded = state.frames.at(-1)!.phone; |
| 368 | expect(loaded?.getObjectByName("device-screen")).toBe(display); |
| 369 | viewer.setFoldAngle(180); |
| 370 | viewer.setFoldAngle(0); |
| 371 | draw(); |
| 372 | expect(state.frames.at(-1)!.phone).toBe(loaded); |
| 373 | viewer.dispose(); |
| 374 | expect(dispose).toHaveBeenCalledOnce(); |
| 375 | }); |
| 376 | |
| 377 | it("retains the loaded model and pose through rotation and framebuffer resolution changes, then releases it once", async () => { |
| 378 | const { viewer, draw, source, state } = fixture(); |
| 379 | viewer.orbit(0.08, 0.04); |
| 380 | draw(); |
| 381 | const yaw = state.frames.at(-1)!.yaw; |
| 382 | viewer.setModel({ id: "iphone-18-pro", url: "/pro.glb" }); |
| 383 | const asset = new Group(); |
| 384 | const body = new Mesh(new BoxGeometry(1.15, 2.3, 0.1), new MeshBasicMaterial()); |
| 385 | body.position.z = -0.02; |
| 386 | const display = new Mesh(new PlaneGeometry(1, 2.2), new MeshBasicMaterial()); |
| 387 | display.geometry.translate(0, 0, 0.043); |
| 388 | display.name = "device-screen"; |
| 389 | asset.add(body, display); |
| 390 | const release = vi.spyOn(body.geometry, "dispose"); |
| 391 | const dispose = vi.fn(() => disposeDeviceModel(asset)); |
| 392 | models.pending[0]!.resolve({ asset, dispose }); |
| 393 | await Promise.resolve(); |
| 394 | draw(); |
| 395 | const loaded = state.frames.at(-1)!.phone; |
| 396 | expect(loaded?.getObjectByName("device-screen")).toBe(display); |
| 397 | expect(state.frames.at(-1)!.yaw).toBe(yaw); |
| 398 | source.width = 2622; |
| 399 | source.height = 1206; |
| 400 | viewer.setScreen({ width: 2622, height: 1206, orientation: "landscape_left" }); |
| 401 | viewer.frameUpdated(); |
| 402 | viewer.resize(700, 400, 1); |
| 403 | draw(); |
| 404 | expect(state.frames.at(-1)!.phone).toBe(loaded); |
| 405 | expect(state.frames.at(-1)!.yaw).toBe(yaw); |
| 406 | expect(display.material.map?.image).toBe(source); |
| 407 | expect(release).not.toHaveBeenCalled(); |
| 408 | expect(gpu.instances).toHaveLength(1); |
| 409 | viewer.dispose(); |
| 410 | viewer.dispose(); |
| 411 | expect(dispose).toHaveBeenCalledOnce(); |
| 412 | expect(release).toHaveBeenCalledOnce(); |
| 413 | }); |
| 414 | |
| 415 | it("attaches and detaches an accessory without replacing the device, stream texture, renderer or pose", async () => { |
| 416 | const { viewer, draw, source, state, onFramingAspect } = fixture(); |
| 417 | viewer.setModel({ id: "ipad-pro-13-m5", url: "/ipad.glb" }); |
| 418 | const asset = new Group(); |
| 419 | const body = new Mesh(new BoxGeometry(1.8, 2.3, 0.1), new MeshBasicMaterial()); |
| 420 | const display = new Mesh(new PlaneGeometry(1.7, 2.2), new MeshBasicMaterial()); |
| 421 | display.geometry.translate(0, 0, 0.043); |
| 422 | display.name = "device-screen"; |
| 423 | asset.add(body, display); |
| 424 | const releaseBody = vi.fn(() => disposeDeviceModel(asset)); |
| 425 | models.pending[0]!.resolve({ asset, dispose: releaseBody }); |
| 426 | await Promise.resolve(); |
| 427 | viewer.orbit(0.1, 0.05); |
| 428 | draw(); |
| 429 | const before = state.frames.at(-1)!; |
| 430 | const bareAspect = onFramingAspect.mock.lastCall![0]; |
| 431 | const keyboard = new Group(); |
| 432 | const deck = new Mesh(new BoxGeometry(2, 1, 1), new MeshBasicMaterial()); |
| 433 | deck.position.set(1, 0, 0.5); |
| 434 | keyboard.add(deck); |
| 435 | const releaseKeyboard = vi.fn(() => disposeDeviceModel(keyboard)); |
| 436 | viewer.setAccessory({ |
| 437 | id: "ipad-pro-13-m5-magic-keyboard", |
| 438 | modelId: "ipad-pro-13-m5", |
| 439 | url: "/keyboard.glb", |
| 440 | }); |
| 441 | models.pending[1]!.resolve({ asset: keyboard, dispose: releaseKeyboard }); |
| 442 | await Promise.resolve(); |
| 443 | draw(); |
| 444 | expect(state.frames.at(-1)!.phone).toBe(before.phone); |
| 445 | expect(keyboard.parent).toBe(asset.parent); |
| 446 | expect(state.frames.at(-1)!.yaw).toBe(before.yaw); |
| 447 | expect(display.material.map?.image).toBe(source); |
| 448 | expect(state.frames.at(-1)!.cameraZ).toBeGreaterThan(before.cameraZ); |
| 449 | expect(onFramingAspect.mock.lastCall![0]).toBeGreaterThan(bareAspect); |
| 450 | const calls = onFramingAspect.mock.calls.length; |
| 451 | viewer.orbit(0.1, 0.05); |
| 452 | viewer.frameUpdated(); |
| 453 | draw(); |
| 454 | expect(onFramingAspect).toHaveBeenCalledTimes(calls); |
| 455 | viewer.setAccessory(null); |
| 456 | draw(); |
| 457 | expect(keyboard.parent).toBeNull(); |
| 458 | expect(onFramingAspect.mock.lastCall![0]).toBeCloseTo(bareAspect); |
| 459 | expect(releaseKeyboard).toHaveBeenCalledOnce(); |
| 460 | expect(releaseBody).not.toHaveBeenCalled(); |
| 461 | expect(state.frames.at(-1)!.phone).toBe(before.phone); |
| 462 | expect(state.frames.at(-1)!.cameraZ).toBeCloseTo(before.cameraZ); |
| 463 | expect(gpu.instances).toHaveLength(1); |
| 464 | viewer.dispose(); |
| 465 | expect(releaseBody).toHaveBeenCalledOnce(); |
| 466 | expect(releaseKeyboard).toHaveBeenCalledOnce(); |
| 467 | }); |
| 468 | |
| 469 | it("rejects incompatible accessories and releases an accessory that finishes after detaching", async () => { |
| 470 | const { viewer } = fixture(); |
| 471 | const keyboard = { |
| 472 | id: "ipad-pro-13-m5-magic-keyboard", |
| 473 | modelId: "ipad-pro-13-m5", |
| 474 | url: "/keyboard.glb", |
| 475 | } as const; |
| 476 | viewer.setAccessory(keyboard); |
| 477 | expect(models.pending).toHaveLength(0); |
| 478 | viewer.setModel({ id: "ipad-pro-13-m5", url: "/ipad.glb" }); |
| 479 | viewer.setAccessory(keyboard); |
| 480 | viewer.setAccessory(null); |
| 481 | expect(models.pending[1]!.signal.aborted).toBe(true); |
| 482 | const dispose = vi.fn(); |
| 483 | const asset = new Group(); |
| 484 | models.pending[1]!.resolve({ asset, dispose }); |
| 485 | await Promise.resolve(); |
| 486 | expect(asset.parent).toBeNull(); |
| 487 | expect(dispose).toHaveBeenCalledOnce(); |
| 488 | viewer.dispose(); |
| 489 | }); |
| 490 | |
| 491 | it("springs an ordinary device back toward its screen, freezes captured input, and stops rendering at rest", () => { |
| 492 | const { viewer, draw, pending, state } = fixture(); |
| 493 | const initialYaw = state.frames.at(-1)!.yaw!; |
| 494 | viewer.setInteractionActive(true, "orbit"); |
| 495 | viewer.orbit(0.8, 0.4); |
| 496 | draw(80); |
| 497 | const dragged = state.frames.at(-1)!; |
| 498 | expect(Math.abs(dragged.yaw! - initialYaw)).toBeGreaterThan(0.05); |
| 499 | viewer.setInteractionActive(false, "orbit"); |
| 500 | draw(160); |
| 501 | viewer.setInteractionActive(true, "touch"); |
| 502 | draw(180); |
| 503 | const captured = state.frames.at(-1)!; |
| 504 | viewer.orbit(1, 1); |
| 505 | viewer.frameUpdated(); |
| 506 | draw(1000); |
| 507 | expect(state.frames.at(-1)!.yaw).toBe(captured.yaw); |
| 508 | expect(state.frames.at(-1)!.cameraZ).toBe(captured.cameraZ); |
| 509 | expect(pending.size).toBe(0); |
| 510 | viewer.setInteractionActive(false, "touch"); |
| 511 | for (let time = 1016; time <= 4000 && pending.size; time += 16) draw(time); |
| 512 | expect(pending.size).toBe(0); |
| 513 | expect(Math.abs(state.frames.at(-1)!.yaw!)).toBeLessThanOrEqual(Math.PI / 3 + 1e-6); |
| 514 | expect(state.frames.at(-1)!.phone).toBe(dragged.phone); |
| 515 | viewer.dispose(); |
| 516 | }); |
| 517 | |
| 518 | it("resets the device to a square front view after orbiting", () => { |
| 519 | const { viewer, draw, pending, state } = fixture(); |
| 520 | const front = state.frames.at(-1)!.rotation!; |
| 521 | expect(front.angleTo(new Quaternion())).toBeLessThan(1e-6); |
| 522 | viewer.orbit(0.8, 0.4); |
| 523 | draw(80); |
| 524 | expect(state.frames.at(-1)!.rotation!.angleTo(front)).toBeGreaterThan(0.05); |
| 525 | viewer.resetPose(); |
| 526 | for (let time = 96; time <= 3000 && pending.size; time += 16) draw(time); |
| 527 | expect(pending.size).toBe(0); |
| 528 | expect(state.frames.at(-1)!.rotation!.angleTo(new Quaternion())).toBeLessThan(1e-6); |
| 529 | viewer.dispose(); |
| 530 | }); |