@preference-sl/pref-viewer 2.17.0-beta.5 → 2.17.0-beta.6

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, 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 and works with perspective and orthographic cameras.
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.
@@ -256,7 +256,7 @@ The inner view is the principal model view. English ViewCube face labels read `I
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 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 is always drawn with an orthographic camera, whatever the projection or FOV of the viewer camera, and 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.
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
260
 
261
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
262
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preference-sl/pref-viewer",
3
- "version": "2.17.0-beta.5",
3
+ "version": "2.17.0-beta.6",
4
4
  "description": "Web Component to preview GLTF models with Babylon.js",
5
5
  "author": "Alex Moreno Palacio <amoreno@preference.es>",
6
6
  "scripts": {
@@ -851,14 +851,15 @@ export default class BabylonJSViewCubeController {
851
851
  * @param {ArrayLike<number>} matrix - Camera view matrix elements.
852
852
  * @param {number} mode - Camera projection mode.
853
853
  * @param {boolean} rightHanded - Whether the scene is right-handed.
854
+ * @param {number} fov - Camera vertical field of view in radians.
854
855
  * @returns {boolean} Whether anything differs from the previous call.
855
856
  */
856
- #updateGeometryKey(matrix, mode, rightHanded) {
857
+ #updateGeometryKey(matrix, mode, rightHanded, fov) {
857
858
  const key = this.#geometryKey;
858
- if (key && key.mode === mode && key.rightHanded === rightHanded && key.matrix.every((value, i) => value === matrix[i])) {
859
+ if (key && key.mode === mode && key.rightHanded === rightHanded && key.fov === fov && key.matrix.every((value, i) => value === matrix[i])) {
859
860
  return false;
860
861
  }
861
- this.#geometryKey = { matrix: Array.from(matrix), mode, rightHanded };
862
+ this.#geometryKey = { matrix: Array.from(matrix), mode, rightHanded, fov };
862
863
  return true;
863
864
  }
864
865
 
@@ -1114,7 +1115,7 @@ export default class BabylonJSViewCubeController {
1114
1115
  this.#dirty = true;
1115
1116
  }
1116
1117
  const rightHanded = camera.getScene().useRightHandedSystem;
1117
- if (this.#updateGeometryKey(camera.getViewMatrix().m, camera.mode, rightHanded)) {
1118
+ if (this.#updateGeometryKey(camera.getViewMatrix().m, camera.mode, rightHanded, camera.fov)) {
1118
1119
  const pose = this.#readPose(camera);
1119
1120
  const rotation = this.#rotationMatrix(pose.rotation);
1120
1121
  const towardObserver = Vector3.TransformNormal(new Vector3(0, 0, rightHanded ? 1 : -1), rotation).normalize();
@@ -1124,7 +1125,7 @@ export default class BabylonJSViewCubeController {
1124
1125
  const angles = anglesFromDirection(horizontal, this.#alpha);
1125
1126
  if (angles) {
1126
1127
  this.#alpha = angles.alpha;
1127
- renderer.setPose(angles);
1128
+ renderer.setPose({ ...angles, mode: camera.mode, fov: camera.fov });
1128
1129
  }
1129
1130
  const nearest = this.#regions.reduce((best, r) => (Vector3.Dot(r.direction, towardObserver) > Vector3.Dot(best.direction, towardObserver) ? r : best));
1130
1131
  renderer.setCurrent(nearest.id);
@@ -20,6 +20,11 @@ const BORDER_INSET = 4;
20
20
  const BORDER_WIDTH = 4;
21
21
  const ORTHO_EXTENT = 1.15;
22
22
  const CAMERA_RADIUS = 5;
23
+ const DEFAULT_FOV = 0.8;
24
+ // Perspective depth range around the cube, which is at most ~0.87 from its centre (corner), so +-2 leaves margin.
25
+ const PERSPECTIVE_DEPTH_MARGIN = 2;
26
+ // Distance from the cube centre to a corner sphere centre (half the cube diagonal).
27
+ const CORNER_DISTANCE = Math.sqrt(3) / 2;
23
28
  const TEXTURE_SIZE = 128;
24
29
  // Hits farther than this behind the nearest one are other sides of the cube, not overlapping parts.
25
30
  const PICK_DEPTH_TOLERANCE = 0.15;
@@ -82,8 +87,8 @@ const FACE_LABEL_KEYS = Object.freeze({ "1,0,0": "left", "-1,0,0": "right", "0,1
82
87
  /**
83
88
  * Renders the 3D view cube on its own Babylon.js engine and scene, bound to a small canvas.
84
89
  *
85
- * The scene is left-handed and Y-up like the viewer scene and its orthographic ArcRotateCamera uses the host
86
- * camera's alpha/beta unchanged, so no axis mirroring is needed. Every pickable mesh carries
90
+ * The scene is left-handed and Y-up like the viewer scene and its ArcRotateCamera (orthographic or perspective, following
91
+ * the host camera) uses the host camera's alpha/beta unchanged, so no axis mirroring is needed. Every pickable mesh carries
87
92
  * `metadata.regionId` equal to a VIEW_CUBE_DIRECTIONS id. The class owns no render loop and no
88
93
  * interaction logic: the host calls render() and decides what pick() results mean. It touches neither
89
94
  * `window` nor `document` itself, so it can be imported in any environment.
@@ -93,6 +98,8 @@ export default class BabylonJSViewCubeRenderer {
93
98
  #engine;
94
99
  #scene;
95
100
  #camera;
101
+ #mode = Camera.ORTHOGRAPHIC_CAMERA;
102
+ #fov = DEFAULT_FOV;
96
103
  #colors;
97
104
  #labels;
98
105
  #devicePixelRatio;
@@ -159,23 +166,33 @@ export default class BabylonJSViewCubeRenderer {
159
166
  }
160
167
 
161
168
  /**
162
- * Orients the cube like the host camera.
163
- * @param {{alpha: number, beta: number}} pose - Host camera angles in radians; beta is clamped away from the poles.
169
+ * Orients the cube like the host camera and follows its projection. An orthographic host gives an orthographic
170
+ * cube (radius 5, extents +-1.15). A perspective host gives a perspective cube with the host's vertical fov at
171
+ * radius 1.15 / tan(fov / 2), so the cube keeps the same on-screen size.
172
+ * @param {{alpha: number, beta: number, mode?: number, fov?: number}} pose - Host camera angles in radians (beta is clamped away from the poles), projection mode (Camera.ORTHOGRAPHIC_CAMERA or Camera.PERSPECTIVE_CAMERA; absent keeps the last one) and vertical field of view in radians (absent or not a finite positive number keeps the last one, 0.8 initially).
164
173
  * @returns {void}
165
174
  */
166
- setPose({ alpha, beta }) {
175
+ setPose({ alpha, beta, mode, fov }) {
167
176
  if (this.#disposed) {
168
177
  return;
169
178
  }
170
- this.#camera.alpha = alpha;
171
- this.#camera.beta = clampBeta(beta);
172
- this.#camera.getViewMatrix(true);
179
+ const camera = this.#camera;
180
+ if (mode === Camera.ORTHOGRAPHIC_CAMERA || mode === Camera.PERSPECTIVE_CAMERA) {
181
+ this.#mode = mode;
182
+ }
183
+ if (typeof fov === "number" && Number.isFinite(fov) && fov > 0) {
184
+ this.#fov = fov;
185
+ }
186
+ this.#applyProjection(camera);
187
+ camera.alpha = alpha;
188
+ camera.beta = clampBeta(beta);
189
+ camera.getViewMatrix(true);
173
190
  }
174
191
 
175
192
  /**
176
193
  * Finds the region under a point. Coordinates are CSS pixels from the canvas top-left; they are scaled by
177
194
  * the canvas client size (falling back to the render size when the client size is unknown) and cast as an
178
- * orthographic ray, so the result does not depend on hardware scaling. Corners win over edges and edges over
195
+ * ray (parallel for an orthographic cube, from the camera for a perspective one), so the result does not depend on hardware scaling. Corners win over edges and edges over
179
196
  * faces when they overlap on screen. A front-facing edge or corner whose projected centre is within the pick tolerance
180
197
  * of the pointer wins over the face under it (corners before edges, then the nearest centre). The tolerance is
181
198
  * min(8 CSS px, 1/12 of the cube side), so small cubes keep their face centres pickable. It also rescues a ray that
@@ -200,13 +217,16 @@ export default class BabylonJSViewCubeRenderer {
200
217
  const forward = camera.target.subtract(camera.position).normalize();
201
218
  const right = Vector3.Cross(camera.upVector, forward).normalize();
202
219
  const up = Vector3.Cross(forward, right);
203
- const halfWidth = (camera.orthoRight - camera.orthoLeft) / 2;
204
- const halfHeight = (camera.orthoTop - camera.orthoBottom) / 2;
205
- const origin = camera.position
206
- .add(right.scale((x / width * 2 - 1) * halfWidth))
207
- .add(up.scale((1 - y / height * 2) * halfHeight));
220
+ const perspective = camera.mode === Camera.PERSPECTIVE_CAMERA;
221
+ // Half extents of the view plane through the cube centre: fixed for orthographic, radius * tan(fov / 2) for perspective.
222
+ const halfHeight = perspective ? camera.radius * Math.tan(camera.fov / 2) : (camera.orthoTop - camera.orthoBottom) / 2;
223
+ const halfWidth = perspective ? halfHeight * (width / height) : (camera.orthoRight - camera.orthoLeft) / 2;
224
+ const offset = right.scale((x / width * 2 - 1) * halfWidth).add(up.scale((1 - y / height * 2) * halfHeight));
225
+ const ray = perspective
226
+ ? new Ray(camera.position, forward.scale(camera.radius).add(offset).normalize(), 100)
227
+ : new Ray(camera.position.add(offset), forward, 100);
208
228
  const hits = this.#scene
209
- .multiPickWithRay(new Ray(origin, forward, 100), (mesh) => Boolean(mesh.metadata?.regionId))
229
+ .multiPickWithRay(ray, (mesh) => Boolean(mesh.metadata?.regionId))
210
230
  .filter((hit) => hit.hit)
211
231
  .sort((a, b) => a.distance - b.distance);
212
232
  const near = this.#pickByTolerance(x, y, width, height, forward, computePickTolerance(Math.min(width, height)));
@@ -373,6 +393,24 @@ export default class BabylonJSViewCubeRenderer {
373
393
  return best?.id ?? null;
374
394
  }
375
395
 
396
+ // Orthographic: fixed radius and extents. Perspective: the host fov and the radius that frames the same half extent.
397
+ #applyProjection(camera) {
398
+ if (this.#mode === Camera.PERSPECTIVE_CAMERA) {
399
+ camera.mode = Camera.PERSPECTIVE_CAMERA;
400
+ camera.fovMode = Camera.FOVMODE_VERTICAL_FIXED;
401
+ camera.fov = this.#fov;
402
+ camera.radius = ORTHO_EXTENT / Math.tan(this.#fov / 2);
403
+ // A wide fov brings the camera closer than the margin: keep the near plane positive and before the nearest corner.
404
+ camera.minZ = Math.max(camera.radius - PERSPECTIVE_DEPTH_MARGIN, Math.min(0.1, (camera.radius - CORNER_DISTANCE) / 2));
405
+ camera.maxZ = camera.radius + PERSPECTIVE_DEPTH_MARGIN;
406
+ return;
407
+ }
408
+ camera.mode = Camera.ORTHOGRAPHIC_CAMERA;
409
+ camera.radius = CAMERA_RADIUS;
410
+ camera.minZ = 0.1;
411
+ camera.maxZ = CAMERA_RADIUS * 4;
412
+ }
413
+
376
414
  #createCamera() {
377
415
  const camera = new ArcRotateCamera("view-cube-camera", Math.PI / 2, Math.PI / 2, CAMERA_RADIUS, Vector3.Zero(), this.#scene);
378
416
  camera.inputs.clear();
@@ -413,9 +451,10 @@ export default class BabylonJSViewCubeRenderer {
413
451
  const [x, y, z] = region.axes;
414
452
  const mesh = MeshBuilder.CreatePlane(`view-cube-face-${region.id}`, { size: CUBE_HALF * 2 }, this.#scene);
415
453
  mesh.position.set(x * CUBE_HALF, y * CUBE_HALF, z * CUBE_HALF);
416
- // A plane shows its texture upright from -Z, so aim local +Z into the cube; text stays unmirrored from outside.
454
+ // Babylon's plane is visible, with its texture upright and unmirrored, from the side its normal points to; aim local
455
+ // +Z outward so back-face culling keeps the near faces (checked on screen: aimed inward, the far faces showed through).
417
456
  const up = y ? new Vector3(0, 0, -y) : Vector3.Up();
418
- mesh.rotationQuaternion = Quaternion.FromLookDirectionLH(new Vector3(-x, -y, -z), up);
457
+ mesh.rotationQuaternion = Quaternion.FromLookDirectionLH(new Vector3(x, y, z), up);
419
458
  region.texture = new DynamicTexture(`view-cube-label-${region.id}`, this.#createLabelCanvas(), this.#scene, true);
420
459
  region.material.diffuseTexture = region.texture;
421
460
  region.material.emissiveColor = new Color3(EMISSIVE_SCALE, EMISSIVE_SCALE, EMISSIVE_SCALE);