@preference-sl/pref-viewer 2.17.0 → 2.17.2-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,7 +6,7 @@
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.
9
+ - Default-enabled, draggable 3D ViewCube with 26 selectable orientations: click a face, edge or corner for a smooth, model-fitted view, or drag the cube to orbit the model. It follows the camera in real time, including its projection: the cube is orthographic for an orthographic viewer camera and perspective, with the same vertical FOV, for a perspective one.
10
10
  - Model-only, geometry-dependent orbital zoom protection with near-plane sampling, independent of ViewCube visibility.
11
11
  - Viewer dialogs above the render menu and ViewCube, with background pointer-input protection.
12
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.
@@ -88,8 +88,8 @@ viewer.setOptions({
88
88
  intensity: 1,
89
89
  shadows: true,
90
90
  },
91
- lighting: {
92
- timeOfDay: 0.5,
91
+ render: {
92
+ lightingTimeOfDay: 0.5,
93
93
  },
94
94
  });
95
95
  ```
@@ -239,13 +239,13 @@ Storage descriptors are passed through `model.storage`, `scene.storage`, `materi
239
239
 
240
240
  ## ViewCube
241
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.
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, or drag the cube to orbit the model (see [Dragging the cube](#dragging-the-cube)). 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
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.
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: the cube keeps following the camera, but hover, click and drag do nothing. Selection does nothing when usable model bounds, a supported camera or positive viewport dimensions are unavailable.
245
245
 
246
246
  ### Orientation and feedback
247
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:
248
+ The inner view is the principal model view. English ViewCube face labels read `Inner` and `Outer`; Spanish labels are `Interior` and `Exterior`. Labels are drawn on the cube faces exactly as translated and follow culture changes. Face names use this world-axis convention:
249
249
 
250
250
  | Direction | World axis | Reference |
251
251
  | --- | --- | --- |
@@ -256,17 +256,37 @@ The inner view is the principal model view. English ViewCube labels use `Inner`
256
256
  | Top / Superior | +Y | Looks down toward the model. |
257
257
  | Bottom / Inferior | -Y | Looks up toward the model. |
258
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.
259
+ The cube is drawn on its own small canvas, with a separate Babylon.js engine and virtual scene, in a PrefSketch-style look: six square labelled faces, thin bars on the twelve edges and small spheres on the eight corners; there are no chamfers. Every part of an edge or corner selects the same orientation. The cube follows the projection and FOV of the viewer camera: it is drawn with an orthographic camera when the viewer camera is orthographic, and with a perspective camera using the same vertical FOV when it is perspective (the cube keeps the same on-screen size either way, and an FOV change redraws it). Apart from that it follows only the direction of the camera, independent of model zoom. It lives in its own scene, so it adds nothing to the model scene, to model picking or to exports.
260
+
261
+ Hover paints the region under the pointer with `styles.colorPrimary` (`--pref-viewer-color-primary`). Any CSS colour syntax is accepted: hex, `rgb()`, `hsl()` or a named colour. The angularly nearest of the 26 views is shown as the current region, painted with the primary colour mixed 25% into the face fill; hover takes precedence until the pointer leaves. A primary-colour change is picked up within 250 ms, or immediately after a resize, a culture change or re-enabling the cube. The rest of the palette (face fill, borders, label text, edge bars and corner spheres) is a fixed light palette and is not configurable. There are no region tooltips.
262
+
263
+ Edges and corners are thin, so selection has a tolerance: a front-facing edge or corner whose centre is within 8 CSS pixels of the pointer is picked ahead of the face under it, corners before edges. On cubes smaller than 96 px the tolerance shrinks to a twelfth of the cube side so face centres stay selectable. Hover and click use the same pick, so the highlighted region is the one a click selects.
264
+
265
+ ### Dragging the cube
266
+
267
+ Press on the cube and move the pointer more than 3 CSS pixels to drag it. Dragging orbits the viewer camera around its target at 0.012 radians per pixel, in the same direction as dragging the model with the mouse on the main canvas: the model follows the pointer, and the cube follows the camera. Elevation stops just short of the poles and the camera keeps world +Y as up. Dragging works with ArcRotateCamera, FreeCamera and UniversalCamera; authored alpha/beta limits of an ArcRotateCamera keep being enforced by Babylon on the next frame.
260
268
 
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.
269
+ The whole cube canvas is the drag surface, including its transparent corners, and a press on it never starts a gesture on the main camera. A press that moves 3 pixels or less is a click. A drag never selects a region, even if it ends where it started, and it cancels a running click transition. Locked cameras, active XR tracked cameras and unsupported camera classes keep the cube display-only. The cursor is `grab` over the cube, `pointer` over a selectable region and `grabbing` while dragging.
262
270
 
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.
271
+ ### ViewCube accessibility
272
+
273
+ The cube canvas has `role="img"` and one localized accessible name (`View orientation` in English, `Orientación de vista` in Spanish). Individual regions have no accessible names. The cube is pointer-driven: the canvas is not a tab stop, does not take keyboard focus after pointer use and draws no focus ring. Keyboard operation of the cube is not provided.
264
274
 
265
275
  ### ViewCube layout and lifecycle
266
276
 
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.
277
+ The cube is a square at the top right of the 3D view: **top 0px**, **right 0px** unless the host sets the inset below. Its side, in CSS pixels, is `min(clamp(22% of min(viewer width, viewer height), 96, 180), 40% of min(viewer width, viewer height))`, rounded to an integer. For example, a viewer whose smaller side is 300 px gets a 96 px cube, 600 px gets 132 px, 900 px or more gets the 180 px maximum, and a 200 px viewer gets 80 px (the 40% cap). A viewer without a usable size shows no cube.
278
+
279
+ Hosts can inset the cube from the right edge with the CSS custom property `--pref-viewer-view-cube-inset-right` (default `0px`). Set it on `<pref-viewer>` or on any ancestor; it inherits into the viewer:
280
+
281
+ ```css
282
+ pref-viewer {
283
+ --pref-viewer-view-cube-inset-right: 24px;
284
+ }
285
+ ```
286
+
287
+ When both overlays are enabled, the render-menu top equals the **measured side of the cube canvas** and updates when that size changes; the menu offset is measured, not a fixed 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. The internal `view-cube-layout` event carries `{ offset, height }` in CSS pixels: `offset` is the measured cube side (0 when there is no cube) and `height` is the scene viewport height.
268
288
 
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.
289
+ Cube resources are created only while enabled in a visible 3D view. Disabling it or switching to 2D releases its overlay, listeners, observers, pending transition, engine and WebGL context. An active cube uses a second WebGL context next to the viewer's; it is repainted after a context restore and after a device pixel ratio change. Reactivation uses the current camera without reloading assets. Layout subscriptions connect before activation and are removed when inactive or disconnected.
270
290
 
271
291
  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
292
 
@@ -289,7 +309,9 @@ Increasing the factor makes the fitted model smaller. The other axis can have mo
289
309
 
290
310
  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.
291
311
 
292
- 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.
312
+ Use the mouse wheel over the 3D canvas to zoom an unlocked orbital camera. The default camera uses Babylon's native `zoomToMouseLocation`: pointing at an off-center detail shifts the target toward that region as you zoom in, with the ViewCube shown or hidden. Selecting any cube preset, including the current view, cancels pending wheel motion and recenters the target on the visible model's world-bounds center using the existing fit. This does not guarantee an exact surface point stays at the same screen pixel.
313
+
314
+ 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, which continues until native wheel inertia settles. Named imported orbital cameras retain their existing input settings, and Free/Universal wheel navigation remains unchanged. The internal orbital wheel sensitivity (`wheelDeltaPercentage`) is 0.015. Wheel sensitivity has no public attribute or config option.
293
315
 
294
316
  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.
295
317
 
@@ -447,7 +469,7 @@ The component emits custom events so host applications can integrate loading ind
447
469
 
448
470
  ### Internal Listeners
449
471
 
450
- `<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>`. Internal `pref-viewer-2d-element-click` and `pref-viewer-3d-element-click` events are intercepted and forwarded through the single public `pref-viewer-element-click` contract. `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.
472
+ `<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>`. Internal `pref-viewer-2d-element-click` and `pref-viewer-3d-element-click` events are intercepted and forwarded through the single public `pref-viewer-element-click` contract. `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 `{ offset, height }` in CSS pixels, where `offset` is the measured side of the square cube and `height` is the scene viewport height, so the render menu can sit below the cube; it is not a new public root event.
451
473
 
452
474
  Example:
453
475
 
@@ -499,6 +521,23 @@ Render settings are persisted in `localStorage` under the `pref-viewer/render-se
499
521
 
500
522
  The showroom daylight background uses a shared linear HDR color calibrated with exposure and ACES tone mapping for the current light studio gray target, `#d9d9d9`, in a scene-less view. Both the static look and time-of-day cycle use this same daylight value; the cycle retains its dark night endpoint. Scene geometry can cover the background. The internal `BabylonJSController.config.look.showroom.backgroundDay` value is not a public background-color option and must be recalibrated if tone mapping or exposure changes.
501
523
 
524
+ ## Environment map and scene lights
525
+
526
+ `options.ibl.url` accepts two formats, chosen by the URL's extension (query and hash ignored):
527
+
528
+ | Extension | Loaded as | Notes |
529
+ | --- | --- | --- |
530
+ | `.env` | Prefiltered Babylon environment (`CubeTexture.CreateFromPrefilteredData`) | No prefiltering in the browser; a 4K environment is typically under 1 MB. |
531
+ | anything else | Radiance HDR (`HDRCubeTexture`) | Prefiltered on load, which costs time and memory for large files. |
532
+
533
+ Either file is downloaded once, cached in IndexedDB, and reused across reloads.
534
+
535
+ Directional and spot lights that come with the scene glTF (`KHR_lights_punctual`) cast shadows when `render.shadowsEnabled` is `true` and `ibl.shadows` is off, alongside the built-in light's. Every scene and model mesh casts unless its node sets `extras.castShadows: false`, and receives unless it sets `extras.receiveShadows: false`. A light's shadow darkness comes from its node's `extras.shadowDarkness`, from `0` (black) to `1` (no shadow), and is `0.5` when absent:
536
+
537
+ ```json
538
+ { "name": "Sun", "rotation": [-0.5, 0, 0, 0.866], "extras": { "shadowDarkness": 0.15 }, "extensions": { "KHR_lights_punctual": { "light": 0 } } }
539
+ ```
540
+
502
541
  ## Runtime styles
503
542
 
504
543
  Runtime styles can be passed through the `styles` attribute, the `styles` property setter, `setStyles(styles)`, or `loadConfig({ styles })`.
@@ -513,7 +552,7 @@ viewer.setStyles({
513
552
  });
514
553
  ```
515
554
 
516
- 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.
555
+ The ViewCube uses this same primary color for its hover feedback and, mixed into the face fill, for its current-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.
517
556
 
518
557
  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.
519
558
 
@@ -568,37 +607,24 @@ Focused regressions for this work live in `tests/babylonjs-selection-controller.
568
607
 
569
608
  ### Camera implementation and maintenance
570
609
 
571
- - `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.
610
+ - `src/babylonjs-view-cube-controller.js`: dedicated `BabylonJSViewCubeController` for pointer interaction (hover, click, drag), camera reading and writing, click transitions, size, colours and cleanup. It delegates model bounds and destination-fit calculations to BabylonJSCameraFraming and the drawing to the renderer. Cube-only helpers and constants remain private; region generation remains a public static utility. Its constructor JSDoc documents the host object and callbacks.
611
+ - `src/babylonjs-view-cube-renderer.js`: `BabylonJSViewCubeRenderer`, the cube's own Babylon.js engine and virtual scene with its 26 region meshes, picking and painting. It owns no render loop and no interaction logic; the controller renders it from the host loop.
612
+ - `src/view-cube-orbit-math.js`: pure helpers for orbit angles, the 26 view directions, drag deltas and the cube size rule, without Babylon or DOM dependencies.
572
613
  - `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.
573
614
  - `src/babylonjs-camera-zoom-controller.js`: independent `BabylonJSCameraZoomController` for model-only dynamic radius protection and authored-floor restoration. It works without cube resources.
615
+ - `src/babylonjs-camera-wheel-motion.js`: internal boundary for inspecting and cancelling the coupled native wheel inertia during rendering, geometry protection and camera lifecycle changes. Babylon computes the zoom; this boundary isolates version-sensitive native input state.
574
616
  - `src/babylonjs-controller.js`: owns the scene, active camera, containers, native wheel settings and render scheduler; coordinates both controllers.
575
- - `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.
576
617
  - `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.
577
618
 
578
- 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.
619
+ 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-view-cube-renderer.test.js`, `tests/view-cube-orbit-math.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 cube rendering, hover regions, dragging, focus and cursor behavior, and modal stacking, because the tests run on a null engine.
579
620
 
580
- ### ViewCube SVG style maintenance
621
+ ### ViewCube rendering maintenance
581
622
 
582
- 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`.
623
+ The cube's visual constants (the light palette, face label font and size, edge and corner sizes, lighting) are internal to `src/babylonjs-view-cube-renderer.js`. They are not properties supported by `setStyles` or `config.styles`; only `colorPrimary` reaches the cube, through the hover and current colours. The renderer module also contains a dark palette constant; it is not exported from the package and is not part of the public API.
583
624
 
584
- | CSS variable | Default |
585
- | --- | --- |
586
- | `--cube-fill-color` | `#eef0f3` |
587
- | `--cube-border-color` | `#78838f` |
588
- | `--cube-border-width` | `1.0px` in the SVG coordinate system; also used by the hover outline |
589
- | `--cube-hover-color` | Shared primary color; used by the fill and opaque outline |
590
- | `--cube-hover-opacity` | `0.50` fill opacity |
591
- | `--cube-active-color` | Shared primary color |
592
- | `--cube-active-opacity` | `0.15` fill opacity |
593
- | `--cube-text-font-family` | `Arial, sans-serif` |
594
- | `--cube-text-font-size` | `9px` |
595
- | `--cube-text-color` | `#29323b` |
596
- | `--cube-text-font-weight` | `600` |
597
- | `--cube-text-letter-spacing` | `1.0px` |
598
-
599
- 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.
600
-
601
- ViewCube listener registration and hover updates are private instance methods. Surface creation, region construction, hit-region lookup, extent interpolation and SVG projection are private static helpers; per-update projection values and interpolation progress are passed explicitly. The render menu also encapsulates hue-channel calculation and lighting playback as private methods. Playback uses one callback bound to the menu instance and cancels its scheduled frame on stop or disconnection.
625
+ Keep these rules when changing the renderer: the cube scene stays virtual and its engine stays out of `EngineStore.Instances` while it is alive (otherwise the viewer can create resources on the cube's WebGL context); Babylon input stays detached from the cube canvas (otherwise the cursor and keyboard focus are disturbed); and the engine keeps `loseContextOnDispose` so its WebGL context is released.
626
+
627
+ ViewCube listener registration, drag handling and hover updates are private instance methods. Region construction, colour parsing and extent interpolation are private static helpers; interpolation progress is passed explicitly. The render menu also encapsulates hue-channel calculation and lighting playback as private methods. Playback uses one callback bound to the menu instance and cancels its scheduled frame on stop or disconnection.
602
628
 
603
629
  ## Package scripts
604
630
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preference-sl/pref-viewer",
3
- "version": "2.17.0",
3
+ "version": "2.17.2-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,54 @@
1
+ /**
2
+ * Bridges native mouse-location wheel inertia to the viewer's render and navigation lifecycle.
3
+ * Babylon 9.11 keeps coupled pan/radius inertia on its wheel input, outside the public
4
+ * camera offsets. Keep that version-sensitive access here; Babylon still computes all zoom.
5
+ */
6
+ export default class BabylonJSCameraWheelMotion {
7
+ /**
8
+ * Returns the active native mouse-location input, without creating or replacing inputs.
9
+ * @param {object|null|undefined} camera - Camera to inspect.
10
+ * @returns {object|null} Native input when its mouse-location mode is enabled.
11
+ */
12
+ static #input(camera) {
13
+ const input = camera?.inputs?.attached?.mousewheel;
14
+ return input?.zoomToMouseLocation ? input : null;
15
+ }
16
+
17
+ /**
18
+ * Detects the complete native inertia tail, including pan without a public radius offset.
19
+ * @param {object|null|undefined} camera - Camera to inspect.
20
+ * @returns {boolean} Whether native wheel motion is pending.
21
+ */
22
+ static isMoving(camera) {
23
+ const input = this.#input(camera);
24
+ return Boolean(input && (Math.abs(input._zoomToMouseRadiusOffset || 0) > 0 || input._inertialPanning?.lengthSquared() > 0));
25
+ }
26
+
27
+ /**
28
+ * Stops coupled inward motion when geometry protection clamps the camera.
29
+ * @param {object|null|undefined} camera - Camera constrained by the geometry guard.
30
+ * @returns {void}
31
+ */
32
+ static stopInward(camera) {
33
+ if (this.#input(camera)?._zoomToMouseRadiusOffset > 0) {
34
+ this.cancel(camera);
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Cancels native wheel inertia and invalidates its old focal plane without altering orbit/pan inputs.
40
+ * @param {object|null|undefined} camera - Camera being reframed, deactivated or disposed.
41
+ * @returns {void}
42
+ */
43
+ static cancel(camera) {
44
+ const input = this.#input(camera);
45
+ if (!input) {
46
+ return;
47
+ }
48
+ if (typeof input._zoomToMouseRadiusOffset === "number") {
49
+ input._zoomToMouseRadiusOffset = 0;
50
+ }
51
+ input._inertialPanning?.setAll(0);
52
+ input._hitPlane = null;
53
+ }
54
+ }
@@ -2,6 +2,7 @@ import { Camera } from "@babylonjs/core/Cameras/camera.js";
2
2
  import { ArcRotateCamera } from "@babylonjs/core/Cameras/arcRotateCamera.js";
3
3
  import { Ray } from "@babylonjs/core/Culling/ray.js";
4
4
  import { Vector3 } from "@babylonjs/core/Maths/math.vector.js";
5
+ import BabylonJSCameraWheelMotion from "./babylonjs-camera-wheel-motion.js";
5
6
 
6
7
  /**
7
8
  * Maintains a geometry-dependent orbital zoom floor without moving the camera target.
@@ -245,6 +246,9 @@ export default class BabylonJSCameraZoomController {
245
246
  const length = Math.max(basis.distance, this.#previousDistance);
246
247
  const minimum = Math.max(this.#originalLimit ?? 0, this.#minimumDistance(basis, length) / basis.scale);
247
248
  camera.lowerRadiusLimit = minimum;
249
+ if (camera.radius <= minimum) {
250
+ BabylonJSCameraWheelMotion.stopInward(camera);
251
+ }
248
252
  if (camera.radius < minimum) {
249
253
  camera.radius = minimum;
250
254
  if (camera.inertialRadiusOffset > 0) {
@@ -1,4 +1,4 @@
1
- import { ArcRotateCamera, AssetContainer, BRDFTextureTools, Camera, Color3, Color4, DefaultRenderingPipeline, DirectionalLight, Engine, FreeCamera, HDRCubeTexture, HemisphericLight, IblShadowsRenderPipeline, ImageProcessingConfiguration, LoadAssetContainerAsync, Material, Matrix, MeshBuilder, PBRMaterial, PointerEventTypes, PointLight, RenderTargetTexture, Scene, ShadowGenerator, SpotLight, SSAORenderingPipeline, Texture, Tools, UniversalCamera, Vector3, WebXRDefaultExperience, WebXRFeatureName, WebXRSessionManager, WebXRState, WhenTextureReadyAsync } from "@babylonjs/core";
1
+ import { ArcRotateCamera, AssetContainer, BRDFTextureTools, Camera, Color3, Color4, CubeTexture, DefaultRenderingPipeline, DirectionalLight, Engine, FreeCamera, HDRCubeTexture, HemisphericLight, IblShadowsRenderPipeline, ImageProcessingConfiguration, LoadAssetContainerAsync, Material, Matrix, MeshBuilder, PBRMaterial, PointerEventTypes, PointLight, RenderTargetTexture, Scene, ShadowGenerator, SpotLight, SSAORenderingPipeline, Texture, Tools, UniversalCamera, Vector3, WebXRDefaultExperience, WebXRFeatureName, WebXRSessionManager, WebXRState, WhenTextureReadyAsync } from "@babylonjs/core";
2
2
  import { DracoCompression } from "@babylonjs/core/Meshes/Compression/dracoCompression.js";
3
3
  import "@babylonjs/loaders";
4
4
  import "@babylonjs/loaders/glTF/2.0/Extensions/KHR_draco_mesh_compression.js";
@@ -11,6 +11,7 @@ import BabylonJSAnimationController from "./babylonjs-animation-controller.js";
11
11
  import BabylonJSViewCubeController from "./babylonjs-view-cube-controller.js";
12
12
  import BabylonJSCameraFraming from "./babylonjs-camera-framing.js";
13
13
  import BabylonJSCameraZoomController from "./babylonjs-camera-zoom-controller.js";
14
+ import BabylonJSCameraWheelMotion from "./babylonjs-camera-wheel-motion.js";
14
15
  import BabylonJSSelectionController from "./babylonjs-selection-controller.js";
15
16
  import OpeningAnimation from "./babylonjs-animation-opening.js";
16
17
  import I18n from "./localization/i18n.js";
@@ -838,7 +839,7 @@ export default class BabylonJSController {
838
839
  */
839
840
  #isArcRotateCameraInMotion(camera) {
840
841
  const EPSILON = 0.00001;
841
- return Math.abs(camera?.inertialAlphaOffset || 0) > EPSILON || Math.abs(camera?.inertialBetaOffset || 0) > EPSILON || Math.abs(camera?.inertialRadiusOffset || 0) > EPSILON || Math.abs(camera?.inertialPanningX || 0) > EPSILON || Math.abs(camera?.inertialPanningY || 0) > EPSILON;
842
+ return BabylonJSCameraWheelMotion.isMoving(camera) || Math.abs(camera?.inertialAlphaOffset || 0) > EPSILON || Math.abs(camera?.inertialBetaOffset || 0) > EPSILON || Math.abs(camera?.inertialRadiusOffset || 0) > EPSILON || Math.abs(camera?.inertialPanningX || 0) > EPSILON || Math.abs(camera?.inertialPanningY || 0) > EPSILON;
842
843
  }
843
844
 
844
845
  /**
@@ -1076,6 +1077,7 @@ export default class BabylonJSController {
1076
1077
  this.#camera.lowerBetaLimit = null;
1077
1078
  this.#camera.allowUpsideDown = false;
1078
1079
  this.#camera.wheelDeltaPercentage = BabylonJSController.config.camera.wheelDeltaPercentage;
1080
+ this.#camera.zoomToMouseLocation = true;
1079
1081
  this.#camera.lowerRadiusLimit = null;
1080
1082
  this.#camera.upperRadiusLimit = 20;
1081
1083
  this.#camera.metadata = { locked: false };
@@ -1332,7 +1334,7 @@ export default class BabylonJSController {
1332
1334
  * @returns {Promise<boolean>} Returns true if the environment texture was changed, false if it was already up to date or failed to load.
1333
1335
  * @description
1334
1336
  * Lifecycle implemented here:
1335
- * 1. If `options.ibl.cachedUrl` exists, create `HDRCubeTexture` from it.
1337
+ * 1. If `options.ibl.cachedUrl` exists, create `HDRCubeTexture` from it (a prefiltered `CubeTexture` when `options.ibl.url` is a `.env`).
1336
1338
  * 2. Wait for readiness, clone it into `#hdrTexture` for reuse.
1337
1339
  * 3. Call `options.ibl.consumeCachedUrl(true)` to revoke temporary object URLs and clear `cachedUrl`.
1338
1340
  * 4. On following reloads, if `options.ibl.valid === true` and no `cachedUrl` is present, use `#hdrTexture.clone()`.
@@ -1346,8 +1348,13 @@ export default class BabylonJSController {
1346
1348
  let hdrTexture = null;
1347
1349
  if (this.#options.ibl?.cachedUrl) {
1348
1350
  const hdrTextureURI = this.#options.ibl.cachedUrl;
1349
- const envTextureSize = BabylonJSController.config.quality.environmentTextureSize || 1024;
1350
- hdrTexture = new HDRCubeTexture(hdrTextureURI, this.#scene, envTextureSize, false, false, false, true, undefined, undefined, false, true, true);
1351
+ if (/\.env$/i.test(String(this.#options.ibl.url ?? "").split(/[?#]/)[0])) {
1352
+ // The cached file is a blob: URL with no extension, so the loader is told it is a .env.
1353
+ hdrTexture = CubeTexture.CreateFromPrefilteredData(hdrTextureURI, this.#scene, ".env");
1354
+ } else {
1355
+ const envTextureSize = BabylonJSController.config.quality.environmentTextureSize || 1024;
1356
+ hdrTexture = new HDRCubeTexture(hdrTextureURI, this.#scene, envTextureSize, false, false, false, true, undefined, undefined, false, true, true);
1357
+ }
1351
1358
  } else if (this.#hdrTexture && this.#options.ibl?.valid === true) {
1352
1359
  hdrTexture = this.#hdrTexture.clone();
1353
1360
  } else {
@@ -1584,7 +1591,7 @@ export default class BabylonJSController {
1584
1591
  return generator.getLight()?.name === "PrefViewerDirLight";
1585
1592
  });
1586
1593
 
1587
- this.#containers.environment?.AssetContainer?.lights.forEach((light) => {
1594
+ this.#containers.environment?.assetContainer?.lights.forEach((light) => {
1588
1595
  // Only shadows for DirectionalLight and SpotLight types
1589
1596
  if (!(light instanceof DirectionalLight || light instanceof SpotLight)) {
1590
1597
  return;
@@ -1593,7 +1600,9 @@ export default class BabylonJSController {
1593
1600
  const shadowGenerator = new ShadowGenerator(1024, light);
1594
1601
  shadowGenerator.useBlurExponentialShadowMap = true;
1595
1602
  shadowGenerator.blurKernel = 16;
1596
- shadowGenerator.darkness = 0.5;
1603
+ // KHR_lights_punctual parents the light to its glTF node, which carries the node's extras.
1604
+ const extrasShadowDarkness = light.parent?.metadata?.gltf?.extras?.shadowDarkness;
1605
+ shadowGenerator.darkness = typeof extrasShadowDarkness === "number" && Number.isFinite(extrasShadowDarkness) ? Math.min(Math.max(extrasShadowDarkness, 0), 1) : 0.5;
1597
1606
  shadowGenerator.bias = 0.0005;
1598
1607
  shadowGenerator.normalBias = 0.02;
1599
1608
  shadowGenerator.getShadowMap().refreshRate = RenderTargetTexture.REFRESHRATE_RENDER_ONCE;
@@ -2656,6 +2665,8 @@ export default class BabylonJSController {
2656
2665
  cameraState.setSuccess(true);
2657
2666
  }
2658
2667
  }
2668
+ BabylonJSCameraWheelMotion.cancel(this.#scene.activeCamera);
2669
+ BabylonJSCameraWheelMotion.cancel(camera);
2659
2670
  this.#applyShowroomCameraFraming(camera);
2660
2671
  this.#fitDefaultCameraToModel(camera);
2661
2672
  this.#scene.activeCamera?.detachControl();
@@ -3458,6 +3469,8 @@ export default class BabylonJSController {
3458
3469
  * @returns {Promise<void>}
3459
3470
  */
3460
3471
  async disable() {
3472
+ BabylonJSCameraWheelMotion.cancel(this.#scene?.activeCamera);
3473
+ BabylonJSCameraWheelMotion.cancel(this.#camera);
3461
3474
  this.#cameraZoom?.dispose();
3462
3475
  this.#cameraZoom = null;
3463
3476
  this.#viewCubeSuspended = true;