@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.
Files changed (67) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/{needle-engine.bundle-CTHxmSih.min.js → needle-engine.bundle-BvxvjlSN.min.js} +170 -164
  3. package/dist/{needle-engine.bundle-BAT9_opF.js → needle-engine.bundle-CEba8eTv.js} +5956 -5899
  4. package/dist/{needle-engine.bundle-BiLCbb4D.umd.cjs → needle-engine.bundle-GUaSFX8u.umd.cjs} +142 -136
  5. package/dist/needle-engine.d.ts +33 -28
  6. package/dist/needle-engine.js +282 -282
  7. package/dist/needle-engine.min.js +1 -1
  8. package/dist/needle-engine.umd.cjs +1 -1
  9. package/lib/engine/api.d.ts +1 -1
  10. package/lib/engine/api.js +1 -1
  11. package/lib/engine/api.js.map +1 -1
  12. package/lib/engine/engine_camera.fit.js +25 -2
  13. package/lib/engine/engine_camera.fit.js.map +1 -1
  14. package/lib/engine/engine_init.js +2 -2
  15. package/lib/engine/engine_init.js.map +1 -1
  16. package/lib/engine/engine_license.d.ts +7 -7
  17. package/lib/engine/engine_license.js +71 -71
  18. package/lib/engine/engine_license.js.map +1 -1
  19. package/lib/engine/engine_networking_blob.js +3 -3
  20. package/lib/engine/engine_networking_blob.js.map +1 -1
  21. package/lib/engine/engine_utils_qrcode.js +2 -2
  22. package/lib/engine/engine_utils_qrcode.js.map +1 -1
  23. package/lib/engine/engine_utils_screenshot.d.ts +3 -1
  24. package/lib/engine/engine_utils_screenshot.js +103 -19
  25. package/lib/engine/engine_utils_screenshot.js.map +1 -1
  26. package/lib/engine/engine_utils_screenshot.needlego.d.ts +44 -0
  27. package/lib/engine/engine_utils_screenshot.needlego.js +22 -0
  28. package/lib/engine/engine_utils_screenshot.needlego.js.map +1 -0
  29. package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js +2 -2
  30. package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js.map +1 -1
  31. package/lib/engine/webcomponents/needle menu/needle-menu.js +5 -5
  32. package/lib/engine/webcomponents/needle menu/needle-menu.js.map +1 -1
  33. package/lib/engine/webcomponents/needle-engine.js +2 -2
  34. package/lib/engine/webcomponents/needle-engine.js.map +1 -1
  35. package/lib/engine/webcomponents/needle-engine.loading.js +2 -2
  36. package/lib/engine/webcomponents/needle-engine.loading.js.map +1 -1
  37. package/lib/engine/xr/NeedleXRSession.d.ts +3 -0
  38. package/lib/engine/xr/NeedleXRSession.js +41 -40
  39. package/lib/engine/xr/NeedleXRSession.js.map +1 -1
  40. package/lib/engine/xr/TempXRContext.js +2 -2
  41. package/lib/engine/xr/TempXRContext.js.map +1 -1
  42. package/lib/engine/xr/init.js +4 -5
  43. package/lib/engine/xr/init.js.map +1 -1
  44. package/lib/engine-components/GroundProjection.js +36 -16
  45. package/lib/engine-components/GroundProjection.js.map +1 -1
  46. package/lib/engine-components/export/usdz/USDZExporter.js +4 -4
  47. package/lib/engine-components/export/usdz/USDZExporter.js.map +1 -1
  48. package/package.json +1 -1
  49. package/plugins/common/license.js +4 -4
  50. package/plugins/vite/license.js +4 -4
  51. package/src/engine/api.ts +1 -1
  52. package/src/engine/engine_camera.fit.ts +19 -2
  53. package/src/engine/engine_init.ts +2 -2
  54. package/src/engine/engine_license.ts +68 -68
  55. package/src/engine/engine_networking_blob.ts +3 -3
  56. package/src/engine/engine_utils_qrcode.ts +2 -2
  57. package/src/engine/engine_utils_screenshot.needlego.ts +53 -0
  58. package/src/engine/engine_utils_screenshot.ts +107 -20
  59. package/src/engine/webcomponents/needle menu/needle-menu-spatial.ts +2 -2
  60. package/src/engine/webcomponents/needle menu/needle-menu.ts +5 -5
  61. package/src/engine/webcomponents/needle-engine.loading.ts +6 -6
  62. package/src/engine/webcomponents/needle-engine.ts +2 -2
  63. package/src/engine/xr/NeedleXRSession.ts +39 -45
  64. package/src/engine/xr/TempXRContext.ts +2 -2
  65. package/src/engine/xr/init.ts +4 -5
  66. package/src/engine-components/GroundProjection.ts +38 -17
  67. 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.setSize(prevWidth, prevHeight, false);
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
- otherPlaneMesh = makeFullscreenPlane();
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
- renderer.setSize(expectedWidth, expectedHeight);
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
- // First we update the render texture which will hold the camera image
841
- if (!updateTextureFromXRFrame(args.renderer, threeTexture)) {
842
- console.error("Could not update texture from XR frame");
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
- const camBg = GameObject.findObjectOfType(WebARCameraBackground);
846
- if (camBg) {
847
- // the scene uses WebARCameraBackground, so we make sure it has the latest camera-access texture
848
- camBg.setTexture(threeTexture);
849
- }
850
- else {
851
- // the scene doesn't use WebARCameraBackground, so we render the camera feed fullscreen
852
- backgroundPlane.setTexture(threeTexture);
853
- renderer.render(backgroundPlane, args.camera);
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
- const _context = customCanvas.getContext('2d', { alpha: false })!;
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 { Ojgqq } from "../../engine_license.js";
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 && Ojgqq()) {
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 { Ojgqq, _lEbGNc, Telemetry } from "../../engine_license.js";
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(() => _lEbGNc(res => {
782
- if (res == true && Ojgqq() && !debugNonCommercial) {
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 (!Ojgqq()) {
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 (!Ojgqq() || debugNonCommercial) {
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 { Ojgqq, __OBvkie, zis } from "../engine_license.js";
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 = __OBvkie();
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 = Ojgqq();
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() && zis) {
396
+ // if (!isDevEnvironment() && _$OuASD) {
397
397
  // if (debugLicense) console.log("Waiting for runtime license check");
398
- // await zis;
399
- // commercialLicense = Ojgqq();
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 { Ojgqq, Telemetry } from "../engine_license.js";
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 = Ojgqq();
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
- // Cache AND de-duplicate the check by storing the in-flight PROMISE (not just the
403
- // resolved value). A burst of callers in the same tick (XR buttons, offer-session,
404
- // support checks) then shares ONE underlying navigator.xr.isSessionSupported() call
405
- // per mode. Each call otherwise repeats the `xr-spatial-tracking` Permissions-Policy
406
- // check and floods the console. It also keeps user-input onclick checks fast.
407
- const cached = this._sessionSupportedCache[mode];
408
- if (cached !== undefined) return cached;
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 = (this.xrSystem?.isSessionSupported(mode) ?? Promise.resolve(false))
431
+ result = xrSystem.isSessionSupported(mode)
422
432
  .catch(err => { if (debug) console.error(err); return false; });
423
433
  }
424
- this._sessionSupportedCache[mode] = result;
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
- private static _sessionSupportedCache: { [mode in XRSessionMode]?: Promise<boolean> } = {};
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
- this.xrSystem?.addEventListener?.("devicechange", () => { this._sessionSupportedCache = {}; });
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
- // Prewarm the appclip by opening the URL (with an extra ?prewarm=1 param) to fetch the app clip
618
- // metadata faster - this fixes a double-tap issue on iOS.
619
- // BUT on safari triggering this with multiple URLs causes the
620
- // Browser to show the dialogue AGAIN in the background
621
- // SO: we don't do it here...
622
- // We also skip prewarming for a custom branded experience: that URL must match the registered
623
- // App Clip experience URL exactly, so we must not append any query parameters to it.
624
- const key = "appclip-prewarm:" + url.origin;
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 { Ojgqq } from "../engine_license.js";
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 (Ojgqq()) {
218
+ if (__QhCnOwd()) {
219
219
  const htmlComponent = document.querySelector("needle-engine");
220
220
  if (htmlComponent) {
221
221
  const licenseLogo = htmlComponent.getAttribute("logo-src");
@@ -12,12 +12,11 @@ export function initXR() {
12
12
 
13
13
  if (DeviceUtilities.isiOS()) {
14
14
 
15
- // Prefetch (uses the custom branded AppClip experience if NeedleXRSession.appClipUrl is set, otherwise the default Needle AppClip)
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.