@preference-sl/pref-viewer 2.16.2 → 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.
package/README.md CHANGED
@@ -6,6 +6,10 @@
6
6
 
7
7
  - Native custom elements: `<pref-viewer>`, `<pref-viewer-2d>`, `<pref-viewer-3d>`, `<pref-viewer-dialog>`, and `<pref-viewer-menu-3d>`.
8
8
  - Babylon.js-based 3D visualization for glTF and GLB assets.
9
+ - Default-enabled ViewCube with 26 selectable orientations, synchronized camera feedback, smooth model framing and perspective/orthographic support.
10
+ - Model-only, geometry-dependent orbital zoom protection with near-plane sampling, independent of ViewCube visibility.
11
+ - Viewer dialogs above the render menu and ViewCube, with background pointer-input protection.
12
+ - Proportional mouse-wheel zoom for unlocked orbital cameras: increments decrease as the camera approaches its target, using Babylon's native input without an additional viewer zoom increment. Wheel sensitivity is currently an internal setting, not a public attribute or option.
9
13
  - 2D SVG drawing support with pan and zoom controls.
10
14
  - Unified `<pref-viewer>` wrapper that coordinates 2D, 3D, menu, dialog, localization, and loading lifecycle.
11
15
  - Sequential task queue for deterministic config, model, scene, materials, drawing, options, and culture updates.
