@needle-tools/engine 5.1.0-next.1780579670.dramatic-pierogi.b8fa941 → 5.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/components.needle.json +1 -1
  3. package/dist/gltf-progressive-BGQ-cjly.umd.cjs +10 -0
  4. package/dist/gltf-progressive-BvQHrERj.min.js +10 -0
  5. package/dist/{gltf-progressive-CH7aqHHr.js → gltf-progressive-CtYnVfLc.js} +420 -351
  6. package/dist/{materialx-B85WjP7E.min.js → materialx-B5gKeM65.min.js} +1 -1
  7. package/dist/{materialx-B47Bz-xs.umd.cjs → materialx-CFRcCYWy.umd.cjs} +1 -1
  8. package/dist/{materialx-jkHmVPez.js → materialx-DqGmteNO.js} +1 -1
  9. package/dist/{needle-engine.bundle-BSPk6RkD.min.js → needle-engine.bundle-BUT4ua7U.min.js} +162 -150
  10. package/dist/{needle-engine.bundle-DCPHDPAd.umd.cjs → needle-engine.bundle-Cb39lTJn.umd.cjs} +158 -146
  11. package/dist/{needle-engine.bundle-WQeWxmZD.js → needle-engine.bundle-Dv-p5m4z.js} +6956 -6782
  12. package/dist/needle-engine.d.ts +111 -43
  13. package/dist/needle-engine.js +611 -609
  14. package/dist/needle-engine.min.js +1 -1
  15. package/dist/needle-engine.umd.cjs +1 -1
  16. package/dist/three.js +3 -0
  17. package/dist/three.min.js +22 -22
  18. package/dist/three.umd.cjs +24 -24
  19. package/dist/{vendor-BQ2Vuntm.min.js → vendor-2--bD1c9.min.js} +19 -19
  20. package/dist/{vendor-BslSKZPo.js → vendor-BNtcIZkv.js} +1141 -1138
  21. package/dist/{vendor-1UvpPPSB.umd.cjs → vendor-vbe9sv1t.umd.cjs} +20 -20
  22. package/lib/engine/api.d.ts +1 -1
  23. package/lib/engine/api.js +1 -1
  24. package/lib/engine/api.js.map +1 -1
  25. package/lib/engine/engine_context.js +16 -3
  26. package/lib/engine/engine_context.js.map +1 -1
  27. package/lib/engine/engine_init.js +2 -2
  28. package/lib/engine/engine_init.js.map +1 -1
  29. package/lib/engine/engine_license.d.ts +7 -7
  30. package/lib/engine/engine_license.js +72 -72
  31. package/lib/engine/engine_license.js.map +1 -1
  32. package/lib/engine/engine_networking_blob.js +3 -3
  33. package/lib/engine/engine_networking_blob.js.map +1 -1
  34. package/lib/engine/engine_networking_peer.js +19 -13
  35. package/lib/engine/engine_networking_peer.js.map +1 -1
  36. package/lib/engine/engine_utils_qrcode.js +2 -2
  37. package/lib/engine/engine_utils_qrcode.js.map +1 -1
  38. package/lib/engine/postprocessing/postprocessing.d.ts +2 -1
  39. package/lib/engine/postprocessing/postprocessing.js +3 -2
  40. package/lib/engine/postprocessing/postprocessing.js.map +1 -1
  41. package/lib/engine/webcomponents/WebXRButtons.js +5 -6
  42. package/lib/engine/webcomponents/WebXRButtons.js.map +1 -1
  43. package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js +2 -2
  44. package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js.map +1 -1
  45. package/lib/engine/webcomponents/needle menu/needle-menu.d.ts +2 -2
  46. package/lib/engine/webcomponents/needle menu/needle-menu.js +9 -9
  47. package/lib/engine/webcomponents/needle menu/needle-menu.js.map +1 -1
  48. package/lib/engine/webcomponents/needle-engine.js +2 -2
  49. package/lib/engine/webcomponents/needle-engine.js.map +1 -1
  50. package/lib/engine/webcomponents/needle-engine.loading.js +2 -2
  51. package/lib/engine/webcomponents/needle-engine.loading.js.map +1 -1
  52. package/lib/engine/xr/NeedleXRSession.d.ts +30 -0
  53. package/lib/engine/xr/NeedleXRSession.js +121 -17
  54. package/lib/engine/xr/NeedleXRSession.js.map +1 -1
  55. package/lib/engine/xr/TempXRContext.js +2 -2
  56. package/lib/engine/xr/TempXRContext.js.map +1 -1
  57. package/lib/engine/xr/init.js +2 -3
  58. package/lib/engine/xr/init.js.map +1 -1
  59. package/lib/engine-components/Component.js +14 -1
  60. package/lib/engine-components/Component.js.map +1 -1
  61. package/lib/engine-components/ContactShadows.d.ts +7 -2
  62. package/lib/engine-components/ContactShadows.js +65 -45
  63. package/lib/engine-components/ContactShadows.js.map +1 -1
  64. package/lib/engine-components/GroundProjection.d.ts +6 -0
  65. package/lib/engine-components/GroundProjection.js +69 -7
  66. package/lib/engine-components/GroundProjection.js.map +1 -1
  67. package/lib/engine-components/NeedleMenu.d.ts +1 -1
  68. package/lib/engine-components/NeedleMenu.js +5 -2
  69. package/lib/engine-components/NeedleMenu.js.map +1 -1
  70. package/lib/engine-components/SceneSwitcher.d.ts +2 -0
  71. package/lib/engine-components/SceneSwitcher.js +26 -14
  72. package/lib/engine-components/SceneSwitcher.js.map +1 -1
  73. package/lib/engine-components/SeeThrough.d.ts +39 -0
  74. package/lib/engine-components/SeeThrough.js +69 -1
  75. package/lib/engine-components/SeeThrough.js.map +1 -1
  76. package/lib/engine-components/Skybox.js +9 -0
  77. package/lib/engine-components/Skybox.js.map +1 -1
  78. package/lib/engine-components/api.d.ts +1 -0
  79. package/lib/engine-components/api.js +1 -0
  80. package/lib/engine-components/api.js.map +1 -1
  81. package/lib/engine-components/export/usdz/USDZExporter.js +4 -4
  82. package/lib/engine-components/export/usdz/extensions/behavior/BehaviourComponents.js +9 -5
  83. package/lib/engine-components/export/usdz/extensions/behavior/BehaviourComponents.js.map +1 -1
  84. package/lib/engine-components/particlesystem/ParticleSystem.js +4 -0
  85. package/lib/engine-components/particlesystem/ParticleSystem.js.map +1 -1
  86. package/lib/engine-components/postprocessing/Volume.d.ts +1 -9
  87. package/lib/engine-components/postprocessing/Volume.js +2 -14
  88. package/lib/engine-components/postprocessing/Volume.js.map +1 -1
  89. package/lib/engine-components/webxr/WebXR.js +25 -14
  90. package/lib/engine-components/webxr/WebXR.js.map +1 -1
  91. package/lib/engine-components/webxr/WebXRImageTracking.js +3 -0
  92. package/lib/engine-components/webxr/WebXRImageTracking.js.map +1 -1
  93. package/package.json +4 -4
  94. package/plugins/common/license.js +4 -4
  95. package/plugins/types/userconfig.d.ts +10 -0
  96. package/plugins/vite/build-pipeline.d.ts +19 -0
  97. package/plugins/vite/build-pipeline.js +67 -5
  98. package/plugins/vite/license.js +4 -4
  99. package/src/engine/api.ts +1 -1
  100. package/src/engine/engine_context.ts +15 -2
  101. package/src/engine/engine_gameobject.ts +1 -1
  102. package/src/engine/engine_init.ts +2 -2
  103. package/src/engine/engine_license.ts +69 -69
  104. package/src/engine/engine_networking_blob.ts +3 -3
  105. package/src/engine/engine_networking_peer.ts +12 -13
  106. package/src/engine/engine_utils_qrcode.ts +2 -2
  107. package/src/engine/postprocessing/postprocessing.ts +3 -2
  108. package/src/engine/webcomponents/WebXRButtons.ts +5 -6
  109. package/src/engine/webcomponents/needle menu/needle-menu-spatial.ts +2 -2
  110. package/src/engine/webcomponents/needle menu/needle-menu.ts +10 -10
  111. package/src/engine/webcomponents/needle-engine.loading.ts +6 -6
  112. package/src/engine/webcomponents/needle-engine.ts +2 -2
  113. package/src/engine/xr/NeedleXRSession.ts +124 -20
  114. package/src/engine/xr/TempXRContext.ts +2 -2
  115. package/src/engine/xr/init.ts +2 -3
  116. package/src/engine-components/Component.ts +13 -1
  117. package/src/engine-components/ContactShadows.ts +75 -49
  118. package/src/engine-components/GroundProjection.ts +71 -7
  119. package/src/engine-components/NeedleMenu.ts +6 -3
  120. package/src/engine-components/SceneSwitcher.ts +23 -13
  121. package/src/engine-components/SeeThrough.ts +75 -1
  122. package/src/engine-components/Skybox.ts +8 -0
  123. package/src/engine-components/api.ts +1 -0
  124. package/src/engine-components/export/usdz/USDZExporter.ts +4 -4
  125. package/src/engine-components/export/usdz/extensions/behavior/BehaviourComponents.ts +12 -5
  126. package/src/engine-components/particlesystem/ParticleSystem.ts +4 -0
  127. package/src/engine-components/postprocessing/Volume.ts +2 -13
  128. package/src/engine-components/webxr/WebXR.ts +23 -15
  129. package/src/engine-components/webxr/WebXRImageTracking.ts +1 -0
  130. package/dist/gltf-progressive-9tlS5iCx.umd.cjs +0 -10
  131. package/dist/gltf-progressive-CVg80COO.min.js +0 -10
