@preference-sl/pref-viewer 2.18.0 → 3.0.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.
Files changed (35) hide show
  1. package/README.md +267 -31
  2. package/index.d.ts +75 -6
  3. package/package.json +8 -2
  4. package/src/babylonjs-animation-controller.js +18 -10
  5. package/src/babylonjs-animation-opening-menu.js +80 -14
  6. package/src/babylonjs-animation-opening.js +3 -3
  7. package/src/babylonjs-ar-floor-bounds.js +169 -0
  8. package/src/babylonjs-ar-glass.js +128 -0
  9. package/src/babylonjs-ar-placement-feedback.js +449 -0
  10. package/src/babylonjs-ar-placement.js +1038 -0
  11. package/src/babylonjs-camera-wheel-motion.js +35 -2
  12. package/src/babylonjs-controller.js +542 -125
  13. package/src/babylonjs-orbital-pointers-input.js +186 -0
  14. package/src/{render-math.js → babylonjs-render-math.js} +11 -11
  15. package/src/babylonjs-selection-controller.js +4 -4
  16. package/src/babylonjs-view-cube-canvas-controller.js +1287 -0
  17. package/src/babylonjs-view-cube-canvas-orbit-math.js +220 -0
  18. package/src/babylonjs-view-cube-canvas-renderer.js +775 -0
  19. package/src/babylonjs-view-cube-controller.js +74 -1103
  20. package/src/babylonjs-view-cube-svg-controller.js +1012 -0
  21. package/src/file-storage.js +22 -4
  22. package/src/gltf-resolver.js +83 -13
  23. package/src/localization/translations.js +12 -0
  24. package/src/panzoom-controller.js +4 -4
  25. package/src/pref-viewer-2d.js +2 -2
  26. package/src/pref-viewer-3d.js +55 -9
  27. package/src/pref-viewer-controls-presentation.js +292 -0
  28. package/src/pref-viewer-dialog.js +86 -7
  29. package/src/pref-viewer-menu-3d.js +134 -17
  30. package/src/pref-viewer-style-controller.js +320 -0
  31. package/src/pref-viewer.js +133 -109
  32. package/src/quick-look-action.js +291 -0
  33. package/src/styles.js +702 -98
  34. package/src/babylonjs-view-cube-renderer.js +0 -548
  35. package/src/view-cube-orbit-math.js +0 -199
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, including its projection: the cube is orthographic for an orthographic viewer camera and perspective, with the same vertical FOV, for a perspective one.
9
+ - Default-enabled, selectable SVG or Canvas 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.
@@ -20,6 +20,36 @@
20
20
  - Export helpers for model, scene, or combined model + scene downloads.
21
21
  - Type declarations for TypeScript and JSX usage.
22
22
 
