1import { Matrix4, Quaternion, Vector3 } from "three";
2import { nearestDeviceView } from "./deviceViewSnap.ts";
3import type { DeviceScreenSize } from "./stream.ts";
4
5export type DuoRestFace = "cover" | "inside" | "left" | "right";
6export type DuoRestFrame = { face: DuoRestFace; normal: Vector3; up: Vector3; center: Vector3 };
7export type DuoViewSnap = {
8 rotation: Quaternion;
9 face: DuoRestFace;
10 orientation: DeviceScreenSize["orientation"];
11 center: Vector3;
12 yawLimit: number;
13};
14const orientations = [
15 "portrait",
16 "landscape_left",
17 "portrait_upside_down",
18 "landscape_right",
19] as const;
20const z = new Vector3(0, 0, 1);
21const y = new Vector3(0, 1, 0);
22
23/** Build views from the actual hinged display planes, rather than model-specific Euler offsets. */
24export function duoViewSnaps(frames: readonly DuoRestFrame[], panel: 1 | 3) {
25 const snaps: DuoViewSnap[] = [];
26 for (const frame of frames) {
27 const normal = frame.normal.clone().normalize();
28 if (normal.lengthSq() < 0.5) continue;
29 const right = frame.up.clone().cross(normal).normalize();
30 const up = normal.clone().cross(right).normalize();
31 const faceRotation = new Quaternion()
32 .setFromRotationMatrix(new Matrix4().makeBasis(right, up, normal))
33 .invert();
34 for (let index = 0; index < orientations.length; index++) {
35 // Only these rolls put the partner below the focused lid. Other quarter
36 // turns belong to the upright, whole-display family.
37 if (frame.face === "right" && index !== 0) continue;
38 if (frame.face === "left" && index !== 2) continue;
39 const roll = (panel === 3 ? Math.PI / 2 : 0) - (index * Math.PI) / 2;
40 const rotation = faceRotation.clone().premultiply(new Quaternion().setFromAxisAngle(z, roll));
41 // A leaf-focused view looks slightly down onto its partner, as in a seated laptop.
42 if (frame.face === "left" || frame.face === "right")
43 rotation.premultiply(new Quaternion().setFromAxisAngle(new Vector3(1, 0, 0), Math.PI / 12));
44 if (
45 panel === 3 &&
46 frames.some(
47 (leaf) =>
48 (leaf.face === "left" || leaf.face === "right") &&
49 leaf.normal.clone().applyQuaternion(rotation).z < 0.04,
50 )
51 )
52 continue;
53 // Bound yaw using both real leaf normals. A narrow fold cannot tolerate
54 // the same side view as a fully open display.
55 let yawLimit = frame.face === "cover" ? Math.PI / 9 : Math.PI / 3;
56 if (panel === 3) {
57 for (const sign of [-1, 1]) {
58 for (let step = 1; step <= 60; step++) {
59 const turn = new Quaternion().setFromAxisAngle(y, (sign * step * Math.PI) / 180);
60 if (
61 frames.some(
62 (leaf) =>
63 (leaf.face === "left" || leaf.face === "right") &&
64 leaf.normal.clone().applyQuaternion(rotation).applyQuaternion(turn).z < 0.04,
65 )
66 ) {
67 yawLimit = Math.min(yawLimit, ((step - 1) * Math.PI) / 180);
68 break;
69 }
70 }
71 }
72 }
73 snaps.push({
74 rotation,
75 face: frame.face,
76 orientation: orientations[index]!,
77 center: frame.center.clone(),
78 yawLimit,
79 });
80 }
81 }
82 return snaps;
83}
84
85/** Fold-specific candidates use the base viewer's nearest-family selection. */
86export const nearestDuoView = nearestDeviceView<DuoViewSnap>;