@preference-sl/pref-viewer 2.17.2-beta.2 → 2.18.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -309,7 +309,7 @@ Increasing the factor makes the fitted model smaller. The other axis can have mo
309
309
 
310
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.
311
311
 
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.
312
+ Use the mouse wheel over the 3D canvas to zoom an unlocked orbital camera. The default camera uses Babylon's native `zoomToMouseLocation` focused on the visible model surface under the pointer: the picked model point stays at the same screen pixel as you zoom, and the target moves toward it, with the ViewCube shown or hidden. Only visible, enabled, pickable model meshes supply the focus; environment geometry never does. When the pointer is not over the model, the focus falls back to Babylon's native plane through the current target. 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.
313
313
 
314
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.
315
315
 
@@ -521,6 +521,8 @@ Render settings are persisted in `localStorage` under the `pref-viewer/render-se
521
521
 
522
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.
523
523
 
524
+ The model's transmissive glass (`KHR_materials_transmission`, as authored by the SDK) also carries a base color alpha of `0.15`, which would multiply its reflection away and leave it flat grey. The viewer sets that alpha to `1` and `metallicF0Factor` to `3` (`BabylonJSController.config.look.glass`, internal like the showroom look) so the environment reflection reads while the glass stays see-through through transmission. Glass in the environment glTF is unaffected: it is simplified to alpha blending instead.
525
+
524
526
  ## Environment map and scene lights
525
527
 
526
528
  `options.ibl.url` accepts two formats, chosen by the URL's extension (query and hash ignored):
@@ -612,7 +614,7 @@ Focused regressions for this work live in `tests/babylonjs-selection-controller.
612
614
  - `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.
613
615
  - `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.
614
616
  - `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.
617
+ - `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, and for focusing the native mouse-location zoom on the model surface under the pointer. Babylon computes the zoom; this boundary isolates version-sensitive native input state.
616
618
  - `src/babylonjs-controller.js`: owns the scene, active camera, containers, native wheel settings and render scheduler; coordinates both controllers.
