1import { afterEach, expect, it, vi } from "vite-plus/test";
2import type { Scene, PerspectiveCamera, Box3, Quaternion } from "three";
3
4const gpu = vi.hoisted(() => ({
5 instances: [] as {
6 blank: boolean;
7 allocations: number;
8 frames: Scene[];
9 views: { camera: PerspectiveCamera; bounds: Box3; quaternion: Quaternion }[];
10 dispose: ReturnType<typeof vi.fn>;
11 forceContextLoss: ReturnType<typeof vi.fn>;
12 }[],
13 environmentDispose: vi.fn(),
14}));
15vi.mock("three", async () => {
16 const actual = await vi.importActual<typeof import("three")>("three");
17 return {
18 ...actual,
19 WebGLRenderer: class {
20 state = {
21 blank: true,
22 allocations: 0,
23 frames: [] as Scene[],
24 views: [] as { camera: PerspectiveCamera; bounds: Box3; quaternion: Quaternion }[],
25 dispose: vi.fn(),
26 forceContextLoss: vi.fn(),
27 };
28 constructor() {
29 gpu.instances.push(this.state);
30 }
31 setDrawingBufferSize() {
32 this.state.blank = true;
33 this.state.allocations++;
34 }
35 render(scene: Scene, camera: PerspectiveCamera) {
36 this.state.blank = false;
37 this.state.frames.push(scene);
38 const root = scene.children.find((child) => child instanceof actual.Group)!;
39 root.updateMatrixWorld(true);
40 camera.updateMatrixWorld(true);
41 this.state.views.push({
42 camera: camera.clone(),
43 bounds: new actual.Box3().setFromObject(root),
44 quaternion: root.quaternion.clone(),
45 });
46 }
47 dispose() {
48 this.state.dispose();
49 }
50 forceContextLoss() {
51 this.state.forceContextLoss();
52 }
53 },
54 PMREMGenerator: class {
55 fromScene() {
56 return { texture: new actual.Texture(), dispose: gpu.environmentDispose };
57 }
58 dispose() {}
59 },
60 };
61});
62const models = vi.hoisted(() => ({
63 resolve: (_model: { asset: import("three").Group; dispose: () => void }) => {},
64 signal: null as AbortSignal | null,
65}));
66vi.mock("./modelScene.ts", () => ({
67 loadDeviceModel: (_source: unknown, signal: AbortSignal) => {
68 models.signal = signal;
69 return new Promise((resolve) => {
70 models.resolve = resolve;
71 });
72 },
73}));
74import {
75 Group,
76 Mesh,
77 MeshBasicMaterial,
78 PlaneGeometry,
79 Vector3,
80 Euler,
81 Quaternion as Rotation,
82} from "three";
83import { createDuoViewer } from "./duoViewer.ts";
84
85afterEach(() => {
86 vi.useRealTimers();
87 vi.unstubAllGlobals();
88 gpu.instances.length = 0;
89 gpu.environmentDispose.mockClear();
90});
91
92function fixture(
93 reduced = true,
94 onOrientationRequested = vi.fn(),
95 onPanelRequested?: (panel: 1 | 3) => void,
96) {
97 const pending = new Map<number, FrameRequestCallback>();
98 let id = 0;
99 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
100 pending.set(++id, callback);
101 return id;
102 });
103 vi.stubGlobal("cancelAnimationFrame", (id: number) => pending.delete(id));
104 vi.stubGlobal("matchMedia", () => ({ matches: reduced }));
105 const drawImage = vi.fn();
106 vi.stubGlobal("document", {
107 createElement: () => ({
108 width: 0,
109 height: 0,
110 getContext: () => ({
111 fillRect() {},
112 save() {},
113 restore() {},
114 translate() {},
115 rotate() {},
116 drawImage,
117 getImageData: () => ({ data: new Uint8ClampedArray(8 * 8 * 4).fill(255) }),
118 }),
119 }),
120 });
121 const canvas = new EventTarget() as HTMLCanvasElement;
122 const viewer = createDuoViewer({
123 canvas,
124 sources: {
125 1: { width: 1398, height: 2034 } as HTMLCanvasElement,
126 3: { width: 2007, height: 2853 } as HTMLCanvasElement,
127 },
128 model: { id: "iphone-duo", url: "/duo.glb" },
129 onUnavailable: vi.fn(),
130 onOrientationRequested,
131 ...(onPanelRequested ? { onPanelRequested } : {}),
132 });
133 const draw = () => {
134 const callbacks = [...pending.values()];
135 pending.clear();
136 callbacks.forEach((callback) => callback(0));
137 };
138 return { viewer, draw, pending, state: gpu.instances[0]!, onOrientationRequested, drawImage };
139}
140
141function asset() {
142 const group = new Group();
143 for (const [name, names] of [
144 ["left-half", ["cover-display", "inner-display-left"]],
145 ["right-half", ["inner-display-right"]],
146 ] as const) {
147 const leaf = new Group();
148 leaf.name = name;
149 for (const name of names) {
150 const mesh = new Mesh(new PlaneGeometry(1, 2), new MeshBasicMaterial());
151 if (name === "cover-display") mesh.geometry.rotateY(Math.PI);
152 mesh.geometry.translate(
153 name === "inner-display-right" ? 0.5 : -0.5,
154 0,
155 name === "cover-display" ? -0.05 : 0.05,
156 );
157 mesh.name = name;
158 leaf.add(mesh);
159 }
160 group.add(leaf);
161 }
162 return group;
163}
164
165it("coalesces resize with redraw, retains the renderer and scene, and settles without an idle animation loop", async () => {
166 let now = 0;
167 vi.stubGlobal("performance", { now: () => now });
168 const { viewer, draw, pending, state } = fixture();
169 const dispose = vi.fn();
170 models.resolve({ asset: asset(), dispose });
171 await Promise.resolve();
172 viewer.setScreen({
173 width: 1398,
174 height: 2034,
175 orientation: "portrait",
176 screenId: 1,
177 hingeAngle: 0,
178 });
179 viewer.resize(500, 700, 2);
180 draw();
181 const scene = state.frames.at(-1);
182 viewer.resize(450, 700, 2);
183 viewer.resize(400, 700, 2);
184 viewer.orbit(0.1, 0.04);
185 expect(state.blank).toBe(false);
186 expect(state.allocations).toBe(1);
187 expect(pending.size).toBe(1);
188 draw();
189 expect(state.blank).toBe(false);
190 expect(state.allocations).toBe(2);
191 expect(state.frames.at(-1)).toBe(scene);
192 expect(gpu.instances).toHaveLength(1);
193 now += 1000;
194 draw();
195 draw();
196 expect(pending.size).toBe(0);
197 viewer.dispose();
198 viewer.dispose();
199 expect(dispose).toHaveBeenCalledOnce();
200 expect(state.dispose).toHaveBeenCalledOnce();
201 expect(gpu.environmentDispose).toHaveBeenCalledOnce();
202 expect(pending.size).toBe(0);
203});
204
205it("cancels loading and disposes a late model after unmount without installing or rendering it", async () => {
206 const { viewer, draw, state } = fixture();
207 viewer.resize(400, 700, 2);
208 viewer.dispose();
209 expect(models.signal?.aborted).toBe(true);
210 const dispose = vi.fn();
211 models.resolve({ asset: asset(), dispose });
212 await Promise.resolve();
213 draw();
214 expect(dispose).toHaveBeenCalledOnce();
215 expect(state.frames).toHaveLength(0);
216});
217
218it("keeps the chosen release view when an app locks orientation, freezes it during contact, and stops drawing after release", async () => {
219 let now = 0;
220 vi.stubGlobal("performance", { now: () => now });
221 const { viewer, draw, state, pending, onOrientationRequested } = fixture();
222 models.resolve({ asset: asset(), dispose: vi.fn() });
223 await Promise.resolve();
224 const screen = {
225 width: 1398,
226 height: 2034,
227 orientation: "portrait" as const,
228 screenId: 1,
229 hingeAngle: 0,
230 };
231 viewer.setScreen(screen);
232 viewer.resize(500, 700, 2);
233 draw();
234 const initial = state.views.at(-1)!.quaternion.clone();
235 viewer.setInteractionActive(true, "orbit");
236 viewer.orbit(0, Math.PI / 3);
237 draw();
238 const held = state.views.at(-1)!.quaternion.clone();
239 now = 1000;
240 viewer.frameUpdated(1);
241 draw();
242 expect(state.views.at(-1)!.quaternion.angleTo(held)).toBeLessThan(1e-6);
243 expect(onOrientationRequested).not.toHaveBeenCalled();
244 viewer.setInteractionActive(false, "orbit");
245 draw();
246 expect(onOrientationRequested).toHaveBeenCalledOnce();
247 const chosen = state.views.at(-1)!.quaternion.clone();
248 expect(chosen.angleTo(initial)).toBeGreaterThan(1);
249 viewer.setScreen({ ...screen }); // Native config confirms the sensor command, even if the app stays portrait.
250 draw();
251 expect(state.views.at(-1)!.quaternion.angleTo(chosen)).toBeLessThan(1e-6);
252 expect(pending.size).toBe(0);
253 viewer.resetPose();
254 draw();
255 expect(onOrientationRequested).toHaveBeenCalledTimes(2);
256 expect(onOrientationRequested.mock.lastCall?.[0]).toBe("portrait");
257 viewer.setScreen({ ...screen });
258 draw();
259 expect(pending.size).toBe(0);
260 viewer.dispose();
261});
262
263it("settles a hinge transition after a throttled frame instead of stretching time", async () => {
264 let now = 100;
265 vi.stubGlobal("performance", { now: () => now });
266 const { viewer, draw, pending } = fixture(false);
267 models.resolve({ asset: asset(), dispose: vi.fn() });
268 await Promise.resolve();
269 viewer.resize(400, 700, 2);
270 viewer.setScreen({
271 width: 1398,
272 height: 2034,
273 orientation: "portrait",
274 screenId: 1,
275 hingeAngle: 0,
276 });
277 draw();
278 viewer.setScreen({
279 width: 2007,
280 height: 2853,
281 orientation: "portrait",
282 screenId: 3,
283 hingeAngle: 180,
284 });
285 now += 1_000;
286 draw();
287 draw();
288 expect(pending.size).toBe(0);
289 viewer.dispose();
290});
291
292it("faces a display handed off by native rotation without requesting another sensor rotation", async () => {
293 let now = 0;
294 vi.stubGlobal("performance", { now: () => now });
295 const { viewer, draw, pending, onOrientationRequested } = fixture();
296 const loaded = asset();
297 models.resolve({ asset: loaded, dispose: vi.fn() });
298 await Promise.resolve();
299 viewer.resize(500, 700, 2);
300 viewer.setScreen({
301 width: 2007,
302 height: 2853,
303 orientation: "portrait",
304 screenId: 3,
305 hingeAngle: 90,
306 });
307 draw();
308 viewer.setInteractionActive(true, "orbit");
309 viewer.orbit(0, Math.PI / 6);
310 viewer.setInteractionActive(false, "orbit");
311 now = 1000;
312 draw();
313 const requests = onOrientationRequested.mock.calls.length;
314 expect(requests).toBe(1);
315 viewer.setScreen({
316 width: 1398,
317 height: 2034,
318 orientation: "landscape_left",
319 screenId: 1,
320 hingeAngle: 90,
321 });
322 draw();
323 draw();
324 const cover = loaded.getObjectByName("cover-display") as Mesh;
325 const normal = new Vector3()
326 .fromBufferAttribute(cover.geometry.getAttribute("normal"), 0)
327 .transformDirection(cover.matrixWorld);
328 expect(normal.z).toBeGreaterThan(0.45);
329 expect(onOrientationRequested).toHaveBeenCalledTimes(requests);
330 expect(pending.size).toBe(0);
331 viewer.dispose();
332});
333
334// Recorded from the iPhone Duo simulator: a fold button across the closed
335// position lets the cover report the inner display's orientation before handing
336// off. A button's pending angle reaches the viewer as a hinge preview first.
337it("presents fold buttons centered on the hinge: book, tablet, and an upright closed phone", async () => {
338 // Configurations arrive while the view is still easing; time only advances to settle.
339 let now = 0;
340 vi.stubGlobal("performance", { now: () => now });
341 const { viewer, draw, state, onOrientationRequested } = fixture(false);
342 const loaded = asset();
343 models.resolve({ asset: loaded, dispose: vi.fn() });
344 await Promise.resolve();
345 viewer.resize(500, 700, 2);
346 const cover = { width: 1398, height: 2034, screenId: 1, hingePose: null } as const;
347 const inner = { width: 2007, height: 2853, screenId: 3, hingePose: null } as const;
348 const press = (
349 angle: number,
350 configs: ReadonlyArray<{ orientation: "portrait" | "landscape_left"; screenId: 1 | 3 }>,
351 ) => {
352 viewer.setHingePreview(angle);
353 configs.forEach((config, index) => {
354 viewer.setScreen({
355 ...(config.screenId === 1 ? cover : inner),
356 ...config,
357 hingeAngle: angle,
358 });
359 // The command reply follows the first configuration.
360 if (index === 0) viewer.setHingePreview(null);
361 draw();
362 });
363 now += 5_000;
364 draw();
365 draw();
366 };
367 const expectInnerFacing = (minimum: number) => {
368 for (const name of ["inner-display-left", "inner-display-right"]) {
369 const mesh = loaded.getObjectByName(name) as Mesh;
370 const normal = new Vector3()
371 .fromBufferAttribute(mesh.geometry.getAttribute("normal"), 0)
372 .transformDirection(mesh.matrixWorld);
373 expect(normal.z).toBeGreaterThan(minimum);
374 }
375 };
376 const upright = new Rotation().setFromEuler(new Euler(0, Math.PI / 2, 0, "YXZ"));
377 const closeAndExpectUpright = () => {
378 press(0, [
379 { screenId: 3, orientation: "landscape_left" },
380 { screenId: 1, orientation: "portrait" },
381 ]);
382 expect(state.views.at(-1)!.quaternion.angleTo(upright)).toBeLessThan(0.00001);
383 };
384 viewer.setScreen({ ...inner, orientation: "landscape_left", hingeAngle: 180 });
385 draw();
386 closeAndExpectUpright();
387 press(90, [
388 { screenId: 1, orientation: "portrait" },
389 { screenId: 1, orientation: "landscape_left" },
390 { screenId: 3, orientation: "landscape_left" },
391 // Election can flap back to the cover once before settling.
392 { screenId: 1, orientation: "landscape_left" },
393 { screenId: 3, orientation: "landscape_left" },
394 ]);
395 expectInnerFacing(0.45);
396 // Opening the book flat faces the whole tablet; folding back centers the book again.
397 press(180, [{ screenId: 3, orientation: "landscape_left" }]);
398 expectInnerFacing(0.99);
399 press(90, [{ screenId: 3, orientation: "landscape_left" }]);
400 expectInnerFacing(0.45);
401 closeAndExpectUpright();
402 expect(onOrientationRequested).not.toHaveBeenCalled();
403 viewer.dispose();
404});
405
406it("eases a fold button's hinge while a pinch follows the fingers", async () => {
407 let now = 0;
408 vi.stubGlobal("performance", { now: () => now });
409 const { viewer, draw } = fixture(false);
410 const loaded = asset();
411 models.resolve({ asset: loaded, dispose: vi.fn() });
412 await Promise.resolve();
413 viewer.resize(500, 700, 2);
414 viewer.setScreen({
415 width: 2007,
416 height: 2853,
417 orientation: "landscape_left",
418 screenId: 3,
419 hingeAngle: 180,
420 });
421 draw();
422 const leaf = loaded.getObjectByName("left-half")!;
423 const open = ((180 - 90) * Math.PI) / 360;
424 viewer.setHingePreview(90);
425 draw();
426 expect(leaf.rotation.y).toBeGreaterThan(0);
427 expect(leaf.rotation.y).toBeLessThan(open * 0.9);
428 now += 1_000;
429 draw();
430 expect(leaf.rotation.y).toBeCloseTo(open);
431 viewer.setHingePreview(30, true);
432 expect(leaf.rotation.y).toBeCloseTo(((180 - 30) * Math.PI) / 360);
433 viewer.dispose();
434});
435
436it.each(["book", "laptop", "open"] as const)(
437 "faces native cover readback for %s even when configuration arrives before the model",
438 async (hingePose) => {
439 const { viewer, draw } = fixture();
440 viewer.resize(500, 700, 2);
441 viewer.setScreen({
442 width: 1398,
443 height: 2034,
444 orientation: "landscape_left",
445 screenId: 1,
446 hingeAngle: hingePose === "open" ? 180 : 90,
447 hingePose,
448 });
449 const loaded = asset();
450 models.resolve({ asset: loaded, dispose: vi.fn() });
451 await Promise.resolve();
452 draw();
453 draw();
454 const cover = loaded.getObjectByName("cover-display") as Mesh;
455 const normal = new Vector3()
456 .fromBufferAttribute(cover.geometry.getAttribute("normal"), 0)
457 .transformDirection(cover.matrixWorld);
458 expect(normal.z).toBeGreaterThan(0.45);
459 viewer.dispose();
460 },
461);
462
463it("keeps default-zoom orbits framed across folds and fits the current assembly", async () => {
464 const { viewer, draw, state } = fixture();
465 models.resolve({ asset: asset(), dispose: vi.fn() });
466 await Promise.resolve();
467 viewer.resize(380, 620, 2);
468 viewer.resetPose();
469 viewer.setInteractionActive(true, "orbit");
470 let minimumDistance = Infinity;
471 let maximumDistance = 0;
472 for (const hingeAngle of [0, 30, 90, 127, 180]) {
473 viewer.setScreen({
474 width: 2007,
475 height: 2853,
476 orientation: "portrait",
477 screenId: 3,
478 hingeAngle,
479 });
480 for (let turn = 0; turn < 16; turn++) {
481 viewer.orbit(0.1, turn % 2 ? -0.08 : 0.08);
482 draw();
483 const { camera, bounds } = state.views.at(-1)!;
484 const center = bounds.getCenter(new Vector3()).project(camera);
485 expect(Math.abs(center.x)).toBeLessThan(0.18);
486 expect(Math.abs(center.y)).toBeLessThan(0.18);
487 state.frames.at(-1)!.traverse((object) => {
488 if (!(object instanceof Mesh)) return;
489 const positions = object.geometry.getAttribute("position");
490 for (let index = 0; index < positions.count; index++) {
491 const point = new Vector3()
492 .fromBufferAttribute(positions, index)
493 .applyMatrix4(object.matrixWorld)
494 .project(camera);
495 expect(Math.abs(point.x)).toBeLessThan(1);
496 expect(Math.abs(point.y)).toBeLessThan(1);
497 }
498 });
499 minimumDistance = Math.min(minimumDistance, camera.position.z);
500 maximumDistance = Math.max(maximumDistance, camera.position.z);
501 }
502 }
503 expect(maximumDistance).toBeGreaterThan(minimumDistance);
504 viewer.dispose();
505});
506
507it("pinches the hinge while preserving the laptop lid orientation and blocking unconfirmed input", async () => {
508 const { viewer, draw, pending } = fixture();
509 const loaded = asset();
510 models.resolve({ asset: loaded, dispose: vi.fn() });
511 await Promise.resolve();
512 viewer.resize(500, 700, 2);
513 viewer.setScreen({
514 width: 2853,
515 height: 2007,
516 orientation: "landscape_left",
517 screenId: 3,
518 hingeAngle: 90,
519 hingePose: "laptop",
520 });
521 draw();
522 const lid = loaded.getObjectByName("right-half")!;
523 const presentation = lid.getWorldQuaternion(new Rotation());
524 for (const value of [127, 50, 170]) {
525 viewer.setHingePreview(value, true);
526 draw();
527 expect(loaded.getObjectByName("left-half")!.rotation.y).toBeCloseTo(
528 ((180 - value) * Math.PI) / 360,
529 );
530 expect(lid.getWorldQuaternion(new Rotation()).angleTo(presentation)).toBeLessThan(0.00001);
531 expect(viewer.screenPoint(0.5, 0.5)).toBeNull();
532 viewer.setScreen({
533 width: 2007,
534 height: 2853,
535 orientation: "portrait",
536 screenId: 3,
537 hingeAngle: value,
538 hingePose: null,
539 });
540 draw();
541 expect(lid.getWorldQuaternion(new Rotation()).angleTo(presentation)).toBeLessThan(0.00001);
542 }
543 viewer.setHingePreview(null, true);
544 draw();
545 expect(pending.size).toBe(0);
546 expect(lid.getWorldQuaternion(new Rotation()).angleTo(presentation)).toBeLessThan(0.00001);
547 viewer.dispose();
548});
549
550it("does not restart an animated preset on duplicate native configurations", async () => {
551 let now = 100;
552 vi.stubGlobal("performance", { now: () => now });
553 const { viewer, draw, pending } = fixture(false);
554 models.resolve({ asset: asset(), dispose: vi.fn() });
555 await Promise.resolve();
556 viewer.resize(400, 700, 2);
557 viewer.setScreen({
558 width: 1398,
559 height: 2034,
560 orientation: "portrait",
561 screenId: 1,
562 hingeAngle: 0,
563 hingePose: "closed",
564 });
565 draw();
566 const next = {
567 width: 2007,
568 height: 2853,
569 orientation: "portrait" as const,
570 screenId: 3,
571 hingeAngle: 180,
572 hingePose: "open" as const,
573 };
574 viewer.setScreen(next);
575 now += 1_000;
576 viewer.setScreen(next);
577 draw();
578 now += 1000;
579 draw();
580 draw();
581 expect(pending.size).toBe(0);
582 viewer.dispose();
583});
584
585it.each([true, false])(
586 "stands Tent on both edges when the cover reads it back (configuration before model: %s)",
587 async (configurationFirst) => {
588 // Recorded from the iPhone Duo simulator: Tent can rest with the cover active.
589 const { viewer, draw } = fixture();
590 viewer.resize(500, 700, 2);
591 const tent = {
592 width: 1398,
593 height: 2034,
594 orientation: "portrait",
595 screenId: 1,
596 hingeAngle: 80,
597 hingePose: "tent",
598 } as const;
599 if (configurationFirst) viewer.setScreen(tent);
600 const loaded = asset();
601 models.resolve({ asset: loaded, dispose: vi.fn() });
602 await Promise.resolve();
603 if (!configurationFirst) viewer.setScreen(tent);
604 draw();
605 draw();
606 // Each leaf runs from the hinge down to the surface it rests on.
607 for (const [name, outward] of [
608 ["left-half", -1],
609 ["right-half", 1],
610 ] as const) {
611 const leaf = loaded.getObjectByName(name)!;
612 const down = new Vector3(outward, 0, 0).applyQuaternion(
613 leaf.getWorldQuaternion(new Rotation()),
614 );
615 expect(down.y).toBeLessThan(-0.4);
616 }
617 viewer.dispose();
618 },
619);
620
621it("lets standalone rotation leave Laptop and Tent and stand a closed device upright after hinge edits", async () => {
622 const { viewer, draw, state } = fixture();
623 models.resolve({ asset: asset(), dispose: vi.fn() });
624 await Promise.resolve();
625 viewer.resize(500, 700, 2);
626 viewer.resetPose();
627 for (const hingePose of ["laptop", "tent"] as const) {
628 viewer.setScreen({
629 width: 1398,
630 height: 2034,
631 orientation: "landscape_left",
632 screenId: 1,
633 hingeAngle: 90,
634 hingePose,
635 });
636 draw();
637 viewer.setHingePreview(0);
638 viewer.setScreen({
639 width: 1398,
640 height: 2034,
641 orientation: "landscape_left",
642 screenId: 1,
643 hingeAngle: 0,
644 hingePose: null,
645 });
646 viewer.setHingePreview(null);
647 draw();
648 viewer.cancelInput(); // Explicit toolbar rotation ends folding ownership.
649 viewer.setScreen({
650 width: 1398,
651 height: 2034,
652 orientation: "portrait",
653 screenId: 1,
654 hingeAngle: 0,
655 hingePose: null,
656 });
657 draw();
658 const upright = new Rotation().setFromEuler(new Euler(0, Math.PI / 2, 0, "YXZ"));
659 expect(state.views.at(-1)!.quaternion.angleTo(upright)).toBeLessThan(0.00001);
660 const bounds = state.views.at(-1)!.bounds;
661 expect(bounds.max.y - bounds.min.y).toBeGreaterThan(bounds.max.x - bounds.min.x);
662 }
663 viewer.dispose();
664});
665
666it("snaps onto the opposite screen, requests native handoff once and retains the chosen view on readback", async () => {
667 vi.useFakeTimers();
668 let now = 0;
669 vi.stubGlobal("performance", { now: () => now });
670 const onPanelRequested = vi.fn();
671 const { viewer, draw, state, pending, onOrientationRequested } = fixture(
672 true,
673 vi.fn(),
674 onPanelRequested,
675 );
676 models.resolve({ asset: asset(), dispose: vi.fn() });
677 await Promise.resolve();
678 viewer.resize(500, 700, 2);
679 const inner = {
680 width: 2007,
681 height: 2853,
682 orientation: "portrait" as const,
683 screenId: 3,
684 hingeAngle: 90,
685 supportsPhysicalOrientation: true,
686 };
687 viewer.setScreen(inner);
688 draw();
689 viewer.setInteractionActive(true, "orbit");
690 viewer.orbit(Math.PI / 3, 0);
691 draw();
692 viewer.setInteractionActive(false, "orbit");
693 draw();
694 expect(onPanelRequested).toHaveBeenCalledExactlyOnceWith(1);
695 expect(onOrientationRequested).not.toHaveBeenCalled();
696 expect(viewer.screenPoint(0.5, 0.5)).toBeNull();
697 const chosen = state.views.at(-1)!.quaternion.clone();
698 viewer.setScreen({ ...inner, hingePose: null }); // Command acknowledgement precedes sensor readback.
699 draw();
700 expect(state.views.at(-1)!.quaternion.angleTo(chosen)).toBeLessThan(1e-6);
701 viewer.setScreen({ ...inner, width: 1398, height: 2034, screenId: 1 });
702 draw();
703 expect(state.views.at(-1)!.quaternion.angleTo(chosen)).toBeLessThan(1e-6);
704 vi.advanceTimersByTime(5000);
705 expect(onPanelRequested).toHaveBeenCalledTimes(1);
706 expect(pending.size).toBe(0);
707 viewer.setInteractionActive(true, "orbit");
708 viewer.orbit(-Math.PI / 3, 0);
709 draw();
710 viewer.setInteractionActive(false, "orbit");
711 draw();
712 expect(onPanelRequested).toHaveBeenLastCalledWith(3);
713 const reverse = state.views.at(-1)!.quaternion.clone();
714 viewer.setScreen(inner);
715 draw();
716 expect(state.views.at(-1)!.quaternion.angleTo(reverse)).toBeLessThan(1e-6);
717 vi.advanceTimersByTime(5000);
718 draw();
719 expect(pending.size).toBe(0);
720 expect(onPanelRequested).toHaveBeenCalledTimes(2);
721 viewer.dispose();
722 vi.useRealTimers();
723});
724
725it("rolls an unconfirmed opposite-screen snap back to the native active screen after timeout", async () => {
726 vi.useFakeTimers();
727 const request = vi.fn();
728 const { viewer, draw, state, pending } = fixture(true, vi.fn(), request);
729 models.resolve({ asset: asset(), dispose: vi.fn() });
730 await Promise.resolve();
731 viewer.resize(500, 700, 2);
732 viewer.setScreen({
733 width: 2007,
734 height: 2853,
735 orientation: "portrait",
736 screenId: 3,
737 hingeAngle: 90,
738 supportsPhysicalOrientation: true,
739 });
740 draw();
741 viewer.setInteractionActive(true, "orbit");
742 viewer.orbit(Math.PI / 3, 0);
743 draw();
744 viewer.setInteractionActive(false, "orbit");
745 draw();
746 const unconfirmed = state.views.at(-1)!.quaternion.clone();
747 expect(request).toHaveBeenCalledExactlyOnceWith(1);
748 vi.advanceTimersByTime(5000);
749 draw();
750 expect(state.views.at(-1)!.quaternion.angleTo(unconfirmed)).toBeGreaterThan(0.5);
751 expect(request).toHaveBeenCalledOnce();
752 draw();
753 expect(pending.size).toBe(0);
754 viewer.dispose();
755});
756
757it("uses the elected primary frame during handoff and never lets a stale fixed-panel feed overwrite it", async () => {
758 const { viewer, drawImage } = fixture();
759 const cover = {
760 width: 1398,
761 height: 2034,
762 orientation: "portrait" as const,
763 screenId: 1,
764 hingeAngle: 90,
765 };
766 viewer.setScreen(cover);
767 const primary = { width: 1398, height: 2034 } as HTMLCanvasElement;
768 viewer.frameUpdated(1, primary);
769 expect(drawImage.mock.calls.at(-1)?.[0]).toBe(primary);
770 const uploads = drawImage.mock.calls.length;
771 viewer.frameUpdated(1);
772 expect(drawImage).toHaveBeenCalledTimes(uploads);
773 viewer.frameUpdated(3, primary);
774 expect(drawImage).toHaveBeenCalledTimes(uploads);
775 viewer.setScreen({ ...cover, width: 2007, height: 2853, screenId: 3 });
776 const inner = { width: 2007, height: 2853 } as HTMLCanvasElement;
777 viewer.frameUpdated(3, inner);
778 expect(drawImage.mock.calls.at(-1)?.[0]).toBe(inner);
779 viewer.setScreen(cover);
780 viewer.frameUpdated(1);
781 expect(drawImage.mock.calls.length).toBeGreaterThan(uploads + 1);
782 viewer.dispose();
783});
784
785it.each(["left", "right"] as const)(
786 "keeps the primary surface oriented when pinching over the %s half through closure and reopening",
787 async (face) => {
788 const { viewer, draw, state, pending, onOrientationRequested } = fixture();
789 const loaded = asset();
790 models.resolve({ asset: loaded, dispose: vi.fn() });
791 await Promise.resolve();
792 viewer.resize(500, 700, 2);
793 const inner = {
794 width: 2007,
795 height: 2853,
796 orientation: "portrait" as const,
797 screenId: 3,
798 hingeAngle: 180,
799 hingePose: "open" as const,
800 };
801 viewer.setScreen(inner);
802 draw();
803 expect(viewer.beginHinge(-1, -1)).toBe(false);
804 const leaf = loaded.getObjectByName("right-half")!;
805 const mesh = loaded.getObjectByName(`inner-display-${face}`) as Mesh;
806 const center = mesh.geometry.boundingBox!.getCenter(new Vector3());
807 const point = mesh.localToWorld(center).project(state.views.at(-1)!.camera);
808 expect(viewer.beginHinge((point.x + 1) / 2, (1 - point.y) / 2)).toBe(true);
809 const orientation = leaf.getWorldQuaternion(new Rotation());
810 const partner = loaded.getObjectByName("left-half")!;
811 const partnerStart = partner.getWorldQuaternion(new Rotation());
812 for (const value of [150, 90, 30, 0, 30, 90, 180]) {
813 viewer.setHingePreview(value, true);
814 draw();
815 expect(leaf.getWorldQuaternion(new Rotation()).angleTo(orientation)).toBeLessThan(1e-6);
816 if (value === 90)
817 expect(partner.getWorldQuaternion(new Rotation()).angleTo(partnerStart)).toBeGreaterThan(1);
818 const next =
819 value === 0
820 ? { ...inner, width: 1398, height: 2034, screenId: 1, hingeAngle: value, hingePose: null }
821 : { ...inner, hingeAngle: value, hingePose: null };
822 viewer.setScreen(next);
823 viewer.setHingePreview(null, true);
824 draw();
825 viewer.setScreen({ ...next }); // Late native readback cannot change the view.
826 draw();
827 expect(leaf.getWorldQuaternion(new Rotation()).angleTo(orientation)).toBeLessThan(1e-6);
828 expect(viewer.screenPoint(0.5, 0.5)).toBeNull(); // No fresh native frame yet.
829 if (value === 0) {
830 const cover = loaded.getObjectByName("cover-display") as Mesh;
831 const center = cover.geometry.boundingBox!.getCenter(new Vector3());
832 const hit = cover.localToWorld(center).project(state.views.at(-1)!.camera);
833 expect(viewer.beginHinge((hit.x + 1) / 2, (1 - hit.y) / 2)).toBe(true);
834 }
835 }
836 expect(onOrientationRequested).not.toHaveBeenCalled();
837 draw();
838 expect(pending.size).toBe(0);
839 viewer.dispose();
840 },
841);