authorgravatar for 30420087+gabrielelpidio@users.noreply.github.comGabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> 2026-10-07 14:36:18-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 14:36:18-04:00
logba0ea3d152150f399827d36a5b4b86c5bbda2d7f
tree33fbf445aecaf8ebb27beae7eb3aeed85d8c9e3f
parent588d32e22b10932404856d9bdd9f2f39c7e38070
signature Signed by PGP key B5690EEEBB952194

fix(web): iPhone Duo folds animate, center on the hinge, and keep the phone's orientation (#16885)

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

6 files changed, 289 insertions(+), 28 deletions(-)

apps/web/src/components/device/DeviceDuoControls.tsx+29-4
......@@ -1,5 +1,7 @@
1import { useState } from "react";
12import {
23 duoFoldState,
4 duoHoldOrientation,
35 type DuoCommand,
46 type DuoControlState,
57} from "@t3tools/client-runtime/device/duo-control";
......@@ -31,7 +33,18 @@ export function DeviceDuoControls(props: {
3133 onCommand: (command: DuoCommand) => void;
3234}) {
3335 const { screen } = props;
34 const { fold, stand, phoneVertical } = duoFoldState(screen);
36 const { fold, stand, phoneVertical: reportedVertical, settled } = duoFoldState(screen);
37 // A fold never changes how the phone is held. Keep the last settled reading
38 // through display handoffs, whose interim orientation belongs to the other display.
39 const [settledVertical, setSettledVertical] = useState(reportedVertical);
40 if (settled && settledVertical !== reportedVertical) setSettledVertical(reportedVertical);
41 // Stands rotate the device. Folding out of one returns it to how it was held before.
42 const [standVertical, setStandVertical] = useState(settledVertical);
43 const phoneVertical = stand ? standVertical : settledVertical;
44 // Folding out of a stand sends the rotation back, then the fold, and only one command can
45 // wait. Hold the fold buttons until a stand, or anything queued on one, has landed; the
46 // rotation must also be read in the frame of the display the stand settles on.
47 const foldWaits = props.state.pending && (stand || props.state.requested?.control === "pose");
3548 const foldLabels = {
3649 closed: "Closed",
3750 half: phoneVertical ? "Book" : "Laptop",
......@@ -43,6 +56,7 @@ export function DeviceDuoControls(props: {
4356 pressed: boolean,
4457 onClick: () => void,
4558 glyph: React.ReactNode,
59 waits = false,
4660 ) => (
4761 <Tooltip key={key}>
4862 <TooltipTrigger
......@@ -50,7 +64,7 @@ export function DeviceDuoControls(props: {
5064 <Button
5165 size="icon"
5266 variant={pressed ? "secondary" : "ghost"}
53 disabled={!props.enabled}
67 disabled={!props.enabled || waits}
5468 aria-label={label}
5569 aria-pressed={pressed}
5670 data-pressed={pressed ? "" : undefined}
......@@ -73,8 +87,16 @@ export function DeviceDuoControls(props: {
7387 id,
7488 foldLabels[id],
7589 !stand && fold === id,
76 () => props.onCommand({ control: "angle", value }),
90 () => {
91 if (stand)
92 props.onCommand({
93 control: "orientation",
94 value: duoHoldOrientation(standVertical, screen.screenId),
95 });
96 props.onCommand({ control: "angle", value });
97 },
7798 <DeviceDuoGlyph pose={id === "half" ? "book" : id} rotated={!phoneVertical} />,
99 foldWaits,
78100 ),
79101 )}
80102 </div>
......@@ -84,7 +106,10 @@ export function DeviceDuoControls(props: {
84106 id,
85107 label,
86108 screen.hingePose === id,
87 () => props.onCommand({ control: "pose", value: id }),
109 () => {
110 if (!stand) setStandVertical(settledVertical);
111 props.onCommand({ control: "pose", value: id });
112 },
88113 <DeviceDuoGlyph pose={id} />,
89114 ),
90115 )}
apps/web/src/components/device/DeviceDuoViewport.tsx+1-1
......@@ -127,7 +127,7 @@ export function DeviceDuoViewport(props: {
127127 (screenRef.current?.screenId === 1 ? 0 : 180),
128128 contains: (x, y) => !!screenRef.current && viewer.beginHinge(x, y),
129129 change: (angle) => {
130 viewer.setHingePreview(angle ?? previewRef.current);
130 viewer.setHingePreview(angle ?? previewRef.current, true);
131131 if (angle !== null) client.current?.controlDuo({ control: "angle", value: angle });
132132 },
133133 });
packages/client-runtime/src/device/duoControl.test.ts+40-3
......@@ -1,5 +1,11 @@
11import { afterEach, expect, it, vi } from "vite-plus/test";
2import { createDuoControl, createDuoPinch, duoFoldState, type DuoCommand } from "./duoControl.ts";
2import {
3 createDuoControl,
4 createDuoPinch,
5 duoFoldState,
6 duoHoldOrientation,
7 type DuoCommand,
8} from "./duoControl.ts";
39afterEach(() => vi.useRealTimers());
410
511it("keeps a failed send visible, including a disconnect while draining queued motion", () => {
......@@ -136,13 +142,27 @@ it.each([
136142 ],
137143 ["half-open book", { screenId: 3, orientation: "landscape_left", hingeAngle: 90 }, "half", true],
138144] as const)("reads a %s", (_name, screen, fold, phoneVertical) => {
139 expect(duoFoldState(screen)).toEqual({ fold, stand: false, phoneVertical });
145 expect(duoFoldState(screen)).toEqual({ fold, stand: false, phoneVertical, settled: true });
146});
147
148it("marks a display handoff unsettled while the cover reports the inner display's orientation", () => {
149 // Recorded from the iPhone Duo simulator opening a closed vertical phone to a book.
150 const handoff = [
151 { screenId: 1, orientation: "portrait", hingeAngle: 90 },
152 { screenId: 1, orientation: "landscape_left", hingeAngle: 90 },
153 { screenId: 3, orientation: "landscape_left", hingeAngle: 90 },
154 ] as const;
155 expect(handoff.map((screen) => duoFoldState(screen).settled)).toEqual([false, false, true]);
156 // Closing hands back to the cover the same way.
157 expect(duoFoldState({ screenId: 3, orientation: "landscape_left", hingeAngle: 0 }).settled).toBe(
158 false,
159 );
140160});
141161
142162it("marks native stands so the fold group does not also claim them", () => {
143163 expect(
144164 duoFoldState({ screenId: 3, orientation: "portrait", hingeAngle: 90, hingePose: "laptop" }),
145 ).toEqual({ fold: "half", stand: true, phoneVertical: false });
165 ).toEqual({ fold: "half", stand: true, phoneVertical: false, settled: true });
146166});
147167
148168it("falls back like the 3D view when hinge fields are missing", () => {
......@@ -156,3 +176,20 @@ it("falls back like the 3D view when hinge fields are missing", () => {
156176 phoneVertical: true,
157177 });
158178});
179
180it("rotates a stand back to how the phone was held, in the frame of the display receiving it", () => {
181 // Recorded from the iPhone Duo simulator: Tent can rest on either display.
182 // Rotating the inner display to landscape_left, or the cover to portrait,
183 // leaves a phone that opens and closes vertical.
184 expect(duoHoldOrientation(true, 3)).toBe("landscape_left");
185 expect(duoHoldOrientation(true, 1)).toBe("portrait");
186 // A quarter turn apart, these describe the same horizontal phone.
187 expect(duoHoldOrientation(false, 1)).toBe("landscape_left");
188 expect(duoHoldOrientation(false, 3)).toBe("portrait_upside_down");
189 for (const screenId of [1, 3])
190 for (const vertical of [true, false])
191 expect(
192 duoFoldState({ screenId, orientation: duoHoldOrientation(vertical, screenId) })
193 .phoneVertical,
194 ).toBe(vertical);
195});
packages/client-runtime/src/device/duoControl.ts+25
......@@ -20,6 +20,17 @@ export type DuoControlState = {
2020 error: string | null;
2121};
2222
23/**
24 * Whether the reporting display is the one the fold shows: the cover when closed, the inner
25 * display when open. A fold across the closed position hands off in steps, and the outgoing
26 * display first reports the incoming one's orientation, so an unsettled screen's orientation
27 * does not say how the phone is held. Stands such as Tent can also rest unsettled on the cover.
28 */
29export function duoScreenSettled(screen: Pick<DeviceScreenSize, "screenId" | "hingeAngle">) {
30 const angle = screen.hingeAngle ?? (screen.screenId === 1 ? 0 : 180);
31 return (screen.screenId === 1) === (angle === 0);
32}
33
2334/**
2435 * The fold the device is in and the way it is held. Missing hinge fields fall back the same way
2536 * the 3D view does, so controls never disagree with what is drawn. The inner panel is mounted a
......@@ -34,9 +45,23 @@ export function duoFoldState(
3445 fold: angle === 0 ? "closed" : angle === 180 ? "open" : "half",
3546 stand: screen.hingePose === "laptop" || screen.hingePose === "tent",
3647 phoneVertical: screen.screenId === 1 ? !landscape : landscape,
48 settled: duoScreenSettled(screen),
3749 } as const;
3850}
3951
52/**
53 * The rotation that holds the phone vertical or horizontal. Native rotation is
54 * read in the frame of the display active when it arrives, and the inner panel
55 * is mounted a quarter turn from the cover.
56 */
57export function duoHoldOrientation(
58 vertical: boolean,
59 screenId: number | undefined,
60): DuoOrientation {
61 if (screenId === 1) return vertical ? "portrait" : "landscape_left";
62 return vertical ? "landscape_left" : "portrait_upside_down";
63}
64
4065/** One in-flight native transaction. Hinge motion coalesces; presets replace queued motion. Nothing replays after reconnect. */
4166export function createDuoControl(options: {
4267 send: (request: { requestId: number; command: DuoCommand }) => boolean;
packages/client-runtime/src/device/duoViewer.test.ts+143-5
......@@ -331,6 +331,108 @@ it("faces a display handed off by native rotation without requesting another sen
331331 viewer.dispose();
332332});
333333
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
334436it.each(["book", "laptop", "open"] as const)(
335437 "faces native cover readback for %s even when configuration arrives before the model",
336438 async (hingePose) => {
......@@ -402,7 +504,7 @@ it("keeps default-zoom orbits framed across folds and fits the current assembly"
402504 viewer.dispose();
403505});
404506
405it("previews hinge articulation while preserving the laptop lid orientation and blocking unconfirmed input", async () => {
507it("pinches the hinge while preserving the laptop lid orientation and blocking unconfirmed input", async () => {
406508 const { viewer, draw, pending } = fixture();
407509 const loaded = asset();
408510 models.resolve({ asset: loaded, dispose: vi.fn() });
......@@ -420,7 +522,7 @@ it("previews hinge articulation while preserving the laptop lid orientation and
420522 const lid = loaded.getObjectByName("right-half")!;
421523 const presentation = lid.getWorldQuaternion(new Rotation());
422524 for (const value of [127, 50, 170]) {
423 viewer.setHingePreview(value);
525 viewer.setHingePreview(value, true);
424526 draw();
425527 expect(loaded.getObjectByName("left-half")!.rotation.y).toBeCloseTo(
426528 ((180 - value) * Math.PI) / 360,
......@@ -438,7 +540,7 @@ it("previews hinge articulation while preserving the laptop lid orientation and
438540 draw();
439541 expect(lid.getWorldQuaternion(new Rotation()).angleTo(presentation)).toBeLessThan(0.00001);
440542 }
441 viewer.setHingePreview(null);
543 viewer.setHingePreview(null, true);
442544 draw();
443545 expect(pending.size).toBe(0);
444546 expect(lid.getWorldQuaternion(new Rotation()).angleTo(presentation)).toBeLessThan(0.00001);
......@@ -480,6 +582,42 @@ it("does not restart an animated preset on duplicate native configurations", asy
480582 viewer.dispose();
481583});
482584
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
483621it("lets standalone rotation leave Laptop and Tent and stand a closed device upright after hinge edits", async () => {
484622 const { viewer, draw, state } = fixture();
485623 models.resolve({ asset: asset(), dispose: vi.fn() });
......@@ -672,7 +810,7 @@ it.each(["left", "right"] as const)(
672810 const partner = loaded.getObjectByName("left-half")!;
673811 const partnerStart = partner.getWorldQuaternion(new Rotation());
674812 for (const value of [150, 90, 30, 0, 30, 90, 180]) {
675 viewer.setHingePreview(value);
813 viewer.setHingePreview(value, true);
676814 draw();
677815 expect(leaf.getWorldQuaternion(new Rotation()).angleTo(orientation)).toBeLessThan(1e-6);
678816 if (value === 90)
......@@ -682,7 +820,7 @@ it.each(["left", "right"] as const)(
682820 ? { ...inner, width: 1398, height: 2034, screenId: 1, hingeAngle: value, hingePose: null }
683821 : { ...inner, hingeAngle: value, hingePose: null };
684822 viewer.setScreen(next);
685 viewer.setHingePreview(null);
823 viewer.setHingePreview(null, true);
686824 draw();
687825 viewer.setScreen({ ...next }); // Late native readback cannot change the view.
688826 draw();
packages/client-runtime/src/device/duoViewer.ts+51-15
......@@ -29,11 +29,12 @@ import { createDeviceMotion } from "./deviceMotion.ts";
2929import { duoViewSnaps, nearestDuoView, type DuoRestFace } from "./duoSnap.ts";
3030import { createRenderScheduler } from "./renderScheduler.ts";
3131import type { DeviceScreenSize } from "./stream.ts";
32import type { DuoPose } from "./duoControl.ts";
32import { duoScreenSettled, type DuoPose } from "./duoControl.ts";
3333
3434export interface DuoViewer {
3535 readonly setScreen: (screen: DeviceScreenSize | null) => void;
36 readonly setHingePreview: (angle: number | null) => void;
36 /** A pinch holds the leaf under it and follows the fingers; a fold button eases the hinge and re-presents the device. */
37 readonly setHingePreview: (angle: number | null, pinch?: boolean) => void;
3738 readonly setInteractionActive: (active: boolean, mode?: "touch" | "orbit") => void;
3839 readonly rejectOrientation: () => void;
3940 readonly frameUpdated: (panel: DuoPanelId, primary?: HTMLCanvasElement) => void;
......@@ -186,6 +187,7 @@ export function createDuoViewer(options: {
186187 const framing = createDeviceFraming();
187188 const framingBounds = new Box3();
188189 let firstPose = true;
190 let foldPending = false;
189191 let physicalPose: DuoPose = "open";
190192 let presentationAngle = 180;
191193 let targetPresentation = new Quaternion();
......@@ -291,7 +293,7 @@ export function createDuoViewer(options: {
291293 if (!model || disposed) return;
292294 scene.add(model.root);
293295 applyPose();
294 if (screen?.screenId === 1) {
296 if (screen?.screenId === 1 && physicalPose !== "tent") {
295297 const snap = nearestDuoView(orbit.rotation, activeSnaps());
296298 if (snap) {
297299 restFace = snap.face;
......@@ -323,13 +325,25 @@ export function createDuoViewer(options: {
323325 const previous = screen;
324326 const ownedHandoff = requestedPanel !== null;
325327 if (!next || next.screenId === requestedPanel) clearHandoff();
326 const ownedRotation = requestedOrientation !== null && next?.screenId === previous?.screenId;
328 const requestedRotation = requestedOrientation !== null;
329 const ownedRotation = requestedRotation && next?.screenId === previous?.screenId;
327330 const changedDisplay = next?.screenId !== previous?.screenId;
328331 requestedOrientation = null;
329332 screen = next;
330333 const changedPose = next?.hingePose && next.hingePose !== previous?.hingePose;
331334 const folding = hingeLeaf !== null && !changedPose;
335 // A hinge change no held leaf owns re-presents the device, centered on the
336 // hinge, once the display that shows the new fold reports. Across the closed
337 // position that takes steps: the outgoing display first reports the incoming
338 // one's orientation.
339 const angleBefore = previous?.hingeAngle ?? (previous?.screenId === 1 ? 0 : 180);
340 const angleAfter = next?.hingeAngle ?? (next?.screenId === 1 ? 0 : 180);
341 if (!next || folding || changedPose) foldPending = false;
342 else if (previous && angleBefore !== angleAfter) foldPending = true;
343 const landedFold = foldPending && !!next && duoScreenSettled(next) && !ownedHandoff;
344 if (next && duoScreenSettled(next)) foldPending = false;
332345 const rotated =
346 !foldPending &&
333347 next?.screenId === previous?.screenId &&
334348 next?.hingeAngle === previous?.hingeAngle &&
335349 next?.orientation !== previous?.orientation;
......@@ -340,16 +354,21 @@ export function createDuoViewer(options: {
340354 !folding &&
341355 !next?.hingePose &&
342356 previewAngle === null;
343 if (firstPose || changedPose || leftPhysicalPose) {
357 if (firstPose || changedPose || leftPhysicalPose || landedFold) {
344358 physicalPose = next?.hingePose ?? (next?.screenId === 1 ? "closed" : "open");
345359 presentationAngle = next?.hingeAngle ?? (next?.screenId === 1 ? 0 : 180);
346360 }
347361 // A command reply/config confirms hinge state. Display identity, never an angle heuristic, owns input.
348362 targetAngle = previewAngle ?? next?.hingeAngle ?? (next?.screenId === 1 ? 0 : 180);
349 // Native angle commands clear hingePose. They change articulation only;
350 // preserve the viewing pose, including laptop/tent and user orbit. A
351 // separate rotation clears the physical preset and follows panel orientation.
352 if (firstPose || changedPose || (rotated && !ownedRotation && !ownedHandoff && !folding)) {
363 // Native angle commands clear hingePose. While a leaf is held (a pinch, or a
364 // fold on a stand) they change articulation only and preserve the viewing
365 // pose. A separate rotation clears the physical preset and follows panel orientation.
366 if (
367 firstPose ||
368 changedPose ||
369 landedFold ||
370 (rotated && !ownedRotation && !ownedHandoff && !folding)
371 ) {
353372 hingeLeaf = null;
354373 viewOrientation = next?.orientation ?? null;
355374 const poseAngle = presentationAngle;
......@@ -371,9 +390,12 @@ export function createDuoViewer(options: {
371390 targetPresentation.premultiply(
372391 new Quaternion().setFromAxisAngle(new Vector3(0, 0, 1), roll),
373392 );
374 if (next?.screenId === 1) {
393 if (next?.screenId === 1 && !landedFold && physicalPose !== "tent") {
375394 // Native readback can retain the cover even for an open physical preset.
376395 // A screen-facing rest must use that display's actual hinged normal.
396 // A landed fold is shut, so its pose already faces the cover; the
397 // hinge is still moving and its current normal would be stale.
398 // Tent stands on both edges with the cover outward; facing it flat would tip it over.
377399 const snap = nearestDuoView(targetPresentation, activeSnaps());
378400 if (snap) targetPresentation.copy(snap.rotation);
379401 }
......@@ -381,9 +403,18 @@ export function createDuoViewer(options: {
381403 restFace = next?.screenId === 1 ? "cover" : physicalPose === "laptop" ? "right" : "inside";
382404 orbit.setPose(targetPresentation, performance.now(), firstPose);
383405 firstPose = false;
384 } else if (next && changedDisplay && !ownedHandoff && !folding) {
406 } else if (
407 next &&
408 changedDisplay &&
409 !ownedHandoff &&
410 !folding &&
411 (restFace === "cover") !== (next.screenId === 1) &&
412 (requestedRotation || duoScreenSettled(next))
413 ) {
385414 // A sensor rotation can hand ownership to the other native display.
386415 // Face that display without sending another rotation and creating a feedback loop.
416 // Native election can briefly flap to the display a fold does not show and
417 // back; neither is a handoff, and the view may still be easing into place.
387418 const snap = nearestDuoView(orbit.rotation, activeSnaps());
388419 if (snap) {
389420 restFace = snap.face;
......@@ -395,19 +426,24 @@ export function createDuoViewer(options: {
395426 fit();
396427 scheduler.invalidate();
397428 },
398 setHingePreview(next) {
429 setHingePreview(next, pinch = false) {
399430 if (disposed || (next !== null && (!Number.isFinite(next) || next < 0 || next > 180))) return;
400431 if (!moving()) lastTime = performance.now();
401432 previewAngle = next;
402 if (next !== null && !hingeLeaf && model) {
433 if (next !== null && !pinch) {
434 // A fold button holds no leaf, so the device is presented, centered on
435 // the hinge, once the fold lands.
436 hingeLeaf = null;
437 } else if (next !== null && !hingeLeaf && model) {
403438 clearHandoff();
404439 requestedOrientation = null;
405440 hingeLeaf = screen?.screenId === 1 && angle > 20 ? "left" : "right";
406441 orbit.setPose(orbit.rotation.clone(), performance.now(), true);
407442 }
408 orbit.hold(interactionActive || next !== null, performance.now());
443 orbit.hold(interactionActive || (pinch && next !== null), performance.now());
409444 targetAngle = next ?? screen?.hingeAngle ?? (screen?.screenId === 1 ? 0 : 180);
410 if (next !== null) {
445 // A pinch follows the fingers; anything else eases toward the target.
446 if (next !== null && pinch) {
411447 angle = next;
412448 applyPose();
413449 fit();