617
619
  - `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.
618
620
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preference-sl/pref-viewer",
3
- "version": "2.17.2-beta.2",
3
+ "version": "2.18.0-beta.1",
4
4
  "description": "Web Component to preview GLTF models with Babylon.js",
5
5
  "author": "Alex Moreno Palacio <amoreno@preference.es>",
6
6
  "scripts": {
@@ -4,6 +4,12 @@
4
4
  * camera offsets. Keep that version-sensitive access here; Babylon still computes all zoom.
5
5
  */
6
6
  export default class BabylonJSCameraWheelMotion {
7
+ /**
8
+ * Native focal lookups replaced by surface focus, keyed by the wheel input that owns them.
9
+ * @type {WeakMap<object, {original: (function(): object), hadOwn: boolean, getMeshes: function(): (Array<object>|null|undefined)}>}
10
+ */
11
+ static #patched = new WeakMap();
12
+
7
13
  /**
8
14
  * Returns the active native mouse-location input, without creating or replacing inputs.
9
15
  * @param {object|null|undefined} camera - Camera to inspect.
@@ -51,4 +57,69 @@ export default class BabylonJSCameraWheelMotion {
51
57
  input._inertialPanning?.setAll(0);
52
58
  input._hitPlane = null;
53
59
  }
60
+
61
+ /**
62
+ * Makes native mouse-location zoom focus on the model surface under the pointer.
63
+ * Babylon otherwise intersects the pointer ray with a plane through the camera target, so the
64
+ * visible surface drifts. Returning the picked point keeps Babylon's coupled pan an exact homothety
65
+ * around it. Falls back to the native lookup on a miss, without eligible meshes or with a screen offset.
66
+ * Idempotent: repeated calls only replace the mesh provider.
67
+ * @param {object|null|undefined} camera - Camera whose native wheel input is patched.
68
+ * @param {function(): (Array<object>|null|undefined)} getMeshes - Returns the current model meshes; the caller must exclude environment meshes.
69
+ * @returns {void}
70
+ */
71
+ static focusOnSurface(camera, getMeshes) {
72
+ const input = this.#input(camera);
73
+ if (!input || typeof getMeshes !== "function") {
74
+ return;
75
+ }
76
+ const state = this.#patched.get(input);
77
+ if (state) {
78
+ state.getMeshes = getMeshes;
79
+ return;
80
+ }
81
+ const entry = { original: input._getPosition, hadOwn: Object.prototype.hasOwnProperty.call(input, "_getPosition"), getMeshes };
82
+ this.#patched.set(input, entry);
83
+ input._getPosition = () => this.#surfacePoint(input, entry) ?? entry.original.call(input);
84
+ }
85
+
86
+ /**
87
+ * Restores the native focal lookup replaced by focusOnSurface.
88
+ * @param {object|null|undefined} camera - Camera whose wheel input was patched.
89
+ * @returns {void}
90
+ */
91
+ static releaseSurfaceFocus(camera) {
92
+ const input = camera?.inputs?.attached?.mousewheel;
93
+ const entry = input && this.#patched.get(input);
94
+ if (!entry) {
95
+ return;
96
+ }
97
+ if (entry.hadOwn) {
98
+ input._getPosition = entry.original;
99
+ } else {
100
+ delete input._getPosition;
101
+ }
102
+ this.#patched.delete(input);
103
+ }
104
+
105
+ /**
106
+ * Picks the eligible model mesh under the pointer.
107
+ * @param {object} input - Patched native wheel input.
108
+ * @param {{getMeshes: function(): (Array<object>|null|undefined)}} entry - Patch state.
109
+ * @returns {object|null} World-space surface point, or null when native behavior must apply.
110
+ */
111
+ static #surfacePoint(input, entry) {
112
+ const camera = input.camera;
113
+ const scene = camera?.getScene?.();
114
+ if (!scene || camera.targetScreenOffset?.x || camera.targetScreenOffset?.y) {
115
+ return null;
116
+ }
117
+ const meshes = new Set((entry.getMeshes() ?? []).filter((mesh) =>
118
+ !mesh.isDisposed() && mesh.isPickable && mesh.isEnabled() && mesh.isVisible && mesh.visibility > 0));
119
+ if (meshes.size === 0) {
120
+ return null;
121
+ }
122
+ const point = scene.pick(scene.pointerX, scene.pointerY, (mesh) => meshes.has(mesh), false, camera)?.pickedPoint;
123
+ return point && Number.isFinite(point.x) && Number.isFinite(point.y) && Number.isFinite(point.z) ? point.clone() : null;
124
+ }
54
125
  }
@@ -216,6 +216,14 @@ export default class BabylonJSController {
216
216
  // Re-solve these if exposure, contrast or the tonemapping curve change.
217
217
  backgroundDay: [1.424, 1.424, 1.424],
218
218
  },
219
+ // The model's physical glass (KHR_materials_transmission). The SDK also authors it with a
220
+ // baseColor alpha of 0.15, which multiplies the reflection away; the viewer sets the alpha
221
+ // to 1 and raises the dielectric F0 (MaterialUtils.tunePhysicalGlassMaterial) so the
222
+ // environment reads on the glass while transmission keeps it see-through.
223
+ glass: {
224
+ alpha: 1,
225
+ metallicF0Factor: 3,
226
+ },
219
227
  },
220
228
  pointerPicking: {
221
229
  movePickIntervalMs: 50, // Cap expensive scene.pick calls to ~20 Hz while moving the pointer
@@ -1084,6 +1092,19 @@ export default class BabylonJSController {
1084
1092
  this.#applyShowroomCameraFraming(this.#camera);
1085
1093
  this.#camera.attachControl(this.#canvas, true);
1086
1094
  this.#scene.activeCamera = this.#camera;
1095
+ this.#focusWheelZoomOnModel(this.#camera);
1096
+ }
1097
+
1098
+ /**
1099
+ * Makes native mouse-location wheel zoom focus on the visible model surface under the pointer.
1100
+ * Only meshes of the current visible model container are eligible; environment geometry never is.
1101
+ * Falls back to Babylon's target-plane focus when the pointer is not over the model.
1102
+ * @private
1103
+ * @param {import("@babylonjs/core").ArcRotateCamera} camera - Camera with native mouse-location zoom.
1104
+ * @returns {void}
1105
+ */
1106
+ #focusWheelZoomOnModel(camera) {
1107
+ BabylonJSCameraWheelMotion.focusOnSurface(camera, () => this.#containers.model?.state?.isVisible ? this.#containers.model.assetContainer?.meshes : null);
1087
1108
  }
1088
1109
 
1089
1110
  /**
@@ -2863,6 +2884,21 @@ export default class BabylonJSController {
2863
2884
  assetContainer.lights = [];
2864
2885
  }
2865
2886
 
2887
+ /**
2888
+ * Gives the model container's physical glass (KHR_materials_transmission) a visible reflection.
2889
+ * Model container only: environment glass is simplified instead (see
2890
+ * #assetContainer_simplifyTransmissiveMaterials), so it is never tuned here.
2891
+ * @private
2892
+ * @param {AssetContainer} assetContainer - Model container whose glass materials should be tuned.
2893
+ * @returns {void}
2894
+ */
2895
+ #assetContainer_tuneModelGlass(assetContainer) {
2896
+ const look = BabylonJSController.config.look.glass;
2897
+ assetContainer.materials?.forEach((mat) => {
2898
+ MaterialUtils.tunePhysicalGlassMaterial(mat, look);
2899
+ });
2900
+ }
2901
+
2866
2902
  /**
2867
2903
  * Strips KHR_materials_transmission, KHR_materials_ior and KHR_materials_volume from
2868
2904
  * environment-container (scene) materials, converting them to simple alpha blending.
@@ -3078,6 +3114,7 @@ export default class BabylonJSController {
3078
3114
  if (container.state.name === "model") {
3079
3115
  this.#assetContainer_deleteLights(assetContainer);
3080
3116
  this.#assetContainer_stopAnimations(assetContainer);
3117
+ this.#assetContainer_tuneModelGlass(assetContainer);
3081
3118
  newModelMetadata = { ...(container.state.update.metadata ?? {}) };
3082
3119
  }
3083
3120
  if (container.state.name === "model" || container.state.name === "environment") {
@@ -3472,6 +3509,7 @@ export default class BabylonJSController {
3472
3509
  async disable() {
3473
3510
  BabylonJSCameraWheelMotion.cancel(this.#scene?.activeCamera);
3474
3511
  BabylonJSCameraWheelMotion.cancel(this.#camera);
3512
+ BabylonJSCameraWheelMotion.releaseSurfaceFocus(this.#camera);
3475
3513
  this.#cameraZoom?.dispose();
3476
3514
  this.#cameraZoom = null;
3477
3515
  this.#viewCubeSuspended = true;
@@ -47,6 +47,36 @@ export default class MaterialUtils {
47
47
  return Boolean(mat?.subSurface?.isRefractionEnabled || mat?.subSurface?.isTranslucencyEnabled);
48
48
  }
49
49
 
50
+ /**
51
+ * Gives the model's physical glass a visible reflection.
52
+ *
53
+ * The SDK authors window glass with KHR_materials_transmission AND a baseColor alpha of 0.15 in
54
+ * alpha-blend. Babylon's transmission extension turns on `transparencyAsCoverage` for the whole
55
+ * GLB, which makes `useSpecularOverAlpha` / `useRadianceOverAlpha` false, so the ~4 % Fresnel
56
+ * reflection is multiplied by that alpha and the glass reads as flat grey. Setting the alpha to
57
+ * 1, raising the dielectric F0 and re-enabling the over-alpha reflections lets the environment
58
+ * show up on the glass while transmission keeps it see-through.
59
+ *
60
+ * Only physical glass (see `isPrefViewerPhysicalGlassMaterial`) is touched; `transparencyMode`
61
+ * and `subSurface` are left as authored so the glass stays in the transparent pass.
62
+ * @public
63
+ * @static
64
+ * @param {import("@babylonjs/core").PBRMaterial|null|undefined} mat - Material to tune.
65
+ * @param {{ alpha?: number, metallicF0Factor?: number }} [look] - Glass look; defaults to alpha 1 and F0 factor 3.
66
+ * @returns {boolean} True when the material was physical glass and has been changed.
67
+ */
68
+ static tunePhysicalGlassMaterial(mat, look = {}) {
69
+ if (!MaterialUtils.isPrefViewerPhysicalGlassMaterial(mat)) {
70
+ return false;
71
+ }
72
+
73
+ mat.alpha = look?.alpha ?? 1;
74
+ mat.metallicF0Factor = look?.metallicF0Factor ?? 3;
75
+ mat.useSpecularOverAlpha = true;
76
+ mat.useRadianceOverAlpha = true;
77
+ return true;
78
+ }
79
+
50
80
  /**
51
81
  * Returns true when a material should be placed in the transparent rendering group (renderingGroupId=1).
52
82
  *