23
+ ## AR support
24
+
25
+ | Browser/platform | Viewer route |
26
+ | --- | --- |
27
+ | Android Chrome with confirmed `immersive-ar` and native plane hit-test support | WebXR AR at authored metre scale |
28
+ | Eligible Safari on iOS/iPadOS without WebXR AR support | AR Quick Look using a client-generated USDZ of the current visible model |
29
+ | Other browsers without a supported AR route | No AR action |
30
+ | iPhone VR | Not implemented |
31
+ | visionOS WebXR VR | Platform capability; this viewer does not implement `immersive-vr` |
32
+
33
+ Android WebXR preserves the glTF's authored metre dimensions, including existing unit-conversion transforms. It never fits the model to the screen or permits pinch scaling. Assets must encode their dimensions in metres and orient their interior toward the viewer's existing Inner (+Z) direction; the viewer cannot infer an interior face or correct arbitrary source units. The upstream exporter owns those asset conventions.
34
+
35
+ During Android WebXR AR, model materials using physical transmission/refraction or translucency temporarily use alpha blending so the camera background can show through the glass. This is a scalar approximation: full transmission uses 0.35 opacity, combined with existing geometric opacity; linked-refraction alpha is interpreted as transmission rather than geometric opacity. Colors, textures and reflection parameters remain unchanged, but physical camera refraction, blur and transmission texture masks are not reproduced. Opaque and existing ordinary alpha-transparent materials are left alone. Original glass values are restored on exit/cancellation, model or material replacement and teardown; normal 3D and Quick Look preparation outside WebXR retain their original materials. The fallback also applies to the no-floor preview. Shared material objects are adapted once and restored together. Real Android glass appearance still requires a device check.
36
+
37
+ On AR entry, the virtual environment is hidden. If no qualifying floor is available, the first finite native camera pose centres the preview two metres along its view ray, retaining the authored dimensions, with Inner facing the phone horizontally. The preview retains its initial world position and yaw instead of following the phone, and cannot be dragged. It is not a verified physical anchor or calibrated 1:1 measurement; erroneous native camera motion can still change its apparent size and position. Pending, failed or empty hit tests do not prevent this preview. A missing native camera pose still prevents initial placement. When a native horizontal-plane hit below the phone becomes available and the phone has a usable horizontal heading, the preview transitions to floor placement: its bottom centre is placed on that height, with its phone-facing orientation corrected by the minimum yaw to align model axis lines with the detected floor axes (nearest multiple of 90 degrees, at most 45 degrees correction). The preferred initial distance is three horizontal metres ahead. Optional native plane detection supplies a floor contour; the viewer checks the full horizontal bounding footprint at its authored scale and initial yaw. If it cannot fit at three metres, it uses the farthest fitting centre on the same forward segment up to three metres. If contours are unavailable/unusable or no full fit exists, it provisionally places the model at three metres without a fit guarantee. Later contour changes never reposition an already placed model. This transition can move the model visibly. It stays in world space as the user moves. The detected horizontal plane pose supplies the grid axes independently of contour-fit availability: a contour containing the accepted hit takes priority, otherwise the horizontal pose below the camera nearest in height to that hit is used (horizontal proximity breaks ties). Without a usable native pose, the X/Z axes of the WebXR tracking reference space provide the reported fallback. Pose-only orientation never changes the retained floor height or guarantees that the footprint fits. Diagnostics report `floorAlignmentSource`, `floorAlignmentStatus`, `floorAlignmentYaw` and `floorAlignmentHeightDifference`. The grid is frozen for model initial placement and gesture feedback; later camera/contour changes do not re-snap the model. Touch the model and drag with one finger to translate it on the retained floor plane, keeping the grab offset. A short tap outside the model places its bottom centre at the tapped point on that plane. Placement commits on release; moving the finger by more than one degree of ray direction cancels an off-model tap, even if it returns to its starting point. Adding a second tracked finger cancels translation or a pending floor tap and starts two-finger rotation around the vertical bottom-centre axis. Rotate the two fingers to change yaw (the rotation sense is inverted from the preceding implementation); pinching never changes scale. The bottom anchor stays fixed. Lifting either finger, a third finger or lost input cancels rotation; lift all fingers before starting another translation. This requires the Android runtime to expose both screen input sources. Fresh surface hits are not required after floor detection: other-height surfaces do not change the retained height. Missing/invalid input tracking, rays parallel to the floor or intersections behind the camera retain the last placement. This uses the mathematical plane at the observed height and does not verify its physical extent or rule out obstacles. WebXR does not universally distinguish a floor from a table. A contour fit checks the reported approximate surface, not obstacles or physical room boundaries; provisional placement provides no contour-fit guarantee. Leaving AR restores the authored model transforms and environment. `getDebugInfo().ar` reports placement state, metre units, initial distance, position and drag state. The diagnostic state `preview` distinguishes the camera-based fallback from `placed` on a detected floor; the fallback does not claim a detected floor or solve native calibration/tracking failures. Large models may extend beyond the viewport because their authored scale is preserved. The no-floor preview remains centred two metres along the initial camera ray.
38
+
39
+ After floor placement, a reticle marks a pending off-model floor tap and briefly confirms a committed destination. Four floor-space arrows indicate model dragging, with movement direction highlighted using the current primary style (`styles.colorPrimary`, scoped to the owning viewer), including runtime updates; curved arrows indicate rotation and highlight its actual direction with the same primary style. A committed floor tap briefly highlights the entire reticle. Reticles and arrows are flat, double-sided surfaces without tubular sections or side walls. Reticle circle/cross strokes are 8/2 cm wide, retaining the 40 cm centreline circle diameter and 16 cm total cross span. Both use exterior-only 1 cm contours; the cross is one joined surface. Translation/rotation arrow bands are 8 cm wide with filled heads measuring 24 cm across the base and 10 cm from base to tip. Each arrow uses one continuous shaft-and-tip surface and an exterior-only contour offset by 1 cm, preventing accumulated transparency at the join. Indicators use black outlines at 40% opacity and fully opaque white/primary foregrounds, with a narrower reticle stroke and wider arrow shafts. Their materials bypass scene exposure, tone mapping, postprocess linear-color output and fog to preserve UI sRGB colors; portions hidden by opaque model geometry remain faintly visible without changing model transparency. Feedback clears on release/cancellation or lost tracking and is excluded from model and full-scene downloads. These markers use the retained mathematical plane and do not certify new surface coverage or obstacle clearance. Native Android rotation and camera-overlay appearance still require physical-device validation.
40
+
41
+ The stationary fallback preserves authored dimensions but cannot compensate unreliable native tracking or guarantee constant projected appearance. Native calibration/tracking remains necessary for reliable physical scale and spatial anchoring.
42
+
43
+ Android placement and gestures have automated coverage using real Babylon geometry and simulated native XR frames. AR entry wakes the idle renderer and keeps Babylon's native frame pump running through entry, AR and exit; ordinary 3D can become idle again afterward. Observed floor height is retained while lifting the phone to a usable heading, even if the centre ray stops hitting that floor; a reference-space change or session exit discards it. `getDebugInfo().ar.floorHeight` reports the observed height. A regression check exercises the installed Babylon session manager through model visibility on a simulated floor hit. Galaxy Tab S8 Ultra captures confirm accepted floor placement and native transient-hit dragging in depth after correcting the finger subscription. Lateral hits on other levels exposed the limits of fresh-hit-only dragging; the user selected retained-plane dragging and off-model tap placement. The user confirms lateral dragging and off-model floor taps work on the Galaxy. A subsequent native retry delivered three detected planes and exercised provisional three-metre placement when no full fit existed. Successful bounded fits are covered by automated native-contour fixtures; physical dimensions and camera alignment remain separate acceptance checks.
44
+
45
+ While AR waits for floor placement (including `preview`), `getDebugInfo().ar.diagnostics` distinguishes delivered XR frames (`frames`), tracked poses (`trackedFrames`), native source setup (`hitTestStatus`), the current wait stage (`phase`) and placement hit counts (`lastHitCount`/`maxHitCount`). `lastSurface` records the latest checked plane position, normal, camera height and acceptance/rejection reason; `lastError` records a source-setup failure. `sessionActive`, `referenceSpaceReady` and `floorSourceActive` report current resource availability. These diagnostics are bounded snapshots, with no frame history. After exit, `diagnostics.ended` is true and the last attempt remains inspectable until the next entry; placement and scene state still reset normally. `floorHeight: null` alone cannot distinguish missing native hits from surfaces rejected by the filter. `ar.initialDistance` is the preferred floor distance (3 m); `diagnostics.placementDistance` records the actual initial floor distance. `placementStatus` is `bounded-fit`, `provisional-no-contour`, `provisional-no-fit` or `not-placed`; `floorBoundsStatus` and `detectedPlaneCount` explain contour availability at initial placement. Those diagnostics do not change when a user later repositions the model. `floorAlignmentSource` distinguishes `detected-plane`, `reference-space` and `not-placed`; `floorAlignmentYaw` and `initialYaw` record the frozen floor-frame and initial model yaw in radians.
46
+
47
+ On eligible Safari, the AR control uses the existing XR appearance and position. The viewer prepares USDZ after model/material loading; the control becomes usable when that preparation finishes. Updates invalidate the previous file. A failed preparation offers a retry through the same control. No new public method or configuration is required, and the existing USDZ download methods remain downloads.
48
+
49
+ The link uses `rel="ar"`, `download="model.usdz"` and a `model/vnd.usdz+zip` Blob URL with the `#allowsContentScaling=0` fragment, so its native navigation runs directly on the user tap and Quick Look keeps the authored real-world size (no pinch scaling, as on Android WebXR). Only model meshes with supported materials and current visibility are exported. Environment and viewer overlays are excluded. Physical transmission glass is exported with an equivalent alpha opacity, because USDZ has no transmission. The viewer bundles the `fflate` helper the exporter needs, so no request to `unpkg.com` is made.
50
+
51
+ Manual iOS Quick Look validation has been reported: its AR button remains usable even when the Babylon 3D view is empty. The empty scene/model failure has also been reproduced on an iPhone and remains unresolved; Quick Look validation does not establish successful in-page 3D rendering. Device/browser versions and itemized scale, glass, texture, close/reopen, iPad and production-iframe results were not supplied, so those details remain unconfirmed. Safari remote inspection is unavailable in the current test setup because no macOS system is available. An iframe must permit the browser's native AR/navigation behavior; no automatic top-level redirect or parent-message protocol is added. Used Blob URLs are retained through model replacement/detach until an observed browser return; if Safari emits no return signal, their lifetime ends with the document.
52
+
23
53
  ## Installation
24
54
 
25
55
  ```bash
@@ -96,7 +126,7 @@ viewer.setOptions({
96
126
 
97
127
  ## Configuration model
98
128
 
99
- The main `config` payload can include model, scene, materials, drawing, options, culture, styles, and boolean `showRenderMenu` / `showViewCube` flags.
129
+ The main `config` payload can include model, scene, materials, drawing, options, culture, styles, boolean `showRenderMenu` / `showViewCube` flags, and optional `viewCubeRenderer` (`svg`/`canvas`).
100
130
 
101
131
  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`.
102
132
 