@@ -23,6 +23,28 @@ import type { IXRRig } from "./XRRig.js";
23
23
  const measure_SessionStartedMarker = "NeedleXRSession onStart";
24
24
  const measure_SessionEndedMarker = "NeedleXRSession onEnd";
25
25
 
26
+ /**
27
+ * Whether the `xr-spatial-tracking` permission policy is allowed in the current document.
28
+ * WebXR APIs (isSessionSupported / requestSession) require it; an embedded page/iframe must
29
+ * be granted it via the iframe `allow` attribute (delegated from a parent that has it) and/or
30
+ * a `Permissions-Policy` response header. Returns true when the policy API is unavailable
31
+ * (can't determine → don't block, preserve prior behavior).
32
+ * @link https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/iframe#allow
33
+ */
34
+ function isXrSpatialTrackingAllowed(): boolean {
35
+ try {
36
+ const doc = globalThis.document as (Document & { permissionsPolicy?: { allowsFeature(f: string): boolean }, featurePolicy?: { allowsFeature(f: string): boolean } }) | undefined;
37
+ const policy = doc?.permissionsPolicy ?? doc?.featurePolicy;
38
+ if (policy && typeof policy.allowsFeature === "function") {
39
+ return policy.allowsFeature("xr-spatial-tracking");
40
+ }
41
+ }
42
+ catch {
43
+ /* ignore — fall through to default */
44
+ }
45
+ return true;
46
+ }
47
+
26
48
 
