@preference-sl/pref-viewer 2.16.2 → 2.17.0-beta.1

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,171 @@
1
+ import { Matrix, Quaternion, Vector3 } from "@babylonjs/core/Maths/math.vector.js";
2
+ import BabylonJSController from "./babylonjs-controller.js";
3
+
4
+ /**
5
+ * Supported camera adapters; the host keeps ownership of their lifecycle.
6
+ * @typedef {import("@babylonjs/core").ArcRotateCamera|import("@babylonjs/core").FreeCamera|import("@babylonjs/core").UniversalCamera} FramingCamera
7
+ */
8
+
9
+ /**
10
+ * Model-only world bounds used for framing.
11
+ * @typedef {Object} CameraFramingBounds
12
+ * @property {Vector3} center - World-space bounding-box center.
13
+ * @property {number} radius - Half the bounding-box diagonal.
14
+ * @property {Vector3[]} corners - Eight world-space bounding-box corners.
15
+ */
16
+
17
+ /**
18
+ * Destination framing shared by the camera adapters.
19
+ * @typedef {Object} CameraFramingFit
20
+ * @property {Vector3} target - Model center in world space.
21
+ * @property {number} distance - Destination camera-to-target world distance.
22
+ * @property {Quaternion} rotation - Destination world orientation.
23
+ * @property {number} halfWidth - Orthographic horizontal half-extent.
24
+ * @property {number} halfHeight - Orthographic vertical half-extent.
25
+ * @property {number} radius - Model bounds radius used for clipping adjustments.
26
+ */
27
+
28
+ /**
29
+ * BabylonJSCameraFraming - Calculates model-only world bounds and projection-aware camera fits.
30
+ *
31
+ * Stateless utilities shared by camera navigation controllers. Destination calculations preserve
32
+ * the camera and its projection; callers apply the returned pose and orthographic extents.
33
+ * Reads the shared camera margin at call time and does not own assets or rendering resources.
34
+ * @class
35
+ */
36
+ export default class BabylonJSCameraFraming {
37
+
38
+ /**
39
+ * Checks that all three vector components are finite numbers.
40
+ * @private
41
+ * @static
42
+ * @param {Vector3|null|undefined} v - Candidate vector.
43
+ * @returns {boolean|null|undefined} True for usable coordinates; otherwise false or the absent input.
44
+ */
45
+ static #finiteVector(v) {
46
+ return v && [v.x, v.y, v.z].every(Number.isFinite);
47
+ }
48
+
49
+ /**
50
+ * Computes model-only world bounds from visible, enabled meshes with valid geometry.
51
+ * Updates mesh world matrices; never includes an environment container implicitly.
52
+ * @public
53
+ * @static
54
+ * @param {import("@babylonjs/core").AssetContainer|null|undefined} container - Model asset container.
55
+ * @returns {CameraFramingBounds|null} Bounds and eight corners, or null for missing/degenerate geometry.
56
+ */
57
+ static getModelBounds(container) {
58
+ let min = null;
59
+ let max = null;
60
+ for (const mesh of container?.meshes ?? []) {
61
+ if ((mesh?.getTotalVertices?.() ?? 0) <= 0 || mesh.isDisposed?.() || mesh.isEnabled?.() === false || mesh.isVisible === false || mesh.visibility <= 0) {
62
+ continue;
63
+ }
64
+ mesh.computeWorldMatrix(true);
65
+ const box = mesh.getBoundingInfo?.()?.boundingBox;
66
+ if (!BabylonJSCameraFraming.#finiteVector(box?.minimumWorld) || !BabylonJSCameraFraming.#finiteVector(box?.maximumWorld)) {
67
+ continue;
68
+ }
69
+ min = min ? Vector3.Minimize(min, box.minimumWorld) : box.minimumWorld.clone();
70
+ max = max ? Vector3.Maximize(max, box.maximumWorld) : box.maximumWorld.clone();
71
+ }
72
+ if (!min || !max) {
73
+ return null;
74
+ }
75
+ const radius = Vector3.Distance(min, max) / 2;
76
+ if (!Number.isFinite(radius) || radius <= 0) {
77
+ return null;
78
+ }
79
+ const corners = [];
80
+ for (const x of [min.x, max.x]) {
81
+ for (const y of [min.y, max.y]) {
82
+ for (const z of [min.z, max.z]) {
83
+ corners.push(new Vector3(x, y, z));
84
+ }
85
+ }
86
+ }
87
+ return { center: Vector3.Center(min, max), radius, corners };
88
+ }
89
+
90
+ /**
91
+ * Checks whether the camera has a supported navigation adapter.
92
+ * @public
93
+ * @static
94
+ * @param {import("@babylonjs/core").Camera|null|undefined} camera - Camera to inspect.
95
+ * @returns {boolean} Whether it is an ArcRotateCamera, FreeCamera or UniversalCamera.
96
+ */
97
+ static isCameraSupported(camera) {
98
+ return ["ArcRotateCamera", "FreeCamera", "UniversalCamera"].includes(camera?.getClassName?.());
99
+ }
100
+
101
+ /**
102
+ * Builds a destination view basis using world +Y as up and the scene handedness.
103
+ * Applies a minimal horizontal offset at the poles to avoid a singular look-at matrix.
104
+ * @private
105
+ * @static
106
+ * @param {Vector3} direction - World-space direction from the target towards the camera.
107
+ * @param {boolean} rightHanded - Whether the scene uses right-handed coordinates.
108
+ * @returns {{view: Matrix, rotation: Quaternion}} View matrix and world orientation.
109
+ */
110
+ static #viewBasis(direction, rightHanded) {
111
+ // A tiny pole offset avoids a singular look-at while keeping world +Y as camera up.
112
+ const eye = direction.clone();
113
+ if (eye.x * eye.x + eye.z * eye.z < 1e-12) {
114
+ eye.z = 0.0001;
115
+ }
116
+ const view = (rightHanded ? Matrix.LookAtRH : Matrix.LookAtLH)(eye.normalize(), Vector3.Zero(), Vector3.Up());
117
+ const rotation = Quaternion.Identity();
118
+ view.clone().invert().decompose(undefined, rotation);
119
+ return { view, rotation };
120
+ }
121
+
122
+ /**
123
+ * Calculates destination framing from model corners without modifying the camera.
124
+ * Uses BabylonJSController.config.camera.fitMarginFactor as the total free viewport fraction.
125
+ * A factor of 0.1 leaves 90% available, reserving 5% at each opposing edge of the limiting axis.
126
+ * Preserves projection, including horizontal-fixed FOV. The factor must be finite and in [0, 0.5].
127
+ * Fits projected world-box corners; minimum near clearance can increase the requested inset.
128
+ * @public
129
+ * @static
130
+ * @param {FramingCamera} camera - Active supported camera.
131
+ * @param {CameraFramingBounds|null} bounds - Model-only world bounds.
132
+ * @param {Vector3} direction - Selected preset direction towards the observer.
133
+ * @param {{width: number, height: number}} viewport - Scene viewport dimensions in pixels.
134
+ * @returns {CameraFramingFit|null} Destination fit, or null when navigation inputs are unusable.
135
+ */
136
+ static calculateCameraFit(camera, bounds, direction, viewport) {
137
+ if (!bounds || !BabylonJSCameraFraming.isCameraSupported(camera) || !(viewport?.width > 0 && viewport?.height > 0)) {
138
+ return null;
139
+ }
140
+ const aspect = viewport.width / viewport.height;
141
+ const basis = BabylonJSCameraFraming.#viewBasis(direction, camera.getScene().useRightHandedSystem);
142
+ // Transform normals: discard the look-at matrix translation for offsets about the model center.
143
+ const points = bounds.corners.map((p) => Vector3.TransformNormal(p.subtract(bounds.center), basis.view));
144
+ const marginFactor = BabylonJSController.config.camera.fitMarginFactor;
145
+ if (!Number.isFinite(marginFactor) || marginFactor < 0 || marginFactor > 0.5) {
146
+ return null;
147
+ }
148
+ const available = 1 - marginFactor;
149
+ let distance = camera.mode === 1 ? bounds.radius * 1.01 : 0;
150
+ let halfWidth = 0;
151
+ let halfHeight = 0;
152
+ const tan = Math.tan(camera.fov / 2);
153
+ const tanX = camera.fovMode === 1 ? tan : tan * aspect;
154
+ const tanY = camera.fovMode === 1 ? tan / aspect : tan;
155
+ if (camera.mode !== 1 && (!(tanX > 0) || !(tanY > 0) || !Number.isFinite(tanX + tanY))) {
156
+ return null;
157
+ }
158
+ for (const p of points) {
159
+ halfWidth = Math.max(halfWidth, Math.abs(p.x) / available);
160
+ halfHeight = Math.max(halfHeight, Math.abs(p.y) / available);
161
+ // p.z is positive towards the observer in RH, negative in LH.
162
+ const towardObserver = camera.getScene().useRightHandedSystem ? p.z : -p.z;
163
+ if (camera.mode !== 1) {
164
+ distance = Math.max(distance, towardObserver + Math.max(bounds.radius * 0.001, 0.00001), towardObserver + Math.abs(p.x) / (tanX * available), towardObserver + Math.abs(p.y) / (tanY * available));
165
+ }
166
+ }
167
+ halfHeight = Math.max(halfHeight, halfWidth / aspect, bounds.radius * 0.001);
168
+ halfWidth = halfHeight * aspect;
169
+ return { target: bounds.center.clone(), distance, rotation: basis.rotation, halfWidth, halfHeight, radius: bounds.radius };
170
+ }
171
+ }
@@ -0,0 +1,272 @@
1
+ import { Camera } from "@babylonjs/core/Cameras/camera.js";
2
+ import { ArcRotateCamera } from "@babylonjs/core/Cameras/arcRotateCamera.js";
3
+ import { Ray } from "@babylonjs/core/Culling/ray.js";
4
+ import { Vector3 } from "@babylonjs/core/Maths/math.vector.js";
5
+
6
+ /**
7
+ * Maintains a geometry-dependent orbital zoom floor without moving the camera target.
8
+ * Checks only visible, enabled, pickable meshes in the current model asset container.
9
+ * The host owns camera selection, rendering and assets; dispose restores authored limits.
10
+ * Samples the near-plane footprint after orbital input, including parent-space conversion.
11
+ * The host may opt its default camera into adaptive positive near clipping; authored clipping remains unchanged.
12
+ * This is sampled ray protection, not continuous volume collision or target-motion constraints.
13
+ * @class
14
+ */
15
+ export default class BabylonJSCameraZoomController {
16
+ #getModelContainer;
17
+ #isLocked;
18
+ #isTransitioning;
19
+ #canAdjustClipping;
20
+ #camera = null;
21
+ #observer = null;
22
+ #originalLimit = null;
23
+ #previousDistance = 0;
24
+ #margin = 0.05;
25
+ #minimum = 0.01;
26
+
27
+ /**
28
+ * Creates the model-only zoom guard; setCamera attaches it to a supported orbital camera.
29
+ * Callbacks resolve the current visible model and host lock/transition policy on every update.
30
+ * @public
31
+ * @param {function(): (import("@babylonjs/core").AssetContainer|null)} getModelContainer - Current model container; excludes environment assets.
32
+ * @param {function(import("@babylonjs/core").Camera): boolean} isLocked - Navigation lock/XR policy.
33
+ * @param {function(): boolean} isTransitioning - Whether explicit ViewCube framing is in progress.
34
+ * @param {function(import("@babylonjs/core").Camera): boolean} [canAdjustClipping] - Allows adaptive near clipping for host-owned cameras; authored cameras default to preservation.
35
+ */
36
+ constructor(getModelContainer, isLocked, isTransitioning, canAdjustClipping = () => false) {
37
+ this.#getModelContainer = getModelContainer;
38
+ this.#isLocked = isLocked;
39
+ this.#isTransitioning = isTransitioning;
40
+ this.#canAdjustClipping = canAdjustClipping;
41
+ }
42
+
43
+ /**
44
+ * Computes world coordinates and the radius-to-world conversion for parented cameras.
45
+ * @private
46
+ * @returns {{target: Vector3, direction: Vector3, distance: number, scale: number}|null} Current ray basis.
47
+ */
48
+ #basis() {
49
+ const camera = this.#camera;
50
+ camera.getViewMatrix(true);
51
+ const parent = camera.parent?.getWorldMatrix();
52
+ const target = parent ? Vector3.TransformCoordinates(camera.target, parent) : camera.target.clone();
53
+ const direction = camera.globalPosition.subtract(target);
54
+ const distance = direction.length();
55
+ if (!Number.isFinite(distance) || distance <= 0 || !Number.isFinite(camera.radius) || camera.radius === 0) {
56
+ return null;
57
+ }
58
+ direction.scaleInPlace(1 / distance);
59
+ if (camera.radius < 0) {
60
+ direction.scaleInPlace(-1);
61
+ }
62
+ return { target, direction, distance, scale: distance / Math.abs(camera.radius) };
63
+ }
64
+
65
+ /**
66
+ * Brings the default camera's near plane closer to its eye as orbital zoom approaches the target.
67
+ * Keeps a positive plane distance, retains smaller existing values and leaves authored cameras unchanged.
68
+ * Requires a model container; neither its bounds nor environment geometry determine this projection cap.
69
+ * @private
70
+ * @returns {void}
71
+ */
72
+ #adjustNearClipping() {
73
+ const camera = this.#camera;
74
+ if (!this.#canAdjustClipping(camera) || !this.#getModelContainer()?.meshes?.length) {
75
+ return;
76
+ }
77
+ const cap = Math.max(0.00001, Math.abs(camera.radius) * 0.001);
78
+ const current = camera.minZ > 0 ? camera.minZ : Infinity;
79
+ const near = Math.min(current, cap);
80
+ if (camera.minZ !== near) {
81
+ camera.minZ = near;
82
+ camera.getProjectionMatrix(true);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Samples the near-plane footprint in world space instead of only the optical axis.
88
+ * Projection matrix scales account for FOV/aspect; the inverse view handles camera parents.
89
+ * Perspective uses the center, four edge midpoints and four corners; orthographic uses only the center.
90
+ * Orthographic radius does not change image scale, so this method does not sample its full footprint.
91
+ * @private
92
+ * @returns {{offsets: Vector3[], clearance: number}} Probe offsets and surface clearance.
93
+ */
94
+ #nearPlane() {
95
+ const camera = this.#camera;
96
+ const inverseView = camera.getViewMatrix(true).clone().invert();
97
+ const near = Math.max(0, camera.minZ);
98
+ const depth = Vector3.TransformNormal(new Vector3(0, 0, near), inverseView).length();
99
+ const offsets = [Vector3.Zero()];
100
+ if (camera.mode === Camera.PERSPECTIVE_CAMERA && near > 0) {
101
+ const projection = camera.getProjectionMatrix(true);
102
+ const right = Vector3.TransformNormal(new Vector3(near / projection.m[0], 0, 0), inverseView);
103
+ const up = Vector3.TransformNormal(new Vector3(0, near / projection.m[5], 0), inverseView);
104
+ for (const x of [-1, 0, 1]) {
105
+ for (const y of [-1, 0, 1]) {
106
+ if (x !== 0 || y !== 0) {
107
+ offsets.push(right.scale(x).add(up.scale(y)));
108
+ }
109
+ }
110
+ }
111
+ }
112
+ return { offsets, clearance: depth + this.#margin };
113
+ }
114
+
115
+ /**
116
+ * Finds an outward exit when a checked near-plane point is already inside a closed model mesh.
117
+ * The bounding box only limits the probe; triangle crossings distinguish solids from empty gaps.
118
+ * Advances beyond each crossing to avoid counting both triangles of the same face twice.
119
+ * This recovery assumes closed geometry; open/non-manifold surfaces retain sampled-ray limitations.
120
+ * @private
121
+ * @param {import("@babylonjs/core").AbstractMesh} mesh - Eligible mesh from the model container only.
122
+ * @param {Vector3} point - Checked near-plane point in world coordinates.
123
+ * @param {Vector3} direction - Unit outward camera direction in world coordinates.
124
+ * @returns {number} World distance to the first exit for an interior point, or zero otherwise.
125
+ */
126
+ #escapeDistance(mesh, point, direction) {
127
+ const bounds = mesh.getBoundingInfo().boundingBox;
128
+ if (!bounds.intersectsPoint(point)) {
129
+ return 0;
130
+ }
131
+ const length = Math.max(...bounds.vectorsWorld.map((vertex) => Vector3.Dot(vertex.subtract(point), direction))) + this.#margin;
132
+ if (!Number.isFinite(length) || length <= 0) {
133
+ return 0;
134
+ }
135
+ const epsilon = Math.max(1e-6, length * 1e-8);
136
+ let travelled = 0, crossings = 0, firstExit = 0;
137
+ while (travelled < length) {
138
+ const ray = new Ray(point.add(direction.scale(travelled)), direction, length - travelled);
139
+ const hit = ray.intersectsMesh(mesh, false);
140
+ if (!hit.hit || !Number.isFinite(hit.distance) || hit.distance < 0) {
141
+ break;
142
+ }
143
+ const distance = travelled + hit.distance;
144
+ if (crossings === 0) {
145
+ firstExit = distance;
146
+ }
147
+ crossings++;
148
+ travelled = distance + epsilon;
149
+ }
150
+ return crossings % 2 === 1 ? firstExit : 0;
151
+ }
152
+
153
+ /**
154
+ * Finds current model triangle surfaces along the sampled near-plane footprint.
155
+ * Filters disposed/ineligible meshes and refreshes only model matrices; environment meshes are never visited.
156
+ * Reverse rays protect camera-facing surfaces when the target lies beyond a solid.
157
+ * Interior near-plane probes recover through actual exits even when both segment endpoints are inside.
158
+ * @private
159
+ * @param {{target: Vector3, direction: Vector3, distance: number, scale: number}} basis - World-space target, outward unit direction, distance and world-distance/local-radius ratio.
160
+ * @param {number} length - Probe distance including the previous zoom position.
161
+ * @returns {number} Sampled world-distance floor including near-plane clearance; 0.01 when no model hit exists.
162
+ */
163
+ #minimumDistance(basis, length) {
164
+ const meshes = (this.#getModelContainer()?.meshes ?? []).filter((mesh) =>
165
+ !mesh.isDisposed() && mesh.isPickable && mesh.isEnabled() && mesh.isVisible && mesh.visibility > 0);
166
+ if (meshes.length === 0) {
167
+ return this.#minimum;
168
+ }
169
+ for (const mesh of meshes) {
170
+ mesh.computeWorldMatrix(true);
171
+ }
172
+ const { offsets, clearance } = this.#nearPlane();
173
+ let minimum = this.#minimum;
174
+ for (const offset of offsets) {
175
+ const origin = basis.target.add(offset);
176
+ const outward = new Ray(origin, basis.direction, length);
177
+ const inward = new Ray(origin.add(basis.direction.scale(length)), basis.direction.negate(), length);
178
+ const hit = outward.intersectsMeshes(meshes, false)[0];
179
+ const reverseHit = inward.intersectsMeshes(meshes, false)[0];
180
+ if (hit?.hit && Number.isFinite(hit.distance)) {
181
+ minimum = Math.max(minimum, hit.distance + clearance);
182
+ }
183
+ if (reverseHit?.hit && Number.isFinite(reverseHit.distance)) {
184
+ minimum = Math.max(minimum, length - reverseHit.distance + clearance);
185
+ }
186
+ const probeDistance = Math.max(0, basis.distance - clearance);
187
+ const point = origin.add(basis.direction.scale(probeDistance));
188
+ for (const mesh of meshes) {
189
+ const exit = this.#escapeDistance(mesh, point, basis.direction);
190
+ if (exit > 0) {
191
+ minimum = Math.max(minimum, probeDistance + exit + clearance);
192
+ }
193
+ }
194
+ }
195
+ return minimum;
196
+ }
197
+
198
+ /**
199
+ * Binds post-input updates for ArcRotateCamera and releases the previous subscription.
200
+ * Captures its authored floor; other camera types remain unbound and selecting the same camera is a no-op.
201
+ * @public
202
+ * @param {import("@babylonjs/core").Camera|null} camera - Effective active camera.
203
+ * @returns {void}
204
+ */
205
+ setCamera(camera) {
206
+ if (camera === this.#camera) {
207
+ return;
208
+ }
209
+ this.dispose();
210
+ if (!(camera instanceof ArcRotateCamera)) {
211
+ return;
212
+ }
213
+ this.#camera = camera;
214
+ this.#originalLimit = camera.lowerRadiusLimit;
215
+ this.#previousDistance = this.#basis()?.distance ?? 0;
216
+ this.#observer = camera.onAfterCheckInputsObservable.add(() => this.update());
217
+ }
218
+
219
+ /**
220
+ * Resolves the current visible model and updates lowerRadiusLimit after input, before drawing.
221
+ * Missing/empty models use the unobstructed floor; environment geometry never supplies a floor.
222
+ * Combines the geometry floor with authored limits; skips updates for locked cameras and active XR.
223
+ * Temporarily restores the captured authored floor during explicit ViewCube fitting.
224
+ * Clamps an insufficient radius, recovers checked points inside closed model meshes and cancels inward zoom inertia on contact; outward zoom remains available.
225
+ * Converts sampled world clearance to the parent-local radius; never changes target, orientation or projection mode.
226
+ * Adapts the host-owned default camera near plane before computing its footprint; authored cameras retain clipping.
227
+ * @public
228
+ * @returns {void}
229
+ */
230
+ update() {
231
+ const camera = this.#camera;
232
+ if (!camera || this.#isLocked(camera)) {
233
+ return;
234
+ }
235
+ const basis = this.#basis();
236
+ if (!basis) {
237
+ return;
238
+ }
239
+ if (this.#isTransitioning()) {
240
+ camera.lowerRadiusLimit = this.#originalLimit;
241
+ this.#previousDistance = basis.distance;
242
+ return;
243
+ }
244
+ this.#adjustNearClipping();
245
+ const length = Math.max(basis.distance, this.#previousDistance);
246
+ const minimum = Math.max(this.#originalLimit ?? 0, this.#minimumDistance(basis, length) / basis.scale);
247
+ camera.lowerRadiusLimit = minimum;
248
+ if (camera.radius < minimum) {
249
+ camera.radius = minimum;
250
+ if (camera.inertialRadiusOffset > 0) {
251
+ camera.inertialRadiusOffset = 0;
252
+ }
253
+ camera.getViewMatrix(true);
254
+ }
255
+ this.#previousDistance = Math.abs(camera.radius) * basis.scale;
256
+ }
257
+
258
+ /**
259
+ * Unsubscribes and restores the selected camera's original lower radius limit.
260
+ * @public
261
+ * @returns {void}
262
+ */
263
+ dispose() {
264
+ if (this.#camera) {
265
+ this.#camera.onAfterCheckInputsObservable.remove(this.#observer);
266
+ this.#camera.lowerRadiusLimit = this.#originalLimit;
267
+ }
268
+ this.#camera = null;
269
+ this.#observer = null;
270
+ this.#previousDistance = 0;
271
+ }
272
+ }