@preference-sl/pref-viewer 2.17.0-beta.4 → 2.17.0-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Pure orbit and view math for the draggable view cube.
3
+ *
4
+ * Uses the Babylon.js ArcRotateCamera convention (Y-up): the camera sits at
5
+ * target + radius * (sin(beta) * cos(alpha), cos(beta), sin(beta) * sin(alpha)).
6
+ * A camera on +Z therefore has alpha = PI / 2 and beta = PI / 2. The module has no Babylon or DOM
7
+ * dependency, so it is safe to import from any environment.
8
+ */
9
+
10
+ /**
11
+ * Orbit pose of a camera around its target, in radians.
12
+ * @typedef {Object} OrbitAngles
13
+ * @property {number} alpha - Longitude around the Y axis, atan2(z, x) convention.
14
+ * @property {number} beta - Polar angle measured from +Y, in [0, PI].
15
+ */
16
+
17
+ /**
18
+ * Plain three-component vector.
19
+ * @typedef {Object} Vec3
20
+ * @property {number} x
21
+ * @property {number} y
22
+ * @property {number} z
23
+ */
24
+
25
+ /**
26
+ * One of the 26 cube views.
27
+ * @typedef {Object} ViewCubeDirection
28
+ * @property {string} id - Signed-axis triple such as "0,0,1" (face), "1,0,1" (edge) or "1,1,1" (corner).
29
+ * @property {Vec3} direction - Normalized direction from the target towards the camera.
30
+ */
31
+
32
+ const TWO_PI = Math.PI * 2;
33
+ const POLE_EPSILON = 1e-9;
34
+
35
+ /**
36
+ * Safety margin, in radians, kept between beta and the poles to avoid gimbal flips.
37
+ * @type {number}
38
+ */
39
+ export const BETA_MARGIN = 0.05;
40
+
41
+ /**
42
+ * Wraps an angle into the half-open interval (-PI, PI].
43
+ * @param {number} angle - Angle in radians; any magnitude.
44
+ * @returns {number} Equivalent angle in (-PI, PI].
45
+ */
46
+ export function normalizeAngle(angle) {
47
+ return angle - TWO_PI * Math.ceil((angle - Math.PI) / TWO_PI);
48
+ }
49
+
50
+ /**
51
+ * Computes the signed shortest angular difference between two angles.
52
+ * @param {number} from - Start angle in radians.
53
+ * @param {number} to - End angle in radians.
54
+ * @returns {number} Signed difference in (-PI, PI]; positive means counter-clockwise growth of the angle.
55
+ */
56
+ export function shortestDelta(from, to) {
57
+ return normalizeAngle(to - from);
58
+ }
59
+
60
+ /**
61
+ * Clamps beta to [BETA_MARGIN, PI - BETA_MARGIN].
62
+ * @param {number} beta - Polar angle in radians.
63
+ * @returns {number} Clamped polar angle.
64
+ */
65
+ export function clampBeta(beta) {
66
+ return Math.min(Math.max(beta, BETA_MARGIN), Math.PI - BETA_MARGIN);
67
+ }
68
+
69
+ /**
70
+ * Converts orbit angles into the unit vector pointing from the target towards the camera.
71
+ * @param {OrbitAngles} pose - Orbit angles in radians.
72
+ * @returns {Vec3} Unit direction (sin(beta) * cos(alpha), cos(beta), sin(beta) * sin(alpha)).
73
+ */
74
+ export function directionFromAngles({ alpha, beta }) {
75
+ const sinBeta = Math.sin(beta);
76
+ return { x: sinBeta * Math.cos(alpha), y: Math.cos(beta), z: sinBeta * Math.sin(alpha) };
77
+ }
78
+
79
+ /**
80
+ * Converts a direction from the target towards the camera into orbit angles.
81
+ * The vector need not be normalized. Beta is clamped with clampBeta(), so pole directions map to
82
+ * the nearest allowed pose. When the horizontal component vanishes (pole) alpha is undefined and
83
+ * fallbackAlpha is returned unchanged to avoid an arbitrary spin.
84
+ * A zero-length or non-finite vector has no direction: the function returns null instead of throwing.
85
+ * @param {Vec3} direction - Direction from the target towards the camera.
86
+ * @param {number} [fallbackAlpha=0] - Alpha to keep at the poles.
87
+ * @returns {OrbitAngles|null} Orbit angles, or null when the vector has no usable direction.
88
+ */
89
+ export function anglesFromDirection({ x, y, z }, fallbackAlpha = 0) {
90
+ const length = Math.hypot(x, y, z);
91
+ if (!Number.isFinite(length) || length === 0) {
92
+ return null;
93
+ }
94
+ const beta = clampBeta(Math.acos(Math.min(Math.max(y / length, -1), 1)));
95
+ const alpha = Math.hypot(x, z) / length < POLE_EPSILON ? fallbackAlpha : Math.atan2(z, x);
96
+ return { alpha, beta };
97
+ }
98
+
99
+ /**
100
+ * Interpolates two orbit poses; alpha follows the shortest angular path, beta is linear.
101
+ * @param {OrbitAngles} a - Start pose.
102
+ * @param {OrbitAngles} b - End pose.
103
+ * @param {number} t - Interpolation factor, 0 gives a and 1 gives b (equivalent modulo 2*PI for alpha).
104
+ * @returns {OrbitAngles} New pose; inputs are not modified.
105
+ */
106
+ export function lerpAngles(a, b, t) {
107
+ return {
108
+ alpha: a.alpha + shortestDelta(a.alpha, b.alpha) * t,
109
+ beta: a.beta + (b.beta - a.beta) * t,
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Applies a drag increment to a pose. Alpha is not wrapped; beta is clamped away from the poles.
115
+ * @param {OrbitAngles} pose - Current pose.
116
+ * @param {number} dAlpha - Alpha increment in radians.
117
+ * @param {number} dBeta - Beta increment in radians.
118
+ * @returns {OrbitAngles} New pose; the input is not modified.
119
+ */
120
+ export function applyDragDelta({ alpha, beta }, dAlpha, dBeta) {
121
+ return { alpha: alpha + dAlpha, beta: clampBeta(beta + dBeta) };
122
+ }
123
+
124
+ /**
125
+ * Builds the 26 signed-axis views in the same order as the cube controller: faces, edges, corners.
126
+ * Ids and directions equal those of BabylonJSViewCubeController.createViewCubeRegions(); a test
127
+ * guards against drift.
128
+ * @returns {ViewCubeDirection[]}
129
+ */
130
+ function createViewCubeDirections() {
131
+ const axes = [];
132
+ for (let i = 0; i < 3; i++) {
133
+ for (const s of [-1, 1]) {
134
+ const axis = [0, 0, 0];
135
+ axis[i] = s;
136
+ axes.push(axis);
137
+ }
138
+ }
139
+ for (let i = 0; i < 3; i++) {
140
+ for (let j = i + 1; j < 3; j++) {
141
+ for (const s of [-1, 1]) {
142
+ for (const t of [-1, 1]) {
143
+ const axis = [0, 0, 0];
144
+ axis[i] = s;
145
+ axis[j] = t;
146
+ axes.push(axis);
147
+ }
148
+ }
149
+ }
150
+ }
151
+ for (const x of [-1, 1]) {
152
+ for (const y of [-1, 1]) {
153
+ for (const z of [-1, 1]) {
154
+ axes.push([x, y, z]);
155
+ }
156
+ }
157
+ }
158
+ return axes.map((axis) => {
159
+ const length = Math.hypot(...axis);
160
+ const [x, y, z] = axis.map((c) => c / length);
161
+ return Object.freeze({ id: axis.join(","), direction: Object.freeze({ x, y, z }) });
162
+ });
163
+ }
164
+
165
+ /**
166
+ * The 26 cube views: 6 faces, 12 edges and 8 corners.
167
+ * @type {ReadonlyArray<ViewCubeDirection>}
168
+ */
169
+ export const VIEW_CUBE_DIRECTIONS = Object.freeze(createViewCubeDirections());
170
+
171
+ // Cube sizing, from an ergonomic evaluation of the 26-region cube. WCAG 2.2 sets a 24x24 CSS px minimum target
172
+ // size, and at the former fixed 100 px the corner spheres measure about 11 px and the edge bars about 4 px
173
+ // (0.26 and 0.09 over the +-1.15 orthographic extent). Finger-sized 44-48 px targets are unreachable with 26
174
+ // regions on one cube, so the cube grows with the viewer within a bounded range and the renderer adds a pick
175
+ // tolerance for small regions.
176
+ /** Share of the smaller viewer dimension used as the cube side before clamping. */
177
+ export const VIEW_CUBE_SIZE_RATIO = 0.22;
178
+ /** Smallest preferred cube side, in CSS pixels, applied before the viewer-share cap. */
179
+ export const VIEW_CUBE_MIN_SIDE = 96;
180
+ /** Largest cube side, in CSS pixels. */
181
+ export const VIEW_CUBE_MAX_SIDE = 180;
182
+ /** Hard cap: the cube never takes more than this share of the smaller viewer dimension. */
183
+ export const VIEW_CUBE_MAX_VIEWPORT_SHARE = 0.4;
184
+
185
+ /**
186
+ * Computes the side of the square view cube for a viewer.
187
+ * side = min(clamp(0.22 * m, 96, 180), 0.4 * m) where m is the smaller viewport dimension, rounded to an integer.
188
+ * @param {number} viewportWidth - Viewer width in CSS pixels.
189
+ * @param {number} viewportHeight - Viewer height in CSS pixels.
190
+ * @returns {number} Integer side in CSS pixels, or 0 when the viewport is empty or not finite.
191
+ */
192
+ export function computeViewCubeSize(viewportWidth, viewportHeight) {
193
+ const smaller = Math.min(viewportWidth, viewportHeight);
194
+ if (!Number.isFinite(viewportWidth) || !Number.isFinite(viewportHeight) || smaller <= 0) {
195
+ return 0;
196
+ }
197
+ const preferred = Math.min(Math.max(VIEW_CUBE_SIZE_RATIO * smaller, VIEW_CUBE_MIN_SIDE), VIEW_CUBE_MAX_SIDE);
198
+ return Math.round(Math.min(preferred, VIEW_CUBE_MAX_VIEWPORT_SHARE * smaller));
199
+ }