@@ -234,8 +264,10 @@ Storage descriptors are passed through `model.storage`, `scene.storage`, `materi
234
264
  | `show-model` | Shows or hides the current 3D model. Use `true` or `false`. |
235
265
  | `show-scene` | Shows or hides the current 3D scene/environment. Use `true` or `false`. |
236
266
  | `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). |
267
+ | `view-cube-renderer` | `canvas` (default) or `svg`. Also available as the `viewCubeRenderer` property on root and standalone 3D. Invalid/missing values select Canvas. Changes replace only the cube, preserving model/camera state and visibility. |
268
+ | `controls-density` | `auto` (default), `compact`, or `comfortable`. Auto uses comfortable controls for a primary coarse pointer. Changes apply immediately without reloading assets. Also supported on standalone `<pref-viewer-3d>`; nested 3D inherits the root preference. Invalid/missing values use auto. |
237
269
  | `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). |
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`. |
270
+ | `styles` | JSON primary/ViewCube appearance patch; same schema as `setStyles()` and `config.styles`. Null leaves/groups reset customizations. See [Runtime styles](#runtime-styles). |
239
271
 
240
272
  ## ViewCube
241
273
 
@@ -258,7 +290,7 @@ The inner view is the principal model view. English ViewCube face labels read `I
258
290
 
259
291
  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
292
 
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.
293
+ Hover paints the region with the configured hover color (primary by default), using 50% blend strength; the nearest of 26 orientations is active at 15%. Hover takes precedence. Palette and label typography are configurable through `styles.viewCube` for both renderers, including runtime updates while idle. Canvas neutralizes base hue before highlight blending, then applies its own lighting. No region tooltips are provided. See [Runtime styles](#runtime-styles).
262
294
 
263
295
  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
296
 
@@ -266,7 +298,7 @@ Edges and corners are thin, so selection has a tolerance: a front-facing edge or
266
298
 
267
299
  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.
268
300
 
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.
301
+ 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 the default outside selectable regions, `pointer` over a selectable region, and the shared custom rotation cursor during dragging.
270
302
 
271
303
  ### ViewCube accessibility
272
304
 
@@ -274,15 +306,9 @@ The cube canvas has `role="img"` and one localized accessible name (`View orient
274
306
 
275
307
  ### ViewCube layout and lifecycle
276
308
 
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:
309
+ Both variants are square widgets at the top right of the 3D view: **top 0px**, **right 0px** by default. Both variants use the same CSS square size: 120 px compact and 140 px comfortable, capped to the smaller available viewer dimension. A viewer without a usable size shows no cube.
280
310
 
281
- ```css
282
- pref-viewer {
283
- --pref-viewer-view-cube-inset-right: 24px;
284
- }
285
- ```
311
+ External CSS inset tokens are no longer a supported customization mechanism on `<pref-viewer>`. Control layout is managed internally.
286
312
 
287
313
  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.
288
314
 
@@ -290,7 +316,9 @@ Cube resources are created only while enabled in a visible 3D view. Disabling it
290
316
 
291
317
  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.
292
318
 
293
- ## 3D camera and wheel zoom
319
+ ## 3D camera, wheel and touch zoom
320
+
321
+ Unlocked orbital cameras use Babylon's native distance-proportional zoom for both the mouse wheel and two-finger pinch. The same pinch movement produces a smaller absolute increment as the camera approaches its target. Both inputs retain native inertia and the existing model geometry zoom guard. Two-finger touch uses an internal pointers-input class that compares accumulated separation and midpoint movement, batching finger events per camera frame. Motion is deferred while ambiguous: separation changes of at least 3 CSS pixels favor pinch; parallel translation needs at least 6 CSS pixels with relatively stable separation. Intent is confirmed for 32ms before movement is applied, then stays fixed until release. For cameras with mouse-location zoom enabled (including the default camera), a pinch uses the midpoint between the fingers as its focal position: radius and target move together toward the visible model surface there, with the same target-plane fallback as the mouse wheel on a miss. The focal point stays at the same screen position for a stationary midpoint. Authored cameras with mouse-location zoom disabled retain radial pinch; their preference is preserved. Parallel movement remains a separate pan gesture without zoom and cancels any remaining focal-zoom glide. Focus loss or input detachment also cancels that glide; ordinary finger release retains native inertia. Pending movement is discarded on cancellation/focus loss/detachment. Babylon still calculates proportional zoom and panning; its per-event 20px onset threshold is not used. One-finger orbit and mouse panning remain available. Their internal sensitivities are separate: `BabylonJSController.config.camera.wheelDeltaPercentage` defaults to `0.015`, and `pinchDeltaPercentage` to `0.001`, because Babylon measures pinch changes using squared finger separation rather than wheel deltas. Natural pinch's direct-radius override is disabled when an orbital camera becomes interactive. These are internal runtime settings, not new viewer attributes or public options.
294
322
 
295
323
  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.
296
324
 
@@ -309,7 +337,7 @@ Increasing the factor makes the fitted model smaller. The other axis can have mo
309
337
 
310
338
  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
339
 
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.
340
+ 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 or focal-pinch motion and recenters the target on the visible model's world-bounds center using the existing fit.
313
341
 
314
342
  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
343
 
@@ -331,7 +359,8 @@ These are sampled ray checks, not continuous volume collision. Small surfaces be
331
359
  | `loadMaterials(materials)` | Queues a materials update. |
332
360
  | `loadDrawing(drawing)` | Queues a 2D drawing update. |
333
361
  | `setOptions(options)` | Queues camera, material, IBL, lighting, animation-menu, highlight, and render option updates. |
334
- | `setStyles(styles)` | Queues supported runtime style overrides. Currently supports `{ colorPrimary }`, persists it in `localStorage`, updates `PrefViewerStyleVariables.colorPrimary`, and applies `--pref-viewer-color-primary`. |
362
+ | `setStyles(styles)` | Queues validated primary/ViewCube appearance patches with null resets and optional versioned persistence. |
363
+ | `clearPersistedStyles()` | Removes only the configured saved style entry, preserving appearance. Returns success as boolean. |
335
364
  | `setIBL(ibl)` | Convenience wrapper for `setOptions({ ibl })`. |
336
365
  | `setCulture(culture)` | Changes the active UI locale and persists it in `localStorage`. |
337
366
  | `setMode(mode)` | Switches between `2d` and `3d`. |
@@ -389,8 +418,13 @@ The turnable methods store and publish semantic state for host integration. They
389
418
 
390
419
  Opening animations are discovered from paired glTF animation groups named `animation_open_<name>` and `animation_close_<name>`. State values are `0` (paused), `1` (closed), `2` (opened), `3` (opening) and `4` (closing). Enable `options.render.showAnimationMenu` to create the animation controller. Disabling that setting releases the controller: `getAnimations()` returns an empty array and imperative animation actions return `false`. A controller that is enabled can still receive imperative actions while its controls are closed.
391
420
 
421
+ Open the animation controls with a **right mouse click** on an animated opening or, on touch screens, **hold one finger still on it for about half a second**. A short tap keeps its existing model selection/deselection behavior. Moving more than the click tolerance, adding another finger, cancelling the touch or losing window focus cancels the pending hold. After a recognized hold, lifting the finger leaves the menu open and does not emit a model-selection click. If highlight is enabled, the opening is temporarily highlighted while the hold is pending. That feedback disappears when the half-second hold completes, on early release or on cancellation. Touch-generated movement never acts as persistent mouse hover; a brief tap can show feedback while the finger is down, but cannot leave it highlighted. Real mouse hover remains available, including on hybrid devices. Touch the playback buttons normally; a normal canvas tap dismisses the menu. Pending holds are also cancelled on reload/disable and do not open controls over a modal or while the viewer is hidden/in XR.
422
+
423
+
392
424
  ### Dialogs and downloads
393
425
 
426
+ Dialogs opened with `openDialog()`, including the **Ctrl+Alt+D** download dialog, inherit the viewer's `controls-density`. Comfortable presentation uses 16px text, an 18px title and at least 48px-high actions and option labels. Compact keeps smaller controls. Open dialogs adapt immediately when density changes without resetting selections. Dialogs stay within the viewer, scroll when its height is limited, and wrap download choices and footer buttons in narrow layouts, including iframes. Shared `--pref-viewer-controls-dialog-*` values initialize the local variables in `PrefViewerDialogStyles`.
427
+
394
428
  | Method | Description |
395
429
  | --- | --- |
396
430
  | `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. |
@@ -409,13 +443,15 @@ These setters are convenience bridges for framework bindings and property-based
409
443
 
410
444
  | Property | Description |
411
445
  | --- | --- |
446
+ | `viewCubeRenderer` | Read/write `canvas` (default) or `svg`; reflects `view-cube-renderer` and changes only the active cube. Also available on standalone 3D. |
412
447
  | `config` | Forwards an object or JSON string to `loadConfig(value)`, including boolean overlay flags with the same queue ordering and omission rules. |
413
448
  | `model` | Forwards to `loadModel(value)`. |
414
449
  | `scene` | Forwards to `loadScene(value)`. |
415
450
  | `materials` | Forwards to `loadMaterials(value)`. |
416
451
  | `drawing` | Forwards to `loadDrawing(value)`. |
417
452
  | `options` | Forwards to `setOptions(value)`. |
418
- | `styles` | Forwards to `setStyles(value)`. |
453
+ | `styles` | Read/write appearance patches; getter returns a detached merged snapshot. |
454
+ | `stylesPersistence` | Read/write `{ enabled, key }`; configure before insertion when disabling persistence. |
419
455
  | `culture` | Forwards to `setCulture(value)`. |
420
456
  | `mode` | Forwards to `setMode(value)`. |
421
457
 
@@ -542,21 +578,104 @@ Directional and spot lights that come with the scene glTF (`KHR_lights_punctual`
542
578
 
543
579
  ## Runtime styles
544
580
 
545
- Runtime styles can be passed through the `styles` attribute, the `styles` property setter, `setStyles(styles)`, or `loadConfig({ styles })`.
581
+ `setStyles(patch)`, the `styles` property/JSON attribute, and `config.styles` accept one schema. The root `<pref-viewer>` owns and validates these values per instance; changing one mounted viewer does not mutate another viewer's defaults. Updates use the existing sequential task queue and do not reload assets or reset the camera.
546
582
 
547
- ```html
548
- <pref-viewer styles='{"colorPrimary":"#0055cc"}'></pref-viewer>
583
+ ```js
584
+ viewer.styles = {
585
+ colorPrimary: "#0055cc",
586
+ viewCube: {
587
+ colors: {
588
+ face: "#eef0f3", edge: "#d5d5da", corner: "#c8c6c8",
589
+ border: "#78838f", text: "#29323b",
590
+ hover: "#0055cc", active: "#0055cc"
591
+ },
592
+ hoverOpacity: 0.5,
593
+ activeOpacity: 0.15,
594
+ typography: {
595
+ fontFamily: "Arial, sans-serif", fontSize: 10, fontWeight: 600,
596
+ letterSpacing: 1, textTransform: "uppercase"
597
+ }
598
+ }
599
+ };
600
+
601
+ // Partial updates preserve unrelated leaves.
602
+ viewer.setStyles({ viewCube: { colors: { corner: "#b8b8c0" } } });
603
+ // Same schema through configuration or JSON attributes:
604
+ viewer.loadConfig({ styles: { colorPrimary: "#0055cc" } });
605
+ viewer.setAttribute("styles", JSON.stringify({ viewCube: { hoverOpacity: 0.4 } }));
606
+
607
+ // Reset a leaf/group. Hover/active colors follow colorPrimary again when reset.
608
+ viewer.setStyles({ viewCube: { colors: { hover: null } } });
609
+ viewer.setStyles({ viewCube: null });
610
+ const snapshot = viewer.styles; // detached merged overrides plus effective colorPrimary
549
611
  ```
550
612
 
613
+ | Field | Default / validation | Applies to |
614
+ | --- | --- | --- |
615
+ | `colorPrimary` | `#ff6700`; valid CSS color | UI accents, default cube highlights, animation-opening highlight and AR feedback |
616
+ | `viewCube.colors.face` | `#eef0f3`; valid CSS color | SVG shell and Canvas faces |
617
+ | `viewCube.colors.border` | `#78838f`; valid CSS color | SVG outlines only |
618
+ | `viewCube.colors.edge` | `#d5d5da`; valid CSS color | Canvas edge prisms |
619
+ | `viewCube.colors.corner` | `#c8c6c8`; valid CSS color | Canvas corner spheres |
620
+ | `viewCube.colors.text` | `#29323b`; valid CSS color | Both renderers' labels |
621
+ | `viewCube.colors.hover`, `active` | Follow `colorPrimary`; valid CSS colors | Both renderers |
622
+ | `viewCube.hoverOpacity`, `activeOpacity` | `0.5`, `0.15`; numbers in 0..1 | Highlight blend strength, not material transparency |
623
+ | `viewCube.typography.fontFamily` | Viewer default Roboto/system stack; valid CSS font family | Both renderers |
624
+ | `viewCube.typography.fontSize` | 9 compact / 10 comfortable; positive numeric reference pixels | Both renderers; scaled to Canvas texture pixels |
625
+ | `viewCube.typography.fontWeight` | 600; integer 1..1000 | Both renderers |
626
+ | `viewCube.typography.letterSpacing` | 1; nonnegative numeric reference pixels | Both renderers |
627
+ | `viewCube.typography.textTransform` | `uppercase`; `none`, `uppercase`, `lowercase` | Both renderers |
628
+
629
+ Unknown fields and invalid leaves are ignored independently. Colors/families cannot reference external CSS (`var()`, `env()`, `currentColor`, inheritance/reset keywords). A null leaf or group removes its customizations, including stored ones, restoring current internal defaults. An omitted leaf preserves its value. Getter snapshots omit uncustomized leaves except `colorPrimary`; modifying a snapshot does not change the viewer. A malformed JSON string throws. `setStyles()` returns void for queued objects (not a Promise), or false for a non-object payload.
630
+
631
+ Changing the primary color synchronizes animation-opening highlights, including an active highlight, without changing whether highlighting is enabled. The render-menu color control stays synchronized. An explicit `options.render.highlightColor` remains supported; a later primary style update replaces it. Canvas applies its material lighting, highlights and SSAO after resolving the palette; displayed tones can differ from the flat SVG. Renderer selection remains on the existing separate `viewCubeRenderer` API. Canvas lighting, material/specular settings, SSAO and other rendering internals are not exposed through styles.
632
+
633
+ ### Style persistence
634
+
635
+ Persistence defaults to `{ enabled: true, key: "pref-viewer/styles" }`, preserving the existing default storage location. Configure it per instance before insertion to avoid reading storage when disabled:
636
+
551
637
  ```js
552
- viewer.setStyles({
553
- colorPrimary: "#0055cc",
554
- });
638
+ const viewer = document.createElement("pref-viewer");
639
+ viewer.stylesPersistence = { enabled: true, key: "my-app/product-preview/styles" };
640
+ viewer.styles = { colorPrimary: "#0055cc" };
641
+ document.querySelector("#preview").append(viewer);
642
+
643
+ // When the application owns persistence:
644
+ viewer.stylesPersistence = { enabled: false };
555
645
  ```
556
646
 
557
- 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.
647
+ Only supported overrides are saved, not a snapshot of defaults:
558
648
 
559
- 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.
649
+ ```json
650
+ {"version":1,"styles":{"colorPrimary":"#0055cc","viewCube":{"colors":{"corner":"#b8b8c0"}}}}
651
+ ```
652
+
653
+ Defaults are overlaid by saved preferences, then explicit application patches. Explicit values and null resets retain priority when switching storage keys or reconnecting. Switching keys/enabling storage restores that key without automatically writing it; disabling removes the saved layer from the current appearance and preserves explicit patches. Accepted style patches write the merged overrides when persistence is enabled. Resetting a group/leaf removes it from the next saved envelope.
654
+
655
+ Keys are exact `localStorage` keys scoped to the page origin. The default key is shared: instances using it read the same saved preferences on restoration; choose distinct keys for isolation. Mounted viewers do not automatically synchronize across instances/tabs. Legacy `{ "colorPrimary": "..." }` records are restored and converted to version 1 on the next accepted patch. Invalid records, unsupported versions and blocked/full storage do not prevent rendering.
656
+
657
+ ```js
658
+ viewer.clearPersistedStyles(); // true on successful removal, false if disabled/unavailable
659
+ ```
660
+
661
+ Clearing removes only the configured key and preserves the current appearance; a future patch can save it again. It does not erase locale, render settings or other storage. Use null style resets to restore appearance as well. Getter `stylesPersistence` returns a detached copy.
662
+
663
+ ### Migration from external CSS
664
+
665
+ **Breaking change:** inherited CSS variables are no longer a supported styling API for controls inside root `<pref-viewer>`. The shadow wrapper initializes internal tokens and typography; CSS on the host or its ancestors cannot override controls through old `--pref-viewer-*` tokens or internal aliases. Use the style object instead:
666
+
667
+ ```js
668
+ // Replaces host CSS: --pref-viewer-view-cube-corner-color: #b8b8c0
669
+ viewer.setStyles({ viewCube: { colors: { corner: "#b8b8c0" } } });
670
+ ```
671
+
672
+ Legacy `fill-color` maps to `viewCube.colors.face`, `border/text/edge/corner/hover/active-color` map to the corresponding `viewCube.colors` keys, and `text-font-*`, `text-letter-spacing`, `text-transform` map to `viewCube.typography`. Size and spacing inputs use numeric pixels in the API. This API belongs to root `<pref-viewer>`; standalone low-level components do not gain an independent style/persistence facade.
673
+
674
+ Applications still control the host's width, height, position and visibility. Encapsulation is not a security boundary: JavaScript can access the open shadow root, and arbitrary HTML supplied to dialogs is application-owned.
675
+
676
+ ### Confluence publication checklist
677
+
678
+ On the next requested PrefViewer Confluence documentation update, include this unified schema, defaults, partial/null reset semantics, validation, queue behavior, keyed persistence/migration/clearing, CSS encapsulation and migration examples. Also cover SVG/Canvas selection via its existing API, shared/custom colors and typography, compact/comfortable presentation, gestures/cursor, and the distinction between palette values and Canvas lighting/material/SSAO internals. This repository change does not publish to Confluence.
560
679
 
561
680
  ## Localization
562
681
 
@@ -601,7 +720,7 @@ The playground is available from `playground/index.html` at `http://localhost:80
601
720
 
602
721
  JavaScript classes, methods and named helper functions outside tests are documented with JSDoc, including parameters and return values where applicable. This also covers existing helpers and webpack configuration callbacks. Keep those comments and this README aligned with the current implementation. Edited code files use Windows CRLF line endings.
603
722
 
604
- Shared calculations live in the static `RenderMath` and `MaterialUtils` classes. `I18n` owns the shared locale and translation subscribers, with private lookup helpers and public static methods. These modules export their classes only; viewer components and tests call their static methods directly. Former named function/constant exports and utility reexports from `BabylonJSController` have been removed. These helpers are internal modules, not additional package-root API exports. `BabylonJSController` encapsulates resize application, metadata path parsing and debug serialization as private methods, passing scene mesh snapshots explicitly to material diagnostics.
723
+ Shared calculations live in the static `BabylonJSRenderMath` and `MaterialUtils` classes. `I18n` owns the shared locale and translation subscribers, with private lookup helpers and public static methods. These modules export their classes only; viewer components and tests call their static methods directly. Former named function/constant exports and utility reexports from `BabylonJSController` have been removed. These helpers are internal modules, not additional package-root API exports. `BabylonJSController` encapsulates resize application, metadata path parsing and debug serialization as private methods, passing scene mesh snapshots explicitly to material diagnostics.
605
724
 
606
725
  `BabylonJSAnimationOverlayController` owns one opening-highlight group: its utility scene, cached overlay bases, transformed presentation meshes and observers. It creates clones directly in the utility scene, hides geometry bases used only by instances, and releases only its own resources through an idempotent `dispose()`. These animation overlays remain separate from semantic 3D selection overlays. The temporary legacy storage module and opening-control callbacks retain their existing structure.
607
726
 
@@ -609,20 +728,25 @@ Focused regressions for this work live in `tests/babylonjs-selection-controller.
609
728
 
610
729
  ### Camera implementation and maintenance
611
730
 
612
- - `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.
613
- - `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.
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.
731
+ - `src/babylonjs-view-cube-controller.js`: `BabylonJSViewCubeController` facade selecting one implementation and owning replacement/disposal. `babylonjs-view-cube-svg-controller.js` preserves the SVG widget; `babylonjs-view-cube-canvas-controller.js` preserves the independent Babylon Canvas widget. Each implementation handles its input, navigation, synchronization and resource lifecycle using the same callback-based host contract and shared camera-framing utilities.
732
+ - `src/babylonjs-view-cube-canvas-renderer.js`: `BabylonJSViewCubeCanvasRenderer`, 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.
733
+ - `src/babylonjs-view-cube-canvas-orbit-math.js`: `BabylonJSViewCubeCanvasOrbitMath` encapsulates static pure calculations for orbit angles, the 26 view directions, drag deltas and legacy adaptive sizing calculations (the current overlay size comes from shared CSS). Numerical constants and region construction are private; the polar margin and immutable directions have read-only getters. It has no Babylon or DOM dependencies.
615
734
  - `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.
616
735
  - `src/babylonjs-camera-zoom-controller.js`: independent `BabylonJSCameraZoomController` for model-only dynamic radius protection and authored-floor restoration. It works without cube resources.
617
736
  - `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.
618
737
  - `src/babylonjs-controller.js`: owns the scene, active camera, containers, native wheel settings and render scheduler; coordinates both controllers.
619
738
  - `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.
620
739
 
621
- 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.
740
+ 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-canvas-renderer.test.js`, `tests/babylonjs-view-cube-canvas-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.
622
741
 
623
742
  ### ViewCube rendering maintenance
624
743
 
625
- 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.
744
+ Canvas currently enables an experimental SSAO2 contact-shading pass when its own engine supports SSAO2, draw buffers and renderable float/half-float textures. Settings are localized in `BabylonJSViewCubeCanvasRenderer`: `#SSAO_ENABLED` and `#SSAO_SETTINGS` (8 samples, half-resolution geometry/first blur stage, radius 0.12 cube units, strength 0.6). Only the 26 solid surfaces enter the geometry buffer; transparent labels are excluded. Composition preserves the transparent background and FXAA runs last. Unsupported engines retain the normal render, including NullEngine tests. The pipeline and geometry buffer are disposed with the cube and use its existing demand-render loop. This is a visual trial; real-device performance has not been benchmarked.
745
+
746
+
747
+ Both ViewCubes use the palette defined once by `PrefViewerViewCubePalette` in `src/styles.js`: face fill `#eef0f3`, border `#78838f` and text `#29323b`, primary-color hover at 50% and current-view tint at 15%. SVG applies CSS directly; Canvas reads the same computed variables and composites highlight tints over a neutral gray derived from each base color using RGB luminance weights (0.2126 R + 0.7152 G + 0.0722 B). This removes the base hue from hover/current highlights; unhighlighted surfaces retain their original colors and hover takes precedence over current. Canvas currently uses lit surface materials without emission, light intensity 1.0 and neutral hemispheric lower fill (0.75, 0.75, 0.75) for moderate contrast between faces. A white lower directional support light, tilted along (-0.4, 1, 0.4) at intensity 0.16, illuminates Bottom (-Y), Left (+X) and Outer (-Z) with white specular highlights while preserving the hemispheric fill; both lights belong to the isolated cube scene. Light specular strength is 0.75 per channel to soften highlights, especially in Top; lighting and specular highlights still affect the displayed tones. Faces, edge prisms and corner spheres all use solid diffuseColor with no emission, giving their base colors the same lighting path. Each face has a separate, non-pickable label plane with a transparent 512 px glyph texture and an unlit alpha-blended material for readability. Labels inherit face orientation, sit 0.0005 units outward with a depth bias to avoid flickering, and retain depth testing/back-face culling so rear labels stay hidden. The six extra meshes and materials belong to the private cube scene and are disposed with it. Hover and current-view tints affect the solid surfaces, while CSS controls label color and typography. Edge prisms use `#d5d5da` and corner spheres use `#c8c6c8`, independent of SVG borders. Both base colors are darkened proportionally by 5% per RGB channel from the original edge/corner palette, with rounding to whole channel values, then given a 5-point blue-channel boost. The cube engine requests native antialiasing and its own camera also uses FXAA; both stay isolated from the model scene. All 26 surface materials use specular color (0.5, 0.5, 0.5); faces and edges use specularPower 64, while corner spheres use 32 for broader highlights; the six label materials have no specular highlights. Geometry sizes remain renderer-specific. Both renderers share label typography from CSS: the viewer font family, weight 600, uppercase and 1 px letter spacing, with a reference font size of 9 px in compact and 10 px in comfortable. Canvas scales font size and letter spacing from the 62-unit frontal SVG face to its 512 px texture; these are texture pixels, independent of device pixel ratio. Face textures use mipmaps, trilinear sampling and anisotropic filtering (level 8, limited by the GPU) for sharper labels at oblique angles. Typography changes repaint existing textures, including while the main scene is idle. Public `styles.viewCube.typography` overrides are projected onto internal tokens shared by both renderers.
748
+
749
+ Public customization uses the unified [Runtime styles](#runtime-styles) schema. The CSS tokens in `PrefViewerViewCubePalette` and `PrefViewerViewCubeTypography` are internal implementation details projected inside the shadow boundary. Palette defaults are centralized; Canvas resolves the computed internal values and falls back for invalid colors. The optional internal dark palette is not automatically selected or a package-root export.
626
750
 
627
751
  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.
628
752
 
@@ -637,9 +761,62 @@ ViewCube listener registration, drag handling and hover updates are private inst
637
761
  | `npm run build:prefweb:dev` | Creates a development webpack UMD build with external source maps for PrefWeb/browser debugging. Source map module names point back to project source paths. |
638
762
  | `npm start` | Builds the standard bundle and starts a local HTTP server on port `8080`. |
639
763
  | `npm test` | Runs the Vitest test suite. |
764
+ | `npm run test:e2e:install` | Installs the WebKit browser matching the pinned Playwright version. |
765
+ | `npm run test:e2e` | Builds the ESM bundle and runs WebKit compatibility tests with iPhone/iPad profiles. |
766
+ | `npm run test:e2e:report` | Opens the last HTML compatibility-test report. |
640
767
  | `npm run release` | Creates a standard-version release. |
641
768
  | `npm run release:beta` | Creates a beta prerelease with standard-version. |
642
769
 
770
+ ## WebKit compatibility tests
771
+
772
+ On Windows 11, run `npm ci`, `npm run test:e2e:install`, then `npm run test:e2e`.
773
+ The browser installation is stored under ignored `node_modules` by default; `PLAYWRIGHT_BROWSERS_PATH`
774
+ can override it. The configuration in `playwright.config.js` uses WebKit with the iPhone 13 and
775
+ iPad Pro 11 device profiles. Filter runs with, for example, `npm run test:e2e -- --project=webkit-iphone`.
776
+
777
+ Tests detect this repository's running server on ports 8080–8090, 3000 or 5173 (and `PORT` if set),
778
+ or start and stop an in-process local server on a free port. To use a different running local port,
779
+ set `PLAYWRIGHT_BASE_URL`, for example `$env:PLAYWRIGHT_BASE_URL='http://localhost:8095'` in PowerShell.
780
+ The server must serve this repository root, including `/dist/` and `/tests/e2e/fixtures/`.
781
+ The deterministic viewer and iframe fixtures use that same local origin; they do not edit the playground.
782
+
783
+ The suite checks actual model pixels, touch-pointer camera handling, trusted touch taps, AR anchor
784
+ layout, USDZ ZIP contents/MIME/download, model replacement, visibility, localization, export retry,
785
+ modal protection, iframe delivery and orientation/hidden resize. Unexpected JavaScript errors,
786
+ console errors, failed requests and HTTP errors fail tests. Google Fonts CSS is explicitly fulfilled
787
+ with an empty stylesheet to exercise the system-font fallback without external certificate/network
788
+ dependencies. The fixture supplies the real `fflate` development dependency locally; it does not
789
+ validate production access to the exporter's CDN. A further fixture generates real Draco-compressed
790
+ glTF in memory and loads it over HTTP; the real Draco 1.5.7 decoder/WASM is supplied from the
791
+ `draco3dgltf` development dependency through an explicit CDN route. This tests decoding, HTTP/cache
792
+ loading and USDZ export independently of external certificate/network availability. Export-failure
793
+ injection is confined to its negative test.
794
+
795
+ Failures retain screenshots, videos and traces under `test-results/`. The HTML report is in
796
+ `playwright-report/` and includes browser errors and `getDebugInfo()` diagnostics. Open a trace with
797
+ `node tests/e2e/run.mjs show-trace <path-to-trace.zip>`. These output directories are ignored by Git.
798
+
799
+ Playwright is temporarily pinned to `1.65.0-alpha-2026-10-15` (WebKit build 2376) because stable
800
+ `1.64.1`/WebKit 2370 produces blank WebGL captures after buffer resize on Windows:
801
+ [upstream issue #42885](https://github.com/microsoft/playwright/issues/42885).
802
+ The newer browser is a test-only dependency. Recheck the visual suite before replacing this pin with
803
+ a stable release containing the fix; do not bypass the assertion or change production rendering to
804
+ work around a Windows test-browser capture bug.
805
+
806
+ These are **WebKit compatibility emulations on Windows, not Safari on real iOS/iPadOS**.
807
+ The `rel="ar"` action downloads USDZ here; it cannot launch or validate Apple's native AR Quick Look,
808
+ camera permissions, placement/scale/material fidelity, real mobile GPU behavior, or production
809
+ cross-origin iframe policies. Those checks still require actual iPhone/iPad devices. The minimal
810
+ fixtures also do not cover textured production-asset fidelity or Android WebXR sessions.
811
+
812
+ The viewer now loads optional font CSS without blocking its local shadow styles. Hidden canvases
813
+ skip engine resize, and resize notifications defer buffer/layout changes to a later animation frame.
814
+ Deferring resize work reduces repeated intrinsic-buffer scaling and observer feedback loops;
815
+ unexpected observer errors remain browser-test failures. The existing render-effect settings and
816
+ demand-rendering policy are preserved.
817
+ Cache writes also observe the IndexedDB request and transaction promises together, so a failed write
818
+ does not leak an unhandled transaction-abort rejection while the existing loading fallback proceeds.
819
+
643
820
  ## TypeScript and JSX
644
821
 
645
822
  The package ships `index.d.ts`, including JSX support for `<pref-viewer>`.
@@ -678,3 +855,62 @@ export function ProductViewer() {
678
855
  Proprietary. Copyright (c) 2026 Preference S.L. All rights reserved.
679
856
 
680
857
  This package is published for authorized Preference S.L. customers and partners. Use, distribution, and access are governed by the applicable commercial agreement with Preference S.L. See [LICENSE.md](./LICENSE.md).
858
+
859
+ ## Responsive controls
860
+
861
+ `controls-density="auto"` selects comfortable controls for a primary coarse pointer and compact controls otherwise. Set `compact` or `comfortable` explicitly to override this, including on hybrid devices. Each viewer owns its capability/resize subscriptions; multiple instances and same-origin iframe layouts are independent. Display pixel ratio affects rendering sharpness, not control target sizes. Changing density does not reload the scene or change model scale.
862
+
863
+ ```html
864
+ <pref-viewer controls-density="auto" mode="3d"></pref-viewer>
865
+ <!-- Standalone 3D also accepts the attribute. -->
866
+ <pref-viewer-3d controls-density="comfortable"></pref-viewer-3d>
867
+ ```
868
+
869
+ Comfortable presentation uses 40px playback buttons, 48px settings actions, a 140px ViewCube in either renderer and a 48×48px AR action with the same padding/background as the settings trigger and no added AR text. Settings use 16/14px text. In both densities, the animation menu fits the available viewer width and buttons wrap onto multiple rows when necessary. Compact retains 32px playback buttons. Space below is reserved only when a visible AR button overlaps horizontally; otherwise the menu uses the normal bottom margin. The playback container adds no background, corner rounding or padding; its slider retains the original fill, border, thumb and spacing in both densities. Settings stay open after touch and can be closed with the gear, close action or Escape. Narrow comfortable viewers open settings in a bounded scrolling bottom panel. A compact override on a touch device still exposes settings without requiring hover. The cube’s face/edge/corner regions are not individually guaranteed to be 48px targets. Very small viewers constrain how many controls can be visible concurrently.
870
+
871
+ In both densities, AR is circular and shares the settings trigger's width, height, background, hover color, border and padding, without growing on hover. The AR glyph shows an isometric cube surrounded by scanning corners; its square SVG matches the settings glyph's width (24px compact, 28px comfortable). Compact AR measures 46×46px. Native WebXR and Quick Look actions use the same 16px edge spacing. Shared values are grouped in `ViewerControlsPresentationStyles` with the `--pref-viewer-controls-` prefix, including `toggle-bg-color`, `toggle-bg-hover-color`, `toggle-border-color`, `toggle-border-width`, `toggle-height`, `toggle-radius`, `toggle-spacing`, `toggle-icon-size`, `toggle-transition-duration`, `panel-bg-color`, and `edge-spacing`. `PrefViewerMenu3DStyles` keeps its local variables (such as `--toggle-bg-color` and `--panel-bg-color`), initialized from those shared values with standalone defaults. AR width and label size use `--pref-viewer-controls-ar-width` and `--pref-viewer-controls-ar-font-size`.
872
+
873
+ For the SVG variant, with an unlocked `ArcRotateCamera`, hold the left mouse button or press with one finger on a ViewCube face, edge or corner and drag to orbit the scene, without a long-press delay. This works independently of canvas camera input attachment and keeps the target, radius, projection and angular limits. Touch presses use the same fill and outline as mouse hover before dragging, with native blue tap highlighting disabled; feedback clears as soon as the drag threshold is crossed, or on release/cancellation. Short clicks and taps still select predefined views; dragging does not refit the model or select a view on release. During dragging a cube-with-orbit-arrows cursor identifies ViewCube rotation (CSS variable `--cube-rotation-cursor`, with `grabbing` fallback). Capture continues outside the cube and ends on cancellation, focus loss, Escape, camera/projection changes, hiding or disposal. Full camera lock and AR prevent navigation. Free/Universal cameras retain view-selection clicks; orbital dragging supports mouse and single-finger touch. A second touch on the cube cancels rotation, and SVG touch-action prevents browser scrolling/zoom from taking over that gesture.
874
+
875
+ SVG and Canvas ViewCube dimensions come from `--cube-size` in `PrefViewerViewCubeStyles`, initialized from `--pref-viewer-controls-cube-size` with a 120px fallback. Density defaults are 120px compact and 140px comfortable. CSS owns width/height; the controller only caps the square to the available viewport. The settings trigger and panel move to the upper right, immediately left of the ViewCube when the normal trigger position below the cube would exceed the viewer height or overlap a visible AR action (including the shared control gap). They return to the right when space becomes available. This is evaluated from the normal right-side position to avoid alternating sides after a move, and responds to viewer/control resizing, cube visibility and AR insertion/removal/visibility in both densities.
876
+
877
+ Render settings suppress the browser's native tap flash throughout the gear and panel. Touch presses use the same feedback as mouse hover: the gear and panel buttons change background, while enabled switches and sliders receive a light outline. Feedback clears on release/cancellation, a second touch, window blur or menu removal; keyboard focus remains visible. Coarse-pointer devices do not retain simulated mouse-hover styling after tapping.
878
+
879
+ Viewer dialogs use the same temporary touch feedback for buttons, radios, checkboxes and other form controls, including controls supplied through `openDialog`. Pressing a radio/checkbox label highlights its associated input; native selection and keyboard focus remain available. Radios and checkboxes use the viewer's primary accent color. Dialog content replacement and removal also clear owned press state.
880
+
881
+ Animation playback, loop and animation-selection buttons also suppress native tap flash and share hover/press feedback. Pressed active controls retain their active theme; disabled playback controls are inactive. Progress dragging keeps the original track styling and highlights the thumb while pressed, with visible keyboard focus. Release/cancellation, another touch, window blur, disabling a pressed control and menu disposal clear transient feedback.
882
+
883
+ The embedding page still needs `<meta name="viewport" content="width=device-width, initial-scale=1">` for mobile layout; the viewer never inserts or changes page viewport metadata. Keep browser zoom available. Automated WebKit compatibility tests do not establish native iOS AR behavior or physical touch comfort; real Android/iOS validation remains useful.
884
+
885
+ For failed repositioning, inspect `getDebugInfo().ar.diagnostics.drag` after attempting a one-finger drag. `selectStarts` counts received native selections; `selectEnds` counts releases/removals. `lastInputMode`, `status`, `pickedMesh` and `lastError` distinguish missing input/poses, floor-intersection rejection, pending/cancelled/placed taps and active dragging. `hitTestStatus: "retained-floor"` means gestures use the previously observed plane without another native subscription; the drag `lastHitCount` remains zero. `frames` and `moves` count processed input frames and applied horizontal position changes across the session. Status describes the last gesture outcome and survives release/exit until another gesture or session starts; `ar.dragging` separately reports current activity. These bounded counters do not establish a device root cause or successful physical dragging.
886
+
887
+
888
+ ### Touch gesture reference
889
+
890
+ | Touch location and gesture | Behavior |
891
+ | --- | --- |
892
+ | Main 3D scene: separate or bring two fingers together | Proportional zoom around their midpoint when the camera uses focal wheel zoom; focus is the visible model surface or the same target-plane fallback as the wheel. |
893
+ | Main 3D scene: move two fingers in parallel | Pan after intent recognition, without zoom; stops any preceding focal-zoom glide. |
894
+ | SVG or Canvas ViewCube: press a selectable region | Show hover feedback immediately, without a long-press delay. A short release over the same region selects that view. |
895
+ | SVG or Canvas ViewCube: press and move more than 3 CSS pixels | Orbit the camera, clear hover and keep only active-orientation feedback. Releasing after a drag does not select a view. |
896
+ | ViewCube: release or cancel touch | Clear touch hover; later camera updates do not restore it. Mouse hover remains available on hybrid devices. |
897
+
898
+ These scene-navigation gestures do not change Android AR model placement/rotation gestures or add model pinch scaling in AR. Windows WebKit checks verify emulated interactions and rendering; physical-device comfort remains a separate check.
899
+
900
+ ### Selectable ViewCube renderers
901
+
902
+ `<pref-viewer>` and standalone `<pref-viewer-3d>` support two orientation widgets. Canvas is the default. Explicitly selecting SVG keeps the vector cube, touch/mouse drag, rotation cursor and CSS sizing (120 px compact, 140 px comfortable). Canvas uses a real 3D cube in a dedicated Babylon Engine/Scene, including the projection, picking, DPR and context-restoration behavior from PWB-7397. It shares the SVG CSS dimensions: 120 px compact and 140 px comfortable, capped to the available viewport. Both use the same SVG rotation cursor, including its shadow and hotspot, during mouse drag. On touch, pressing a selectable region shows its hover feedback until the drag threshold is crossed; rotation then shows only the active orientation. Release or cancellation clears touch hover, while ordinary mouse hover remains available on hybrid devices. Exactly one implementation is active per viewer; switching releases the previous widget's graphics and input resources. The Canvas engine/scene stay outside Babylon's global registries. Top and Bottom synchronization preserves the polar view rather than applying the drag safety margin; the private camera uses only a 1e-7 rad numerical offset, so those faces remain centered and square. Its camera frames a bounding sphere that includes all faces, edge bars and corner spheres, with 2% padding. Orthographic extents and perspective distance fit this envelope without changing during rotation; the host field of view is retained and the whole cube stays inside the viewport.
903
+
904
+ ```html
905
+ <!-- Canvas is selected when the attribute is omitted. -->
906
+ <pref-viewer></pref-viewer>
907
+ <pref-viewer view-cube-renderer="svg"></pref-viewer>
908
+ <pref-viewer view-cube-renderer="canvas"></pref-viewer>
909
+ ```
910
+
911
+ ```js
912
+ viewer.viewCubeRenderer = "canvas";
913
+ viewer.loadConfig({ viewCubeRenderer: "svg", model: { storage: { url: "/model.glb" } } });
914
+ ```
915
+
916
+ The reflected `viewCubeRenderer` property accepts `svg`/`canvas`; invalid values and removing the attribute resolve to Canvas. `loadConfig` applies an explicitly supplied preference in queue order before asset loading; omission preserves the current preference. Direct changes do not reload assets or reframe/change the main camera. `show-view-cube` still controls visibility independently. Changes while disconnected, in 2D or disabled retain the preference without creating a cube. Separate viewers can use different variants. Both variants retain camera locking/XR restrictions and cube drag independent of scene rotation input. The render menu uses the actual widget dimensions to avoid overlaps. Only the Canvas variant allocates a second WebGL context, which is released on replacement/disable/disposal. Applications that require the vector widget without that additional graphics context must explicitly select `svg`; there is no automatic device-based renderer selection.