@needle-tools/engine 5.1.3 → 5.1.4
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 +7 -0
- package/dist/{needle-engine.bundle-CTHxmSih.min.js → needle-engine.bundle-BvxvjlSN.min.js} +170 -164
- package/dist/{needle-engine.bundle-BAT9_opF.js → needle-engine.bundle-CEba8eTv.js} +5956 -5899
- package/dist/{needle-engine.bundle-BiLCbb4D.umd.cjs → needle-engine.bundle-GUaSFX8u.umd.cjs} +142 -136
- package/dist/needle-engine.d.ts +33 -28
- package/dist/needle-engine.js +282 -282
- package/dist/needle-engine.min.js +1 -1
- package/dist/needle-engine.umd.cjs +1 -1
- 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_camera.fit.js +25 -2
- package/lib/engine/engine_camera.fit.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_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/engine_utils_screenshot.d.ts +3 -1
- package/lib/engine/engine_utils_screenshot.js +103 -19
- package/lib/engine/engine_utils_screenshot.js.map +1 -1
- package/lib/engine/engine_utils_screenshot.needlego.d.ts +44 -0
- package/lib/engine/engine_utils_screenshot.needlego.js +22 -0
- package/lib/engine/engine_utils_screenshot.needlego.js.map +1 -0
- 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.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 +3 -0
- package/lib/engine/xr/NeedleXRSession.js +41 -40
- 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/init.js +4 -5
- package/lib/engine/xr/init.js.map +1 -1
- package/lib/engine-components/GroundProjection.js +36 -16
- package/lib/engine-components/GroundProjection.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/package.json +1 -1
- 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_camera.fit.ts +19 -2
- package/src/engine/engine_init.ts +2 -2
- package/src/engine/engine_license.ts +68 -68
- package/src/engine/engine_networking_blob.ts +3 -3
- package/src/engine/engine_utils_qrcode.ts +2 -2
- package/src/engine/engine_utils_screenshot.needlego.ts +53 -0
- package/src/engine/engine_utils_screenshot.ts +107 -20
- 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.loading.ts +6 -6
- package/src/engine/webcomponents/needle-engine.ts +2 -2
- package/src/engine/xr/NeedleXRSession.ts +39 -45
- package/src/engine/xr/TempXRContext.ts +2 -2
- package/src/engine/xr/init.ts +4 -5
- package/src/engine-components/GroundProjection.ts +38 -17
- package/src/engine-components/export/usdz/USDZExporter.ts +4 -4
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { DeviceUtilities } from "./engine_utils.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Arguments for the native screenshot bridge exposed by the Needle Go App Clip (injected as `window.NeedleGoNativeScreenshot`).
|
|
5
|
+
*/
|
|
6
|
+
declare type NeedleGoNativeScreenshotArgs = {
|
|
7
|
+
/** Data URL (PNG) of the rendered 3D layer with a transparent background. Native code composites the live camera frame behind it. */
|
|
8
|
+
image: string;
|
|
9
|
+
/** "share" presents the native share sheet, "return" sends the composited image back. @default "share" */
|
|
10
|
+
action?: "share" | "return";
|
|
11
|
+
/** Share sheet title (action "share") */
|
|
12
|
+
title?: string;
|
|
13
|
+
/** Share sheet text (action "share") */
|
|
14
|
+
text?: string;
|
|
15
|
+
/** Share sheet url (action "share") */
|
|
16
|
+
url?: string;
|
|
17
|
+
/** Mime type for the returned image (action "return"): "image/png" or "image/jpeg" (default jpeg, the composite is opaque) */
|
|
18
|
+
mimeType?: string;
|
|
19
|
+
/** JPEG quality 0..1 for action "return" (default 0.9) */
|
|
20
|
+
quality?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
declare type NeedleGoNativeScreenshotResult = {
|
|
24
|
+
/** action "share": whether the user completed the share */
|
|
25
|
+
shared?: boolean;
|
|
26
|
+
/** action "share": the activity the user chose, if any */
|
|
27
|
+
activity?: string;
|
|
28
|
+
/** action "return": data URL of the composited image */
|
|
29
|
+
image?: string;
|
|
30
|
+
/** action "return": mime type of the returned image */
|
|
31
|
+
mimeType?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
declare type NeedleGoNativeScreenshotFunction = (args: NeedleGoNativeScreenshotArgs) => Promise<NeedleGoNativeScreenshotResult>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Returns the native screenshot bridge when running inside the Needle Go App Clip.
|
|
38
|
+
*
|
|
39
|
+
* In the App Clip the camera feed is rendered natively *behind* the transparent webview,
|
|
40
|
+
* so the page can never capture it (there is no `camera-access` WebXR feature). The bridge
|
|
41
|
+
* lets us send the rendered 3D layer to the native app, which composites the current camera
|
|
42
|
+
* frame behind it and can present the native share sheet — which, unlike `navigator.share`,
|
|
43
|
+
* does not require (transient) user activation, so a single tap is enough even though the
|
|
44
|
+
* screenshot pipeline is asynchronous.
|
|
45
|
+
*
|
|
46
|
+
* Returns null when not in the App Clip or when the App Clip version doesn't support it yet.
|
|
47
|
+
*/
|
|
48
|
+
export function getNativeScreenshotBridge(): NeedleGoNativeScreenshotFunction | null {
|
|
49
|
+
if (typeof window === "undefined") return null;
|
|
50
|
+
if (!DeviceUtilities.isNeedleAppClip()) return null;
|
|
51
|
+
const fn = (window as any).NeedleGoNativeScreenshot;
|
|
52
|
+
return typeof fn === "function" ? fn as NeedleGoNativeScreenshotFunction : null;
|
|
53
|
+
}
|
|
@@ -11,6 +11,7 @@ import { registerFrameEventCallback } from "./engine_lifecycle_functions_interna
|
|
|
11
11
|
import { Context, FrameEvent } from "./engine_setup.js";
|
|
12
12
|
import { ICamera } from "./engine_types.js";
|
|
13
13
|
import { DeviceUtilities } from "./engine_utils.js";
|
|
14
|
+
import { getNativeScreenshotBridge } from "./engine_utils_screenshot.needlego.js";
|
|
14
15
|
import { updateTextureFromXRFrame } from "./engine_utils_screenshot.xr.js";
|
|
15
16
|
import { RGBAColor } from "./js-extensions/index.js";
|
|
16
17
|
import { setCustomVisibility } from "./js-extensions/Layers.js";
|
|
@@ -355,6 +356,11 @@ export function screenshot2(opts: ScreenshotOptionsShare): Promise<ScreenshotOpt
|
|
|
355
356
|
* ```
|
|
356
357
|
* Without camera access, AR screenshots will only show your 3D content without the real-world background.
|
|
357
358
|
*
|
|
359
|
+
* **Needle Go App Clip**: When running in the Needle Go iOS App Clip, `type: "share"` and `type: "blob"`
|
|
360
|
+
* screenshots are handled by the native app: the camera feed (which is rendered natively and not
|
|
361
|
+
* accessible from the page) is composited automatically, and `type: "share"` presents the native iOS
|
|
362
|
+
* share sheet — which works with a single tap because it doesn't require web user activation.
|
|
363
|
+
*
|
|
358
364
|
* @example Combining multiple options
|
|
359
365
|
* ```ts
|
|
360
366
|
* // High-res transparent screenshot with custom camera
|
|
@@ -401,6 +407,15 @@ export function screenshot2(opts: ScreenshotOptionsDataUrl | ScreenshotOptionsTe
|
|
|
401
407
|
const renderer = context.renderer;
|
|
402
408
|
const isXRScreenshot = renderer.xr.enabled && renderer.xr.isPresenting;
|
|
403
409
|
|
|
410
|
+
// In the Needle Go App Clip the camera feed is rendered natively behind the transparent
|
|
411
|
+
// webview and can not be captured from the page. For "blob"/"share" screenshots we send
|
|
412
|
+
// the rendered (transparent) 3D layer to the native app instead, which composites the
|
|
413
|
+
// current camera frame behind it. For "share" the native share sheet is used, which does
|
|
414
|
+
// not require user activation — so a single tap works despite the async pipeline.
|
|
415
|
+
const nativeScreenshotBridge = "type" in opts && (opts.type === "share" || (opts.type === "blob" && isXRScreenshot))
|
|
416
|
+
? getNativeScreenshotBridge()
|
|
417
|
+
: null;
|
|
418
|
+
|
|
404
419
|
|
|
405
420
|
// Perform XR screenshot in onBeforeRender (after the screenshot we want to render the original camera view)
|
|
406
421
|
// If we do it in onAfterRender we will see one frame of a wrong image which is not what we want
|
|
@@ -422,6 +437,8 @@ export function screenshot2(opts: ScreenshotOptionsDataUrl | ScreenshotOptionsTe
|
|
|
422
437
|
|
|
423
438
|
const prevWidth = domElement.width;
|
|
424
439
|
const prevHeight = domElement.height;
|
|
440
|
+
const prevStyleWidth = domElement.style.width;
|
|
441
|
+
const prevStyleHeight = domElement.style.height;
|
|
425
442
|
|
|
426
443
|
if (!width) width = prevWidth;
|
|
427
444
|
if (!height) height = prevHeight;
|
|
@@ -532,6 +549,9 @@ export function screenshot2(opts: ScreenshotOptionsDataUrl | ScreenshotOptionsTe
|
|
|
532
549
|
scene: context.scene,
|
|
533
550
|
camera: camera,
|
|
534
551
|
renderer: renderer,
|
|
552
|
+
// When the native bridge composites the camera frame we only need the
|
|
553
|
+
// transparent 3D layer here (camera-access is not available in the App Clip)
|
|
554
|
+
skipCameraImage: nativeScreenshotBridge != null,
|
|
535
555
|
});
|
|
536
556
|
}
|
|
537
557
|
else {
|
|
@@ -586,6 +606,19 @@ export function screenshot2(opts: ScreenshotOptionsDataUrl | ScreenshotOptionsTe
|
|
|
586
606
|
return targetTexture.texture;
|
|
587
607
|
}
|
|
588
608
|
else if (opts.type === "blob") {
|
|
609
|
+
if (nativeScreenshotBridge) {
|
|
610
|
+
const fallbackCanvas = outputCanvas;
|
|
611
|
+
return nativeScreenshotBridge({
|
|
612
|
+
image: outputCanvas.toDataURL("image/png"),
|
|
613
|
+
action: "return",
|
|
614
|
+
mimeType: mimeType,
|
|
615
|
+
})
|
|
616
|
+
.then(res => res?.image ? dataURLToBlob(res.image) : null)
|
|
617
|
+
.catch(err => {
|
|
618
|
+
console.warn("Native screenshot failed, falling back to canvas capture", err);
|
|
619
|
+
return new Promise<Blob | null>(resolve => fallbackCanvas.toBlob(resolve, mimeType));
|
|
620
|
+
});
|
|
621
|
+
}
|
|
589
622
|
const promise = new Promise<Blob | null>((resolve, _) => {
|
|
590
623
|
outputCanvas.toBlob(blob => {
|
|
591
624
|
resolve(blob);
|
|
@@ -594,6 +627,20 @@ export function screenshot2(opts: ScreenshotOptionsDataUrl | ScreenshotOptionsTe
|
|
|
594
627
|
return promise;
|
|
595
628
|
}
|
|
596
629
|
else if (opts.type === "share") {
|
|
630
|
+
if (nativeScreenshotBridge) {
|
|
631
|
+
return nativeScreenshotBridge({
|
|
632
|
+
image: outputCanvas.toDataURL("image/png"),
|
|
633
|
+
action: "share",
|
|
634
|
+
title: "title" in opts ? opts.title : undefined,
|
|
635
|
+
text: "text" in opts ? opts.text : undefined,
|
|
636
|
+
url: "url" in opts ? opts.url : undefined,
|
|
637
|
+
})
|
|
638
|
+
.then(res => ({ blob: null, shared: res?.shared === true }))
|
|
639
|
+
.catch(err => {
|
|
640
|
+
console.warn("Native share failed", err);
|
|
641
|
+
return { blob: null, shared: false };
|
|
642
|
+
});
|
|
643
|
+
}
|
|
597
644
|
const promise = new Promise<ScreenshotOptionsShareReturnType>((resolve, _) => {
|
|
598
645
|
outputCanvas.toBlob(blob => {
|
|
599
646
|
if (blob && "share" in navigator) {
|
|
@@ -653,7 +700,15 @@ export function screenshot2(opts: ScreenshotOptionsDataUrl | ScreenshotOptionsTe
|
|
|
653
700
|
finally {
|
|
654
701
|
renderer.setRenderTarget(prevRenderTarget);
|
|
655
702
|
context.scene.background = previousBackground;
|
|
656
|
-
renderer
|
|
703
|
+
// setSize multiplies by the renderer pixel ratio, but prevWidth/prevHeight are the
|
|
704
|
+
// canvas backing store size (device pixels) — divide so the backing store is restored exactly
|
|
705
|
+
const pixelRatio = renderer.getPixelRatio() || 1;
|
|
706
|
+
renderer.setSize(prevWidth / pixelRatio, prevHeight / pixelRatio, false);
|
|
707
|
+
// restore the canvas CSS size that was overridden during the screenshot. This is
|
|
708
|
+
// particularly important for XR screenshots where updateSize (which resets the style
|
|
709
|
+
// to 100%) is not called below — otherwise the canvas stays at the screenshot size
|
|
710
|
+
domElement.style.width = prevStyleWidth;
|
|
711
|
+
domElement.style.height = prevStyleHeight;
|
|
657
712
|
renderer.setClearColor(previousClearColor, previousClearAlpha);
|
|
658
713
|
// Make sure to reset the aspect ratio. This is crucial if the main camera is not the currently active rendering camera
|
|
659
714
|
// For example if we did a screenshot from a different camera that has a different aspect ratio / fov
|
|
@@ -673,6 +728,10 @@ export function screenshot2(opts: ScreenshotOptionsDataUrl | ScreenshotOptionsTe
|
|
|
673
728
|
|
|
674
729
|
|
|
675
730
|
|
|
731
|
+
function dataURLToBlob(dataUrl: string): Promise<Blob | null> {
|
|
732
|
+
return fetch(dataUrl).then(res => res.blob()).catch(() => null);
|
|
733
|
+
}
|
|
734
|
+
|
|
676
735
|
// trim to transparent pixels
|
|
677
736
|
function trimCanvas(originalCanvas: HTMLCanvasElement): HTMLCanvasElement | null {
|
|
678
737
|
if (!("document" in globalThis)) return null;
|
|
@@ -760,10 +819,11 @@ export namespace InternalScreenshotUtils {
|
|
|
760
819
|
|
|
761
820
|
/**
|
|
762
821
|
* Screenshot rendering for AR
|
|
763
|
-
* @param args
|
|
822
|
+
* @param args Set `skipCameraImage` to render only the 3D layer with a transparent background
|
|
823
|
+
* (used when the camera feed is composited elsewhere, e.g. natively in the Needle Go App Clip)
|
|
764
824
|
* @returns The canvas with the screenshot
|
|
765
825
|
*/
|
|
766
|
-
export function compositeWithCameraImage(args: { scene: Scene, camera: Camera, renderer: WebGLRenderer, width: number, height: number }) {
|
|
826
|
+
export function compositeWithCameraImage(args: { scene: Scene, camera: Camera, renderer: WebGLRenderer, width: number, height: number, skipCameraImage?: boolean }) {
|
|
767
827
|
|
|
768
828
|
const { renderer, width, height } = args;
|
|
769
829
|
|
|
@@ -811,7 +871,14 @@ export namespace InternalScreenshotUtils {
|
|
|
811
871
|
}
|
|
812
872
|
|
|
813
873
|
if (!otherPlaneMesh) {
|
|
814
|
-
|
|
874
|
+
// Premultiply alpha when blitting into the canvas (which is premultiplied) so that
|
|
875
|
+
// transparent/anti-aliased pixels survive the drawImage readback without fringing.
|
|
876
|
+
// With an opaque camera background alpha is 1 everywhere and this is a no-op.
|
|
877
|
+
otherPlaneMesh = makeFullscreenPlane({
|
|
878
|
+
defines: {
|
|
879
|
+
PREMULTIPLY_ALPHA: true
|
|
880
|
+
},
|
|
881
|
+
});
|
|
815
882
|
}
|
|
816
883
|
|
|
817
884
|
if (!threeTexture) {
|
|
@@ -834,27 +901,38 @@ export namespace InternalScreenshotUtils {
|
|
|
834
901
|
renderer.xr.enabled = false;
|
|
835
902
|
renderer.autoClear = false;
|
|
836
903
|
renderer.clear();
|
|
837
|
-
|
|
904
|
+
// updateStyle=false: don't write the screenshot size into the live canvas CSS
|
|
905
|
+
renderer.setSize(expectedWidth, expectedHeight, false);
|
|
838
906
|
renderer.setRenderTarget(rtTexture);
|
|
839
907
|
|
|
840
|
-
//
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
908
|
+
// The render target is reused between screenshots: clear it with transparent black
|
|
909
|
+
// so the 3D layer stays transparent where nothing is rendered (no ghosting)
|
|
910
|
+
const prevClearColor = renderer.getClearColor(new Color());
|
|
911
|
+
const prevClearAlpha = renderer.getClearAlpha();
|
|
912
|
+
renderer.setClearColor(0x000000, 0);
|
|
913
|
+
renderer.clear();
|
|
914
|
+
renderer.setClearColor(prevClearColor, prevClearAlpha);
|
|
844
915
|
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
916
|
+
if (!args.skipCameraImage) {
|
|
917
|
+
// First we update the render texture which will hold the camera image
|
|
918
|
+
if (!updateTextureFromXRFrame(args.renderer, threeTexture)) {
|
|
919
|
+
console.error("Could not update texture from XR frame");
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
const camBg = GameObject.findObjectOfType(WebARCameraBackground);
|
|
923
|
+
if (camBg) {
|
|
924
|
+
// the scene uses WebARCameraBackground, so we make sure it has the latest camera-access texture
|
|
925
|
+
camBg.setTexture(threeTexture);
|
|
926
|
+
}
|
|
927
|
+
else {
|
|
928
|
+
// the scene doesn't use WebARCameraBackground, so we render the camera feed fullscreen
|
|
929
|
+
backgroundPlane.setTexture(threeTexture);
|
|
930
|
+
renderer.render(backgroundPlane, args.camera);
|
|
931
|
+
}
|
|
854
932
|
}
|
|
855
933
|
|
|
856
934
|
renderer.clearDepth();
|
|
857
|
-
renderer.setSize(expectedWidth, expectedHeight);
|
|
935
|
+
renderer.setSize(expectedWidth, expectedHeight, false);
|
|
858
936
|
renderer.render(args.scene, args.camera);
|
|
859
937
|
|
|
860
938
|
// Blit the render texture first into our renderer on the GPU,
|
|
@@ -862,7 +940,10 @@ export namespace InternalScreenshotUtils {
|
|
|
862
940
|
renderer.setRenderTarget(null);
|
|
863
941
|
otherPlaneMesh.setTexture(rtTexture.texture);
|
|
864
942
|
renderer.render(otherPlaneMesh, args.camera);
|
|
865
|
-
|
|
943
|
+
// alpha-enabled context: the 3D layer must stay transparent when the camera image
|
|
944
|
+
// is composited elsewhere (with a camera background the image is opaque anyway)
|
|
945
|
+
const _context = customCanvas.getContext('2d')!;
|
|
946
|
+
_context.clearRect(0, 0, customCanvas.width, customCanvas.height);
|
|
866
947
|
_context.drawImage(renderer.domElement, 0, 0, customCanvas.width, customCanvas.height);
|
|
867
948
|
|
|
868
949
|
renderer.setRenderTarget(prevRT);
|
|
@@ -890,6 +971,12 @@ void main() {
|
|
|
890
971
|
gl_FragColor = texColor;
|
|
891
972
|
#include <tonemapping_fragment>
|
|
892
973
|
#include <colorspace_fragment>
|
|
974
|
+
|
|
975
|
+
#ifdef PREMULTIPLY_ALPHA
|
|
976
|
+
|
|
977
|
+
gl_FragColor.rgb *= gl_FragColor.a;
|
|
978
|
+
|
|
979
|
+
#endif
|
|
893
980
|
}
|
|
894
981
|
`;
|
|
895
982
|
|
|
@@ -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 { __QhCnOwd } 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 && __QhCnOwd()) {
|
|
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 { __QhCnOwd, __jFACNo, 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";
|
|
@@ -778,8 +778,8 @@ export class NeedleMenuElement extends HTMLElementBase {
|
|
|
778
778
|
try {
|
|
779
779
|
// if the user has a license then we CAN hide the needle logo
|
|
780
780
|
// calling this method immediately will cause an issue with vite bundling tho
|
|
781
|
-
window.requestAnimationFrame(() =>
|
|
782
|
-
if (res == true &&
|
|
781
|
+
window.requestAnimationFrame(() => __jFACNo(res => {
|
|
782
|
+
if (res == true && __QhCnOwd() && !debugNonCommercial) {
|
|
783
783
|
let visible = this._userRequestedLogoVisible;
|
|
784
784
|
if (visible === undefined) visible = false;
|
|
785
785
|
this.___onSetLogoVisible(visible);
|
|
@@ -830,7 +830,7 @@ export class NeedleMenuElement extends HTMLElementBase {
|
|
|
830
830
|
// ensure the menu is not hidden or removed
|
|
831
831
|
const requiredParent = this?.parentNode;
|
|
832
832
|
if (this.style.display != "flex" || this.style.visibility != "visible" || this.style.opacity != "1" || requiredParent != this._domElement?.shadowRoot) {
|
|
833
|
-
if (!
|
|
833
|
+
if (!__QhCnOwd()) {
|
|
834
834
|
const change = changeEventCounter++;
|
|
835
835
|
// if a user doesn't have a local pro license *but* for development the menu is hidden then we show a warning
|
|
836
836
|
if (isLocalNetwork() && this._userRequestedMenuVisible === false) {
|
|
@@ -918,7 +918,7 @@ export class NeedleMenuElement extends HTMLElementBase {
|
|
|
918
918
|
showNeedleLogo(visible: boolean) {
|
|
919
919
|
this._userRequestedLogoVisible = visible;
|
|
920
920
|
if (!visible) {
|
|
921
|
-
if (!
|
|
921
|
+
if (!__QhCnOwd() || debugNonCommercial) {
|
|
922
922
|
console.warn("[Needle Engine] You need a commercial license to hide the Needle Engine logo in production.");
|
|
923
923
|
const localNetwork = isLocalNetwork()
|
|
924
924
|
if (!localNetwork) return;
|
|
@@ -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 { __QhCnOwd, KLaEb, _$OuASD } 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 = KLaEb();
|
|
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 = __QhCnOwd();
|
|
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() && _$OuASD) {
|
|
397
397
|
// if (debugLicense) console.log("Waiting for runtime license check");
|
|
398
|
-
// await
|
|
399
|
-
// commercialLicense =
|
|
398
|
+
// await _$OuASD;
|
|
399
|
+
// commercialLicense = __QhCnOwd();
|
|
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 { __QhCnOwd, 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 = __QhCnOwd();
|
|
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", {
|
|
@@ -399,13 +399,23 @@ export class NeedleXRSession implements INeedleXRSession {
|
|
|
399
399
|
// Re-probe when XR devices change (e.g. a headset connects).
|
|
400
400
|
this.ensureDeviceChangeInvalidation();
|
|
401
401
|
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
402
|
+
// Return the RESOLVED boolean once we have it. This cache stores booleans (not promises)
|
|
403
|
+
// on purpose: some call sites read it synchronously — e.g. the iOS AppClip path in start()
|
|
404
|
+
// must decide within the user gesture, so it can't await here.
|
|
405
|
+
const resolved = this._sessionSupportedCache[mode];
|
|
406
|
+
if (resolved !== undefined) return Promise.resolve(resolved);
|
|
407
|
+
// De-duplicate concurrent probes: a burst of callers in the same tick (XR buttons,
|
|
408
|
+
// offer-session, support checks) shares ONE underlying navigator.xr.isSessionSupported()
|
|
409
|
+
// call per mode instead of each re-running the `xr-spatial-tracking` Permissions-Policy check.
|
|
410
|
+
const inflight = this._sessionSupportedInFlight[mode];
|
|
411
|
+
if (inflight !== undefined) return inflight;
|
|
412
|
+
|
|
413
|
+
// No WebXR API available right now: report unsupported but do NOT cache it. A WebXR
|
|
414
|
+
// polyfill or emulator (iwer, the WebXR browser extension) may install navigator.xr
|
|
415
|
+
// AFTER engine init, and until it exists there is no devicechange event that could
|
|
416
|
+
// invalidate a cached false.
|
|
417
|
+
const xrSystem = this.xrSystem;
|
|
418
|
+
if (!xrSystem) return Promise.resolve(false);
|
|
409
419
|
|
|
410
420
|
let result: Promise<boolean>;
|
|
411
421
|
// isSessionSupported() requires the `xr-spatial-tracking` permission policy to be
|
|
@@ -418,22 +428,32 @@ export class NeedleXRSession implements INeedleXRSession {
|
|
|
418
428
|
result = Promise.resolve(false);
|
|
419
429
|
}
|
|
420
430
|
else {
|
|
421
|
-
result =
|
|
431
|
+
result = xrSystem.isSessionSupported(mode)
|
|
422
432
|
.catch(err => { if (debug) console.error(err); return false; });
|
|
423
433
|
}
|
|
424
|
-
this.
|
|
434
|
+
this._sessionSupportedInFlight[mode] = result;
|
|
435
|
+
result.then(v => {
|
|
436
|
+
this._sessionSupportedCache[mode] = v;
|
|
437
|
+
delete this._sessionSupportedInFlight[mode];
|
|
438
|
+
});
|
|
425
439
|
return result;
|
|
426
440
|
}
|
|
427
441
|
|
|
428
|
-
|
|
442
|
+
/** Resolved support results, kept as booleans so call sites can read them synchronously (e.g. the iOS AppClip path). */
|
|
443
|
+
private static _sessionSupportedCache: { [mode in XRSessionMode]?: boolean } = {};
|
|
444
|
+
/** In-flight probes, to de-duplicate concurrent isSessionSupported() calls per mode. */
|
|
445
|
+
private static _sessionSupportedInFlight: { [mode in XRSessionMode]?: Promise<boolean> } = {};
|
|
429
446
|
private static _deviceChangeHooked = false;
|
|
430
447
|
/** Invalidate the support cache when the set of XR devices changes, so a newly
|
|
431
448
|
* connected headset is picked up by the next isSessionSupported() call. */
|
|
432
449
|
private static ensureDeviceChangeInvalidation() {
|
|
433
450
|
if (this._deviceChangeHooked) return;
|
|
451
|
+
const xr = this.xrSystem;
|
|
452
|
+
// navigator.xr may not exist yet (polyfill/emulator installed later) — retry on the next call.
|
|
453
|
+
if (!xr) return;
|
|
434
454
|
this._deviceChangeHooked = true;
|
|
435
455
|
try {
|
|
436
|
-
|
|
456
|
+
xr.addEventListener?.("devicechange", () => { this._sessionSupportedCache = {}; this._sessionSupportedInFlight = {}; });
|
|
437
457
|
}
|
|
438
458
|
catch { /* xrSystem may not support events; cache simply won't be invalidated */ }
|
|
439
459
|
}
|
|
@@ -597,11 +617,9 @@ export class NeedleXRSession implements INeedleXRSession {
|
|
|
597
617
|
|
|
598
618
|
// if we are in an iframe, we need to navigate the top window
|
|
599
619
|
const topWindow = window.top || window;
|
|
600
|
-
const originalUrl = topWindow.location.href;
|
|
601
620
|
|
|
602
621
|
// Forward to the AppClip experience. By default this is the Needle AppClip; a custom branded
|
|
603
622
|
// experience can be set via NeedleXRSession.appClipUrl (which is then opened as-is so it matches the registered URL).
|
|
604
|
-
const customAppClip = NeedleXRSession.appClipUrl !== null;
|
|
605
623
|
const url = NeedleXRSession.getAppClipLaunchUrl();
|
|
606
624
|
const urlStr = url.toString();
|
|
607
625
|
|
|
@@ -614,40 +632,16 @@ export class NeedleXRSession implements INeedleXRSession {
|
|
|
614
632
|
try {
|
|
615
633
|
console.debug("iOS device detected - opening Needle App Clip for AR experience", { mode, init, url });
|
|
616
634
|
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
|
|
625
|
-
const allowMultipleNavigations = !customAppClip && sessionStorage.getItem(key) === null;
|
|
626
|
-
if (allowMultipleNavigations) {
|
|
627
|
-
const secondUrl = new URL(url);
|
|
628
|
-
secondUrl.searchParams.set("prewarm", "1");
|
|
629
|
-
// eslint-disable-next-line xss/no-location-href-assign
|
|
630
|
-
topWindow.location.href = secondUrl.toString();
|
|
631
|
-
|
|
632
|
-
// wait a bit to give the device time to fetch the app clip metadata - this seems to fix the double tap issue (500 ms seem to work)
|
|
633
|
-
await new Promise(res => setTimeout(res, 300));
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
// navigate to app clip url but keep the current url in history, open in same tab
|
|
635
|
+
// Navigate to the App Clip invocation URL synchronously, INSIDE the tap's user
|
|
636
|
+
// activation. iOS only presents the App Clip card for a navigation that happens
|
|
637
|
+
// within the user gesture — the previous prewarm sequence (navigate to a
|
|
638
|
+
// `?prewarm=1` variant, await 300ms, navigate again) pushed the real invocation
|
|
639
|
+
// out of the gesture, which is one reason the card regularly needed a second tap.
|
|
640
|
+
// The invocation itself makes iOS fetch the experience metadata, so on a cold
|
|
641
|
+
// metadata cache the card may still need a second tap (the button shows
|
|
642
|
+
// "Tap again" for that case), but a warm cache now presents on the first tap.
|
|
637
643
|
// eslint-disable-next-line xss/no-location-href-assign
|
|
638
644
|
topWindow.location.href = urlStr;
|
|
639
|
-
|
|
640
|
-
if (allowMultipleNavigations && !DeviceUtilities.isSafari()) {
|
|
641
|
-
setTimeout(() => {
|
|
642
|
-
const url2 = new URL(url);
|
|
643
|
-
url2.searchParams.set("prewarm", "1");
|
|
644
|
-
// eslint-disable-next-line xss/no-location-href-assign
|
|
645
|
-
topWindow.location.href = url2.toString();
|
|
646
|
-
}, 500);
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
sessionStorage.setItem(key, "1");
|
|
650
|
-
|
|
651
645
|
}
|
|
652
646
|
catch (e) {
|
|
653
647
|
console.warn(`Error navigating to AppClip ${urlStr}\n`, e);
|
|
@@ -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 { __QhCnOwd } 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 (__QhCnOwd()) {
|
|
219
219
|
const htmlComponent = document.querySelector("needle-engine");
|
|
220
220
|
if (htmlComponent) {
|
|
221
221
|
const licenseLogo = htmlComponent.getAttribute("logo-src");
|
package/src/engine/xr/init.ts
CHANGED
|
@@ -12,12 +12,11 @@ export function initXR() {
|
|
|
12
12
|
|
|
13
13
|
if (DeviceUtilities.isiOS()) {
|
|
14
14
|
|
|
15
|
-
//
|
|
15
|
+
// Note: a page-level fetch() of the AppClip URL was removed here — it only warms the
|
|
16
|
+
// BROWSER's http cache, while the App Clip card is gated on the OS-level experience
|
|
17
|
+
// metadata cache, which page javascript cannot populate. The preconnect below covers
|
|
18
|
+
// the useful part (warming the connection for the invocation navigation).
|
|
16
19
|
const url = NeedleXRSession.getAppClipLaunchUrl();
|
|
17
|
-
const urlStr = url.toString();
|
|
18
|
-
fetch(urlStr, { method: "HEAD", mode: "no-cors" }).catch(() => {
|
|
19
|
-
// appclip prefetch - to get metadata faster on iOS devices, this seems to fix the double tap issue when opening the appclip for AR sessions.
|
|
20
|
-
});
|
|
21
20
|
|
|
22
21
|
try {
|
|
23
22
|
// We add the meta tag here to preload app clip card data for iOS.
|