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

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
@@ -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
  ```
@@ -289,7 +289,9 @@ Increasing the factor makes the fitted model smaller. The other axis can have mo
289
289
 
290
290
  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
291
 
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.
292
+ 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.
293
+
294
+ 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
295
 
294
296
  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
297
 
@@ -499,6 +501,23 @@ Render settings are persisted in `localStorage` under the `pref-viewer/render-se
499
501
 
500
502
  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
503
 
504
+ ## Environment map and scene lights
505
+
506
+ `options.ibl.url` accepts two formats, chosen by the URL's extension (query and hash ignored):
507
+
508
+ | Extension | Loaded as | Notes |
509
+ | --- | --- | --- |
510
+ | `.env` | Prefiltered Babylon environment (`CubeTexture.CreateFromPrefilteredData`) | No prefiltering in the browser; a 4K environment is typically under 1 MB. |
511
+ | anything else | Radiance HDR (`HDRCubeTexture`) | Prefiltered on load, which costs time and memory for large files. |
512
+
513
+ Either file is downloaded once, cached in IndexedDB, and reused across reloads.
514
+
515
+ 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:
516
+
517
+ ```json
518
+ { "name": "Sun", "rotation": [-0.5, 0, 0, 0.866], "extras": { "shadowDarkness": 0.15 }, "extensions": { "KHR_lights_punctual": { "light": 0 } } }
519
+ ```
520
+
502
521
  ## Runtime styles
503
522
 
504
523
  Runtime styles can be passed through the `styles` attribute, the `styles` property setter, `setStyles(styles)`, or `loadConfig({ styles })`.
@@ -571,6 +590,7 @@ Focused regressions for this work live in `tests/babylonjs-selection-controller.
571
590
  - `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.
572
591
  - `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
592
  - `src/babylonjs-camera-zoom-controller.js`: independent `BabylonJSCameraZoomController` for model-only dynamic radius protection and authored-floor restoration. It works without cube resources.
593
+ - `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
594
  - `src/babylonjs-controller.js`: owns the scene, active camera, containers, native wheel settings and render scheduler; coordinates both controllers.
575
595
  - `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
596
  - `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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preference-sl/pref-viewer",
3
- "version": "2.17.0-beta.2",
3
+ "version": "2.17.0-beta.4",
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;
@@ -3,6 +3,7 @@ import { PrefViewerViewCubeStyles } from "./styles.js";
3
3
  import I18n from "./localization/i18n.js";
4
4
  import BabylonJSController from "./babylonjs-controller.js";
5
5
  import BabylonJSCameraFraming from "./babylonjs-camera-framing.js";
6
+ import BabylonJSCameraWheelMotion from "./babylonjs-camera-wheel-motion.js";
6
7
 
7
8
  /**
8
9
  * @typedef {import("./babylonjs-camera-framing.js").FramingCamera} ViewCubeCamera
@@ -395,6 +396,7 @@ export default class BabylonJSViewCubeController {
395
396
  * @returns {void}
396
397
  */
397
398
  #clearInertia(camera) {
399
+ BabylonJSCameraWheelMotion.cancel(camera);
398
400
  for (const key of ["inertialAlphaOffset", "inertialBetaOffset", "inertialRadiusOffset", "inertialPanningX", "inertialPanningY"]) {
399
401
  if (key in camera) {
400
402
  camera[key] = 0;