27
49
  /** @link https://developer.mozilla.org/en-US/docs/Web/API/XRFrame/fillPoses */
28
50
  declare type FillPosesFunction = (spaces: IterableIterator<XRJointSpace>, referenceSpace: XRSpace, targetArray: Float32Array) => void;
@@ -85,6 +107,10 @@ async function handleSessionGranted() {
85
107
  let defaultMode: XRSessionMode = "immersive-vr";
86
108
 
87
109
  try {
110
+ // If xr-spatial-tracking isn't allowed in this document (e.g. an embed whose page/iframe
111
+ // wasn't granted it), XR can't run here — skip the probe so we don't log a
112
+ // Permissions-Policy violation on load.
113
+ if (!isXrSpatialTrackingAllowed()) return;
88
114
  // In app clips we default to AR
89
115
  if (DeviceUtilities.isNeedleAppClip()) {
90
116
  defaultMode = "immersive-ar";
@@ -291,6 +317,51 @@ export class NeedleXRSession implements INeedleXRSession {
291
317
  static get currentSessionRequest(): XRSessionMode | null { return this._currentSessionRequestMode; }
292
318
  private static _currentSessionRequestMode: XRSessionMode | null = null;
293
319
 
320
+ /** Base URL for branded Needle AppClip experiences */
321
+ private static readonly appClipBaseUrl = "https://appclip.needle.tools/x/";
322
+ private static _appClipUrl: string | null = null;
323
+ /**
324
+ * Optionally provide a custom Needle AppClip experience to open on iOS instead of the default Needle AppClip.
325
+ * This enables custom App Clip card branding (your own title and image) when launching AR on iPhone and iPad.
326
+ *
327
+ * You can provide either:
328
+ * - the full URL, e.g. `https://appclip.needle.tools/x/your-experience`
329
+ * - or just the experience id, e.g. `your-experience` (expanded to `https://appclip.needle.tools/x/your-experience`)
330
+ *
331
+ * **IMPORTANT**: the resulting URL must exactly match an App Clip experience URL you have registered (with custom card metadata).
332
+ * For this reason the URL is opened as-is, without appending any query parameters.
333
+ *
334
+ * Note: custom App Clip branding requires an active PRO subscription. See the iOS WebXR App Clip docs (Custom Branding for iOS AR).
335
+ * @link https://engine.needle.tools/docs/how-to-guides/xr/ios-webxr-app-clip#custom-branding-for-ios-ar
336
+ */
337
+ static get appClipUrl(): string | null { return this._appClipUrl; }
338
+ static set appClipUrl(value: string | null) {
339
+ if (!value) {
340
+ this._appClipUrl = null;
341
+ return;
342
+ }
343
+ const trimmed = value.trim();
344
+ // accept a full URL as-is, otherwise treat the value as an experience id and expand it using the base url
345
+ this._appClipUrl = /^https?:\/\//i.test(trimmed)
346
+ ? trimmed
347
+ : this.appClipBaseUrl + trimmed.replace(/^\/+/, "");
348
+ }
349
+
350
+ /**
351
+ * @internal
352
+ * Returns the AppClip launch URL to open (or prefetch) on iOS for the current page.
353
+ * When a custom {@link appClipUrl} is set this is the branded experience URL (opened as-is so it matches the registered URL);
354
+ * otherwise it is the default Needle AppClip launch URL with the current page forwarded via the `url` parameter.
355
+ */
356
+ static getAppClipLaunchUrl(): URL {
357
+ if (this._appClipUrl) return new URL(this._appClipUrl);
358
+ // Forward to the default Needle AppClip experience (using the apple.com url the appclip overlay shows immediately)
359
+ // alternative: `https://appclip.needle.tools/ar?url=${location.href}`
360
+ const url = new URL("https://appclip.apple.com/id?p=tools.needle.launch-app.Clip");
361
+ url.searchParams.set("url", location.href);
362
+ return url;
363
+ }
364
+
294
365
  /**
295
366
  * @returns the active @type {NeedleXRSession} (if any active) or null
296
367
  */
@@ -324,19 +395,49 @@ export class NeedleXRSession implements INeedleXRSession {
324
395
  * @param mode The XRSessionMode to check if it is supported
325
396
  * @returns true if the browser supports the given XRSessionMode
326
397
  */
327
- static isSessionSupported(mode: XRSessionMode) {
328
- // We cache the result of the session support check to avoid unnecessary calls to isSessionSupported. This is especially important for user input events that internally run session support checks during onclick where the event must be resolved as fast as possible.
329
- if (this._sessionSupportedCache[mode] !== undefined) {
330
- return Promise.resolve(this._sessionSupportedCache[mode]);
331
- }
332
- return this.xrSystem?.isSessionSupported(mode).then(supported => {
333
- this._sessionSupportedCache[mode] = supported;
334
- return supported;
335
- }).catch(err => { if (debug) console.error(err); return false }) ?? Promise.resolve(false);
398
+ static isSessionSupported(mode: XRSessionMode): Promise<boolean> {
399
+ // Re-probe when XR devices change (e.g. a headset connects).
400
+ this.ensureDeviceChangeInvalidation();
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;
409
+
410
+ let result: Promise<boolean>;
411
+ // isSessionSupported() requires the `xr-spatial-tracking` permission policy to be
412
+ // allowed in this document. In an embed whose page/iframe wasn't granted it, calling
413
+ // the API logs a Permissions-Policy violation and can't succeed — so skip the call
414
+ // and report "unsupported" instead of spamming the console.
415
+ // See https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/iframe#allow
416
+ if (!isXrSpatialTrackingAllowed()) {
417
+ if (debug) console.warn("[WebXR] xr-spatial-tracking is not allowed in this document — reporting XR as unsupported. The embedding page/iframe must grant the policy.");
418
+ result = Promise.resolve(false);
419
+ }
420
+ else {
421
+ result = (this.xrSystem?.isSessionSupported(mode) ?? Promise.resolve(false))
422
+ .catch(err => { if (debug) console.error(err); return false; });
423
+ }
424
+ this._sessionSupportedCache[mode] = result;
425
+ return result;
426
+ }
427
+
428
+ private static _sessionSupportedCache: { [mode in XRSessionMode]?: Promise<boolean> } = {};
429
+ private static _deviceChangeHooked = false;
430
+ /** Invalidate the support cache when the set of XR devices changes, so a newly
431
+ * connected headset is picked up by the next isSessionSupported() call. */
432
+ private static ensureDeviceChangeInvalidation() {
433
+ if (this._deviceChangeHooked) return;
434
+ this._deviceChangeHooked = true;
435
+ try {
436
+ this.xrSystem?.addEventListener?.("devicechange", () => { this._sessionSupportedCache = {}; });
437
+ }
438
+ catch { /* xrSystem may not support events; cache simply won't be invalidated */ }
336
439
  }
337
440
 
338
- private static _sessionSupportedCache: { [mode in XRSessionMode]?: boolean } = {};
339
-
340
441
  private static _currentSessionRequest?: Promise<XRSession>;
341
442
  private static _activeSession: NeedleXRSession | null;
342
443
 
@@ -498,10 +599,10 @@ export class NeedleXRSession implements INeedleXRSession {
498
599
  const topWindow = window.top || window;
499
600
  const originalUrl = topWindow.location.href;
500
601
 
501
- // Forward to the AppClip experience (Using the apple.com url the appclip overlay shows immediately)
502
- // const url =`https://appclip.needle.tools/ar?url=${(location.href)}`;
503
- const url = new URL("https://appclip.apple.com/id?p=tools.needle.launch-app.Clip");
504
- url.searchParams.set("url", location.href);
602
+ // Forward to the AppClip experience. By default this is the Needle AppClip; a custom branded
603
+ // 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
+ const url = NeedleXRSession.getAppClipLaunchUrl();
505
606
  const urlStr = url.toString();
506
607
 
507
608
  Telemetry.sendEvent(Context.Current, "xr", {
@@ -513,12 +614,15 @@ export class NeedleXRSession implements INeedleXRSession {
513
614
  try {
514
615
  console.debug("iOS device detected - opening Needle App Clip for AR experience", { mode, init, url });
515
616
 
516
- // Prewarm the appclip by opening the URL
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.
517
619
  // BUT on safari triggering this with multiple URLs causes the
518
620
  // Browser to show the dialogue AGAIN in the background
519
621
  // SO: we don't do it here...
520
- const key = "appclip-prewarm:" + new URL(url).origin;
521
- const allowMultipleNavigations = sessionStorage.getItem(key) === null;
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;
522
626
  if (allowMultipleNavigations) {
523
627
  const secondUrl = new URL(url);
524
628
  secondUrl.searchParams.set("prewarm", "1");
@@ -604,7 +708,7 @@ export class NeedleXRSession implements INeedleXRSession {
604
708
  // Setup VR initialization parameters
605
709
  case "immersive-ar":
606
710
  {
607
- const supported = await this.xrSystem?.isSessionSupported('immersive-ar')
711
+ const supported = await NeedleXRSession.isSessionSupported('immersive-ar')
608
712
  if (supported !== true) {
609
713
  console.error(mode + ' is not supported by this browser.');
610
714
  return null;
@@ -625,7 +729,7 @@ export class NeedleXRSession implements INeedleXRSession {
625
729
  // Setup AR initialization parameters
626
730
  case "immersive-vr":
627
731
  {
628
- const supported = await this.xrSystem?.isSessionSupported('immersive-vr')
732
+ const supported = await NeedleXRSession.isSessionSupported('immersive-vr')
629
733
  if (supported !== true) {
630
734
  console.error(mode + ' is not supported by this browser.');
631
735
  return null;
@@ -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 { __EOmPHdLs } from "../engine_license.js";
6
+ import { $Mhcqc } 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 (__EOmPHdLs()) {
218
+ if ($Mhcqc()) {
219
219
  const htmlComponent = document.querySelector("needle-engine");
220
220
  if (htmlComponent) {
221
221
  const licenseLogo = htmlComponent.getAttribute("logo-src");
@@ -12,9 +12,8 @@ export function initXR() {
12
12
 
13
13
  if (DeviceUtilities.isiOS()) {
14
14
 
15
- // Prefetch
16
- const url = new URL("https://appclip.apple.com/id?p=tools.needle.launch-app.Clip");
17
- url.searchParams.set("url", location.href);
15
+ // Prefetch (uses the custom branded AppClip experience if NeedleXRSession.appClipUrl is set, otherwise the default Needle AppClip)
16
+ const url = NeedleXRSession.getAppClipLaunchUrl();
18
17
  const urlStr = url.toString();
19
18
  fetch(urlStr, { method: "HEAD", mode: "no-cors" }).catch(() => {
20
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.
@@ -1107,7 +1107,8 @@ export abstract class Component implements IComponent, EventTarget,
1107
1107
  this.__didCompleteStart = false;
1108
1108
  this.__inEnableOrDisableCallback = false;
1109
1109
  this.__didEnable = false;
1110
- this.__isEnabled = undefined;
1110
+ // Preserve copied/constructor-assigned enabled state while resetting lifecycle flags.
1111
+ // Cloned disabled components must not fall back to the default enabled=true state.
1111
1112
  this.__destroyed = false;
1112
1113
  this._internalInit(init);
1113
1114
  return this;
@@ -1165,6 +1166,17 @@ export abstract class Component implements IComponent, EventTarget,
1165
1166
  }
1166
1167
  this.__didEnable = true;
1167
1168
  this.__isEnabled = true;
1169
+ // Enforce "enabled + not-yet-started ⇒ queued for start". A component can be
1170
+ // re-enabled before it ever started — e.g. removed from the scene (which
1171
+ // strips it from new_script_start via processRemoveFromScene) and then
1172
+ // re-activated through a path other than GameObject.add (raw scene re-add +
1173
+ // setActive, a parent activeInHierarchy flip, …). Those paths re-run onEnable
1174
+ // but never re-enqueue start, leaving the component enabled+active but with
1175
+ // start() never called. Re-queue it here so processStart picks it up.
1176
+ if (!this.__didStart) {
1177
+ const startQueue = this.context?.new_script_start;
1178
+ if (startQueue && !startQueue.includes(this)) startQueue.push(this);
1179
+ }
1168
1180
  this.__inEnableOrDisableCallback = true;
1169
1181
  this.onEnable();
1170
1182
  this.__inEnableOrDisableCallback = false;
@@ -83,7 +83,7 @@ type FitParameters = {
83
83
  */
84
84
  export class ContactShadows extends Behaviour {
85
85
 
86
- private static readonly _instances: Map<Context, ContactShadows> = new Map();
86
+ private static readonly _instances: WeakMap<Context, ContactShadows> = new WeakMap();
87
87
  /**
88
88
  * Create contact shadows for the scene. Automatically fits the shadows to the scene.
89
89
  * The instance of contact shadows will be created only once.
@@ -179,8 +179,9 @@ export class ContactShadows extends Behaviour {
179
179
  * in which case ContactShadows scale would affect the projection
180
180
  **/
181
181
  private readonly shadowsRoot: IGameObject = new Object3D() as IGameObject;
182
- private shadowCamera?: OrthographicCamera;
183
182
  private readonly shadowGroup: Group = new Group();
183
+
184
+ private shadowCamera?: OrthographicCamera;
184
185
 
185
186
  private renderTarget?: WebGLRenderTarget;
186
187
  private renderTargetBlur?: WebGLRenderTarget;
@@ -240,19 +241,86 @@ export class ContactShadows extends Behaviour {
240
241
  if (debug) console.log("Fitted shadows to scene", this.shadowsRoot.scale.clone());
241
242
  }
242
243
 
244
+ /**
245
+ * Call to update the shadows immediately. Only needed if `manualUpdate` is enabled, usually the shadows will update automatically when the scene changes or when the component is enabled.
246
+ */
247
+ renderShadowsNow() {
248
+ this._renderShadows();
249
+ }
243
250
 
244
251
  /** @internal */
245
252
  awake() {
246
253
  ContactShadows._instances.set(this.context, this);
247
- this.shadowsRoot.hideFlags = HideFlags.DontExport;
254
+ }
248
255
 
256
+ /** @internal */
257
+ onEnable(): void {
258
+ this.shadowsRoot.hideFlags = HideFlags.DontExport;
249
259
  // ignore self for autofitting
250
260
  setAutoFitEnabled(this.shadowsRoot, false);
261
+
262
+ this._needsUpdate = true;
263
+ this.autoCleanup(this.context.events.on("scene-content-changed", () => {
264
+ if (!this.autoFit) return;
265
+ this._needsFit = true;
266
+ this._needsUpdate = true;
267
+ }));
268
+
269
+ if (this.autoFit) this.fitShadows();
270
+ else this.applyMinSize();
251
271
  }
252
272
 
273
+ /** @internal */
274
+ onDestroy(): void {
275
+ if (debug) console.warn("Destroying ContactShadows on " + this.gameObject.name);
276
+
277
+ const instance = ContactShadows._instances.get(this.context);
278
+ if (instance === this) {
279
+ ContactShadows._instances.delete(this.context);
280
+ }
281
+
282
+ // dispose the render targets
283
+ this.renderTarget?.dispose();
284
+ this.renderTargetBlur?.dispose();
285
+
286
+ // dispose the materials
287
+ this.depthMaterial?.dispose();
288
+ this.horizontalBlurMaterial?.dispose();
289
+ this.verticalBlurMaterial?.dispose();
290
+
291
+ // dispose the geometries
292
+ this.blurPlane?.geometry.dispose();
293
+ this.plane?.geometry.dispose();
294
+ this.occluderMesh?.geometry.dispose();
295
+ }
253
296
 
254
297
  /** @internal */
255
- start(): void {
298
+ onBeforeRender(_frame: XRFrame | null): void {
299
+
300
+ if (this._needsFit && this.autoFit) {
301
+ this._needsFit = false;
302
+ this.fitShadows();
303
+ }
304
+
305
+ if (this.manualUpdate) {
306
+ if (!this._needsUpdate) return;
307
+ }
308
+ this._needsUpdate = false;
309
+ this._renderShadows();
310
+ }
311
+
312
+ private _ensureSetup() {
313
+
314
+ // Make sure the shadows root is parented to the game object. This is needed for the shadows to be in the correct position and scale relative to the game object. We need to do this before checking if the materials are set up because some properties (e.g. darkness) can be changed in the editor before the component is enabled, so we want to avoid re-creating everything in that case.
315
+ if (this.shadowsRoot.parent !== this.gameObject) {
316
+ this.gameObject.add(this.shadowsRoot);
317
+ }
318
+
319
+ // Check if everything is already set up. We need to do this because some properties (e.g. darkness) can be changed in the editor before the component is enabled, so we want to avoid re-creating everything in that case.
320
+ if (this.depthMaterial && this.horizontalBlurMaterial && this.verticalBlurMaterial && this.planeMaterial) {
321
+ return;
322
+ }
323
+
256
324
  if (debug) console.log("Create ContactShadows on " + this.gameObject.name, this)
257
325
 
258
326
  this.gameObject.add(this.shadowsRoot);
@@ -363,54 +431,11 @@ export class ContactShadows extends Behaviour {
363
431
  this.verticalBlurMaterial.depthTest = false;
364
432
 
365
433
  this.shadowGroup.visible = false;
366
-
367
- if (this.autoFit) this.fitShadows();
368
- else this.applyMinSize();
369
434
  }
370
435
 
371
- onEnable(): void {
372
- this._needsUpdate = true;
373
- this.autoCleanup(this.context.events.on("scene-content-changed", () => {
374
- if (!this.autoFit) return;
375
- this._needsFit = true;
376
- this._needsUpdate = true;
377
- }));
378
- }
436
+ private _renderShadows() {
379
437
 
380
- /** @internal */
381
- onDestroy(): void {
382
- const instance = ContactShadows._instances.get(this.context);
383
- if (instance === this) {
384
- ContactShadows._instances.delete(this.context);
385
- }
386
-
387
- // dispose the render targets
388
- this.renderTarget?.dispose();
389
- this.renderTargetBlur?.dispose();
390
-
391
- // dispose the materials
392
- this.depthMaterial?.dispose();
393
- this.horizontalBlurMaterial?.dispose();
394
- this.verticalBlurMaterial?.dispose();
395
-
396
- // dispose the geometries
397
- this.blurPlane?.geometry.dispose();
398
- this.plane?.geometry.dispose();
399
- this.occluderMesh?.geometry.dispose();
400
- }
401
-
402
- /** @internal */
403
- onBeforeRender(_frame: XRFrame | null): void {
404
-
405
- if (this._needsFit && this.autoFit) {
406
- this._needsFit = false;
407
- this.fitShadows();
408
- }
409
-
410
- if (this.manualUpdate) {
411
- if (!this._needsUpdate) return;
412
- }
413
- this._needsUpdate = false;
438
+ this._ensureSetup();
414
439
 
415
440
  if (!this.renderTarget || !this.renderTargetBlur ||
416
441
  !this.depthMaterial || !this.shadowCamera ||
@@ -536,6 +561,7 @@ export class ContactShadows extends Behaviour {
536
561
  scene.background = initialBackground;
537
562
  renderer.xr.enabled = prevXRState;
538
563
  this.context.scene.matrixWorldAutoUpdate = prevSceneMatrixAutoUpdate;
564
+
539
565
  }
540
566
 
541
567
  // renderTarget --> blurPlane (horizontalBlur) --> renderTargetBlur --> blurPlane (verticalBlur) --> renderTarget
@@ -1,4 +1,4 @@
1
- import { CubeUVReflectionMapping, ShaderMaterial, Texture } from "three";
1
+ import { CubeUVReflectionMapping, Euler, Matrix3, Matrix4, ShaderMaterial, Texture } from "three";
2
2
  import { GroundedSkybox as GroundProjection } from 'three/examples/jsm/objects/GroundedSkybox.js';
3
3
 
4
4
  import { Gizmos } from "../engine/engine_gizmos.js";
@@ -21,8 +21,13 @@ type GroundProjectionShaderUniforms = {
21
21
  needleGroundProjectionBlending: { value: number };
22
22
  needleGroundProjectionAlphaFactor: { value: number };
23
23
  needleGroundProjectionBackgroundIntensity: { value: number };
24
+ needleGroundProjectionBackgroundRotation: { value: Matrix3 };
24
25
  };
25
26
 
27
+ // Reused per-frame to build the background-rotation matrix without allocating in onBeforeRender.
28
+ const _backgroundRotationEuler = new Euler();
29
+ const _backgroundRotationMatrix = new Matrix4();
30
+
26
31
  const needleCubeUvMapVarying = /* glsl */`
27
32
  #ifdef NEEDLE_USE_CUBE_UV_MAP
28
33
  varying vec3 vNeedleGroundProjectionWorldDirection;
@@ -79,7 +84,10 @@ const needleCubeUvMapFragment = /* glsl */`
79
84
 
80
85
  #endif
81
86
 
82
- sampledDiffuseColor.rgb *= mix(1.0, needleGroundProjectionBackgroundIntensity, needleGroundProjectionBlurFactorValue);
87
+ // Apply backgroundIntensity uniformly across the whole projection (center included), matching how the
88
+ // skybox applies it (backgroundCube.glsl: texColor.rgb *= backgroundIntensity). Only the blurriness
89
+ // ramps toward the horizon - the intensity must not.
90
+ sampledDiffuseColor.rgb *= needleGroundProjectionBackgroundIntensity;
83
91
  diffuseColor *= sampledDiffuseColor;
84
92
 
85
93
  #endif
@@ -122,7 +130,8 @@ function getGroundProjectionShaderUniforms(material: GroundProjectionMaterial):
122
130
  needleGroundProjectionBlurriness: { value: 0 },
123
131
  needleGroundProjectionBlending: { value: 0 },
124
132
  needleGroundProjectionAlphaFactor: { value: 1 },
125
- needleGroundProjectionBackgroundIntensity: { value: 1 }
133
+ needleGroundProjectionBackgroundIntensity: { value: 1 },
134
+ needleGroundProjectionBackgroundRotation: { value: new Matrix3() }
126
135
  };
127
136
  }
128
137
 
@@ -133,15 +142,25 @@ function configureGroundProjectionMaterial(material: GroundProjectionMaterial, t
133
142
  shader.uniforms.needleGroundProjectionBlending = projectionUniforms.needleGroundProjectionBlending;
134
143
  shader.uniforms.needleGroundProjectionAlphaFactor = projectionUniforms.needleGroundProjectionAlphaFactor;
135
144
  shader.uniforms.needleGroundProjectionBackgroundIntensity = projectionUniforms.needleGroundProjectionBackgroundIntensity;
145
+ shader.uniforms.needleGroundProjectionBackgroundRotation = projectionUniforms.needleGroundProjectionBackgroundRotation;
136
146
 
137
147
  shader.vertexShader = shader.vertexShader
138
- .replace("#include <uv_pars_vertex>", `#include <uv_pars_vertex>\n${needleCubeUvMapVarying}`)
148
+ .replace("#include <uv_pars_vertex>", `#include <uv_pars_vertex>\n${needleCubeUvMapVarying}
149
+ #ifdef NEEDLE_USE_CUBE_UV_MAP
150
+ uniform mat3 needleGroundProjectionBackgroundRotation;
151
+ #endif`)
139
152
  .replace(
140
153
  "#include <worldpos_vertex>",
141
154
  `#include <worldpos_vertex>
142
155
  #ifdef NEEDLE_USE_CUBE_UV_MAP
143
- // GroundedSkybox mirrors geometry on Z, so undo that before deriving the sampling direction.
144
- vNeedleGroundProjectionWorldDirection = transformDirection( vec3( position.x, position.y, -position.z ), modelMatrix );
156
+ // Sample the environment in the fragment's actual world direction. GroundedSkybox mirrors the geometry
157
+ // on Z (geometry.scale(1,1,-1)) and that mirrored vertex IS the on-screen direction; the skybox and
158
+ // environment lighting are likewise sampled in true world directions. So we must NOT undo the mirror
159
+ // here - doing so reflects the sampling azimuth and flips the panorama left-right vs. the skybox.
160
+ // backgroundRotation is applied to the sampling direction (exactly like the skybox does in
161
+ // backgroundCube.glsl), NOT by rotating the mesh - that keeps the floor flat and matches the skybox at
162
+ // the horizon for any background rotation.
163
+ vNeedleGroundProjectionWorldDirection = needleGroundProjectionBackgroundRotation * transformDirection( position, modelMatrix );
145
164
  #endif`
146
165
  );
147
166
 
@@ -271,6 +290,25 @@ export class GroundProjectedEnv extends Behaviour {
271
290
  private _projection?: GroundProjection;
272
291
  private _watcher?: Watch;
273
292
 
293
+ /** The scene's real backgroundBlurriness, stored while we temporarily override it for the render. */
294
+ private _savedBackgroundBlur: number | null = null;
295
+
296
+ /** pre_render_callback: force the skybox blur just before the render (after all component onBeforeRender). */
297
+ private readonly _forceBackgroundBlur = (_frame?: XRFrame | null) => {
298
+ if (this._savedBackgroundBlur !== null) return; // already overriding (safety)
299
+ if (!(this.scene.background instanceof Texture)) return; // no skybox to blur
300
+ const current = this.scene.backgroundBlurriness ?? 0;
301
+ this._savedBackgroundBlur = current;
302
+ this.scene.backgroundBlurriness = 1;
303
+ };
304
+
305
+ /** post_render_callback: restore the scene's real blur right after the render. */
306
+ private readonly _restoreBackgroundBlur = () => {
307
+ if (this._savedBackgroundBlur === null) return;
308
+ this.scene.backgroundBlurriness = this._savedBackgroundBlur;
309
+ this._savedBackgroundBlur = null;
310
+ };
311
+
274
312
 
275
313
  /** @internal */
276
314
  awake() {
@@ -291,11 +329,23 @@ export class GroundProjectedEnv extends Behaviour {
291
329
  this._needsTextureUpdate = true;
292
330
  });
293
331
  }
332
+ // Force the skybox blur via the render-bracketing callbacks (these run after every component's
333
+ // onBeforeRender, so the override can't be clobbered by e.g. Camera.applyClearFlags).
334
+ if (this.context.pre_render_callbacks.indexOf(this._forceBackgroundBlur) < 0)
335
+ this.context.pre_render_callbacks.push(this._forceBackgroundBlur);
336
+ if (this.context.post_render_callbacks.indexOf(this._restoreBackgroundBlur) < 0)
337
+ this.context.post_render_callbacks.push(this._restoreBackgroundBlur);
294
338
  }
295
339
  /** @internal */
296
340
  onDisable() {
297
341
  this._watcher?.revoke();
298
342
  this._projection?.removeFromParent();
343
+ const pre = this.context.pre_render_callbacks.indexOf(this._forceBackgroundBlur);
344
+ if (pre >= 0) this.context.pre_render_callbacks.splice(pre, 1);
345
+ const post = this.context.post_render_callbacks.indexOf(this._restoreBackgroundBlur);
346
+ if (post >= 0) this.context.post_render_callbacks.splice(post, 1);
347
+ // If we were disabled mid-frame (after force, before restore), put the real value back.
348
+ this._restoreBackgroundBlur();
299
349
  }
300
350
  /** @internal */
301
351
  onEnterXR(): void {
@@ -312,7 +362,21 @@ export class GroundProjectedEnv extends Behaviour {
312
362
  /** @internal */
313
363
  onBeforeRender(): void {
314
364
  if (this._projection && this.scene.backgroundRotation) {
315
- this._projection.rotation.copy(this.scene.backgroundRotation);
365
+ // Mirror three's WebGLBackground: build the same mat3 it applies to the skybox sampling
366
+ // direction and feed it to our shader. This rotates the sampled environment exactly like the
367
+ // skybox (backgroundCube.glsl) instead of rotating the mesh - keeping the floor flat and the
368
+ // horizon aligned with the skybox for any background rotation.
369
+ _backgroundRotationEuler.copy(this.scene.backgroundRotation);
370
+ // accommodate left-handed frame (same as WebGLBackground.js)
371
+ _backgroundRotationEuler.x *= -1;
372
+ _backgroundRotationEuler.y *= -1;
373
+ _backgroundRotationEuler.z *= -1;
374
+ // Note: the extra cube-texture flip in WebGLBackground only applies to raw CubeTextures, not the
375
+ // PMREM render-target env this shader path samples - so it is intentionally omitted here.
376
+ const uniforms = getGroundProjectionShaderUniforms(this._projection.material);
377
+ uniforms.needleGroundProjectionBackgroundRotation.value.setFromMatrix4(
378
+ _backgroundRotationMatrix.makeRotationFromEuler(_backgroundRotationEuler)
379
+ );
316
380
  }
317
381
 
318
382
  if (this._projection && this.context.scene.background instanceof Texture) {
@@ -49,8 +49,8 @@ export class NeedleMenu extends Behaviour {
49
49
  @serializable()
50
50
  position: "top" | "bottom" = "bottom";
51
51
 
52
- /**
53
- * Controls the visibility of the Needle logo in the menu (requires PRO license)
52
+ /**
53
+ * Controls the visibility of the Needle logo in the menu (requires a commercial license)
54
54
  */
55
55
  @serializable()
56
56
  showNeedleLogo: boolean = false;
@@ -102,7 +102,10 @@ export class NeedleMenu extends Behaviour {
102
102
  this.context.menu.showFullscreenOption(true);
103
103
  if (this.createMuteButton === true)
104
104
  this.context.menu.showAudioPlaybackOption(true);
105
- if (this.showSpatialMenu === true)
105
+ // Apply the value in BOTH directions: the spatial menu is enabled by
106
+ // default, so unchecking the option must explicitly disable it. Only skip
107
+ // when the field is unset (undefined) to keep the engine default.
108
+ if (this.showSpatialMenu !== undefined)
106
109
  this.context.menu.showSpatialMenu(this.showSpatialMenu);
107
110
  if (this.createQRCodeButton === true) {
108
111
  if (!DeviceUtilities.isMobileDevice()) {