@@ -92,7 +96,9 @@ viewer.setOptions({
92
96
 
93
97
  ## Configuration model
94
98
 
95
- The main `config` payload can include model, scene, materials, drawing, options, and culture data.
99
+ The main `config` payload can include model, scene, materials, drawing, options, culture, styles, and boolean `showRenderMenu` / `showViewCube` flags.
100
+
101
+ When a queued configuration is processed, explicit boolean flags update the corresponding `show-render-menu` and `show-view-cube` attributes before its assets load. Omitted or non-boolean flags preserve the current settings. Explicit processed flags override the corresponding current attributes; subsequent direct attribute changes remain effective. Reapplying an equal attribute value does not recreate the render menu. The same rules apply to `loadConfig(config)`, the `config` property setter and the JSON `config` attribute; flags follow existing task ordering, including during pending asset loads. These fields belong to the root config, alongside `culture` and `styles`.
96
102
 
97
103
  ```js
98
104
  viewer.loadConfig({
@@ -141,10 +147,39 @@ viewer.loadConfig({
141
147
  colorPrimary: "#ff6700",
142
148
  },
143
149
  culture: "en-EN",
150
+ showRenderMenu: true,
151
+ showViewCube: true,
144
152
  forceReload: false,
145
153
  });
146
154
  ```
147
155
 
156
+ ### Overlay visibility
157
+
158
+ Use boolean values in config and string values in attributes:
159
+
160
+ ```js
161
+ viewer.config = {
162
+ showRenderMenu: true,
163
+ showViewCube: false,
164
+ };
165
+
166
+ // Change only the cube flag; the menu keeps its current setting.
167
+ viewer.loadConfig({ showViewCube: true });
168
+
169
+ // Direct attribute control remains available after processing config.
170
+ viewer.setAttribute("show-view-cube", "false");
171
+ viewer.setAttribute("show-render-menu", "true");
172
+ ```
173
+
174
+ ```html
175
+ <pref-viewer
176
+ mode="3d"
177
+ config='{"showRenderMenu":true,"showViewCube":false}'
178
+ ></pref-viewer>
179
+ ```
180
+
181
+ Both overlays are enabled by default in 3D when their attributes are absent. Use explicit `"true"`/`"false"` strings for attributes: the menu accepts an absent attribute or literal `"true"` as enabled, whereas only literal `"false"` disables the cube. A config value such as `"false"` is a string and does not change visibility.
182
+
148
183
  ## Asset storage descriptors
149
184
 
150
185
  Storage descriptors are passed through `model.storage`, `scene.storage`, `materials.storage`, or `drawing.storage`.
@@ -198,16 +233,75 @@ Storage descriptors are passed through `model.storage`, `scene.storage`, `materi
198
233
  | `mode` | Viewer mode: `3d` or `2d`. Defaults to `3d`. |
199
234
  | `show-model` | Shows or hides the current 3D model. Use `true` or `false`. |
200
235
  | `show-scene` | Shows or hides the current 3D scene/environment. Use `true` or `false`. |
201
- | `show-render-menu` | Shows or hides the floating 3D render settings menu. Defaults to visible. |
236
+ | `show-render-menu` | Shows the floating 3D render settings menu when absent or `"true"`; `"false"` hides it. Also configurable through boolean `config.showRenderMenu`. See [ViewCube layout](#viewcube-layout-and-lifecycle). |
237
+ | `show-view-cube` | Shows the orientation cube in 3D. Enabled by default; only literal `"false"` disables it. Also configurable through boolean `config.showViewCube`. Runtime toggles preserve loaded assets and camera state. See [ViewCube](#viewcube). |
202
238
  | `styles` | JSON string with supported style overrides. Currently supports `colorPrimary`, applies it as `--pref-viewer-color-primary`, updates `PrefViewerStyleVariables.colorPrimary`, and persists it in `localStorage`. |
203
239
 
240
+ ## ViewCube
241
+
242
+ The ViewCube reflects the effective active camera orientation and offers **26 views: six faces, twelve edges and eight corners**. Click any region to change orientation smoothly and refit the model assetContainer. Every accepted click refits, even when selecting the current orientation: previous pan, framing and zoom are discarded. Environment geometry is excluded from this fit; only visible, enabled model meshes with valid geometry contribute to bounds. Hidden or disabled model geometry, including auxiliary walls/floors hidden by viewer options, does not enlarge the fit.
243
+
244
+ The active camera retains its identity and perspective or orthographic projection. Navigation supports Babylon ArcRotateCamera, FreeCamera and UniversalCamera, including supported parent transforms. Other camera classes, locked cameras and active XR tracked cameras are display-only. Selection does nothing when usable model bounds, a supported camera or positive viewport dimensions are unavailable.
245
+
246
+ ### Orientation and feedback
247
+
248
+ The inner view is the principal model view. English ViewCube labels use `Inner` and `Outer` (displayed as `INNER` and `OUTER`); Spanish labels remain `Interior` and `Exterior`. Accessible names and combined edge/corner labels use the same localized terms. Face names use this world-axis convention:
249
+
250
+ | Direction | World axis | Reference |
251
+ | --- | --- | --- |
252
+ | Inner / Front | +Z | Looks toward the model from +Z. |
253
+ | Outer / Back | -Z | Looks toward the model from -Z. |
254
+ | Left | +X | Left relative to the inner view. |
255
+ | Right | -X | Right relative to the inner view. |
256
+ | Top / Superior | +Y | Looks down toward the model. |
257
+ | Bottom / Inferior | -Y | Looks up toward the model. |
258
+
259
+ The cube has sharp edges without chamfers. Edge regions appear as prism strips on both adjacent faces, with boundaries parallel to the edge; corner regions appear as small cubes. All visible surfaces belonging to an edge or corner select the same orientation.
260
+
261
+ Hover uses `styles.colorPrimary`, backed by `PrefViewerStyleVariables.colorPrimary`, with **50% fill opacity** and fully opaque borders around the complete clickable region. Face hover fills only its central clickable area, excluding edge/corner regions. The angularly nearest of the 26 views has **15% primary-color fill** and no highlighted border; hover takes precedence until the pointer leaves. Primary-color and culture changes update the existing overlay. There are no region tooltips; visible face labels use uppercase lettering and increased letter spacing for readability; accessible region names remain localized.
262
+
263
+ Cube perspective follows the active camera's projection and FOV. Mouse movement changes feedback without rebuilding SVG geometry; camera pose/projection changes update the projected geometry. The cube remains independent of model zoom and does not add Babylon scene meshes or appear in exports.
264
+
265
+ ### ViewCube layout and lifecycle
266
+
267
+ The cube stays at **top 0px / right 0px**. When both overlays are enabled, the render-menu top equals the **measured cube SVG viewport height** and updates when that size changes. The maximum cube viewport is 100 × 100 CSS pixels and shrinks to fit smaller scene viewports; the menu offset is measured, not fixed to that value. Without the cube, the menu returns to its style-defined default top. The cube remains below the menu's layer, and viewer modals cover both.
268
+
269
+ Cube resources are created only while enabled in a visible 3D view. Disabling it or switching to 2D releases its overlay, listeners, observers and pending transition. Reactivation uses the current camera without reloading assets. Layout subscriptions connect before activation and are removed when inactive or disconnected.
270
+
271
+ Rapid selections retarget the transition from the displayed pose. Canvas pointer/wheel/key interaction, resize, projection change, camera/model replacement, locking, disabling or disposal cancels pending navigation; unlocking/reactivation does not replay it. Transitions use the existing demand-rendering scheduler and release their activity when finished without stopping unrelated model animations.
272
+
273
+ ## 3D camera and wheel zoom
274
+
275
+ The default orbital camera starts on the **Inner (+Z)** side before the ViewCube is created, so the loading indicator does not first show Outer and then switch to Inner when model framing completes. The initial fit uses the same model bounds and projection-aware calculation as the ViewCube, with the shared framing factor (`BabylonJSController.config.camera.fitMarginFactor`) of 0.15. The resulting fit matches a cube destination for the same orientation, projection and canvas dimensions; the initial orientation is exact Inner, with no elevation, so selecting Inner immediately after loading preserves the view. The initial fit preserves its orbit angles; later model reloads preserve the user's current orientation. Explicitly selected cameras from the model or environment retain their authored view, which may differ from Inner.
276
+
277
+ The shared internal setting `BabylonJSController.config.camera.fitMarginFactor` accepts finite values from **0 to 0.5, inclusive**. It reserves a total fraction of the viewport along the limiting axis; the model's projected bounding envelope can occupy `1 - fitMarginFactor` of that axis. It is applied when automatic or ViewCube framing runs, rather than continuously changing the current zoom.
278
+
279
+ | fitMarginFactor | Space available for the model | Margin at each opposing edge of the limiting axis |
280
+ | --- | --- | --- |
281
+ | 0 | 100% | 0% |
282
+ | 0.1 | 90% | 5% |
283
+ | 0.15 (default) | 85% | 7.5% |
284
+ | 0.5 | 50% | 25% |
285
+
286
+ Increasing the factor makes the fitted model smaller. The other axis can have more free space because framing preserves aspect ratio. The calculation uses visible model world bounds; the actual silhouette may occupy less space, and near-plane safety can require extra clearance. Invalid values leave the existing camera framing unchanged. This setting replaces the internal `fitFactor` name and its previous semantics; it is not a property of the public viewer `config` object.
287
+
288
+ The default orbital camera starts without `lowerBetaLimit` or `upperBetaLimit` and disables upside-down orbiting, whether or not the cube is enabled. ViewCube navigation keeps the camera up direction on world **+Y**, including top/bottom views and subsequent orbiting. Automatic loading preserves authored camera composition and navigation limits; an explicit unlocked cube action may relax incompatible orbit limits and adjust clipping/radius ranges to reach and fit the selected view. Locked cameras retain their view and limits.
289
+
290
+ Use the mouse wheel over the 3D canvas to zoom an unlocked orbital camera. Babylon's native input applies a radius-proportional increment once, so the same wheel input moves less at a smaller radius. Input wakes idle rendering. This applies to the default and selected unlocked orbital cameras independently of the cube; existing Free/Universal wheel navigation remains unchanged. The internal orbital wheel sensitivity (`wheelDeltaPercentage`) is 0.015. Wheel sensitivity has no public attribute or config option.
291
+
292
+ The orbital minimum radius adapts to eligible triangle surfaces in the **current visible model assetContainer only**. Hidden, disabled, non-pickable, disposed or zero-visibility meshes do not contribute. Environment meshes are excluded from both hits and raycast traversal, and model replacement/removal cannot retain an obsolete geometry floor. Clear checked paths allow close inspection without a minimum based on overall model bounds.
293
+
294
+ For the default camera with a visible model, the near clipping plane moves closer to the eye as the radius decreases: `minZ` is capped at `max(0.00001, abs(radius) * 0.001)`, retaining any smaller existing positive value. The plane stays in front of the eye; `maxZ`, FOV and projection mode remain unchanged. Imported cameras retain their clipping configuration, and locks/XR/explicit cube transitions preserve their existing policy. Protection samples the updated projection and accounts for near-plane depth and a small safety margin. Perspective samples the center, edge midpoints and corners of the near-plane footprint; orthographic uses the center probe with near-depth clearance. Large inward steps and inertia are constrained at detected surfaces, while outward retreat remains possible. If a checked near-plane point is already inside a closed model mesh, recovery finds its outward triangle-surface exit even when the previous radius is equally short. Empty gaps within mesh bounds are not treated as solids; environment meshes are never queried during recovery. The geometry guard changes radius rather than moving or constraining the target and preserves projection mode; adaptive default-camera near depth is handled separately. Authored minimum-radius constraints remain respected and are restored when releasing a camera. Explicit cube transitions temporarily suspend the geometry floor so the requested model fit can complete; protection resumes afterward.
295
+
296
+ These are sampled ray checks, not continuous volume collision. Small surfaces between probes, open/non-manifold geometry and custom/deformed rigs are not guaranteed to be protected. Interior-point recovery assumes closed meshes. In extreme close-up or grazing-angle views, small parts between the samples may still show minor edge clipping; this is a known limitation of the sampled protection. It does not guarantee clipping-free inspection of every surface. Orthographic protection does not sample the complete near-plane area.
297
+
204
298
  ## Public methods
205
299
 
206
300
  ### Loading and configuration
207
301
 
208
302
  | Method | Description |
209
303
  | --- | --- |
210
- | `loadConfig(config)` | Queues a full configuration update. Accepts an object or JSON string with model, scene, materials, drawing, options, culture, styles, and visibility descriptors. |
304
+ | `loadConfig(config)` | Queues a full configuration update. Accepts an object or JSON string with model, scene, materials, drawing, options, culture, styles, boolean showRenderMenu/showViewCube flags, and visibility descriptors. |
211
305
  | `loadModel(model)` | Queues a model update. |
212
306
  | `loadScene(scene)` | Queues a scene/environment update. |
213
307
  | `loadMaterials(materials)` | Queues a materials update. |
@@ -265,12 +359,14 @@ Selection colors are rendered through disposable SVG clones; the loaded base SVG
265
359
 
266
360
  | Method | Description |
267
361
  | --- | --- |
268
- | `openDialog(title, content, footer)` | Opens a viewer dialog. |
362
+ | `openDialog(title, content, footer)` | Opens a modal above viewer content, including the render menu and ViewCube. Its backdrop intercepts background pointer input. Returns the dialog element on success or `null` if opening fails. |
269
363
  | `closeDialog()` | Closes the active viewer dialog. |
270
364
  | `downloadModelGLB()` / `downloadModelGLTF()` / `downloadModelUSDZ()` | Exports the current model. |
271
365
  | `downloadSceneGLB()` / `downloadSceneGLTF()` / `downloadSceneUSDZ()` | Exports the current scene/environment. |
272
366
  | `downloadModelAndSceneGLB()` / `downloadModelAndSceneGLTF()` / `downloadModelAndSceneUSDZ()` | Exports the combined model and scene. |
273
367
 
368
+ All viewer dialogs share this priority, including the model-download dialog opened with **Ctrl+Alt+D** while the 3D canvas receives keyboard input. Existing backdrop dismissal remains available: a click over a covered control may close the dialog, but does not also activate that control. Closing the dialog restores access to the underlying controls.
369
+
274
370
  ## Public properties
275
371
 
276
372
  ### Setters
@@ -279,7 +375,7 @@ These setters are convenience bridges for framework bindings and property-based
279
375
 
280
376
  | Property | Description |
281
377
  | --- | --- |
282
- | `config` | Forwards to `loadConfig(value)`. |
378
+ | `config` | Forwards an object or JSON string to `loadConfig(value)`, including boolean overlay flags with the same queue ordering and omission rules. |
283
379
  | `model` | Forwards to `loadModel(value)`. |
284
380
  | `scene` | Forwards to `loadScene(value)`. |
285
381
  | `materials` | Forwards to `loadMaterials(value)`. |
@@ -339,7 +435,7 @@ The component emits custom events so host applications can integrate loading ind
339
435
 
340
436
  ### Internal Listeners
341
437
 
342
- `<pref-viewer>` listens to `pref-viewer-2d-viewport-changed` from `<pref-viewer-2d>`, `pref-viewer-3d-loaded` and `pref-viewer-3d-error` from `<pref-viewer-3d>`, and `pref-viewer-3d-render-settings-change-requested` from `<pref-viewer-menu-3d>`. `BabylonJSController` listens to `pref-viewer-3d-animation-changed` from the nearest `<pref-viewer-3d>` host.
438
+ `<pref-viewer>` listens to `pref-viewer-2d-viewport-changed` from `<pref-viewer-2d>`, `pref-viewer-3d-loaded` and `pref-viewer-3d-error` from `<pref-viewer-3d>`, and `pref-viewer-3d-render-settings-change-requested` from `<pref-viewer-menu-3d>`. `BabylonJSController` listens to `pref-viewer-3d-animation-changed` from the nearest `<pref-viewer-3d>` host. The root also conditionally subscribes to the internal, non-bubbling `view-cube-layout` event while the cube is active in 3D. It carries measured layout dimensions for the render menu and is not a new public root event.
343
439
 
344
440
  Example:
345
441
 
@@ -403,6 +499,8 @@ viewer.setStyles({
403
499
  });
404
500
  ```
405
501
 
502
+ The ViewCube uses this same primary color for its hover and active-orientation feedback. Changing `colorPrimary` through `styles` or `config` attributes, `setStyles`, or `loadConfig({ styles })` also updates animation-opening highlight color, including an opening currently highlighted, without reloading the model or changing whether highlighting is enabled. The render menu color control stays synchronized. Persisted primary styles are applied to highlights when the viewer connects. `options.render.highlightColor` can still set an explicit highlight color; a later primary-style update replaces it.
503
+
406
504
  Only `colorPrimary` is currently supported. The value updates `PrefViewerStyleVariables.colorPrimary`, is exposed on the host as `--pref-viewer-color-primary`, and is persisted in `localStorage` under `pref-viewer/styles`. Persisted styles are restored when a new `<pref-viewer>` instance is created and connected.
407
505
 
408
506
  ## Localization
@@ -440,7 +538,39 @@ Start the local playground:
440
538
  npm start
441
539
  ```
442
540
 
443
- The playground is available from `playground/index.html` and serves the project through `http-server` after bundling the local source.
541
+ The playground is available from `playground/index.html` at `http://localhost:8080/` and serves the project through `http-server` after bundling the local source. Use it for manual ViewCube, wheel-zoom and modal checks with representative model/environment assets. The separate ViewCube test HTML/script and its generated fixture bundle have been removed; automated tests remain.
542
+
543
+ ### Camera implementation and maintenance
544
+
545
+ - `src/babylonjs-view-cube-controller.js`: dedicated `BabylonJSViewCubeController` for SVG regions, orientation/projection feedback, transitions and cleanup. It delegates model bounds and destination-fit calculations to BabylonJSCameraFraming. Cube-only helpers and constants remain private; region generation remains a public static utility. Its constructor JSDoc documents the host object and callbacks.
546
+ - `src/babylonjs-camera-framing.js`: stateless `BabylonJSCameraFraming` with static `getModelBounds`, `calculateCameraFit` and `isCameraSupported` utilities. Bounds use only visible, enabled geometry in the supplied model container; fit calculations preserve camera state and projection, use the shared camera margin and return a destination for the caller to apply. Default-camera automatic fitting uses these same utilities, preserving the current orientation and applying the destination immediately.
547
+ - `src/babylonjs-camera-zoom-controller.js`: independent `BabylonJSCameraZoomController` for model-only dynamic radius protection and authored-floor restoration. It works without cube resources.
548
+ - `src/babylonjs-controller.js`: owns the scene, active camera, containers, native wheel settings and render scheduler; coordinates both controllers.
549
+ - `src/styles.js`: exports `PrefViewerViewCubeStyles`, imported by the cube controller. The SVG stylesheet uses multiline CSS and scoped variables for the main visual properties; no JSDoc is required for this styles constant.
550
+ - `src/pref-viewer-3d.js` and `src/pref-viewer.js`: bridge lazy visibility, queued config, localization and measured menu layout. The root stores the layout callback in `#handlers` and removes its listener during teardown. Shared styles place dialogs above viewer overlays.
551
+
552
+ ViewCube class members are ordered as properties, constructor, static methods, private methods and public methods/getters. New classes and new/modified methods carry JSDoc describing their parameters, return values and current lifecycle/side effects. JavaScript `if` and `for` bodies always use braces, including single-statement bodies. Focused tests live in `tests/babylonjs-camera-framing.test.js`, `tests/babylonjs-view-cube-controller.test.js`, `tests/babylonjs-camera-zoom-controller.test.js`, `tests/babylonjs-controller-wheel.test.js` and `tests/pref-viewer.test.js`. Browser checks in the existing playground remain necessary for visual projection, hover regions and modal stacking.
553
+
554
+ ### ViewCube SVG style maintenance
555
+
556
+ The following variables are defined in the root rule of `PrefViewerViewCubeStyles` in `src/styles.js`. They centralize defaults for future stylesheet changes; they are not additional properties supported by `setStyles` or `config.styles`.
557
+
558
+ | CSS variable | Default |
559
+ | --- | --- |
560
+ | `--cube-fill-color` | `#eef0f3` |
561
+ | `--cube-border-color` | `#78838f` |
562
+ | `--cube-border-width` | `1.0px` in the SVG coordinate system; also used by the hover outline |
563
+ | `--cube-hover-color` | Shared primary color; used by the fill and opaque outline |
564
+ | `--cube-hover-opacity` | `0.50` fill opacity |
565
+ | `--cube-active-color` | Shared primary color |
566
+ | `--cube-active-opacity` | `0.15` fill opacity |
567
+ | `--cube-text-font-family` | `Arial, sans-serif` |
568
+ | `--cube-text-font-size` | `9px` |
569
+ | `--cube-text-color` | `#29323b` |
570
+ | `--cube-text-font-weight` | `600` |
571
+ | `--cube-text-letter-spacing` | `1.0px` |
572
+
573
+ Both feedback colors default to `var(--pref-viewer-color-primary, ...)`, preserving supported runtime primary-color updates. Text size and spacing apply in the SVG coordinate system and scale with its viewport. Active-view borders remain transparent; hover borders remain fully opaque regardless of fill opacity. Geometry and input behavior remain owned by the controller.
444
574
 
445
575
  ## Package scripts
446
576
 
package/index.d.ts CHANGED
@@ -29,6 +29,12 @@ export interface PrefViewerAsset {
29
29
 
30
30
  /** Payload for `loadConfig`. Pass the object; a JSON string is also accepted. */
31
31
  export interface PrefViewerConfig {
32
+ /** Sets show-render-menu when supplied; omission preserves the current attribute. */
33
+ showRenderMenu?: boolean;
34
+ /** Sets show-view-cube when supplied; omission preserves the current attribute. */
35
+ showViewCube?: boolean;
36
+ culture?: string;
37
+ styles?: Record<string, unknown> | string;
32
38
  model?: PrefViewerAsset;
33
39
  scene?: PrefViewerAsset;
34
40
  materials?: PrefViewerAsset;
@@ -250,6 +256,7 @@ export declare class PrefViewer extends HTMLElement {
250
256
  /** Convenience wrapper for `setOptions({ ibl })`. False when the payload is unusable. */
251
257
  setIBL(ibl: Record<string, unknown> | string): boolean | void;
252
258
  /** Runtime style overrides; currently `{ colorPrimary }`. False when unusable. */
259
+ /** Updates primary UI and animation-opening highlight color, including an active highlight. */
253
260
  setStyles(styles: Record<string, unknown> | string): boolean | void;
254
261
  /** 3D runtime diagnostics, or null before the 3D component exists. */
255
262
  getDebugInfo(): Record<string, unknown> | null;
@@ -350,6 +357,8 @@ export interface PrefViewerAttributes {
350
357
  "show-model"?: boolean | "true" | "false";
351
358
  "show-scene"?: boolean | "true" | "false";
352
359
  "show-render-menu"?: boolean | "true" | "false";
360
+ /** Enable the orientation cube in 3D (default: true); false releases its overlay resources. */
361
+ "show-view-cube"?: boolean | "true" | "false";
353
362
  }
354
363
 
355
364
  /** <pref-viewer mode="3d" style="display:block; width:100%; height:600px" /> */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preference-sl/pref-viewer",
3
- "version": "2.16.2",
3
+ "version": "2.17.0-beta.0",
4
4
  "description": "Web Component to preview GLTF models with Babylon.js",
5
5
  "author": "Alex Moreno Palacio <amoreno@preference.es>",
6
6
  "scripts": {
@@ -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
+ }