@needle-tools/engine 5.1.8 → 5.1.10
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/CHANGELOG.md +16 -0
- package/SKILL.md +62 -4
- package/components.needle.json +1 -1
- package/dist/{needle-engine.bundle-CaD5rOJ0.js → needle-engine.bundle-DP--RGAU.js} +6126 -5969
- package/dist/{needle-engine.bundle-X7Y4A-EO.min.js → needle-engine.bundle-DZD-GG7l.min.js} +144 -143
- package/dist/{needle-engine.bundle-D8gP8Nd0.umd.cjs → needle-engine.bundle-Dv5TidOk.umd.cjs} +141 -140
- package/dist/needle-engine.d.ts +133 -36
- package/dist/needle-engine.js +617 -613
- package/dist/needle-engine.min.js +1 -1
- package/dist/needle-engine.umd.cjs +1 -1
- package/dist/three-examples.js +727 -781
- package/dist/three-examples.min.js +12 -12
- package/dist/three-examples.umd.cjs +9 -9
- package/lib/engine/api.d.ts +1 -1
- package/lib/engine/api.js +1 -1
- package/lib/engine/api.js.map +1 -1
- package/lib/engine/engine_init.js +2 -2
- package/lib/engine/engine_init.js.map +1 -1
- package/lib/engine/engine_license.d.ts +7 -7
- package/lib/engine/engine_license.js +71 -71
- package/lib/engine/engine_license.js.map +1 -1
- package/lib/engine/engine_materialpropertyblock.d.ts +13 -4
- package/lib/engine/engine_materialpropertyblock.js +16 -5
- package/lib/engine/engine_materialpropertyblock.js.map +1 -1
- package/lib/engine/engine_networking_blob.js +3 -3
- package/lib/engine/engine_networking_blob.js.map +1 -1
- package/lib/engine/engine_utils_qrcode.js +2 -2
- package/lib/engine/engine_utils_qrcode.js.map +1 -1
- package/lib/engine/postprocessing/postprocessing.d.ts +18 -0
- package/lib/engine/postprocessing/postprocessing.js +31 -2
- package/lib/engine/postprocessing/postprocessing.js.map +1 -1
- package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js +2 -2
- package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js.map +1 -1
- package/lib/engine/webcomponents/needle menu/needle-menu.js +5 -5
- package/lib/engine/webcomponents/needle menu/needle-menu.js.map +1 -1
- package/lib/engine/webcomponents/needle-engine.ar-overlay.js +11 -0
- package/lib/engine/webcomponents/needle-engine.ar-overlay.js.map +1 -1
- package/lib/engine/webcomponents/needle-engine.js +2 -2
- package/lib/engine/webcomponents/needle-engine.js.map +1 -1
- package/lib/engine/webcomponents/needle-engine.loading.js +2 -2
- package/lib/engine/webcomponents/needle-engine.loading.js.map +1 -1
- package/lib/engine/xr/NeedleXRSession.d.ts +7 -2
- package/lib/engine/xr/NeedleXRSession.js +65 -5
- package/lib/engine/xr/NeedleXRSession.js.map +1 -1
- package/lib/engine/xr/TempXRContext.js +2 -2
- package/lib/engine/xr/TempXRContext.js.map +1 -1
- package/lib/engine/xr/XRHandMeshModel.d.ts +31 -0
- package/lib/engine/xr/XRHandMeshModel.js +153 -0
- package/lib/engine/xr/XRHandMeshModel.js.map +1 -0
- package/lib/engine-components/Component.d.ts +2 -1
- package/lib/engine-components/Component.js.map +1 -1
- package/lib/engine-components/ReflectionProbe.js +3 -3
- package/lib/engine-components/ReflectionProbe.js.map +1 -1
- package/lib/engine-components/RendererLightmap.js +1 -1
- package/lib/engine-components/RendererLightmap.js.map +1 -1
- package/lib/engine-components/SyncedRoom.js +5 -0
- package/lib/engine-components/SyncedRoom.js.map +1 -1
- package/lib/engine-components/export/usdz/USDZExporter.js +4 -4
- package/lib/engine-components/export/usdz/USDZExporter.js.map +1 -1
- package/lib/engine-components/postprocessing/Effects/DepthOfField.js +10 -2
- package/lib/engine-components/postprocessing/Effects/DepthOfField.js.map +1 -1
- package/lib/engine-components/postprocessing/index.d.ts +4 -0
- package/lib/engine-components/postprocessing/index.js +7 -0
- package/lib/engine-components/postprocessing/index.js.map +1 -1
- package/lib/engine-components/utils/LookAt.d.ts +13 -4
- package/lib/engine-components/utils/LookAt.js +13 -4
- package/lib/engine-components/utils/LookAt.js.map +1 -1
- package/lib/engine-components/webxr/WebXR.js +1 -1
- package/lib/engine-components/webxr/WebXR.js.map +1 -1
- package/lib/engine-components/webxr/WebXRImageTracking.d.ts +35 -18
- package/lib/engine-components/webxr/WebXRImageTracking.js +93 -78
- package/lib/engine-components/webxr/WebXRImageTracking.js.map +1 -1
- package/lib/engine-components/webxr/WebXRImageTracking.utils.d.ts +35 -0
- package/lib/engine-components/webxr/WebXRImageTracking.utils.js +96 -0
- package/lib/engine-components/webxr/WebXRImageTracking.utils.js.map +1 -0
- package/lib/engine-components/webxr/controllers/XRControllerModel.d.ts +1 -1
- package/lib/engine-components/webxr/controllers/XRControllerModel.js +12 -7
- package/lib/engine-components/webxr/controllers/XRControllerModel.js.map +1 -1
- package/package.json +2 -2
- package/plugins/common/license.js +4 -4
- package/plugins/vite/license.js +4 -4
- package/src/engine/api.ts +1 -1
- package/src/engine/engine_init.ts +2 -2
- package/src/engine/engine_license.ts +68 -68
- package/src/engine/engine_materialpropertyblock.ts +17 -5
- package/src/engine/engine_networking_blob.ts +3 -3
- package/src/engine/engine_utils_qrcode.ts +2 -2
- package/src/engine/postprocessing/postprocessing.ts +32 -2
- package/src/engine/webcomponents/needle menu/needle-menu-spatial.ts +2 -2
- package/src/engine/webcomponents/needle menu/needle-menu.ts +5 -5
- package/src/engine/webcomponents/needle-engine.ar-overlay.ts +13 -0
- package/src/engine/webcomponents/needle-engine.loading.ts +6 -6
- package/src/engine/webcomponents/needle-engine.ts +2 -2
- package/src/engine/xr/NeedleXRSession.ts +68 -8
- package/src/engine/xr/TempXRContext.ts +2 -2
- package/src/engine/xr/XRHandMeshModel.ts +179 -0
- package/src/engine-components/Component.ts +2 -1
- package/src/engine-components/ReflectionProbe.ts +3 -3
- package/src/engine-components/RendererLightmap.ts +1 -1
- package/src/engine-components/SyncedRoom.ts +5 -0
- package/src/engine-components/export/usdz/USDZExporter.ts +4 -4
- package/src/engine-components/postprocessing/Effects/DepthOfField.ts +10 -2
- package/src/engine-components/postprocessing/index.ts +7 -0
- package/src/engine-components/utils/LookAt.ts +13 -4
- package/src/engine-components/webxr/WebXR.ts +1 -1
- package/src/engine-components/webxr/WebXRImageTracking.ts +120 -66
- package/src/engine-components/webxr/WebXRImageTracking.utils.ts +105 -0
- package/src/engine-components/webxr/controllers/XRControllerModel.ts +13 -7
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { FileLoader } from "three";
|
|
2
2
|
|
|
3
3
|
import { showBalloonWarning } from "./debug/index.js";
|
|
4
|
-
import {
|
|
4
|
+
import { __KODK } from "./engine_license.js";
|
|
5
5
|
import { delay } from "./engine_utils.js";
|
|
6
6
|
import { md5AsBytes, md5Hex, sha256Base64 } from "./engine_utils_hash.js";
|
|
7
7
|
|
|
@@ -45,7 +45,7 @@ export namespace BlobStorage {
|
|
|
45
45
|
*/
|
|
46
46
|
export function canUpload(info: { filesize: number }) {
|
|
47
47
|
const sizeInMB = info.filesize / 1024 / 1024;
|
|
48
|
-
if (
|
|
48
|
+
if (__KODK()) {
|
|
49
49
|
return sizeInMB < maxSizeInMB;
|
|
50
50
|
}
|
|
51
51
|
return sizeInMB < maxFreeSizeInMB;
|
|
@@ -100,7 +100,7 @@ export namespace BlobStorage {
|
|
|
100
100
|
console.warn(`Your file is too large for uploading (${filesizeInMB.toFixed(1)}MB). Max allowed size is ${maxSizeInMB}MB`);
|
|
101
101
|
return null;
|
|
102
102
|
}
|
|
103
|
-
else if (!
|
|
103
|
+
else if (!__KODK() && filesizeInMB > maxFreeSizeInMB) {
|
|
104
104
|
if (opts?.silent !== true) showBalloonWarning(`File is too large for uploading. Please get a <a href=\"https://needle.tools/pricing\" target=\"_blank\">commercial license</a> to upload files larger than 5MB`);
|
|
105
105
|
console.warn(`Your file is too large for uploading (${filesizeInMB.toFixed(1)}MB). Max size is 5MB for non-commercial users. Please get a commercial license at https://needle.tools/pricing for larger files (up to 50MB)`);
|
|
106
106
|
return null;
|
|
@@ -4,7 +4,7 @@ import { Quaternion, Vector2, Vector3, Vector4 } from "three";
|
|
|
4
4
|
|
|
5
5
|
import { needleLogoOnlySVG } from "./assets/index.js";
|
|
6
6
|
import { isDevEnvironment } from "./debug/debug.js";
|
|
7
|
-
import {
|
|
7
|
+
import { __KODK } from "./engine_license.js";
|
|
8
8
|
import { InternalAttributeUtils } from "./engine_utils_attributes.js";
|
|
9
9
|
import type { NeedleEngineWebComponent } from "./webcomponents/needle-engine.js";
|
|
10
10
|
|
|
@@ -135,7 +135,7 @@ async function internalRenderQRCodeOverlays(canvas: HTMLCanvasElement, args: { s
|
|
|
135
135
|
console.debug("[QR Code] No web component found")
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
const canUseCustomLogo =
|
|
138
|
+
const canUseCustomLogo = __KODK();
|
|
139
139
|
|
|
140
140
|
// Query logo src from needle-engine attribute.
|
|
141
141
|
// For any supported attribute it's possible to use "falsey" values (e.g. "0" or "false" to disable the logo in the QR code)
|
|
@@ -42,6 +42,32 @@ export class PostProcessing {
|
|
|
42
42
|
get dirty() { return this._isDirty; }
|
|
43
43
|
set dirty(value: boolean) { this._isDirty = value; }
|
|
44
44
|
|
|
45
|
+
private _enabled: boolean = true;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether the postprocessing stack runs at all. When disabled the scene is
|
|
49
|
+
* rendered directly, with no effects and no composer.
|
|
50
|
+
*
|
|
51
|
+
* Every effect keeps its own `enabled` state, so switching this back on
|
|
52
|
+
* restores exactly the stack that was there before. Useful for a quality
|
|
53
|
+
* setting, for a clean screenshot, or for comparing a scene with and
|
|
54
|
+
* without effects.
|
|
55
|
+
*
|
|
56
|
+
* @default true
|
|
57
|
+
* @example
|
|
58
|
+
* ```ts
|
|
59
|
+
* context.postprocessing.enabled = false;
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
get enabled(): boolean { return this._enabled; }
|
|
63
|
+
set enabled(value: boolean) {
|
|
64
|
+
if (value === this._enabled) return;
|
|
65
|
+
this._enabled = value;
|
|
66
|
+
// Rebuild on the next update: the effects are either collected again
|
|
67
|
+
// or torn down.
|
|
68
|
+
this.markDirty();
|
|
69
|
+
}
|
|
70
|
+
|
|
45
71
|
/** The internal PostProcessingHandler that manages the EffectComposer pipeline */
|
|
46
72
|
get handler(): IPostProcessingHandler | null { return this._handler; }
|
|
47
73
|
|
|
@@ -227,8 +253,12 @@ export class PostProcessing {
|
|
|
227
253
|
|
|
228
254
|
this._isDirty = false;
|
|
229
255
|
|
|
230
|
-
// Collect active effects
|
|
231
|
-
|
|
256
|
+
// Collect active effects. A disabled stack collects none, which falls
|
|
257
|
+
// into the teardown below — the same path taken when a scene simply
|
|
258
|
+
// has no effects, so there is only one way to end up bypassed.
|
|
259
|
+
const activeEffects = this._enabled
|
|
260
|
+
? this._effects.filter(e => e.active && e.enabled)
|
|
261
|
+
: [];
|
|
232
262
|
|
|
233
263
|
if (activeEffects.length <= 0) {
|
|
234
264
|
this.restoreTonemapping();
|
|
@@ -2,7 +2,7 @@ import { Mesh, Object3D, TextureLoader, Vector4 } from "three";
|
|
|
2
2
|
import ThreeMeshUI from "three-mesh-ui";
|
|
3
3
|
|
|
4
4
|
import { addNewComponent } from "../../engine_components.js";
|
|
5
|
-
import {
|
|
5
|
+
import { __KODK } from "../../engine_license.js";
|
|
6
6
|
import { OneEuroFilterXYZ } from "../../engine_math.js";
|
|
7
7
|
import type { Context } from "../../engine_setup.js";
|
|
8
8
|
import { lookAtObject } from "../../engine_three_utils.js";
|
|
@@ -321,7 +321,7 @@ export class NeedleSpatialMenu {
|
|
|
321
321
|
}
|
|
322
322
|
if (this.menu) {
|
|
323
323
|
const index = this.menu.children.indexOf(this._poweredByNeedleElement as any);
|
|
324
|
-
if (!this._showNeedleLogo &&
|
|
324
|
+
if (!this._showNeedleLogo && __KODK()) {
|
|
325
325
|
if (index >= 0) {
|
|
326
326
|
this._poweredByNeedleElement.removeFromParent();
|
|
327
327
|
this.markDirty();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { showBalloonMessage } from "../../debug/debug.js";
|
|
2
2
|
import type { Context } from "../../engine_context.js";
|
|
3
|
-
import {
|
|
3
|
+
import { __KODK, __spV, Telemetry } from "../../engine_license.js";
|
|
4
4
|
import { isLocalNetwork } from "../../engine_networking_utils.js";
|
|
5
5
|
import { HTMLElementBase } from "../../engine_ssr.js";
|
|
6
6
|
import { DeviceUtilities, getParam } from "../../engine_utils.js";
|
|
@@ -784,8 +784,8 @@ export class NeedleMenuElement extends HTMLElementBase {
|
|
|
784
784
|
try {
|
|
785
785
|
// if the user has a license then we CAN hide the needle logo
|
|
786
786
|
// calling this method immediately will cause an issue with vite bundling tho
|
|
787
|
-
window.requestAnimationFrame(() =>
|
|
788
|
-
if (res == true &&
|
|
787
|
+
window.requestAnimationFrame(() => __spV(res => {
|
|
788
|
+
if (res == true && __KODK() && !debugNonCommercial) {
|
|
789
789
|
let visible = this._userRequestedLogoVisible;
|
|
790
790
|
if (visible === undefined) visible = false;
|
|
791
791
|
this.___onSetLogoVisible(visible);
|
|
@@ -836,7 +836,7 @@ export class NeedleMenuElement extends HTMLElementBase {
|
|
|
836
836
|
// ensure the menu is not hidden or removed
|
|
837
837
|
const requiredParent = this?.parentNode;
|
|
838
838
|
if (this.style.display != "flex" || this.style.visibility != "visible" || this.style.opacity != "1" || requiredParent != this._domElement?.shadowRoot) {
|
|
839
|
-
if (!
|
|
839
|
+
if (!__KODK()) {
|
|
840
840
|
const change = changeEventCounter++;
|
|
841
841
|
// if a user doesn't have a local pro license *but* for development the menu is hidden then we show a warning
|
|
842
842
|
if (isLocalNetwork() && this._userRequestedMenuVisible === false) {
|
|
@@ -932,7 +932,7 @@ export class NeedleMenuElement extends HTMLElementBase {
|
|
|
932
932
|
showNeedleLogo(visible: boolean) {
|
|
933
933
|
this._userRequestedLogoVisible = visible;
|
|
934
934
|
if (!visible) {
|
|
935
|
-
if (!
|
|
935
|
+
if (!__KODK() || debugNonCommercial) {
|
|
936
936
|
console.warn("[Needle Engine] You need a commercial license to hide the Needle Engine logo in production.");
|
|
937
937
|
const localNetwork = isLocalNetwork()
|
|
938
938
|
if (!localNetwork) return;
|
|
@@ -63,7 +63,20 @@ export class AROverlayHandler {
|
|
|
63
63
|
else {
|
|
64
64
|
console.warn("WebXRViewer: No DOM Overlay found");
|
|
65
65
|
}
|
|
66
|
+
|
|
67
|
+
// Balloon messages raised BEFORE the session started live in the
|
|
68
|
+
// needle-engine shadow root — these platforms hide everything except the
|
|
69
|
+
// overlay root during the session, so exactly the messages that explain
|
|
70
|
+
// a broken session start (e.g. a marker image failing ARKit validation)
|
|
71
|
+
// would be invisible. Move them into the AR overlay; they stay inside
|
|
72
|
+
// the overlay container afterwards, which returns to the shadow root on
|
|
73
|
+
// session end.
|
|
74
|
+
const preSessionLogs = context.domElement?.shadowRoot?.querySelector(":scope > [data-needle_engine_debug_overlay]");
|
|
75
|
+
if (preSessionLogs && this.arContainer && this.arContainer !== preSessionLogs.parentNode) {
|
|
76
|
+
this.arContainer.appendChild(preSessionLogs);
|
|
77
|
+
}
|
|
66
78
|
}
|
|
79
|
+
|
|
67
80
|
this.ensureQuitARButton(this.arContainer);
|
|
68
81
|
}
|
|
69
82
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { needleLogoOnlySVG } from "../assets/index.js"
|
|
2
2
|
import { isDevEnvironment, showBalloonWarning } from "../debug/index.js";
|
|
3
|
-
import {
|
|
3
|
+
import { __KODK, $Ljyi, $AHhz } from "../engine_license.js";
|
|
4
4
|
import { Mathf } from "../engine_math.js";
|
|
5
5
|
import { LoadingProgressArgs } from "../engine_setup.js";
|
|
6
6
|
import { getParam } from "../engine_utils.js";
|
|
@@ -205,7 +205,7 @@ export class EngineLoadingView implements ILoadingViewHandler {
|
|
|
205
205
|
}
|
|
206
206
|
|
|
207
207
|
|
|
208
|
-
const hasLicense =
|
|
208
|
+
const hasLicense = $Ljyi();
|
|
209
209
|
if (!existing) {
|
|
210
210
|
this._loadingElement.style.position = "absolute";
|
|
211
211
|
this._loadingElement.style.width = "100%";
|
|
@@ -373,7 +373,7 @@ export class EngineLoadingView implements ILoadingViewHandler {
|
|
|
373
373
|
|
|
374
374
|
// private async handleRuntimeLicense(loadingElement: HTMLElement) {
|
|
375
375
|
// // First check if we have a commercial license
|
|
376
|
-
// let commercialLicense =
|
|
376
|
+
// let commercialLicense = __KODK();
|
|
377
377
|
// // if it's the case then we don't need to perform a runtime check
|
|
378
378
|
// if (commercialLicense) return;
|
|
379
379
|
|
|
@@ -393,10 +393,10 @@ export class EngineLoadingView implements ILoadingViewHandler {
|
|
|
393
393
|
// loadingElement.appendChild(nonCommercialContainer);
|
|
394
394
|
|
|
395
395
|
// // Use the runtime license check
|
|
396
|
-
// if (!isDevEnvironment() &&
|
|
396
|
+
// if (!isDevEnvironment() && $AHhz) {
|
|
397
397
|
// if (debugLicense) console.log("Waiting for runtime license check");
|
|
398
|
-
// await
|
|
399
|
-
// commercialLicense =
|
|
398
|
+
// await $AHhz;
|
|
399
|
+
// commercialLicense = __KODK();
|
|
400
400
|
// }
|
|
401
401
|
// if (commercialLicense) return;
|
|
402
402
|
// nonCommercialContainer.style.transition = "opacity .5s ease-in-out";
|
|
@@ -3,7 +3,7 @@ import type { Texture } from "three";
|
|
|
3
3
|
import { isDevEnvironment, showBalloonWarning } from "../debug/index.js";
|
|
4
4
|
import { PUBLIC_KEY, VERSION } from "../engine_constants.js";
|
|
5
5
|
import { ContextEvent, ContextRegistry } from "../engine_context_registry.js";
|
|
6
|
-
import {
|
|
6
|
+
import { __KODK, Telemetry } from "../engine_license.js";
|
|
7
7
|
import { onStart } from "../engine_lifecycle_api.js";
|
|
8
8
|
import { setDracoDecoderPath, setDracoDecoderType, setKtx2TranscoderPath } from "../engine_loaders.gltf.js";
|
|
9
9
|
import { Context, ContextCreateArgs } from "../engine_setup.js";
|
|
@@ -579,7 +579,7 @@ export class NeedleEngineWebComponent extends HTMLElementBase implements INeedle
|
|
|
579
579
|
|
|
580
580
|
|
|
581
581
|
// Loading start events
|
|
582
|
-
const allowOverridingDefaultLoading =
|
|
582
|
+
const allowOverridingDefaultLoading = __KODK();
|
|
583
583
|
// default loading can be overriden by calling preventDefault in the onload start event
|
|
584
584
|
this.ensureLoadStartIsRegistered();
|
|
585
585
|
let useDefaultLoading = this.dispatchEvent(new CustomEvent("loadstart", {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Camera, Object3D, PerspectiveCamera, Quaternion, Vector3 } from "three";
|
|
2
2
|
|
|
3
|
-
import { enableSpatialConsole, isDevEnvironment, showBalloonMessage, showBalloonWarning } from "../debug/index.js";
|
|
3
|
+
import { enableSpatialConsole, isDevEnvironment, showBalloonError, showBalloonMessage, showBalloonWarning } from "../debug/index.js";
|
|
4
4
|
import { Application, internalOnUserInputRegistered } from "../engine_application.js";
|
|
5
5
|
import { Context, FrameEvent } from "../engine_context.js";
|
|
6
6
|
import { ContextEvent, ContextRegistry } from "../engine_context_registry.js";
|
|
@@ -72,8 +72,8 @@ let debugFPSFramesSinceLastUpdate = 0;
|
|
|
72
72
|
export interface INeedleXRSessionEventReceiver extends Pick<IComponent, "destroyed"> {
|
|
73
73
|
get activeAndEnabled(): boolean;
|
|
74
74
|
supportsXR?(mode: XRSessionMode): boolean;
|
|
75
|
-
/** Called before requesting a XR session */
|
|
76
|
-
onBeforeXR?(mode: XRSessionMode, args: XRSessionInit): void
|
|
75
|
+
/** Called before requesting a XR session. May return a promise that is awaited before requesting. */
|
|
76
|
+
onBeforeXR?(mode: XRSessionMode, args: XRSessionInit): void | Promise<void>;
|
|
77
77
|
onEnterXR?(args: NeedleXREventArgs): void;
|
|
78
78
|
onUpdateXR?(args: NeedleXREventArgs): void;
|
|
79
79
|
onLeaveXR?(args: NeedleXREventArgs): void;
|
|
@@ -243,6 +243,20 @@ if (typeof window !== "undefined" && DeviceUtilities.isDesktop() && isDevEnviron
|
|
|
243
243
|
});
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
+
// Native ARKit diagnostics forwarded by the Needle App Clip (WebXRBridge
|
|
247
|
+
// logToWebConsole dispatches these alongside a console line): surface warnings and
|
|
248
|
+
// errors as balloon messages — the balloon overlay lives in the dom-overlay root,
|
|
249
|
+
// so they are visible INSIDE a running AR session (e.g. "marker failed ARKit
|
|
250
|
+
// validation" instead of a marker that silently never tracks).
|
|
251
|
+
if (typeof document !== "undefined") {
|
|
252
|
+
document.addEventListener("needlego-log", evt => {
|
|
253
|
+
const detail = (evt as CustomEvent<{ l?: string, m?: string }>).detail;
|
|
254
|
+
if (!detail?.m) return;
|
|
255
|
+
if (detail.l === "error") showBalloonError(detail.m);
|
|
256
|
+
else if (detail.l === "warn") showBalloonWarning(detail.m);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
246
260
|
function handleAutoStart(value: string | null) {
|
|
247
261
|
if (!value) return;
|
|
248
262
|
switch (value?.toLowerCase()) {
|
|
@@ -757,7 +771,15 @@ export class NeedleXRSession implements INeedleXRSession {
|
|
|
757
771
|
console.log("%c" + `Requesting ${mode} session`, "font-weight:bold;");
|
|
758
772
|
for (const script of scripts) {
|
|
759
773
|
if (script.onBeforeXR && script.activeAndEnabled && !script.destroyed) {
|
|
760
|
-
|
|
774
|
+
try {
|
|
775
|
+
// Await: onBeforeXR may be async (e.g. WebXRImageTracking awaits marker bitmap loads
|
|
776
|
+
// so late-decoding markers aren't dropped). A failing hook must not abort the session
|
|
777
|
+
// request for every other component.
|
|
778
|
+
await script.onBeforeXR(mode, init);
|
|
779
|
+
}
|
|
780
|
+
catch (err) {
|
|
781
|
+
console.error("[NeedleXRSession] onBeforeXR hook failed:", err);
|
|
782
|
+
}
|
|
761
783
|
}
|
|
762
784
|
}
|
|
763
785
|
this.invokeSessionRequestStart(mode, init, context ?? Context.Current);
|
|
@@ -888,7 +910,13 @@ export class NeedleXRSession implements INeedleXRSession {
|
|
|
888
910
|
/** XR Session Mode: AR or VR */
|
|
889
911
|
readonly mode: XRSessionMode;
|
|
890
912
|
|
|
891
|
-
/**
|
|
913
|
+
/** The framebuffer scale factor this session was started with. On this
|
|
914
|
+
* version the engine never requests a custom scale, so it is always 1 —
|
|
915
|
+
* the property exists so session-resolution handling (e.g. the App Clip
|
|
916
|
+
* canvas scaling) reads one authoritative value. */
|
|
917
|
+
readonly appliedFramebufferScaleFactor: number = 1;
|
|
918
|
+
|
|
919
|
+
/**
|
|
892
920
|
* The XRSession interface's read-only interactionMode property describes the best space (according to the user agent) for the application to draw an interactive UI for the current session.
|
|
893
921
|
* @link https://developer.mozilla.org/en-US/docs/Web/API/XRSession/interactionMode
|
|
894
922
|
*/
|
|
@@ -1320,13 +1348,19 @@ export class NeedleXRSession implements INeedleXRSession {
|
|
|
1320
1348
|
const dpr = window.devicePixelRatio || 1;
|
|
1321
1349
|
const currentWidth = canvas.width;
|
|
1322
1350
|
const currentHeight = canvas.height;
|
|
1351
|
+
// The NeedleGo polyfill renders through the canvas, so the canvas buffer
|
|
1352
|
+
// size IS the AR render resolution — apply the session's framebuffer
|
|
1353
|
+
// scale here too (a real WebXR framebuffer gets it via
|
|
1354
|
+
// setFramebufferScaleFactor; without this the App Clip silently renders
|
|
1355
|
+
// at full native DPR and e.g. the splat low preset's 0.8 scale is lost).
|
|
1356
|
+
const scale = dpr * this.appliedFramebufferScaleFactor;
|
|
1323
1357
|
// Check if DPR is already applied (avoid double-scaling)
|
|
1324
|
-
const expectedWidth = Math.floor(window.innerWidth *
|
|
1325
|
-
const expectedHeight = Math.floor(window.innerHeight *
|
|
1358
|
+
const expectedWidth = Math.floor(window.innerWidth * scale);
|
|
1359
|
+
const expectedHeight = Math.floor(window.innerHeight * scale);
|
|
1326
1360
|
if (Math.abs(currentWidth - expectedWidth) > 2 || Math.abs(currentHeight - expectedHeight) > 2) {
|
|
1327
1361
|
canvas.width = expectedWidth;
|
|
1328
1362
|
canvas.height = expectedHeight;
|
|
1329
|
-
console.debug("Applied DPR scaling for Needle AppClip XR session", dpr, canvas.width, canvas.height);
|
|
1363
|
+
console.debug("Applied DPR scaling for Needle AppClip XR session", dpr, this.appliedFramebufferScaleFactor, canvas.width, canvas.height);
|
|
1330
1364
|
}
|
|
1331
1365
|
});
|
|
1332
1366
|
}
|
|
@@ -1420,6 +1454,32 @@ export class NeedleXRSession implements INeedleXRSession {
|
|
|
1420
1454
|
|
|
1421
1455
|
console.debug("XR Session ended");
|
|
1422
1456
|
|
|
1457
|
+
// Diagnostic for the App Clip post-session freeze: on device, exactly ONE
|
|
1458
|
+
// render-loop tick runs after session end, then the page appears dead while
|
|
1459
|
+
// focus/visibility events still arrive (observed on every App Clip AR exit).
|
|
1460
|
+
// This heartbeat distinguishes the possible causes: no heartbeat logs = JS
|
|
1461
|
+
// fully suspended by the native layer; heartbeats with rAF ticks ≈ 0 = the
|
|
1462
|
+
// compositor starves rAF (webview still marked hidden); rAF ticks ≈ 60 while
|
|
1463
|
+
// the engine renders nothing = three's animation-loop handoff from the
|
|
1464
|
+
// polyfill session back to the window broke.
|
|
1465
|
+
if (DeviceUtilities.isNeedleAppClip() && isDevEnvironment()) {
|
|
1466
|
+
let rafTicks = 0;
|
|
1467
|
+
let beats = 0;
|
|
1468
|
+
let rafHandle = window.requestAnimationFrame(function countRaf() {
|
|
1469
|
+
rafTicks++;
|
|
1470
|
+
rafHandle = window.requestAnimationFrame(countRaf);
|
|
1471
|
+
});
|
|
1472
|
+
const heartbeat = window.setInterval(() => {
|
|
1473
|
+
beats++;
|
|
1474
|
+
console.debug(`[XR] post-session heartbeat ${beats}/10: window rAF ticks last second=${rafTicks}, engine frame=${this.context.time.frameCount}, visibility=${document.visibilityState}, focus=${document.hasFocus()}`);
|
|
1475
|
+
rafTicks = 0;
|
|
1476
|
+
if (beats >= 10) {
|
|
1477
|
+
window.clearInterval(heartbeat);
|
|
1478
|
+
window.cancelAnimationFrame(rafHandle);
|
|
1479
|
+
}
|
|
1480
|
+
}, 1000);
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1423
1483
|
Telemetry.sendEvent(Context.Current, "xr", {
|
|
1424
1484
|
action: "session_end",
|
|
1425
1485
|
mode: this.mode,
|
|
@@ -3,7 +3,7 @@ import { ArrayCamera, AxesHelper, Camera, Color, DirectionalLight, Fog, GridHelp
|
|
|
3
3
|
import { needleLogoOnlySVG } from "../assets/index.js";
|
|
4
4
|
import { isDevEnvironment } from "../debug/index.js";
|
|
5
5
|
import { ObjectUtils, PrimitiveType } from "../engine_create_objects.js";
|
|
6
|
-
import {
|
|
6
|
+
import { __KODK } from "../engine_license.js";
|
|
7
7
|
import { Mathf } from "../engine_math.js";
|
|
8
8
|
import { delay, DeviceUtilities } from "../engine_utils.js";
|
|
9
9
|
|
|
@@ -215,7 +215,7 @@ export class TemporaryXRContext {
|
|
|
215
215
|
this._scene.background = new Color(0x000000);
|
|
216
216
|
|
|
217
217
|
let logoSrc = needleLogoOnlySVG;
|
|
218
|
-
if (
|
|
218
|
+
if (__KODK()) {
|
|
219
219
|
const htmlComponent = document.querySelector("needle-engine");
|
|
220
220
|
if (htmlComponent) {
|
|
221
221
|
const licenseLogo = htmlComponent.getAttribute("logo-src");
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { type Bone, type Object3D, type XRHandSpace } from "three";
|
|
2
|
+
import type { GLTF } from "three/examples/jsm/loaders/GLTFLoader.js";
|
|
3
|
+
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Vendored from three's `examples/jsm/webxr/XRHandMeshModel.js` (r185).
|
|
7
|
+
*
|
|
8
|
+
* WHY THIS IS OWNED BY THE ENGINE AND NOT PATCHED IN THE THREE FORK
|
|
9
|
+
* -----------------------------------------------------------------
|
|
10
|
+
* three addons carry no API stability guarantee and this one changed under us twice:
|
|
11
|
+
*
|
|
12
|
+
* - r184 replaced `const object = gltf.scene.children[0]` with
|
|
13
|
+
* `const object = clone(gltf.scene.children[0])`. That single word broke every custom
|
|
14
|
+
* (Unity-exported) hand model, silently: `SkeletonUtils.clone` runs `Object3D.copy`,
|
|
15
|
+
* which deep-copies userData via `JSON.parse(JSON.stringify(...))`. Needle stores live
|
|
16
|
+
* component instances in `userData.components`, and components hold a `gameObject`
|
|
17
|
+
* back-reference, so that throws "Converting circular structure to JSON" — swallowed by
|
|
18
|
+
* GLTFLoader into an onError nobody had set, leaving an empty, invisible hand.
|
|
19
|
+
* - The clone also broke component creation: `XRControllerModel` creates the glTF's builtin
|
|
20
|
+
* components after load, but the parser's node -> object associations point at the parsed
|
|
21
|
+
* original while the clone is what gets displayed.
|
|
22
|
+
*
|
|
23
|
+
* DELIBERATE DIFFERENCES FROM UPSTREAM
|
|
24
|
+
* ------------------------------------
|
|
25
|
+
* 1. No clone. Upstream needs it only because of `customCache`, where several hands share
|
|
26
|
+
* one parsed gltf. We never pass a cache and build a fresh GLTFLoader per hand load, so
|
|
27
|
+
* nothing is shared and the clone was pure overhead. Using the parsed object directly
|
|
28
|
+
* restores the pre-r184 contract the engine is written against: `handModel.add(object)`
|
|
29
|
+
* reparents it out of `gltf.scene` (Object3D.add calls removeFromParent), object identity
|
|
30
|
+
* is preserved so the parser's node map stays valid, and components created during parse
|
|
31
|
+
* live on the object that is actually rendered.
|
|
32
|
+
* 2. `customCache` is dropped entirely rather than left unused — sharing a parsed hand
|
|
33
|
+
* between models would reintroduce the aliasing the clone existed to prevent.
|
|
34
|
+
* 3. The hand mesh inherits the layer mask of the object it is added to (the engine puts
|
|
35
|
+
* hands on the ignore-raycast layer).
|
|
36
|
+
* 4. Load failures are reported instead of vanishing.
|
|
37
|
+
* 5. A missing SkinnedMesh or missing joints no longer throw / spam per joint.
|
|
38
|
+
*
|
|
39
|
+
* When bumping three, diff this against the upstream file and fold in real fixes by hand.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const DEFAULT_HAND_PROFILE_PATH = "https://cdn.jsdelivr.net/npm/@webxr-input-profiles/assets@1.0/dist/profiles/generic-hand/";
|
|
43
|
+
|
|
44
|
+
/** The joints of a WebXR hand, in the order three's hand models declare them. */
|
|
45
|
+
const JOINT_NAMES = [
|
|
46
|
+
"wrist",
|
|
47
|
+
"thumb-metacarpal",
|
|
48
|
+
"thumb-phalanx-proximal",
|
|
49
|
+
"thumb-phalanx-distal",
|
|
50
|
+
"thumb-tip",
|
|
51
|
+
"index-finger-metacarpal",
|
|
52
|
+
"index-finger-phalanx-proximal",
|
|
53
|
+
"index-finger-phalanx-intermediate",
|
|
54
|
+
"index-finger-phalanx-distal",
|
|
55
|
+
"index-finger-tip",
|
|
56
|
+
"middle-finger-metacarpal",
|
|
57
|
+
"middle-finger-phalanx-proximal",
|
|
58
|
+
"middle-finger-phalanx-intermediate",
|
|
59
|
+
"middle-finger-phalanx-distal",
|
|
60
|
+
"middle-finger-tip",
|
|
61
|
+
"ring-finger-metacarpal",
|
|
62
|
+
"ring-finger-phalanx-proximal",
|
|
63
|
+
"ring-finger-phalanx-intermediate",
|
|
64
|
+
"ring-finger-phalanx-distal",
|
|
65
|
+
"ring-finger-tip",
|
|
66
|
+
"pinky-finger-metacarpal",
|
|
67
|
+
"pinky-finger-phalanx-proximal",
|
|
68
|
+
"pinky-finger-phalanx-intermediate",
|
|
69
|
+
"pinky-finger-phalanx-distal",
|
|
70
|
+
"pinky-finger-tip",
|
|
71
|
+
] as const;
|
|
72
|
+
|
|
73
|
+
type JointBone = Bone & { jointName?: string };
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Renders a skinned hand mesh and drives its bones from the tracked WebXR joint poses.
|
|
77
|
+
*
|
|
78
|
+
* @see {@link XRControllerModel} which creates one per tracked hand.
|
|
79
|
+
*/
|
|
80
|
+
export class XRHandMeshModel {
|
|
81
|
+
|
|
82
|
+
/** The three XRHandSpace whose `joints` drive this mesh. */
|
|
83
|
+
readonly controller: XRHandSpace;
|
|
84
|
+
/** The object the loaded hand mesh is added to. */
|
|
85
|
+
readonly handModel: Object3D;
|
|
86
|
+
/** The bones driven by {@link updateMesh}, in {@link JOINT_NAMES} order. Sparse if the model lacks joints. */
|
|
87
|
+
readonly bones: Array<JointBone | undefined> = [];
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param handModel the object the loaded mesh is parented to
|
|
91
|
+
* @param controller the hand space providing joint poses
|
|
92
|
+
* @param path base path for the model (defaults to the generic hand profile CDN)
|
|
93
|
+
* @param handedness file name to load (without extension) — either "left"/"right" for the
|
|
94
|
+
* default profile, or a full path for a custom hand model
|
|
95
|
+
* @param loader loader to use; a plain GLTFLoader is created when omitted
|
|
96
|
+
* @param onLoad invoked with the loaded hand object once it has been added
|
|
97
|
+
*/
|
|
98
|
+
constructor(
|
|
99
|
+
handModel: Object3D,
|
|
100
|
+
controller: XRHandSpace,
|
|
101
|
+
path: string | null,
|
|
102
|
+
handedness: string,
|
|
103
|
+
loader: GLTFLoader | null = null,
|
|
104
|
+
onLoad: ((object: Object3D) => void) | null = null,
|
|
105
|
+
) {
|
|
106
|
+
this.controller = controller;
|
|
107
|
+
this.handModel = handModel;
|
|
108
|
+
|
|
109
|
+
const pathToUse = path || DEFAULT_HAND_PROFILE_PATH;
|
|
110
|
+
|
|
111
|
+
if (loader === null) {
|
|
112
|
+
loader = new GLTFLoader();
|
|
113
|
+
loader.setPath(pathToUse);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const url = `${handedness}.glb`;
|
|
117
|
+
loader.load(url,
|
|
118
|
+
(gltf: GLTF) => {
|
|
119
|
+
const object = gltf.scene.children[0];
|
|
120
|
+
if (!object) {
|
|
121
|
+
console.error(`XRHandMeshModel: "${pathToUse}${url}" contains no hand object.`);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// NOTE: adding reparents `object` out of `gltf.scene` (Object3D.add calls
|
|
126
|
+
// removeFromParent), which is what lets the caller detect that the hand has
|
|
127
|
+
// been taken over and create the glTF's builtin components on it.
|
|
128
|
+
this.handModel.add(object);
|
|
129
|
+
// the hand mesh inherits the layers of whatever it was added to
|
|
130
|
+
object.layers.mask = this.handModel.layers.mask;
|
|
131
|
+
|
|
132
|
+
const mesh = object.getObjectByProperty("type", "SkinnedMesh");
|
|
133
|
+
if (mesh) {
|
|
134
|
+
mesh.frustumCulled = false;
|
|
135
|
+
mesh.castShadow = true;
|
|
136
|
+
mesh.receiveShadow = true;
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
console.warn(`XRHandMeshModel: no SkinnedMesh in the ${handedness} hand model — it will not deform.`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const missing: string[] = [];
|
|
143
|
+
for (const jointName of JOINT_NAMES) {
|
|
144
|
+
const bone = object.getObjectByName(jointName) as JointBone | undefined;
|
|
145
|
+
if (bone) bone.jointName = jointName;
|
|
146
|
+
else missing.push(jointName);
|
|
147
|
+
this.bones.push(bone);
|
|
148
|
+
}
|
|
149
|
+
// one warning instead of one per joint — a model with the wrong rig misses all 25
|
|
150
|
+
if (missing.length) {
|
|
151
|
+
console.warn(`XRHandMeshModel: the ${handedness} hand model is missing ${missing.length} of ${JOINT_NAMES.length} joints (${missing.join(", ")}).`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
onLoad?.(object);
|
|
155
|
+
},
|
|
156
|
+
undefined,
|
|
157
|
+
(err: unknown) => {
|
|
158
|
+
// Upstream passes no error handler, so a failed hand model silently leaves an
|
|
159
|
+
// empty object and no hand is ever visible. Surface it instead.
|
|
160
|
+
console.error(`XRHandMeshModel: failed to load the ${handedness} hand model from "${pathToUse}${url}" — the hand will not be visible.`, err);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Copies the current tracked joint poses onto the mesh bones. Called every XR frame. */
|
|
165
|
+
updateMesh() {
|
|
166
|
+
const XRJoints = this.controller.joints;
|
|
167
|
+
|
|
168
|
+
for (let i = 0; i < this.bones.length; i++) {
|
|
169
|
+
const bone = this.bones[i];
|
|
170
|
+
if (!bone?.jointName) continue;
|
|
171
|
+
|
|
172
|
+
const XRJoint = XRJoints[bone.jointName];
|
|
173
|
+
if (XRJoint?.visible) {
|
|
174
|
+
bone.position.copy(XRJoint.position);
|
|
175
|
+
bone.quaternion.copy(XRJoint.quaternion);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -930,8 +930,9 @@ export abstract class Component implements IComponent, EventTarget,
|
|
|
930
930
|
* Use to modify session initialization parameters
|
|
931
931
|
* @param mode The XR session mode being requested
|
|
932
932
|
* @param args The session initialization parameters that can be modified
|
|
933
|
+
* @returns void, or a promise that is awaited before the session is requested (e.g. to load assets)
|
|
933
934
|
*/
|
|
934
|
-
onBeforeXR?(mode: XRSessionMode, args: XRSessionInit): void
|
|
935
|
+
onBeforeXR?(mode: XRSessionMode, args: XRSessionInit): void | Promise<void>;
|
|
935
936
|
|
|
936
937
|
/**
|
|
937
938
|
* Called when this component joins an XR session or becomes active in a running session
|
|
@@ -289,9 +289,9 @@ export class ReflectionProbe extends Behaviour {
|
|
|
289
289
|
if (block) {
|
|
290
290
|
const current = block.getOverride("envMap")?.value;
|
|
291
291
|
if (current === this.texture) {
|
|
292
|
-
block.
|
|
293
|
-
block.
|
|
294
|
-
block.
|
|
292
|
+
block.removeOverride("envMap");
|
|
293
|
+
block.removeOverride("envMapRotation");
|
|
294
|
+
block.removeOverride("envMapIntensity");
|
|
295
295
|
}
|
|
296
296
|
}
|
|
297
297
|
}
|
|
@@ -190,6 +190,11 @@ export class SyncedRoom extends Behaviour {
|
|
|
190
190
|
if (val && (typeof val === "string" || typeof val === "number")) {
|
|
191
191
|
hasRoomParameter = true;
|
|
192
192
|
const roomNameParam = utils.sanitizeString(val.toString());
|
|
193
|
+
// The URL parameter takes precedence over the configured roomName. That is
|
|
194
|
+
// easy to miss when a room name was set deliberately, so say so in dev.
|
|
195
|
+
if ((debug || isDevEnvironment()) && this.roomName?.length > 0 && this.roomName !== roomNameParam) {
|
|
196
|
+
console.warn(`[SyncedRoom] Joining "${roomNameParam}" from the "${this.urlParameterName}" URL parameter — the roomName set on the component ("${this.roomName}") is ignored.\nThe URL parameter always wins. To use the component's room name instead, clear "urlParameterName".`);
|
|
197
|
+
}
|
|
193
198
|
this.roomName = roomNameParam;
|
|
194
199
|
}
|
|
195
200
|
else if (this.joinRandomRoom) {
|
|
@@ -3,7 +3,7 @@ import { Euler, Material, Matrix4, Mesh, Object3D, Quaternion, Vector3 } from "t
|
|
|
3
3
|
|
|
4
4
|
import { isDevEnvironment, showBalloonMessage, showBalloonWarning } from "../../../engine/debug/index.js";
|
|
5
5
|
import { findObjectOfType } from "../../../engine/engine_components.js";
|
|
6
|
-
import {
|
|
6
|
+
import { $Ljyi } from "../../../engine/engine_license.js";
|
|
7
7
|
import { serializable } from "../../../engine/engine_serialization.js";
|
|
8
8
|
import { getFormattedDate, Progress } from "../../../engine/engine_time_utils.js";
|
|
9
9
|
import { DeviceUtilities, getParam } from "../../../engine/engine_utils.js";
|
|
@@ -277,7 +277,7 @@ export class USDZExporter extends Behaviour {
|
|
|
277
277
|
let name = this.exportFileName ?? this.objectToExport?.name ?? this.name;
|
|
278
278
|
name += "-" + getFormattedDate(); // seems iOS caches the file in some cases, this ensures we always have a fresh file
|
|
279
279
|
|
|
280
|
-
if (
|
|
280
|
+
if (!$Ljyi()) {
|
|
281
281
|
if (name !== "") name += "-";
|
|
282
282
|
name += "MadeWithNeedle";
|
|
283
283
|
}
|
|
@@ -682,7 +682,7 @@ export class USDZExporter extends Behaviour {
|
|
|
682
682
|
if (debug)
|
|
683
683
|
showBalloonMessage("Quicklook url: " + callToActionURL);
|
|
684
684
|
if (callToActionURL) {
|
|
685
|
-
if (
|
|
685
|
+
if (!$Ljyi()) {
|
|
686
686
|
console.warn("Quicklook closed: custom redirects require a Needle Engine Pro license: https://needle.tools/pricing", callToActionURL)
|
|
687
687
|
}
|
|
688
688
|
else {
|
|
@@ -697,7 +697,7 @@ export class USDZExporter extends Behaviour {
|
|
|
697
697
|
private buildQuicklookOverlay(): CustomBranding {
|
|
698
698
|
const obj: CustomBranding = {};
|
|
699
699
|
if (this.customBranding) Object.assign(obj, this.customBranding);
|
|
700
|
-
if (
|
|
700
|
+
if (!$Ljyi()) {
|
|
701
701
|
console.log("Custom Quicklook banner text requires pro license: https://needle.tools/pricing");
|
|
702
702
|
obj.callToAction = "Close";
|
|
703
703
|
obj.checkoutTitle = "🌵 Made with Needle";
|