@preference-sl/pref-viewer 3.1.0-beta.0 → 3.1.0-beta.2
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 +21 -4
- package/index.d.ts +19 -0
- package/package.json +1 -1
- package/src/babylonjs-ar-glass.js +10 -3
- package/src/babylonjs-controller.js +66 -2
- package/src/babylonjs-view-cube-canvas-renderer.js +6 -4
- package/src/ibl-source-resolver.js +107 -0
- package/src/material-utils.js +21 -0
- package/src/pref-viewer-3d-data.js +6 -5
- package/src/pref-viewer-3d.js +24 -6
- package/src/pref-viewer.js +19 -0
- package/src/quick-look-action.js +51 -10
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ VR uses the `local-floor` reference space without teleportation, controller sele
|
|
|
38
38
|
|
|
39
39
|
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.
|
|
40
40
|
|
|
41
|
-
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:
|
|
41
|
+
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: glass the viewer tuned for the web look keeps its authored base color alpha as the AR opacity (the SDK's window glass, `0.15`), so it reads like the 3D view; other physical glass uses 0.35 opacity for full transmission, combined with existing geometric opacity; linked-refraction alpha is interpreted as transmission rather than geometric opacity. The same conversion is applied to the USDZ prepared for iOS Quick Look. 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.
|
|
42
42
|
|
|
43
43
|
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.
|
|
44
44
|
|
|
@@ -54,6 +54,22 @@ On eligible Safari, the AR control uses the existing XR appearance and position.
|
|
|
54
54
|
|
|
55
55
|
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.
|
|
56
56
|
|
|
57
|
+
#### Host-supplied Quick Look asset
|
|
58
|
+
|
|
59
|
+
A host that produces the AR asset elsewhere (for example a server-generated USDZ or USD) can hand it to the Quick Look action with `setQuickLookAssetProvider(provider)`. The provider is a function returning `Promise<{ data: Uint8Array | ArrayBuffer | Blob, mimeType?: string, fileName?: string }>`; `mimeType` defaults to `model/vnd.usdz+zip` and `fileName` to `model.usdz`. Pass `null` to clear it. The viewer never learns how the asset is made.
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
viewer.setQuickLookAssetProvider(async () => {
|
|
63
|
+
const response = await fetch("/my-server/ar-model");
|
|
64
|
+
return { data: await response.arrayBuffer(), mimeType: "model/vnd.usdz+zip", fileName: "model.usdz" };
|
|
65
|
+
});
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
- While a provider is set, the Quick Look link uses its asset instead of the Babylon USDZ export. It is called each time the link is prepared (after model/material loading and changes), only on the Quick Look route (eligible iOS/iPadOS Safari without WebXR AR).
|
|
69
|
+
- Setting or clearing the provider re-prepares the link when Quick Look is active; a provider set earlier (even before the element is connected) is retained.
|
|
70
|
+
- The ZIP signature check applies only when `mimeType` is `model/vnd.usdz+zip`. Any other MIME type (for example `model/vnd.usd`, or `text/plain` for USDA) is passed through unchanged as the Blob type, with `fileName` as the link's `download` attribute. AR Quick Look officially opens only `.usdz`/`.reality`, so what iOS does with other types is the host's experiment.
|
|
71
|
+
- If the provider throws, rejects or returns empty data, the viewer logs `PrefViewer: Quick Look asset provider failed` with `console.warn` and falls back to its own USDZ export.
|
|
72
|
+
|
|
57
73
|
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.
|
|
58
74
|
|
|
59
75
|
## Installation
|
|
@@ -438,6 +454,7 @@ Dialogs opened with `openDialog()`, including the **Ctrl+Alt+D** download dialog
|
|
|
438
454
|
| `downloadModelGLB()` / `downloadModelGLTF()` / `downloadModelUSDZ()` | Exports the current model. |
|
|
439
455
|
| `downloadSceneGLB()` / `downloadSceneGLTF()` / `downloadSceneUSDZ()` | Exports the current scene/environment. |
|
|
440
456
|
| `downloadModelAndSceneGLB()` / `downloadModelAndSceneGLTF()` / `downloadModelAndSceneUSDZ()` | Exports the combined model and scene. |
|
|
457
|
+
| `setQuickLookAssetProvider(provider \| null)` | Lets the host supply the asset opened by the iOS AR Quick Look action instead of the viewer's client-generated USDZ (for example a file produced by a server). See [AR and VR support](#ar-and-vr-support). |
|
|
441
458
|
|
|
442
459
|
All viewer dialogs share this priority, including the model-download dialog opened with **Ctrl+Alt+D** while the 3D canvas receives keyboard input. Existing backdrop dismissal remains available: a click over a covered control may close the dialog, but does not also activate that control. Closing the dialog restores access to the underlying controls.
|
|
443
460
|
|
|
@@ -563,7 +580,7 @@ Render settings are persisted in `localStorage` under the `pref-viewer/render-se
|
|
|
563
580
|
|
|
564
581
|
The showroom daylight background uses a shared linear HDR color calibrated with exposure and ACES tone mapping for the current light studio gray target, `#d9d9d9`, in a scene-less view. Both the static look and time-of-day cycle use this same daylight value; the cycle retains its dark night endpoint. Scene geometry can cover the background. The internal `BabylonJSController.config.look.showroom.backgroundDay` value is not a public background-color option and must be recalibrated if tone mapping or exposure changes.
|
|
565
582
|
|
|
566
|
-
The model's transmissive glass (`KHR_materials_transmission`, as authored by the SDK) also carries a base color alpha of `0.15`, which would multiply its reflection away and leave it flat grey. The viewer sets that alpha to `1` and `metallicF0Factor` to `3` (`BabylonJSController.config.look.glass`, internal like the showroom look) so the environment reflection reads while the glass stays see-through through transmission. Glass in the environment glTF is unaffected: it is simplified to alpha blending instead.
|
|
583
|
+
The model's transmissive glass (`KHR_materials_transmission`, as authored by the SDK) also carries a base color alpha of `0.15`, which would multiply its reflection away and leave it flat grey. The viewer sets that alpha to `1` and `metallicF0Factor` to `3` (`BabylonJSController.config.look.glass`, internal like the showroom look) so the environment reflection reads while the glass stays see-through through transmission. The authored alpha is remembered and becomes the glass opacity in native AR (Android WebXR and the iOS Quick Look USDZ), where there is no physical transmission. Glass in the environment glTF is unaffected: it is simplified to alpha blending instead.
|
|
567
584
|
|
|
568
585
|
## Environment map and scene lights
|
|
569
586
|
|
|
@@ -574,7 +591,7 @@ The model's transmissive glass (`KHR_materials_transmission`, as authored by the
|
|
|
574
591
|
| `.env` | Prefiltered Babylon environment (`CubeTexture.CreateFromPrefilteredData`) | No prefiltering in the browser; a 4K environment is typically under 1 MB. |
|
|
575
592
|
| anything else | Radiance HDR (`HDRCubeTexture`) | Prefiltered on load, which costs time and memory for large files. |
|
|
576
593
|
|
|
577
|
-
Either file is downloaded once, cached in IndexedDB, and reused across reloads.
|
|
594
|
+
Either file is downloaded once, cached in IndexedDB, and reused across reloads. The cached bytes reach Babylon as an in-memory file, never as a `blob:` URL: iOS WebKit answers requests to such URLs with status 0, and the environment then never became ready, so the 3D load never finished. A cached record that is empty or cannot be read within 5 s is purged and the server URL is used for that load.
|
|
578
595
|
|
|
579
596
|
Directional and spot lights that come with the scene glTF (`KHR_lights_punctual`) cast shadows when `render.shadowsEnabled` is `true` and `ibl.shadows` is off, alongside the built-in light's. Every scene and model mesh casts unless its node sets `extras.castShadows: false`, and receives unless it sets `extras.receiveShadows: false`. A light's shadow darkness comes from its node's `extras.shadowDarkness`, from `0` (black) to `1` (no shadow), and is `0.5` when absent:
|
|
580
597
|
|
|
@@ -754,7 +771,7 @@ Both ViewCubes use the palette defined once by `PrefViewerViewCubePalette` in `s
|
|
|
754
771
|
|
|
755
772
|
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.
|
|
756
773
|
|
|
757
|
-
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.
|
|
774
|
+
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. Both the viewer engine and the cube engine request their WebGL context with an explicit `powerPreference: "default"`: Babylon fills in `"high-performance"` when the option is absent, and iOS 18.7 / Safari 26.6.1 on iPhone returns a context that is already lost for that preference, which left the 3D view blank.
|
|
758
775
|
|
|
759
776
|
ViewCube listener registration, drag handling and hover updates are private instance methods. Region construction, colour parsing and extent interpolation are private static helpers; interpolation progress is passed explicitly. The render menu also encapsulates hue-channel calculation and lighting playback as private methods. Playback uses one callback bound to the menu instance and cancels its scheduled frame on stop or disconnection.
|
|
760
777
|
|
package/index.d.ts
CHANGED
|
@@ -139,6 +139,18 @@ export interface PrefViewerElementClickDetail {
|
|
|
139
139
|
ids: string[];
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/** Asset a host hands to the iOS AR Quick Look action. */
|
|
143
|
+
export interface PrefViewerQuickLookAsset {
|
|
144
|
+
data: Uint8Array | ArrayBuffer | Blob;
|
|
145
|
+
/** Defaults to `model/vnd.usdz+zip`; the ZIP signature is checked only for that type. */
|
|
146
|
+
mimeType?: string;
|
|
147
|
+
/** Defaults to `model.usdz`; used as the link's `download` attribute. */
|
|
148
|
+
fileName?: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Async factory of the Quick Look asset, called each time the Quick Look link is prepared. */
|
|
152
|
+
export type PrefViewerQuickLookAssetProvider = () => Promise<PrefViewerQuickLookAsset>;
|
|
153
|
+
|
|
142
154
|
/** Optional speed/direction for `startTurnable` / `toggleTurnable`. */
|
|
143
155
|
export interface PrefViewerTurnableOptions {
|
|
144
156
|
speed?: number;
|
|
@@ -356,6 +368,13 @@ export declare class PrefViewer extends HTMLElement {
|
|
|
356
368
|
setAnimationProgress(name: string, progress: number): boolean;
|
|
357
369
|
setAnimationLoop(name: string, loop: boolean): boolean;
|
|
358
370
|
|
|
371
|
+
// ── AR Quick Look (iOS) ──
|
|
372
|
+
/**
|
|
373
|
+
* Supplies (or clears with `null`) the asset opened by the iOS AR Quick Look action instead of the
|
|
374
|
+
* viewer's client-generated USDZ. A failing provider falls back to the built-in export with a console warning.
|
|
375
|
+
*/
|
|
376
|
+
setQuickLookAssetProvider(provider: PrefViewerQuickLookAssetProvider | null): void;
|
|
377
|
+
|
|
359
378
|
// ── Downloads ──
|
|
360
379
|
downloadModelGLB(): void;
|
|
361
380
|
downloadModelGLTF(): void;
|
package/package.json
CHANGED
|
@@ -42,7 +42,11 @@ export default class BabylonJSARGlass {
|
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
44
|
* Snapshots physical glass and switches it to alpha blending without depth occlusion.
|
|
45
|
-
*
|
|
45
|
+
* Glass the viewer tuned for the web look (`MaterialUtils.tunePhysicalGlassMaterial`) takes its authored
|
|
46
|
+
* base alpha as the AR opacity when that alpha is below 1, so it reads like the web view instead of the
|
|
47
|
+
* 0.35 floor of the transmission approximation; glass authored opaque keeps the approximation.
|
|
48
|
+
* Linked refraction uses alpha as inverse transmission, so neither the
|
|
49
|
+
* live nor the authored alpha is reused as geometric opacity there.
|
|
46
50
|
* Retains a disposal observer only while the snapshot is owned; disposed materials are forgotten untouched.
|
|
47
51
|
* @param {import("@babylonjs/core").PBRMaterial} material - Current physical glass not already adapted.
|
|
48
52
|
* @returns {void} Does not allocate/dispose materials or textures or modify color/reflection properties.
|
|
@@ -64,8 +68,11 @@ export default class BabylonJSARGlass {
|
|
|
64
68
|
this.#materials.set(material, saved);
|
|
65
69
|
sub.isRefractionEnabled = false;
|
|
66
70
|
sub.isTranslucencyEnabled = false;
|
|
67
|
-
|
|
68
|
-
|
|
71
|
+
const authored = linked ? null : MaterialUtils.authoredGlassAlpha(material);
|
|
72
|
+
material.alpha = authored !== null && authored < 1
|
|
73
|
+
? authored
|
|
74
|
+
: (linked ? 1 : alpha) * Math.max(BabylonJSARGlass.#MIN_GLASS_OPACITY,
|
|
75
|
+
1 - transmission * BabylonJSARGlass.#TRANSMISSION_REDUCTION);
|
|
69
76
|
material.transparencyMode = Material.MATERIAL_ALPHABLEND;
|
|
70
77
|
material.disableDepthWrite = true;
|
|
71
78
|
material.forceDepthWrite = false;
|
|
@@ -72,6 +72,7 @@ import QuickLookAction from "./quick-look-action.js";
|
|
|
72
72
|
* - enable() / disable()
|
|
73
73
|
* - load(force?) / reloadWithCurrentSettings()
|
|
74
74
|
* - downloadGLB(content) / downloadGLTF(content) / downloadUSDZ(content)
|
|
75
|
+
* - setQuickLookAssetProvider(provider)
|
|
75
76
|
* - getRenderSettings() / scheduleRenderSettingsReload(settings)
|
|
76
77
|
* - getDebugInfo()
|
|
77
78
|
* - setContainerVisibility(name, show)
|
|
@@ -133,6 +134,7 @@ export default class BabylonJSController {
|
|
|
133
134
|
#xrButtons = null;
|
|
134
135
|
#xrGeneration = 0;
|
|
135
136
|
#quickLookAction = null;
|
|
137
|
+
#quickLookAssetProvider = null;
|
|
136
138
|
#quickLookGlass = new BabylonJSARGlass();
|
|
137
139
|
#quickLookLoads = 0;
|
|
138
140
|
#quickLookValid = { model: false, materials: true };
|
|
@@ -1221,6 +1223,7 @@ export default class BabylonJSController {
|
|
|
1221
1223
|
* Prepares Quick Look from completed, visible model/material state without changing download helpers.
|
|
1222
1224
|
* Invalid or absent content invalidates the link. Only supported visible model meshes are serialized;
|
|
1223
1225
|
* asynchronous export ownership, stale results and preparation errors are handled by QuickLookAction.
|
|
1226
|
+
* The prepared asset comes from the host provider when one is set (see setQuickLookAssetProvider), otherwise from the export.
|
|
1224
1227
|
* @private
|
|
1225
1228
|
* @returns {void} Starts preparation without waiting for it or changing public task-queue ordering.
|
|
1226
1229
|
*/
|
|
@@ -1248,7 +1251,66 @@ export default class BabylonJSController {
|
|
|
1248
1251
|
}
|
|
1249
1252
|
// AssetContainer contains only model meshes; applied materials and world transforms are retained.
|
|
1250
1253
|
// No environment, XR ground, ViewCube or utility-scene overlays enter this export.
|
|
1251
|
-
void this.#quickLookAction.update(() => this.#
|
|
1254
|
+
void this.#quickLookAction.update(() => this.#prepareQuickLookAsset(asset, eligible));
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/**
|
|
1258
|
+
* Installs or clears the host-supplied provider of the asset opened by the iOS Quick Look action.
|
|
1259
|
+
* The provider is retained even before the Quick Look route exists. While Quick Look is active, changing it
|
|
1260
|
+
* re-prepares the link at once, so clearing returns to the in-browser USDZ export.
|
|
1261
|
+
* @public
|
|
1262
|
+
* @param {?(function(): Promise<{data: (Uint8Array|ArrayBuffer|Blob), mimeType?: string, fileName?: string}>)} provider
|
|
1263
|
+
* Async asset factory (defaults: MIME `model/vnd.usdz+zip`, file name `model.usdz`), or null to clear.
|
|
1264
|
+
* @returns {void} Ignores any value that is neither a function nor null by clearing the provider.
|
|
1265
|
+
*/
|
|
1266
|
+
setQuickLookAssetProvider(provider) {
|
|
1267
|
+
this.#quickLookAssetProvider = typeof provider === "function" ? provider : null;
|
|
1268
|
+
this.#refreshQuickLook();
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/**
|
|
1272
|
+
* Prepares the Quick Look asset: the host provider's asset when one is set, otherwise (or when the provider
|
|
1273
|
+
* fails or returns no data) the in-browser USDZ export of the current model.
|
|
1274
|
+
* @private
|
|
1275
|
+
* @param {import("@babylonjs/core").AssetContainer} asset - Model container used by the fallback export.
|
|
1276
|
+
* @param {function(import("@babylonjs/core").AbstractMesh): boolean} eligible - Mesh filter for the fallback export.
|
|
1277
|
+
* @returns {Promise<Uint8Array|{data: Uint8Array, mimeType?: string, fileName?: string}>} Asset for QuickLookAction.
|
|
1278
|
+
*/
|
|
1279
|
+
async #prepareQuickLookAsset(asset, eligible) {
|
|
1280
|
+
const provider = this.#quickLookAssetProvider;
|
|
1281
|
+
if (provider) {
|
|
1282
|
+
try {
|
|
1283
|
+
return await BabylonJSController.#readQuickLookAsset(provider);
|
|
1284
|
+
} catch (error) {
|
|
1285
|
+
console.warn("PrefViewer: Quick Look asset provider failed", error);
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
return this.#exportQuickLookUSDZ(asset, eligible);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/**
|
|
1292
|
+
* Invokes a host asset provider and converts its data to bytes.
|
|
1293
|
+
* @private
|
|
1294
|
+
* @static
|
|
1295
|
+
* @param {function(): Promise<{data: (Uint8Array|ArrayBuffer|Blob), mimeType?: string, fileName?: string}>} provider - Host asset factory.
|
|
1296
|
+
* @returns {Promise<{data: Uint8Array, mimeType?: string, fileName?: string}>} Nonempty asset with byte data.
|
|
1297
|
+
* @throws {Error} When the provider rejects, or returns no data or empty/unsupported data.
|
|
1298
|
+
*/
|
|
1299
|
+
static async #readQuickLookAsset(provider) {
|
|
1300
|
+
const result = await provider();
|
|
1301
|
+
const raw = result?.data;
|
|
1302
|
+
let data = null;
|
|
1303
|
+
if (raw instanceof Uint8Array) {
|
|
1304
|
+
data = raw;
|
|
1305
|
+
} else if (raw instanceof ArrayBuffer) {
|
|
1306
|
+
data = new Uint8Array(raw);
|
|
1307
|
+
} else if (typeof Blob !== "undefined" && raw instanceof Blob) {
|
|
1308
|
+
data = new Uint8Array(await raw.arrayBuffer());
|
|
1309
|
+
}
|
|
1310
|
+
if (!data || data.length === 0) {
|
|
1311
|
+
throw new Error("PrefViewer: Quick Look asset provider returned no data");
|
|
1312
|
+
}
|
|
1313
|
+
return { data, mimeType: result.mimeType, fileName: result.fileName };
|
|
1252
1314
|
}
|
|
1253
1315
|
|
|
1254
1316
|
/**
|
|
@@ -3878,7 +3940,9 @@ export default class BabylonJSController {
|
|
|
3878
3940
|
}
|
|
3879
3941
|
const enabledGeneration = ++this.#xrGeneration;
|
|
3880
3942
|
this.#configureDracoCompression();
|
|
3881
|
-
|
|
3943
|
+
// powerPreference is explicit: Babylon fills in "high-performance" when it is absent, and iOS 18.7 /
|
|
3944
|
+
// Safari 26.6.1 (iPhone) returns a WebGL2 context that is already lost for that preference.
|
|
3945
|
+
this.#engine = new Engine(this.#canvas, true, { alpha: true, stencil: true, preserveDrawingBuffer: false, powerPreference: "default" }, true);
|
|
3882
3946
|
this.#engine.disableUniformBuffers = true;
|
|
3883
3947
|
|
|
3884
3948
|
// OPTIMIZATION: Detect hardware tier and apply quality settings
|
|
@@ -87,11 +87,13 @@ export default class BabylonJSViewCubeCanvasRenderer {
|
|
|
87
87
|
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
|
-
* Options of the default cube Engine (as in PrefSketch, plus loseContextOnDispose). Babylon only
|
|
91
|
-
* WEBGL_lose_context.loseContext() on dispose when loseContextOnDispose is set; without it the cube's WebGL
|
|
92
|
-
* would linger until garbage collection, and browsers cap the number of live contexts per page.
|
|
90
|
+
* Options of the default cube Engine (as in PrefSketch, plus loseContextOnDispose and powerPreference). Babylon only
|
|
91
|
+
* calls WEBGL_lose_context.loseContext() on dispose when loseContextOnDispose is set; without it the cube's WebGL
|
|
92
|
+
* context would linger until garbage collection, and browsers cap the number of live contexts per page.
|
|
93
|
+
* powerPreference is explicit because Babylon fills in "high-performance" otherwise, and iOS 18.7 / Safari 26.6.1
|
|
94
|
+
* (iPhone) returns a WebGL2 context that is already lost for that preference.
|
|
93
95
|
*/
|
|
94
|
-
static #VIEW_CUBE_ENGINE_OPTIONS = Object.freeze({ alpha: true, antialias: true, premultipliedAlpha: false, stencil: false, loseContextOnDispose: true });
|
|
96
|
+
static #VIEW_CUBE_ENGINE_OPTIONS = Object.freeze({ alpha: true, antialias: true, premultipliedAlpha: false, stencil: false, loseContextOnDispose: true, powerPreference: "default" });
|
|
95
97
|
|
|
96
98
|
static #DEFAULT_LABELS = Object.freeze({ right: "Right", left: "Left", bottom: "Bottom", top: "Top", back: "Back", front: "Front" });
|
|
97
99
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { FilesInputStore } from "@babylonjs/core";
|
|
2
|
+
import FileStorage from "./file-storage.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolves the environment map (`options.ibl.url`) into a source Babylon can load on every browser.
|
|
6
|
+
*
|
|
7
|
+
* The cached environment used to reach Babylon as a `blob:` object URL of the IndexedDB record. iOS WebKit answers
|
|
8
|
+
* an XHR to such a URL with status 0, Babylon retries and gives up, and the texture never becomes ready, so the
|
|
9
|
+
* 3D load never finished. Like `GLTFResolver` does for `.glb` files, the cached bytes are read here into memory and
|
|
10
|
+
* handed to Babylon as a `File` through its `file:` scheme (`FilesInputStore`), so no XHR touches a blob. A record
|
|
11
|
+
* that is empty or cannot be read in time is purged and the server URL is used instead, so the load always completes.
|
|
12
|
+
*
|
|
13
|
+
* Every resolution registers its own file name; the owner releases it with `release` once Babylon has consumed it.
|
|
14
|
+
*/
|
|
15
|
+
export default class IBLSourceResolver {
|
|
16
|
+
static #FILE_PREFIX = "pref-viewer-ibl-";
|
|
17
|
+
static #sequence = 0;
|
|
18
|
+
#fileStorage;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Upper bound for reading a cached environment out of IndexedDB before falling back to the server URL.
|
|
22
|
+
* @public
|
|
23
|
+
* @static
|
|
24
|
+
* @type {number}
|
|
25
|
+
*/
|
|
26
|
+
static CACHE_READ_TIMEOUT_MS = 5000;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {FileStorage} [fileStorage] - Cache to read from; defaults to the viewer's shared store.
|
|
30
|
+
*/
|
|
31
|
+
constructor(fileStorage = new FileStorage("PrefViewer", "Files")) {
|
|
32
|
+
this.#fileStorage = fileStorage;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolves one environment URL into a loadable source.
|
|
37
|
+
* @public
|
|
38
|
+
* @param {string} url - Server URL of the `.env` or `.hdr` file.
|
|
39
|
+
* @returns {Promise<{cachedUrl: string, timeStamp: string|null}|false>} The `file:` source of the in-memory copy
|
|
40
|
+
* (or the server URL when the cache cannot serve it) with the record's time stamp, or false when the file is
|
|
41
|
+
* unavailable both in the cache and on the server.
|
|
42
|
+
*/
|
|
43
|
+
async resolve(url) {
|
|
44
|
+
let record;
|
|
45
|
+
try {
|
|
46
|
+
record = await IBLSourceResolver.#withTimeout(this.#fileStorage.get(url), IBLSourceResolver.CACHE_READ_TIMEOUT_MS);
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
if (!record || !record.blob) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
const timeStamp = record.timeStamp ?? null;
|
|
54
|
+
let bytes = null;
|
|
55
|
+
if (record.blob.size > 0) {
|
|
56
|
+
try {
|
|
57
|
+
bytes = await IBLSourceResolver.#withTimeout(record.blob.arrayBuffer(), IBLSourceResolver.CACHE_READ_TIMEOUT_MS);
|
|
58
|
+
} catch {
|
|
59
|
+
bytes = null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (!bytes || bytes.byteLength === 0) {
|
|
63
|
+
// Fire-and-forget: a hung IndexedDB transaction must not block this load either.
|
|
64
|
+
void Promise.resolve(this.#fileStorage.remove(url)).catch(() => {});
|
|
65
|
+
return { cachedUrl: url, timeStamp };
|
|
66
|
+
}
|
|
67
|
+
const baseName = url.split(/[?#]/)[0].split("/").pop() || "environment";
|
|
68
|
+
const name = `${IBLSourceResolver.#FILE_PREFIX}${++IBLSourceResolver.#sequence}-${baseName}`;
|
|
69
|
+
FilesInputStore.FilesToLoad[name.toLowerCase()] = new File([bytes], name, { type: record.blob.type || "" });
|
|
70
|
+
return { cachedUrl: `file:${name}`, timeStamp };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Releases a source produced by `resolve` (or an older `blob:` URL): drops the in-memory file from Babylon's
|
|
75
|
+
* store or revokes the object URL. Server URLs and empty values are ignored.
|
|
76
|
+
* @public
|
|
77
|
+
* @static
|
|
78
|
+
* @param {string|null|undefined} cachedUrl - Source to release.
|
|
79
|
+
* @returns {void}
|
|
80
|
+
*/
|
|
81
|
+
static release(cachedUrl) {
|
|
82
|
+
if (typeof cachedUrl !== "string") {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (cachedUrl.startsWith("file:")) {
|
|
86
|
+
delete FilesInputStore.FilesToLoad[decodeURIComponent(cachedUrl.slice("file:".length)).toLowerCase()];
|
|
87
|
+
} else if (cachedUrl.startsWith("blob:")) {
|
|
88
|
+
URL.revokeObjectURL(cachedUrl);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Bounds a cache read so an IndexedDB transaction that never settles cannot block the load.
|
|
94
|
+
* @private
|
|
95
|
+
* @static
|
|
96
|
+
* @param {Promise<any>} promise - Read to bound.
|
|
97
|
+
* @param {number} ms - Time allowed before rejecting.
|
|
98
|
+
* @returns {Promise<any>} The read's result, or a rejection after `ms`.
|
|
99
|
+
*/
|
|
100
|
+
static #withTimeout(promise, ms) {
|
|
101
|
+
let timer = null;
|
|
102
|
+
const timeout = new Promise((resolve, reject) => {
|
|
103
|
+
timer = setTimeout(() => reject(new Error(`Cache read timed out after ${ms}ms`)), ms);
|
|
104
|
+
});
|
|
105
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/material-utils.js
CHANGED
|
@@ -6,6 +6,7 @@ import { PBRMaterial } from "@babylonjs/core";
|
|
|
6
6
|
*/
|
|
7
7
|
export default class MaterialUtils {
|
|
8
8
|
static #SIMPLIFIED_TRANSMISSION_KEY = "prefViewerSimplifiedTransmission";
|
|
9
|
+
static #AUTHORED_GLASS_ALPHA_KEY = "prefViewerAuthoredGlassAlpha";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Tags a PBR material as having been converted from physical KHR_materials_transmission to simple
|
|
@@ -70,6 +71,12 @@ export default class MaterialUtils {
|
|
|
70
71
|
return false;
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
if (MaterialUtils.authoredGlassAlpha(mat) === null && Number.isFinite(mat.alpha)) {
|
|
75
|
+
mat.metadata = {
|
|
76
|
+
...(mat.metadata ?? {}),
|
|
77
|
+
[MaterialUtils.#AUTHORED_GLASS_ALPHA_KEY]: Math.max(0, Math.min(1, mat.alpha)),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
73
80
|
mat.alpha = look?.alpha ?? 1;
|
|
74
81
|
mat.metallicF0Factor = look?.metallicF0Factor ?? 3;
|
|
75
82
|
mat.useSpecularOverAlpha = true;
|
|
@@ -77,6 +84,20 @@ export default class MaterialUtils {
|
|
|
77
84
|
return true;
|
|
78
85
|
}
|
|
79
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Returns the base color alpha the glass was authored with (for example the SDK's `0.15`) before
|
|
89
|
+
* `tunePhysicalGlassMaterial` raised it for the web look. Native AR has no physical transmission,
|
|
90
|
+
* so that authored alpha is the opacity that keeps the glass looking like the web view.
|
|
91
|
+
* @public
|
|
92
|
+
* @static
|
|
93
|
+
* @param {import("@babylonjs/core").Material|null|undefined} mat - Material to read.
|
|
94
|
+
* @returns {number|null} The authored alpha in [0, 1], or null when the material was never tuned.
|
|
95
|
+
*/
|
|
96
|
+
static authoredGlassAlpha(mat) {
|
|
97
|
+
const value = mat?.metadata?.[MaterialUtils.#AUTHORED_GLASS_ALPHA_KEY];
|
|
98
|
+
return Number.isFinite(value) ? value : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
80
101
|
/**
|
|
81
102
|
* Returns true when a material should be placed in the transparent rendering group (renderingGroupId=1).
|
|
82
103
|
*
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import IBLSourceResolver from "./ibl-source-resolver.js";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* ContainerData - Represents state, cache metadata, and update lifecycle for a 3D asset container
|
|
3
5
|
* (for example: model, environment, or materials).
|
|
@@ -410,15 +412,14 @@ export class IBLData {
|
|
|
410
412
|
}
|
|
411
413
|
|
|
412
414
|
/**
|
|
413
|
-
*
|
|
415
|
+
* Releases a temporary cached source: a `blob:` object URL is revoked and a `file:` source registered by
|
|
416
|
+
* `IBLSourceResolver` is dropped from Babylon's file store. Server URLs are left alone.
|
|
414
417
|
* @private
|
|
415
|
-
* @param {string|null|undefined} url
|
|
418
|
+
* @param {string|null|undefined} url Cached source to release when applicable.
|
|
416
419
|
* @returns {void}
|
|
417
420
|
*/
|
|
418
421
|
#revokeIfBlobURL(url) {
|
|
419
|
-
|
|
420
|
-
URL.revokeObjectURL(url);
|
|
421
|
-
}
|
|
422
|
+
IBLSourceResolver.release(url);
|
|
422
423
|
}
|
|
423
424
|
|
|
424
425
|
/**
|
package/src/pref-viewer-3d.js
CHANGED
|
@@ -2,7 +2,7 @@ import { CameraData, ContainerData, MaterialData, IBLData } from "./pref-viewer-
|
|
|
2
2
|
import BabylonJSController from "./babylonjs-controller.js";
|
|
3
3
|
import { PrefViewer3DStyles } from "./styles.js";
|
|
4
4
|
import PrefViewerControlsPresentation from "./pref-viewer-controls-presentation.js";
|
|
5
|
-
import
|
|
5
|
+
import IBLSourceResolver from "./ibl-source-resolver.js";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* PrefViewer3D - Custom Web Component for interactive 3D visualization and configuration.
|
|
@@ -51,6 +51,7 @@ import FileStorage from "./file-storage.js";
|
|
|
51
51
|
* - downloadModelGLB(): Downloads the current 3D model as a GLB file.
|
|
52
52
|
* - downloadModelGLTF(): Downloads the current 3D model as a glTF ZIP file.
|
|
53
53
|
* - downloadModelUSDZ(): Downloads the current 3D model as a USDZ file.
|
|
54
|
+
* - setQuickLookAssetProvider(provider): Supplies (or clears) the asset opened by the iOS AR Quick Look action.
|
|
54
55
|
* - downloadModelAndSceneGLB(): Downloads both the model and scene as a GLB file.
|
|
55
56
|
* - downloadModelAndSceneGLTF(): Downloads both the model and scene as a glTF ZIP file.
|
|
56
57
|
* - downloadModelAndSceneUSDZ(): Downloads both the model and scene as a USDZ file.
|
|
@@ -102,6 +103,7 @@ export default class PrefViewer3D extends HTMLElement {
|
|
|
102
103
|
#data = null; // Component data
|
|
103
104
|
|
|
104
105
|
#babylonJSController = null; // BabylonJSController instance
|
|
106
|
+
#quickLookAssetProvider = null; // Host asset provider, reapplied to every controller this element creates
|
|
105
107
|
|
|
106
108
|
/** @returns {"svg"|"canvas"} Per-instance normalized renderer; missing/invalid attributes use Canvas. */
|
|
107
109
|
get viewCubeRenderer() {
|
|
@@ -283,6 +285,7 @@ export default class PrefViewer3D extends HTMLElement {
|
|
|
283
285
|
* Initializes the Babylon controller with asset/option state and the selected visibility-aware cube contract.
|
|
284
286
|
* Supplies current host culture and dispatches measured view-cube-layout events for render-menu positioning.
|
|
285
287
|
* Starts asynchronous engine initialization without waiting for asset loading.
|
|
288
|
+
* Reapplies any retained Quick Look asset provider to the new controller before it starts.
|
|
286
289
|
* @private
|
|
287
290
|
* @returns {void}
|
|
288
291
|
*/
|
|
@@ -303,6 +306,7 @@ export default class PrefViewer3D extends HTMLElement {
|
|
|
303
306
|
*/
|
|
304
307
|
onLayout: (offset, height) => this.dispatchEvent(new CustomEvent("view-cube-layout", { detail: { offset, height } })),
|
|
305
308
|
});
|
|
309
|
+
this.#babylonJSController.setQuickLookAssetProvider(this.#quickLookAssetProvider);
|
|
306
310
|
this.#babylonJSController.enable();
|
|
307
311
|
|
|
308
312
|
}
|
|
@@ -442,12 +446,12 @@ export default class PrefViewer3D extends HTMLElement {
|
|
|
442
446
|
let intensity = undefined;
|
|
443
447
|
|
|
444
448
|
if (typeof options.ibl.url === "string" && options.ibl.url.length > 0) {
|
|
445
|
-
|
|
446
|
-
const
|
|
447
|
-
if (
|
|
449
|
+
// The cached bytes reach Babylon as an in-memory file, never as a blob: URL (iOS WebKit cannot XHR those).
|
|
450
|
+
const source = await new IBLSourceResolver().resolve(options.ibl.url);
|
|
451
|
+
if (source) {
|
|
448
452
|
url = options.ibl.url;
|
|
449
|
-
cachedUrl =
|
|
450
|
-
timeStamp =
|
|
453
|
+
cachedUrl = source.cachedUrl;
|
|
454
|
+
timeStamp = source.timeStamp;
|
|
451
455
|
} else {
|
|
452
456
|
url = null;
|
|
453
457
|
cachedUrl = null;
|
|
@@ -875,6 +879,20 @@ export default class PrefViewer3D extends HTMLElement {
|
|
|
875
879
|
this.#babylonJSController.downloadGLTF(1);
|
|
876
880
|
}
|
|
877
881
|
|
|
882
|
+
/**
|
|
883
|
+
* Sets or clears the host-supplied asset opened by the iOS AR Quick Look action instead of the in-browser USDZ export.
|
|
884
|
+
* The provider is retained across connection cycles, even when set before the element is connected.
|
|
885
|
+
* @public
|
|
886
|
+
* @param {?(function(): Promise<{data: (Uint8Array|ArrayBuffer|Blob), mimeType?: string, fileName?: string}>)} provider
|
|
887
|
+
* Async asset factory (defaults: MIME `model/vnd.usdz+zip`, file name `model.usdz`), or null to restore the built-in export.
|
|
888
|
+
* A failing provider falls back to the built-in export with a console warning.
|
|
889
|
+
* @returns {void}
|
|
890
|
+
*/
|
|
891
|
+
setQuickLookAssetProvider(provider) {
|
|
892
|
+
this.#quickLookAssetProvider = typeof provider === "function" ? provider : null;
|
|
893
|
+
this.#babylonJSController?.setQuickLookAssetProvider(this.#quickLookAssetProvider);
|
|
894
|
+
}
|
|
895
|
+
|
|
878
896
|
/**
|
|
879
897
|
* Downloads the current 3D model as a USDZ file.
|
|
880
898
|
* @public
|
package/src/pref-viewer.js
CHANGED
|
@@ -43,6 +43,7 @@ import I18n from "./localization/i18n.js";
|
|
|
43
43
|
* - showModel / hideModel / showScene / hideScene: Toggle visibility using the internal task queue for consistent sequencing.
|
|
44
44
|
* - zoomCenter / zoomExtentsAll / zoomIn / zoomOut: Forward zoom commands to the 2D controller when active.
|
|
45
45
|
* - downloadModelGLB / downloadModelGLTF / downloadModelUSDZ: Export the loaded model in the requested format.
|
|
46
|
+
* - setQuickLookAssetProvider(provider): Supplies (or clears) the asset opened by the iOS AR Quick Look action.
|
|
46
47
|
* - downloadSceneGLB / downloadSceneGLTF / downloadSceneUSDZ: Export the loaded environment in the requested format.
|
|
47
48
|
* - downloadModelAndSceneGLB / downloadModelAndSceneGLTF / downloadModelAndSceneUSDZ: Export the combined scene in the requested format.
|
|
48
49
|
* - openDialog / closeDialog: Manage PrefViewerDialog instances rendered inside the shadow tree.
|
|
@@ -115,6 +116,7 @@ export default class PrefViewer extends HTMLElement {
|
|
|
115
116
|
#wrapper = null;
|
|
116
117
|
#component2D = null;
|
|
117
118
|
#component3D = null;
|
|
119
|
+
#quickLookAssetProvider = null; // Host asset provider, reapplied to every 3D component this element creates
|
|
118
120
|
#dialog = null;
|
|
119
121
|
#menu3D = null;
|
|
120
122
|
|
|
@@ -394,6 +396,7 @@ export default class PrefViewer extends HTMLElement {
|
|
|
394
396
|
* Creates and appends the 3D viewer component to the shadow DOM, initially hidden.
|
|
395
397
|
* Forwards renderer selection and show-view-cube, stores its layout handler in #handlers and subscribes before activation
|
|
396
398
|
* so the first measured layout positions the render menu correctly.
|
|
399
|
+
* Hands any retained Quick Look asset provider to the new component.
|
|
397
400
|
* @private
|
|
398
401
|
* @returns {void}
|
|
399
402
|
*/
|
|
@@ -401,6 +404,7 @@ export default class PrefViewer extends HTMLElement {
|
|
|
401
404
|
this.#handlers.on3DElementClick = this.#on3DElementClick.bind(this);
|
|
402
405
|
this.#component3D = document.createElement("pref-viewer-3d");
|
|
403
406
|
this.#component3D.setAttribute("visible", "false");
|
|
407
|
+
this.#component3D.setQuickLookAssetProvider?.(this.#quickLookAssetProvider);
|
|
404
408
|
this.#component3D.setAttribute("view-cube-renderer", this.viewCubeRenderer);
|
|
405
409
|
this.#component3D.setAttribute("show-view-cube", this.getAttribute("show-view-cube") === "false" ? "false" : "true");
|
|
406
410
|
this.#handlers.onViewCubeLayout = (event) => {
|
|
@@ -1289,6 +1293,21 @@ export default class PrefViewer extends HTMLElement {
|
|
|
1289
1293
|
this.#component3D.downloadModelGLTF();
|
|
1290
1294
|
}
|
|
1291
1295
|
|
|
1296
|
+
/**
|
|
1297
|
+
* Sets or clears the host-supplied asset opened by the iOS AR Quick Look action instead of the in-browser USDZ export.
|
|
1298
|
+
* The provider is retained across connection cycles, even when set before the element is connected.
|
|
1299
|
+
* The viewer knows nothing about how the host produces the asset.
|
|
1300
|
+
* @public
|
|
1301
|
+
* @param {?(function(): Promise<{data: (Uint8Array|ArrayBuffer|Blob), mimeType?: string, fileName?: string}>)} provider
|
|
1302
|
+
* Async asset factory (defaults: MIME `model/vnd.usdz+zip`, file name `model.usdz`), or null to restore the built-in export.
|
|
1303
|
+
* A failing provider falls back to the built-in export with a console warning.
|
|
1304
|
+
* @returns {void}
|
|
1305
|
+
*/
|
|
1306
|
+
setQuickLookAssetProvider(provider) {
|
|
1307
|
+
this.#quickLookAssetProvider = typeof provider === "function" ? provider : null;
|
|
1308
|
+
this.#component3D?.setQuickLookAssetProvider?.(this.#quickLookAssetProvider);
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1292
1311
|
/**
|
|
1293
1312
|
* Initiates download of the current 3D model in USDZ format.
|
|
1294
1313
|
* @public
|
package/src/quick-look-action.js
CHANGED
|
@@ -17,6 +17,22 @@ export default class QuickLookAction {
|
|
|
17
17
|
*/
|
|
18
18
|
static QUICK_LOOK_FRAGMENT = "allowsContentScaling=0";
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* MIME type of a USDZ archive; the only type whose bytes are validated as a ZIP before being exposed.
|
|
22
|
+
* @public
|
|
23
|
+
* @static
|
|
24
|
+
* @type {string}
|
|
25
|
+
*/
|
|
26
|
+
static USDZ_MIME_TYPE = "model/vnd.usdz+zip";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* File name given to the anchor's `download` attribute when the prepared asset does not name itself.
|
|
30
|
+
* @public
|
|
31
|
+
* @static
|
|
32
|
+
* @type {string}
|
|
33
|
+
*/
|
|
34
|
+
static DEFAULT_FILE_NAME = "model.usdz";
|
|
35
|
+
|
|
20
36
|
#anchor;
|
|
21
37
|
#style;
|
|
22
38
|
#window;
|
|
@@ -55,7 +71,7 @@ export default class QuickLookAction {
|
|
|
55
71
|
this.#anchor = document.createElement("a");
|
|
56
72
|
this.#anchor.className = "babylonVRicon pref-viewer-quick-look";
|
|
57
73
|
this.#anchor.rel = "ar";
|
|
58
|
-
this.#anchor.download =
|
|
74
|
+
this.#anchor.download = QuickLookAction.DEFAULT_FILE_NAME;
|
|
59
75
|
this.#anchor.setAttribute("role", "button");
|
|
60
76
|
this.#anchor.tabIndex = 0;
|
|
61
77
|
this.#anchor.hidden = true;
|
|
@@ -204,11 +220,38 @@ export default class QuickLookAction {
|
|
|
204
220
|
}
|
|
205
221
|
|
|
206
222
|
/**
|
|
207
|
-
*
|
|
223
|
+
* Normalizes an exporter result into the bytes, MIME type and file name of the asset to expose.
|
|
224
|
+
* A bare `Uint8Array` is a USDZ with default metadata; an object carries its own optional metadata.
|
|
225
|
+
* USDZ content must be a nonempty ZIP-signature archive; other MIME types are passed through as-is.
|
|
226
|
+
* @private
|
|
227
|
+
* @static
|
|
228
|
+
* @param {Uint8Array|{data: Uint8Array, mimeType?: string, fileName?: string}} result - Exporter result.
|
|
229
|
+
* @returns {{data: Uint8Array, mimeType: string, fileName: string}} Validated asset description.
|
|
230
|
+
* @throws {Error} When the result is not byte content or a USDZ lacks the ZIP signature.
|
|
231
|
+
*/
|
|
232
|
+
static #resolveAsset(result) {
|
|
233
|
+
const isDescribed = !!result && typeof result === "object" && !(result instanceof Uint8Array);
|
|
234
|
+
const data = isDescribed ? result.data : result;
|
|
235
|
+
const mimeType = (isDescribed && result.mimeType) || QuickLookAction.USDZ_MIME_TYPE;
|
|
236
|
+
const fileName = (isDescribed && result.fileName) || QuickLookAction.DEFAULT_FILE_NAME;
|
|
237
|
+
if (!(data instanceof Uint8Array) || data.length === 0) {
|
|
238
|
+
throw new Error("PrefViewer: invalid Quick Look export");
|
|
239
|
+
}
|
|
240
|
+
// USDZ is a ZIP archive; never expose empty, JSON or glTF results as native AR.
|
|
241
|
+
if (mimeType === QuickLookAction.USDZ_MIME_TYPE
|
|
242
|
+
&& (data.length < 4 || data[0] !== 0x50 || data[1] !== 0x4b || data[2] !== 3 || data[3] !== 4)) {
|
|
243
|
+
throw new Error("PrefViewer: invalid USDZ export");
|
|
244
|
+
}
|
|
245
|
+
return { data, mimeType, fileName };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Queues current-model export and exposes only a valid asset as a native link: a nonempty ZIP-signature USDZ,
|
|
250
|
+
* or, when the exporter describes another MIME type, that asset unchanged.
|
|
208
251
|
* Obsolete revisions are ignored. Export failures are logged and shown as a retry action;
|
|
209
252
|
* they do not reject the returned work promise or expose an older model's content.
|
|
210
253
|
* @public
|
|
211
|
-
* @param {?(function(): Promise<Uint8Array>)} exporter - Current-model serializer, or null to invalidate without preparation.
|
|
254
|
+
* @param {?(function(): Promise<Uint8Array|{data: Uint8Array, mimeType?: string, fileName?: string}>)} exporter - Current-model serializer returning USDZ bytes or a described asset (defaults: USDZ MIME type, `model.usdz`), or null to invalidate without preparation.
|
|
212
255
|
* @returns {Promise<void>} Resolves after this queued preparation settles or becomes obsolete.
|
|
213
256
|
*/
|
|
214
257
|
update(exporter) {
|
|
@@ -231,17 +274,15 @@ export default class QuickLookAction {
|
|
|
231
274
|
return;
|
|
232
275
|
}
|
|
233
276
|
try {
|
|
234
|
-
const
|
|
277
|
+
const result = await exporter();
|
|
235
278
|
if (this.#disposed || revision !== this.#revision) {
|
|
236
279
|
return;
|
|
237
280
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
}
|
|
242
|
-
const blob = new this.#window.Blob([bytes], {
|
|
243
|
-
type: "model/vnd.usdz+zip"
|
|
281
|
+
const asset = QuickLookAction.#resolveAsset(result);
|
|
282
|
+
const blob = new this.#window.Blob([asset.data], {
|
|
283
|
+
type: asset.mimeType
|
|
244
284
|
});
|
|
285
|
+
this.#anchor.download = asset.fileName;
|
|
245
286
|
this.#url = this.#urlAPI.createObjectURL(blob);
|
|
246
287
|
// Quick Look reads the fragment: content scaling off keeps the authored 1:1 metre scale,
|
|
247
288
|
// matching the Android WebXR path. The bare URL is kept for revocation.
|