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

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,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
+ }