@relu-interactives/spatial-ecs 0.4.5 → 0.5.0

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 (47) hide show
  1. package/dist/{System-ln4Q7Eot.d.cts → System-V_mv07le.d.cts} +2 -2
  2. package/dist/{System-ln4Q7Eot.d.ts → System-V_mv07le.d.ts} +2 -2
  3. package/dist/{XRHandPose-CxPv-LBo.d.cts → XRHandPose-CQTLGxFC.d.cts} +218 -2
  4. package/dist/{XRHandPose-BImIa4ZS.d.ts → XRHandPose-CU3vNFuS.d.ts} +218 -2
  5. package/dist/{chunk-4AK4I3QF.js → chunk-BUEKOSR5.js} +102 -3
  6. package/dist/chunk-BUEKOSR5.js.map +1 -0
  7. package/dist/{chunk-SGADE55G.js → chunk-RXBLOO5B.js} +249 -90
  8. package/dist/chunk-RXBLOO5B.js.map +1 -0
  9. package/dist/{chunk-4DWZK4X4.js → chunk-WJD2VKEH.js} +3 -3
  10. package/dist/chunk-WJD2VKEH.js.map +1 -0
  11. package/dist/{chunk-QIIWFP3V.js → chunk-Z6ZMR3CS.js} +1189 -18
  12. package/dist/chunk-Z6ZMR3CS.js.map +1 -0
  13. package/dist/components/index.cjs +99 -0
  14. package/dist/components/index.cjs.map +1 -1
  15. package/dist/components/index.d.cts +3 -3
  16. package/dist/components/index.d.ts +3 -3
  17. package/dist/components/index.js +1 -1
  18. package/dist/docs/guide/spatial-ui.md +165 -0
  19. package/dist/factories/index.cjs +372 -114
  20. package/dist/factories/index.cjs.map +1 -1
  21. package/dist/factories/index.d.cts +13 -3
  22. package/dist/factories/index.d.ts +13 -3
  23. package/dist/factories/index.js +3 -3
  24. package/dist/{index-CuGqxuk-.d.ts → index-C3ik3sA1.d.ts} +16 -3
  25. package/dist/{index-BphKHaV-.d.ts → index-CppKDxbu.d.ts} +145 -3
  26. package/dist/{index-CQsHUez3.d.cts → index-Dlr-7-4m.d.cts} +16 -3
  27. package/dist/{index-CH7qMsQT.d.cts → index-I2IsocYW.d.cts} +145 -3
  28. package/dist/index.cjs +1798 -360
  29. package/dist/index.cjs.map +1 -1
  30. package/dist/index.d.cts +42 -9
  31. package/dist/index.d.ts +42 -9
  32. package/dist/index.js +10 -8
  33. package/dist/index.js.map +1 -1
  34. package/dist/systems/index.cjs +1280 -13
  35. package/dist/systems/index.cjs.map +1 -1
  36. package/dist/systems/index.d.cts +2 -2
  37. package/dist/systems/index.d.ts +2 -2
  38. package/dist/systems/index.js +3 -3
  39. package/dist/types.d.cts +1 -1
  40. package/dist/types.d.ts +1 -1
  41. package/dist/worldSnapshotUtils.d.cts +1 -1
  42. package/dist/worldSnapshotUtils.d.ts +1 -1
  43. package/package.json +1 -1
  44. package/dist/chunk-4AK4I3QF.js.map +0 -1
  45. package/dist/chunk-4DWZK4X4.js.map +0 -1
  46. package/dist/chunk-QIIWFP3V.js.map +0 -1
  47. package/dist/chunk-SGADE55G.js.map +0 -1
@@ -2187,6 +2187,100 @@ var _XRAnchorComponent = class _XRAnchorComponent extends Component {
2187
2187
  __name(_XRAnchorComponent, "XRAnchorComponent");
2188
2188
  var XRAnchorComponent = _XRAnchorComponent;
2189
2189
 
2190
+ // core/components/UIPanel.ts
2191
+ var _UIPanelComponent = class _UIPanelComponent extends Component {
2192
+ constructor(options) {
2193
+ super();
2194
+ this.width = options?.width ?? 0.6;
2195
+ this.height = options?.height ?? 0.4;
2196
+ this.backgroundColor = options?.backgroundColor ?? "#1c1c1c";
2197
+ this.backgroundOpacity = options?.backgroundOpacity ?? 0.92;
2198
+ this.cornerRadius = options?.cornerRadius ?? 0.02;
2199
+ this.borderWidth = options?.borderWidth ?? 0;
2200
+ this.borderColor = options?.borderColor ?? "#333333";
2201
+ this.pixelDensity = options?.pixelDensity ?? 1024;
2202
+ this.follow = options?.follow ?? "world";
2203
+ }
2204
+ };
2205
+ __name(_UIPanelComponent, "UIPanelComponent");
2206
+ var UIPanelComponent = _UIPanelComponent;
2207
+
2208
+ // core/components/UIText.ts
2209
+ var _UITextComponent = class _UITextComponent extends Component {
2210
+ constructor(options) {
2211
+ super();
2212
+ this.text = options?.text ?? "Text";
2213
+ this.width = options?.width ?? 0.5;
2214
+ this.height = options?.height ?? 0.12;
2215
+ this.fontSize = options?.fontSize ?? 0.035;
2216
+ this.fontWeight = options?.fontWeight ?? 500;
2217
+ this.color = options?.color ?? "#ffffff";
2218
+ this.align = options?.align ?? "center";
2219
+ this.valign = options?.valign ?? "middle";
2220
+ this.lineHeight = options?.lineHeight ?? 1.25;
2221
+ this.padding = options?.padding ?? 0.01;
2222
+ }
2223
+ };
2224
+ __name(_UITextComponent, "UITextComponent");
2225
+ var UITextComponent = _UITextComponent;
2226
+
2227
+ // core/components/UIButton.ts
2228
+ var _UIButtonComponent = class _UIButtonComponent extends Component {
2229
+ constructor(options) {
2230
+ super();
2231
+ this.label = options?.label ?? "Button";
2232
+ this.actionId = options?.actionId ?? "";
2233
+ this.width = options?.width ?? 0.3;
2234
+ this.height = options?.height ?? 0.1;
2235
+ this.fontSize = options?.fontSize ?? 0.03;
2236
+ this.fontWeight = options?.fontWeight ?? 600;
2237
+ this.textColor = options?.textColor ?? "#ffffff";
2238
+ this.backgroundColor = options?.backgroundColor ?? "#2a2a2a";
2239
+ this.backgroundOpacity = options?.backgroundOpacity ?? 1;
2240
+ this.cornerRadius = options?.cornerRadius ?? 0.02;
2241
+ this.borderWidth = options?.borderWidth ?? 2e-3;
2242
+ this.borderColor = options?.borderColor ?? "#3a3a3a";
2243
+ }
2244
+ };
2245
+ __name(_UIButtonComponent, "UIButtonComponent");
2246
+ var UIButtonComponent = _UIButtonComponent;
2247
+
2248
+ // core/components/UIToggle.ts
2249
+ var _UIToggleComponent = class _UIToggleComponent extends Component {
2250
+ constructor(options) {
2251
+ super();
2252
+ this.value = options?.value ?? false;
2253
+ this.actionId = options?.actionId ?? "";
2254
+ this.width = options?.width ?? 0.14;
2255
+ this.height = options?.height ?? 0.07;
2256
+ this.trackColor = options?.trackColor ?? "#2a2a2a";
2257
+ this.accentColor = options?.accentColor ?? "#ff6b35";
2258
+ this.knobColor = options?.knobColor ?? "#ffffff";
2259
+ }
2260
+ };
2261
+ __name(_UIToggleComponent, "UIToggleComponent");
2262
+ var UIToggleComponent = _UIToggleComponent;
2263
+
2264
+ // core/components/UISlider.ts
2265
+ var _UISliderComponent = class _UISliderComponent extends Component {
2266
+ constructor(options) {
2267
+ super();
2268
+ this.min = options?.min ?? 0;
2269
+ this.max = options?.max ?? Math.max(1, this.min + 1);
2270
+ if (this.max <= this.min) this.max = this.min + 1;
2271
+ const v = options?.value ?? (this.min + this.max) / 2;
2272
+ this.value = Math.min(this.max, Math.max(this.min, v));
2273
+ this.actionId = options?.actionId ?? "";
2274
+ this.width = options?.width ?? 0.4;
2275
+ this.height = options?.height ?? 0.06;
2276
+ this.trackColor = options?.trackColor ?? "#2a2a2a";
2277
+ this.accentColor = options?.accentColor ?? "#ff6b35";
2278
+ this.knobColor = options?.knobColor ?? "#ffffff";
2279
+ }
2280
+ };
2281
+ __name(_UISliderComponent, "UISliderComponent");
2282
+ var UISliderComponent = _UISliderComponent;
2283
+
2190
2284
  // core/components/XRInteractor.ts
2191
2285
  var _XRInteractorComponent = class _XRInteractorComponent extends Component {
2192
2286
  constructor(options) {
@@ -5288,6 +5382,11 @@ function buildComponentValueMaps(snapshot) {
5288
5382
  "XRHandPoseComponent"
5289
5383
  ),
5290
5384
  xrAnchorValues: readByComponent(snapshot.components, "XRAnchorComponent"),
5385
+ uiPanelValues: readByComponent(snapshot.components, "UIPanelComponent"),
5386
+ uiTextValues: readByComponent(snapshot.components, "UITextComponent"),
5387
+ uiButtonValues: readByComponent(snapshot.components, "UIButtonComponent"),
5388
+ uiToggleValues: readByComponent(snapshot.components, "UIToggleComponent"),
5389
+ uiSliderValues: readByComponent(snapshot.components, "UISliderComponent"),
5291
5390
  xrInteractorSideValues: readByComponent(
5292
5391
  snapshot.components,
5293
5392
  "XRInteractorSideComponent"
@@ -5609,6 +5708,49 @@ function restorePostCreateComponents(world, objectManagementSystem, targetEntity
5609
5708
  }
5610
5709
  }
5611
5710
  }
5711
+ {
5712
+ const restoreUi = /* @__PURE__ */ __name((map, Ctor, existing) => {
5713
+ const source = asRecord3(map.get(sourceEntityId));
5714
+ if (!source) return;
5715
+ let component = existing;
5716
+ if (!component) {
5717
+ component = new Ctor();
5718
+ world.addComponent(targetEntityId, component);
5719
+ }
5720
+ const target = component;
5721
+ for (const [key, value] of Object.entries(source)) {
5722
+ const current = target[key];
5723
+ if ((typeof value === "string" || typeof value === "number" || typeof value === "boolean") && typeof current === typeof value) {
5724
+ target[key] = value;
5725
+ }
5726
+ }
5727
+ }, "restoreUi");
5728
+ restoreUi(
5729
+ values.uiPanelValues,
5730
+ UIPanelComponent,
5731
+ world.getComponent(targetEntityId, UIPanelComponent)
5732
+ );
5733
+ restoreUi(
5734
+ values.uiTextValues,
5735
+ UITextComponent,
5736
+ world.getComponent(targetEntityId, UITextComponent)
5737
+ );
5738
+ restoreUi(
5739
+ values.uiButtonValues,
5740
+ UIButtonComponent,
5741
+ world.getComponent(targetEntityId, UIButtonComponent)
5742
+ );
5743
+ restoreUi(
5744
+ values.uiToggleValues,
5745
+ UIToggleComponent,
5746
+ world.getComponent(targetEntityId, UIToggleComponent)
5747
+ );
5748
+ restoreUi(
5749
+ values.uiSliderValues,
5750
+ UISliderComponent,
5751
+ world.getComponent(targetEntityId, UISliderComponent)
5752
+ );
5753
+ }
5612
5754
  {
5613
5755
  const sourceInteractor = asRecord3(
5614
5756
  values.xrInteractorValues.get(sourceEntityId)
@@ -7031,6 +7173,23 @@ var _Behaviour = class _Behaviour {
7031
7173
  /** Called when this entity is released. Optional XR hook. */
7032
7174
  onRelease(input) {
7033
7175
  }
7176
+ /** A UI button on this entity (or a descendant) was clicked. Clicks fire on
7177
+ * RELEASE while still pointing at the widget — sliding off cancels. */
7178
+ onUiPress(_event) {
7179
+ }
7180
+ /** A pointer began hovering a UI widget on this entity (or a descendant). */
7181
+ onUiHoverStart(_event) {
7182
+ }
7183
+ /** The pointer left the widget. */
7184
+ onUiHoverEnd(_event) {
7185
+ }
7186
+ /** A UI toggle flipped; `event.value` is the new boolean. */
7187
+ onUiToggle(_event) {
7188
+ }
7189
+ /** A UI slider moved; `event.value` is the new number. Fires on real value
7190
+ * changes during the drag and once more on release. */
7191
+ onUiChange(_event) {
7192
+ }
7034
7193
  };
7035
7194
  __name(_Behaviour, "Behaviour");
7036
7195
  /**
@@ -8561,6 +8720,7 @@ var _XRInteractionSystem = class _XRInteractionSystem {
8561
8720
  this.grabs = /* @__PURE__ */ new Map();
8562
8721
  this.hoveredEntityId = null;
8563
8722
  /** Per-hand hover, with the hit distance so the ray can stop at the surface. */
8723
+ this.busyContributors = [];
8564
8724
  this.hovers = /* @__PURE__ */ new Map();
8565
8725
  /** Monotonic seconds, accumulated from deltaTime for velocity sampling. */
8566
8726
  this.elapsed = 0;
@@ -8569,6 +8729,8 @@ var _XRInteractionSystem = class _XRInteractionSystem {
8569
8729
  update(world, deltaTime) {
8570
8730
  if (!world.isXrPresenting()) {
8571
8731
  this.releaseAll(world);
8732
+ this.hovers.clear();
8733
+ this.hoveredEntityId = null;
8572
8734
  return;
8573
8735
  }
8574
8736
  this.elapsed += Math.max(0, deltaTime);
@@ -9191,13 +9353,30 @@ var _XRInteractionSystem = class _XRInteractionSystem {
9191
9353
  */
9192
9354
  isSourceBusy(world, input) {
9193
9355
  if (this.grabs.has(input)) return true;
9356
+ for (const contributor of this.busyContributors) {
9357
+ try {
9358
+ if (contributor(world, input)) return true;
9359
+ } catch {
9360
+ }
9361
+ }
9194
9362
  const hover = this.hovers.get(input);
9195
9363
  if (!hover) return false;
9196
9364
  const interactable = world.getComponent(
9197
9365
  hover.entityId,
9198
9366
  XRInteractableComponent
9199
9367
  );
9200
- return Boolean(interactable?.enabled && interactable.grabbable);
9368
+ return Boolean(
9369
+ interactable?.enabled && (interactable.grabbable || interactable.pointable)
9370
+ );
9371
+ }
9372
+ /** OR-ed into isSourceBusy; see the comment there. */
9373
+ registerBusyContributor(contributor) {
9374
+ this.busyContributors.push(contributor);
9375
+ }
9376
+ /** Hover for a specific input source (handedness-free — Vision Pro's
9377
+ * transient pointers all report handedness "none"). */
9378
+ getHoverForInput(input) {
9379
+ return this.hovers.get(input) ?? null;
9201
9380
  }
9202
9381
  /** True when THIS hand is holding something. */
9203
9382
  isGrabbingWith(handedness) {
@@ -9819,6 +9998,88 @@ function decomposeXrPose(matrixElements, target) {
9819
9998
  }
9820
9999
  __name(decomposeXrPose, "decomposeXrPose");
9821
10000
 
10001
+ // core/xr/poseStability.ts
10002
+ var GLUED_REF_SPEED = 0.12;
10003
+ var STILL_SPEED = 0.05;
10004
+ var GLUED_FRAMES_TO_FREEZE = 3;
10005
+ var STATIC_FRAMES_TO_RECOVER = 10;
10006
+ var SNAP_SPEED = 3;
10007
+ var dist = /* @__PURE__ */ __name((a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z), "dist");
10008
+ var _SurfacePoseGate = class _SurfacePoseGate {
10009
+ constructor() {
10010
+ this.prevRef = null;
10011
+ this.prevView = null;
10012
+ this.gluedStreak = 0;
10013
+ this.staticStreak = 0;
10014
+ this.suspect = false;
10015
+ this.deltas = 0;
10016
+ }
10017
+ /** True while poses are being rejected as head-glued. */
10018
+ get frozen() {
10019
+ return this.suspect;
10020
+ }
10021
+ /**
10022
+ * True once enough motion has been observed to classify the pose at all.
10023
+ * Surface CREATION waits for this: an entity spawned from a glued pose
10024
+ * materializes wherever the head happens to be — a rotated ghost copy of
10025
+ * the room — and freezing it there is barely better than letting it follow.
10026
+ */
10027
+ get warmedUp() {
10028
+ return this.deltas >= GLUED_FRAMES_TO_FREEZE;
10029
+ }
10030
+ /**
10031
+ * Feed this frame's surface position in reference space and in view
10032
+ * (viewer-relative) space. Returns true when the pose should be APPLIED,
10033
+ * false when it should be frozen out.
10034
+ *
10035
+ * `viewPos` may be null when the viewer pose is unavailable (older
10036
+ * runtimes) — the gate then passes everything through, degrading to the
10037
+ * flag-only behavior.
10038
+ */
10039
+ update(refPos, viewPos, deltaTime) {
10040
+ if (!viewPos || deltaTime <= 0) {
10041
+ this.prevRef = { ...refPos };
10042
+ this.prevView = viewPos ? { ...viewPos } : null;
10043
+ return !this.suspect;
10044
+ }
10045
+ const prevRef = this.prevRef;
10046
+ const prevView = this.prevView;
10047
+ this.prevRef = { ...refPos };
10048
+ this.prevView = { ...viewPos };
10049
+ if (!prevRef || !prevView) return !this.suspect;
10050
+ this.deltas += 1;
10051
+ const refSpeed = dist(refPos, prevRef) / deltaTime;
10052
+ const viewSpeed = dist(viewPos, prevView) / deltaTime;
10053
+ const glued = refSpeed > GLUED_REF_SPEED && viewSpeed < STILL_SPEED;
10054
+ const worldStatic = refSpeed < STILL_SPEED && viewSpeed > GLUED_REF_SPEED;
10055
+ if (this.suspect) {
10056
+ if (refSpeed > SNAP_SPEED && viewSpeed > SNAP_SPEED) {
10057
+ this.suspect = false;
10058
+ this.gluedStreak = 0;
10059
+ this.staticStreak = 0;
10060
+ return true;
10061
+ }
10062
+ this.staticStreak = worldStatic ? this.staticStreak + 1 : 0;
10063
+ if (this.staticStreak >= STATIC_FRAMES_TO_RECOVER) {
10064
+ this.suspect = false;
10065
+ this.gluedStreak = 0;
10066
+ this.staticStreak = 0;
10067
+ return true;
10068
+ }
10069
+ return false;
10070
+ }
10071
+ this.gluedStreak = glued ? this.gluedStreak + 1 : 0;
10072
+ if (this.gluedStreak >= GLUED_FRAMES_TO_FREEZE) {
10073
+ this.suspect = true;
10074
+ this.staticStreak = 0;
10075
+ return false;
10076
+ }
10077
+ return true;
10078
+ }
10079
+ };
10080
+ __name(_SurfacePoseGate, "SurfacePoseGate");
10081
+ var SurfacePoseGate = _SurfacePoseGate;
10082
+
9822
10083
  // core/systems/XREnvironmentSystem.ts
9823
10084
  var DEBUG_COLOR = 5088255;
9824
10085
  function debugColorFor(kind, semanticLabel) {
@@ -9862,12 +10123,26 @@ var _XREnvironmentSystem = class _XREnvironmentSystem {
9862
10123
  /** Keyed by the XRPlane/XRMesh object itself — the runtime's own identity. */
9863
10124
  this.planes = /* @__PURE__ */ new Map();
9864
10125
  this.meshes = /* @__PURE__ */ new Map();
10126
+ /**
10127
+ * Surfaces observed but not yet SPAWNED — their gate is still proving the
10128
+ * pose is world-anchored. Creating from an unflagged head-glued pose (seen
10129
+ * on-device after a suspend/resume regenerated the scan identities) plants
10130
+ * a rotated ghost copy of the room wherever the head happens to be, which
10131
+ * the apply-gate then freezes there — wrong forever. So admission itself
10132
+ * is gated: a few frames of world-anchored behavior first.
10133
+ */
10134
+ this.pendingPlanes = /* @__PURE__ */ new Map();
10135
+ this.pendingMeshes = /* @__PURE__ */ new Map();
9865
10136
  this.presenting = false;
9866
10137
  this.debugVisible = false;
9867
10138
  /** Seconds presenting, used to decide the room has genuinely nothing in it. */
9868
10139
  this.elapsed = 0;
9869
10140
  /** `initiateRoomCapture` may be called only ONCE per session. */
9870
10141
  this.roomCaptureRequested = false;
10142
+ /** This frame's inverse viewer matrix, or null when unavailable. */
10143
+ this.viewerInverse = null;
10144
+ this.tmpViewerMatrix = new THREE26__namespace.Matrix4();
10145
+ this.tmpViewPos = new THREE26__namespace.Vector3();
9871
10146
  }
9872
10147
  update(world, deltaTime = 0) {
9873
10148
  const presenting = world.isXrPresenting();
@@ -9905,13 +10180,15 @@ var _XREnvironmentSystem = class _XREnvironmentSystem {
9905
10180
  const meshesAvailable = Boolean(frame.detectedMeshes);
9906
10181
  const useMesh = (mode === "mesh" || mode === "auto") && meshesAvailable;
9907
10182
  const usePlanes = mode === "planes" || mode === "auto" && !useMesh;
10183
+ const viewer = frame.getViewerPose?.(referenceSpace);
10184
+ this.viewerInverse = viewer ? this.tmpViewerMatrix.fromArray(viewer.transform.matrix).invert() : null;
9908
10185
  if (usePlanes) {
9909
- this.syncPlanes(world, frame, referenceSpace);
10186
+ this.syncPlanes(world, frame, referenceSpace, deltaTime);
9910
10187
  } else if (this.planes.size > 0) {
9911
10188
  this.clearMap(world, this.planes);
9912
10189
  }
9913
10190
  if (useMesh) {
9914
- this.syncMeshes(world, frame, referenceSpace);
10191
+ this.syncMeshes(world, frame, referenceSpace, deltaTime);
9915
10192
  } else if (this.meshes.size > 0) {
9916
10193
  this.clearMap(world, this.meshes);
9917
10194
  }
@@ -9998,7 +10275,7 @@ var _XREnvironmentSystem = class _XREnvironmentSystem {
9998
10275
  if (ids.length === 0) return null;
9999
10276
  return world.getComponent(ids[0], XRRigComponent) ?? null;
10000
10277
  }
10001
- syncPlanes(world, frame, referenceSpace) {
10278
+ syncPlanes(world, frame, referenceSpace, deltaTime) {
10002
10279
  const detected = frame.detectedPlanes;
10003
10280
  if (!detected) return;
10004
10281
  for (const [plane, entry] of this.planes) {
@@ -10007,6 +10284,9 @@ var _XREnvironmentSystem = class _XREnvironmentSystem {
10007
10284
  this.planes.delete(plane);
10008
10285
  }
10009
10286
  }
10287
+ for (const plane of this.pendingPlanes.keys()) {
10288
+ if (!detected.has(plane)) this.pendingPlanes.delete(plane);
10289
+ }
10010
10290
  for (const plane of detected) {
10011
10291
  const pose = frame.getPose(plane.planeSpace, referenceSpace);
10012
10292
  if (!pose) continue;
@@ -10015,8 +10295,11 @@ var _XREnvironmentSystem = class _XREnvironmentSystem {
10015
10295
  const changed = plane.lastChangedTime ?? 0;
10016
10296
  if (!entry) {
10017
10297
  if (emulated) continue;
10298
+ const gate = this.admit(this.pendingPlanes, plane, pose, deltaTime);
10299
+ if (!gate) continue;
10018
10300
  const created = this.createPlaneEntity(world, plane);
10019
10301
  if (!created) continue;
10302
+ created.gate = gate;
10020
10303
  entry = created;
10021
10304
  this.planes.set(plane, entry);
10022
10305
  } else if (changed !== entry.lastChangedTime) {
@@ -10036,11 +10319,12 @@ var _XREnvironmentSystem = class _XREnvironmentSystem {
10036
10319
  }
10037
10320
  }
10038
10321
  }
10039
- entry.emulated = emulated;
10040
- if (!emulated) this.applyPose(world, entry.entityId, pose);
10322
+ const allowed = !emulated && this.gateAllows(entry.gate, pose, deltaTime);
10323
+ entry.emulated = emulated || entry.gate.frozen;
10324
+ if (allowed) this.applyPose(world, entry.entityId, pose);
10041
10325
  }
10042
10326
  }
10043
- syncMeshes(world, frame, referenceSpace) {
10327
+ syncMeshes(world, frame, referenceSpace, deltaTime) {
10044
10328
  const detected = frame.detectedMeshes;
10045
10329
  if (!detected) return;
10046
10330
  for (const [mesh, entry] of this.meshes) {
@@ -10049,6 +10333,9 @@ var _XREnvironmentSystem = class _XREnvironmentSystem {
10049
10333
  this.meshes.delete(mesh);
10050
10334
  }
10051
10335
  }
10336
+ for (const mesh of this.pendingMeshes.keys()) {
10337
+ if (!detected.has(mesh)) this.pendingMeshes.delete(mesh);
10338
+ }
10052
10339
  for (const mesh of detected) {
10053
10340
  const pose = frame.getPose(mesh.meshSpace, referenceSpace);
10054
10341
  if (!pose) continue;
@@ -10057,18 +10344,57 @@ var _XREnvironmentSystem = class _XREnvironmentSystem {
10057
10344
  const changed = mesh.lastChangedTime ?? 0;
10058
10345
  if (!entry) {
10059
10346
  if (emulated) continue;
10347
+ const gate = this.admit(this.pendingMeshes, mesh, pose, deltaTime);
10348
+ if (!gate) continue;
10060
10349
  const created = this.createMeshEntity(world, mesh);
10061
10350
  if (!created) continue;
10351
+ created.gate = gate;
10062
10352
  entry = created;
10063
10353
  this.meshes.set(mesh, entry);
10064
10354
  } else if (changed !== entry.lastChangedTime) {
10065
10355
  entry.lastChangedTime = changed;
10066
10356
  writeXrMeshGeometry(mesh.vertices, mesh.indices, entry.geometry);
10067
10357
  }
10068
- entry.emulated = emulated;
10069
- if (!emulated) this.applyPose(world, entry.entityId, pose);
10358
+ const allowed = !emulated && this.gateAllows(entry.gate, pose, deltaTime);
10359
+ entry.emulated = emulated || entry.gate.frozen;
10360
+ if (allowed) this.applyPose(world, entry.entityId, pose);
10070
10361
  }
10071
10362
  }
10363
+ /**
10364
+ * Admission control for a NOT-yet-spawned surface. With a viewer pose
10365
+ * available, the surface must behave world-anchored for a few frames before
10366
+ * an entity is created for it; without one (older runtimes, tests) it is
10367
+ * admitted immediately — the gate can't classify anyway. Returns the
10368
+ * warmed-up gate (carried into the entry so its motion history survives),
10369
+ * or null while the surface must keep waiting.
10370
+ */
10371
+ admit(pending, key, pose, deltaTime) {
10372
+ if (!this.viewerInverse) return new SurfacePoseGate();
10373
+ let gate = pending.get(key);
10374
+ if (!gate) {
10375
+ gate = new SurfacePoseGate();
10376
+ pending.set(key, gate);
10377
+ }
10378
+ const allowed = this.gateAllows(gate, pose, deltaTime);
10379
+ if (!allowed || !gate.warmedUp) return null;
10380
+ pending.delete(key);
10381
+ return gate;
10382
+ }
10383
+ /**
10384
+ * Run a surface's pose through its head-glue gate: position in reference
10385
+ * space vs position relative to the viewer. Without a viewer pose the gate
10386
+ * passes everything, degrading to the flag-only behavior.
10387
+ */
10388
+ gateAllows(gate, pose, deltaTime) {
10389
+ const m = pose.transform.matrix;
10390
+ const refPos = { x: m[12], y: m[13], z: m[14] };
10391
+ let viewPos = null;
10392
+ if (this.viewerInverse) {
10393
+ const v = this.tmpViewPos.set(refPos.x, refPos.y, refPos.z).applyMatrix4(this.viewerInverse);
10394
+ viewPos = { x: v.x, y: v.y, z: v.z };
10395
+ }
10396
+ return gate.update(refPos, viewPos, deltaTime);
10397
+ }
10072
10398
  createPlaneEntity(world, plane) {
10073
10399
  const collider = planeColliderFromPolygon(plane.polygon);
10074
10400
  if (!collider) return null;
@@ -10087,7 +10413,8 @@ var _XREnvironmentSystem = class _XREnvironmentSystem {
10087
10413
  lastChangedTime: plane.lastChangedTime ?? 0,
10088
10414
  geometry: null,
10089
10415
  material: null,
10090
- emulated: false
10416
+ emulated: false,
10417
+ gate: new SurfacePoseGate()
10091
10418
  };
10092
10419
  if (this.debugVisible) {
10093
10420
  this.attachPlaneDebug(world, entry, collider, plane.semanticLabel ?? null);
@@ -10160,7 +10487,8 @@ var _XREnvironmentSystem = class _XREnvironmentSystem {
10160
10487
  lastChangedTime: mesh.lastChangedTime ?? 0,
10161
10488
  geometry,
10162
10489
  material,
10163
- emulated: false
10490
+ emulated: false,
10491
+ gate: new SurfacePoseGate()
10164
10492
  };
10165
10493
  }
10166
10494
  /** A thin box so a detected plane can be SEEN, not just collided with. */
@@ -10241,6 +10569,8 @@ var _XREnvironmentSystem = class _XREnvironmentSystem {
10241
10569
  clearAll(world) {
10242
10570
  this.clearMap(world, this.planes);
10243
10571
  this.clearMap(world, this.meshes);
10572
+ this.pendingPlanes.clear();
10573
+ this.pendingMeshes.clear();
10244
10574
  }
10245
10575
  dispose(world) {
10246
10576
  this.clearAll(world);
@@ -10252,6 +10582,918 @@ function normalizeOrientation(value) {
10252
10582
  return value === "horizontal" || value === "vertical" ? value : null;
10253
10583
  }
10254
10584
  __name(normalizeOrientation, "normalizeOrientation");
10585
+ var MOUSE_CLICK_TRAVEL_PX = 6;
10586
+ var PRESS_TINT_MIN_SECONDS = 0.1;
10587
+ var PRESS_TINT = 0.72;
10588
+ var HOVER_TINT = 0.9;
10589
+ var _UIInteractionSystem = class _UIInteractionSystem {
10590
+ /**
10591
+ * @param interaction The XR interaction system, or null in a world with no
10592
+ * XR (flat 3D experiences) — the mouse path still works via
10593
+ * `options.inputSystem`.
10594
+ */
10595
+ constructor(interaction, options) {
10596
+ this.xrPresses = /* @__PURE__ */ new Map();
10597
+ this.mousePress = null;
10598
+ this.mouseHover = null;
10599
+ /** entityId → seconds of pressed-tint left to display. */
10600
+ this.tintFloors = /* @__PURE__ */ new Map();
10601
+ this.tinted = /* @__PURE__ */ new Set();
10602
+ this.raycaster = new THREE26__namespace.Raycaster();
10603
+ this.interaction = interaction;
10604
+ this.inputSystem = options?.inputSystem ?? null;
10605
+ interaction?.registerBusyContributor(
10606
+ (_world, input) => this.xrPresses.has(input)
10607
+ );
10608
+ }
10609
+ update(world, deltaTime = 0) {
10610
+ if (world.isEditorMode()) return;
10611
+ if (world.isXrPresenting()) {
10612
+ this.updateXr(world);
10613
+ } else {
10614
+ this.updateMouse(world);
10615
+ }
10616
+ this.updateTints(world, deltaTime);
10617
+ }
10618
+ // ── XR path ────────────────────────────────────────────────────────────────
10619
+ updateXr(world) {
10620
+ const interaction = this.interaction;
10621
+ if (!interaction) return;
10622
+ const xrInput = interaction.inputSystem;
10623
+ const inputs = xrInput.getInputs();
10624
+ for (const input of inputs) {
10625
+ const hover = interaction.getHoverForInput(input);
10626
+ const hovered = hover ? this.widgetKind(world, hover.entityId) : null;
10627
+ if (input.selectStart && hover && hovered) {
10628
+ this.xrPresses.set(input, {
10629
+ entityId: hover.entityId,
10630
+ kind: hovered,
10631
+ travelPx: 0
10632
+ });
10633
+ this.beginPressTint(world, hover.entityId);
10634
+ }
10635
+ const press = this.xrPresses.get(input);
10636
+ if (press && press.kind === "slider") {
10637
+ this.dragSlider(world, press.entityId, input);
10638
+ }
10639
+ if (input.selectEnd || !input.connected) {
10640
+ if (press) {
10641
+ this.xrPresses.delete(input);
10642
+ const stillOn = hover?.entityId === press.entityId || // Vision Pro: the transient source is gone from the hover map on
10643
+ // its release frame — confirm against the last latched ray.
10644
+ this.rayHitsWidget(world, press.entityId, input);
10645
+ if (stillOn || press.kind === "slider") {
10646
+ this.fire(world, press, input);
10647
+ }
10648
+ this.endPressTint(press.entityId);
10649
+ }
10650
+ }
10651
+ }
10652
+ for (const [input] of this.xrPresses) {
10653
+ if (!inputs.includes(input)) this.xrPresses.delete(input);
10654
+ }
10655
+ }
10656
+ // ── Mouse path (flat preview) ──────────────────────────────────────────────
10657
+ updateMouse(world) {
10658
+ const input = this.inputSystem;
10659
+ if (!input) return;
10660
+ const hit = this.raycastMouse(world);
10661
+ const hovered = hit && this.widgetKind(world, hit) ? hit : null;
10662
+ if (hovered !== this.mouseHover) {
10663
+ if (this.mouseHover !== null) {
10664
+ this.setTint(world, this.mouseHover, 1);
10665
+ this.dispatchBubbling(
10666
+ world,
10667
+ this.mouseHover,
10668
+ (b, e) => b.onUiHoverEnd(e)
10669
+ );
10670
+ }
10671
+ if (hovered !== null) {
10672
+ this.setTint(world, hovered, HOVER_TINT);
10673
+ this.dispatchBubbling(world, hovered, (b, e) => b.onUiHoverStart(e));
10674
+ }
10675
+ this.mouseHover = hovered;
10676
+ }
10677
+ if (input.getMouseDown("left") && hovered) {
10678
+ const kind = this.widgetKind(world, hovered);
10679
+ if (kind) {
10680
+ this.mousePress = { entityId: hovered, kind, travelPx: 0 };
10681
+ this.beginPressTint(world, hovered);
10682
+ }
10683
+ }
10684
+ if (this.mousePress) {
10685
+ const delta = input.getMouseDelta();
10686
+ this.mousePress.travelPx += Math.hypot(delta.x, delta.y);
10687
+ if (this.mousePress.kind === "slider") {
10688
+ this.dragSliderMouse(world, this.mousePress.entityId);
10689
+ }
10690
+ }
10691
+ if (input.getMouseUp("left") && this.mousePress) {
10692
+ const press = this.mousePress;
10693
+ this.mousePress = null;
10694
+ const stillOn = hovered === press.entityId;
10695
+ const withinTravel = press.travelPx <= MOUSE_CLICK_TRAVEL_PX;
10696
+ if (press.kind === "slider" || stillOn && withinTravel) {
10697
+ this.fire(world, press, null);
10698
+ }
10699
+ this.endPressTint(press.entityId);
10700
+ }
10701
+ }
10702
+ raycastMouse(world) {
10703
+ const input = this.inputSystem;
10704
+ if (!input) return null;
10705
+ const dom = world.getRenderer().domElement;
10706
+ const rect = dom.getBoundingClientRect();
10707
+ let ndcX = 0;
10708
+ let ndcY = 0;
10709
+ if (input.isMousePointerLocked()) {
10710
+ ndcX = 0;
10711
+ ndcY = 0;
10712
+ } else {
10713
+ const pos = input.getMousePosition();
10714
+ ndcX = (pos.x - rect.left) / Math.max(1, rect.width) * 2 - 1;
10715
+ ndcY = -((pos.y - rect.top) / Math.max(1, rect.height) * 2 - 1);
10716
+ if (ndcX < -1 || ndcX > 1 || ndcY < -1 || ndcY > 1) return null;
10717
+ }
10718
+ this.raycaster.setFromCamera(new THREE26__namespace.Vector2(ndcX, ndcY), world.getCamera());
10719
+ const hits = world.raycast(
10720
+ this.raycaster.ray.origin,
10721
+ this.raycaster.ray.direction
10722
+ );
10723
+ for (const hit of hits) {
10724
+ if (typeof hit.entityId !== "number") continue;
10725
+ if (this.widgetKind(world, hit.entityId)) return hit.entityId;
10726
+ return null;
10727
+ }
10728
+ return null;
10729
+ }
10730
+ // ── Shared machinery ───────────────────────────────────────────────────────
10731
+ widgetKind(world, entityId) {
10732
+ if (world.getComponent(entityId, UIButtonComponent)) return "button";
10733
+ if (world.getComponent(entityId, UIToggleComponent)) return "toggle";
10734
+ if (world.getComponent(entityId, UISliderComponent)) return "slider";
10735
+ return null;
10736
+ }
10737
+ fire(world, press, input) {
10738
+ if (press.kind === "button") {
10739
+ this.dispatchBubbling(world, press.entityId, (b, e) => b.onUiPress(e), input);
10740
+ return;
10741
+ }
10742
+ if (press.kind === "toggle") {
10743
+ const toggle = world.getComponent(press.entityId, UIToggleComponent);
10744
+ if (!toggle) return;
10745
+ toggle.value = !toggle.value;
10746
+ this.dispatchBubbling(
10747
+ world,
10748
+ press.entityId,
10749
+ (b, e) => b.onUiToggle(e),
10750
+ input,
10751
+ toggle.value
10752
+ );
10753
+ return;
10754
+ }
10755
+ const slider = world.getComponent(press.entityId, UISliderComponent);
10756
+ if (!slider) return;
10757
+ this.dispatchBubbling(
10758
+ world,
10759
+ press.entityId,
10760
+ (b, e) => b.onUiChange(e),
10761
+ input,
10762
+ slider.value
10763
+ );
10764
+ }
10765
+ dragSlider(world, entityId, input) {
10766
+ const point = this.rayPointOnWidget(world, entityId, input);
10767
+ if (point) this.applySliderPoint(world, entityId, point, input);
10768
+ }
10769
+ dragSliderMouse(world, entityId) {
10770
+ const object = this.widgetObject(world, entityId);
10771
+ if (!object) return;
10772
+ const hits = this.raycaster.intersectObject(object, true);
10773
+ if (hits.length > 0) this.applySliderPoint(world, entityId, hits[0].point, null);
10774
+ }
10775
+ applySliderPoint(world, entityId, worldPoint, input) {
10776
+ const slider = world.getComponent(entityId, UISliderComponent);
10777
+ const object = this.widgetObject(world, entityId);
10778
+ if (!slider || !object) return;
10779
+ const local = object.worldToLocal(worldPoint.clone());
10780
+ const inset = slider.height / 2;
10781
+ const usable = Math.max(1e-3, slider.width - inset * 2);
10782
+ const t = Math.min(1, Math.max(0, local.x / usable + 0.5));
10783
+ const next = slider.min + t * (slider.max - slider.min);
10784
+ if (Math.abs(next - slider.value) < 1e-4) return;
10785
+ slider.value = next;
10786
+ this.dispatchBubbling(
10787
+ world,
10788
+ entityId,
10789
+ (b, e) => b.onUiChange(e),
10790
+ input,
10791
+ next
10792
+ );
10793
+ }
10794
+ widgetObject(world, entityId) {
10795
+ return world.getComponent(entityId, Object3DRef)?.object;
10796
+ }
10797
+ rayHitsWidget(world, entityId, input) {
10798
+ const object = this.widgetObject(world, entityId);
10799
+ if (!object) return false;
10800
+ this.raycaster.set(
10801
+ input.rayOrigin.clone(),
10802
+ input.rayDirection.clone().normalize()
10803
+ );
10804
+ return this.raycaster.intersectObject(object, true).length > 0;
10805
+ }
10806
+ rayPointOnWidget(world, entityId, input) {
10807
+ const object = this.widgetObject(world, entityId);
10808
+ if (!object) return null;
10809
+ this.raycaster.set(
10810
+ input.rayOrigin.clone(),
10811
+ input.rayDirection.clone().normalize()
10812
+ );
10813
+ const hits = this.raycaster.intersectObject(object, true);
10814
+ return hits.length > 0 ? hits[0].point : null;
10815
+ }
10816
+ /**
10817
+ * Dispatch to the widget's Behaviours, then BUBBLE up the parent chain so a
10818
+ * panel script hears its children. Bubbling (not a world broadcast) keeps
10819
+ * two spawned copies of the same menu prefab independent.
10820
+ */
10821
+ dispatchBubbling(world, entityId, visit, input = null, value) {
10822
+ const event = {
10823
+ targetEntityId: entityId,
10824
+ actionId: this.actionIdOf(world, entityId),
10825
+ input,
10826
+ ...value === void 0 ? {} : { value }
10827
+ };
10828
+ const visited = /* @__PURE__ */ new Set();
10829
+ let current = entityId;
10830
+ for (; ; ) {
10831
+ if (visited.has(current)) break;
10832
+ visited.add(current);
10833
+ const script = world.getComponent(current, ScriptComponent);
10834
+ const instances = script?.scriptInstances;
10835
+ if (instances) {
10836
+ for (const instance of instances.values()) {
10837
+ if (!(instance instanceof Behaviour)) continue;
10838
+ try {
10839
+ visit(instance, event);
10840
+ } catch (error) {
10841
+ console.error("[UIInteractionSystem] hook failed:", error);
10842
+ }
10843
+ }
10844
+ }
10845
+ const parentId = world.getComponent(current, ParentId)?.value;
10846
+ if (typeof parentId !== "number") break;
10847
+ current = parentId;
10848
+ }
10849
+ }
10850
+ actionIdOf(world, entityId) {
10851
+ const button = world.getComponent(entityId, UIButtonComponent);
10852
+ if (button?.actionId) return button.actionId;
10853
+ const toggle = world.getComponent(entityId, UIToggleComponent);
10854
+ if (toggle?.actionId) return toggle.actionId;
10855
+ const slider = world.getComponent(entityId, UISliderComponent);
10856
+ if (slider?.actionId) return slider.actionId;
10857
+ const name = world.getComponent(entityId, Name)?.value;
10858
+ return typeof name === "string" ? name : String(entityId);
10859
+ }
10860
+ // ── Pressed/hover tint ─────────────────────────────────────────────────────
10861
+ beginPressTint(world, entityId) {
10862
+ this.setTint(world, entityId, PRESS_TINT);
10863
+ this.tintFloors.set(entityId, PRESS_TINT_MIN_SECONDS);
10864
+ }
10865
+ endPressTint(entityId) {
10866
+ if (!this.tintFloors.has(entityId)) {
10867
+ this.tintFloors.set(entityId, 0);
10868
+ }
10869
+ }
10870
+ updateTints(world, deltaTime) {
10871
+ for (const [entityId, remaining] of this.tintFloors) {
10872
+ const next = remaining - deltaTime;
10873
+ const stillHeld = [...this.xrPresses.values()].some((p) => p.entityId === entityId) || this.mousePress?.entityId === entityId;
10874
+ if (stillHeld) continue;
10875
+ if (next <= 0) {
10876
+ this.tintFloors.delete(entityId);
10877
+ this.setTint(
10878
+ world,
10879
+ entityId,
10880
+ this.mouseHover === entityId ? HOVER_TINT : 1
10881
+ );
10882
+ } else {
10883
+ this.tintFloors.set(entityId, next);
10884
+ }
10885
+ }
10886
+ }
10887
+ setTint(world, entityId, scalar) {
10888
+ const object = this.widgetObject(world, entityId);
10889
+ if (!object) return;
10890
+ if (scalar === 1) this.tinted.delete(entityId);
10891
+ else this.tinted.add(entityId);
10892
+ object.traverse((child) => {
10893
+ const material = child.material;
10894
+ if (material?.color) material.color.setScalar(scalar);
10895
+ });
10896
+ }
10897
+ };
10898
+ __name(_UIInteractionSystem, "UIInteractionSystem");
10899
+ var UIInteractionSystem = _UIInteractionSystem;
10900
+
10901
+ // core/ui/uiCanvas.ts
10902
+ var DEFAULT_PIXELS_PER_METER = 1024;
10903
+ var MIN_PIXELS_PER_METER = 256;
10904
+ var MAX_PIXELS_PER_METER = 2048;
10905
+ var MAX_CANVAS_SIDE = 2048;
10906
+ function resolveCanvasSize(widthMeters, heightMeters, pixelDensity = DEFAULT_PIXELS_PER_METER) {
10907
+ const w = Math.max(0.01, widthMeters);
10908
+ const h = Math.max(0.01, heightMeters);
10909
+ const authored = Math.min(
10910
+ MAX_PIXELS_PER_METER,
10911
+ Math.max(MIN_PIXELS_PER_METER, pixelDensity)
10912
+ );
10913
+ const density = Math.min(authored, MAX_CANVAS_SIDE / Math.max(w, h));
10914
+ const even = /* @__PURE__ */ __name((v) => Math.max(2, 2 * Math.round(v * density / 2)), "even");
10915
+ return { pxWidth: even(w), pxHeight: even(h), density };
10916
+ }
10917
+ __name(resolveCanvasSize, "resolveCanvasSize");
10918
+ function wrapText(measure, text, maxWidthPx) {
10919
+ const lines = [];
10920
+ for (const paragraph of text.split("\n")) {
10921
+ const words = paragraph.split(/\s+/).filter((w) => w.length > 0);
10922
+ if (words.length === 0) {
10923
+ lines.push("");
10924
+ continue;
10925
+ }
10926
+ let line = "";
10927
+ for (const word of words) {
10928
+ const candidate = line ? `${line} ${word}` : word;
10929
+ if (measure(candidate) <= maxWidthPx) {
10930
+ line = candidate;
10931
+ continue;
10932
+ }
10933
+ if (line) {
10934
+ lines.push(line);
10935
+ line = "";
10936
+ }
10937
+ if (measure(word) <= maxWidthPx) {
10938
+ line = word;
10939
+ continue;
10940
+ }
10941
+ let chunk = "";
10942
+ for (const char of word) {
10943
+ const next = chunk + char;
10944
+ if (measure(next) <= maxWidthPx || chunk.length === 0) {
10945
+ chunk = next;
10946
+ } else {
10947
+ lines.push(chunk);
10948
+ chunk = char;
10949
+ }
10950
+ }
10951
+ line = chunk;
10952
+ }
10953
+ lines.push(line);
10954
+ }
10955
+ return lines;
10956
+ }
10957
+ __name(wrapText, "wrapText");
10958
+ var ELLIPSIS = "\u2026";
10959
+ function clampLines(measure, lines, maxLines, maxWidthPx) {
10960
+ if (maxLines <= 0) return [];
10961
+ if (lines.length <= maxLines) return lines;
10962
+ const kept = lines.slice(0, maxLines);
10963
+ let last = kept[maxLines - 1];
10964
+ while (last.length > 0 && measure(last + ELLIPSIS) > maxWidthPx) {
10965
+ last = last.slice(0, -1);
10966
+ }
10967
+ kept[maxLines - 1] = last + ELLIPSIS;
10968
+ return kept;
10969
+ }
10970
+ __name(clampLines, "clampLines");
10971
+ var UI_FONT_STACK = '-apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
10972
+ function tracePath(ctx, x, y, w, h, r) {
10973
+ const radius = Math.max(0, Math.min(r, w / 2, h / 2));
10974
+ ctx.beginPath();
10975
+ if (typeof ctx.roundRect === "function") {
10976
+ ctx.roundRect(x, y, w, h, radius);
10977
+ return;
10978
+ }
10979
+ ctx.moveTo(x + radius, y);
10980
+ ctx.arcTo(x + w, y, x + w, y + h, radius);
10981
+ ctx.arcTo(x + w, y + h, x, y + h, radius);
10982
+ ctx.arcTo(x, y + h, x, y, radius);
10983
+ ctx.arcTo(x, y, x + w, y, radius);
10984
+ ctx.closePath();
10985
+ }
10986
+ __name(tracePath, "tracePath");
10987
+ function withOpacity(ctx, color, opacity) {
10988
+ if (opacity >= 1) return color;
10989
+ ctx.save();
10990
+ ctx.fillStyle = color;
10991
+ const parsed = ctx.fillStyle;
10992
+ ctx.restore();
10993
+ if (parsed.startsWith("#") && (parsed.length === 7 || parsed.length === 4)) {
10994
+ const hex = parsed.length === 4 ? `#${parsed[1]}${parsed[1]}${parsed[2]}${parsed[2]}${parsed[3]}${parsed[3]}` : parsed;
10995
+ const r = Number.parseInt(hex.slice(1, 3), 16);
10996
+ const g = Number.parseInt(hex.slice(3, 5), 16);
10997
+ const b = Number.parseInt(hex.slice(5, 7), 16);
10998
+ return `rgba(${r}, ${g}, ${b}, ${opacity})`;
10999
+ }
11000
+ return parsed;
11001
+ }
11002
+ __name(withOpacity, "withOpacity");
11003
+ function drawChrome(ctx, pxWidth, pxHeight, density, style) {
11004
+ ctx.clearRect(0, 0, pxWidth, pxHeight);
11005
+ const border = Math.max(0, style.borderWidthMeters * density);
11006
+ const radius = style.cornerRadiusMeters * density;
11007
+ tracePath(ctx, 0, 0, pxWidth, pxHeight, radius);
11008
+ ctx.fillStyle = withOpacity(
11009
+ ctx,
11010
+ style.backgroundColor,
11011
+ Math.max(0, Math.min(1, style.backgroundOpacity))
11012
+ );
11013
+ ctx.fill();
11014
+ if (border > 0) {
11015
+ tracePath(
11016
+ ctx,
11017
+ border / 2,
11018
+ border / 2,
11019
+ pxWidth - border,
11020
+ pxHeight - border,
11021
+ Math.max(0, radius - border / 2)
11022
+ );
11023
+ ctx.strokeStyle = style.borderColor;
11024
+ ctx.lineWidth = border;
11025
+ ctx.stroke();
11026
+ }
11027
+ }
11028
+ __name(drawChrome, "drawChrome");
11029
+ function drawTextBlock(ctx, pxWidth, pxHeight, density, text, style) {
11030
+ const fontPx = Math.max(4, style.fontSizeMeters * density);
11031
+ const paddingPx = style.paddingMeters * density;
11032
+ const contentW = Math.max(1, pxWidth - paddingPx * 2);
11033
+ const contentH = Math.max(1, pxHeight - paddingPx * 2);
11034
+ const lineHeightPx = fontPx * style.lineHeight;
11035
+ ctx.font = `${style.fontWeight} ${fontPx}px ${UI_FONT_STACK}`;
11036
+ const measure = /* @__PURE__ */ __name((s) => ctx.measureText(s).width, "measure");
11037
+ const maxLines = Math.max(1, Math.floor(contentH / lineHeightPx));
11038
+ const lines = clampLines(
11039
+ measure,
11040
+ wrapText(measure, text, contentW),
11041
+ maxLines,
11042
+ contentW
11043
+ );
11044
+ const blockH = lines.length * lineHeightPx;
11045
+ const top = style.valign === "top" ? paddingPx : style.valign === "bottom" ? pxHeight - paddingPx - blockH : (pxHeight - blockH) / 2;
11046
+ const anchorX = style.align === "left" ? paddingPx : style.align === "right" ? pxWidth - paddingPx : pxWidth / 2;
11047
+ ctx.save();
11048
+ tracePath(ctx, 0, 0, pxWidth, pxHeight, 0);
11049
+ ctx.clip();
11050
+ ctx.fillStyle = style.color;
11051
+ ctx.textAlign = style.align;
11052
+ ctx.textBaseline = "middle";
11053
+ for (let i = 0; i < lines.length; i += 1) {
11054
+ ctx.fillText(lines[i], anchorX, top + (i + 0.5) * lineHeightPx);
11055
+ }
11056
+ ctx.restore();
11057
+ }
11058
+ __name(drawTextBlock, "drawTextBlock");
11059
+ function panelSignature(widthM, heightM, size, style) {
11060
+ return JSON.stringify([
11061
+ widthM,
11062
+ heightM,
11063
+ size.pxWidth,
11064
+ size.pxHeight,
11065
+ style.backgroundColor,
11066
+ style.backgroundOpacity,
11067
+ style.cornerRadiusMeters,
11068
+ style.borderWidthMeters,
11069
+ style.borderColor
11070
+ ]);
11071
+ }
11072
+ __name(panelSignature, "panelSignature");
11073
+ function textSignature(widthM, heightM, size, text, style) {
11074
+ return JSON.stringify([
11075
+ widthM,
11076
+ heightM,
11077
+ size.pxWidth,
11078
+ size.pxHeight,
11079
+ text,
11080
+ style.fontSizeMeters,
11081
+ style.fontWeight,
11082
+ style.color,
11083
+ style.align,
11084
+ style.valign,
11085
+ style.lineHeight,
11086
+ style.paddingMeters
11087
+ ]);
11088
+ }
11089
+ __name(textSignature, "textSignature");
11090
+ function buttonSignature(widthM, heightM, size, panel, label, text) {
11091
+ return JSON.stringify([
11092
+ panelSignature(widthM, heightM, size, panel),
11093
+ label,
11094
+ text.fontSizeMeters,
11095
+ text.fontWeight,
11096
+ text.color
11097
+ ]);
11098
+ }
11099
+ __name(buttonSignature, "buttonSignature");
11100
+ function trackSignature(widthM, heightM, size, panel, accentColor, knobColor) {
11101
+ return JSON.stringify([
11102
+ panelSignature(widthM, heightM, size, panel),
11103
+ accentColor,
11104
+ knobColor
11105
+ ]);
11106
+ }
11107
+ __name(trackSignature, "trackSignature");
11108
+ function drawRoundedBar(ctx, pxX, pxY, pxW, pxH, color) {
11109
+ tracePath(ctx, pxX, pxY, pxW, pxH, pxH / 2);
11110
+ ctx.fillStyle = color;
11111
+ ctx.fill();
11112
+ }
11113
+ __name(drawRoundedBar, "drawRoundedBar");
11114
+
11115
+ // core/systems/UIPanelSystem.ts
11116
+ var FACE_RENDER_ORDER = 10;
11117
+ var CHILD_Z = 2e-3;
11118
+ var _UIPanelSystem = class _UIPanelSystem {
11119
+ constructor(options) {
11120
+ this.entries = /* @__PURE__ */ new Map();
11121
+ this.createCanvas = options?.createCanvas ?? (() => document.createElement("canvas"));
11122
+ }
11123
+ update(world, deltaTime = 0) {
11124
+ const live = /* @__PURE__ */ new Set();
11125
+ for (const entityId of world.query(UIPanelComponent, Object3DRef)) {
11126
+ const panel = world.getComponent(entityId, UIPanelComponent);
11127
+ if (!panel) continue;
11128
+ live.add(entityId);
11129
+ this.syncPanel(world, entityId, panel);
11130
+ this.applyFollow(world, entityId, panel);
11131
+ }
11132
+ for (const entityId of world.query(UITextComponent, Object3DRef)) {
11133
+ const text = world.getComponent(entityId, UITextComponent);
11134
+ if (!text) continue;
11135
+ live.add(entityId);
11136
+ this.syncText(world, entityId, text);
11137
+ }
11138
+ for (const entityId of world.query(UIButtonComponent, Object3DRef)) {
11139
+ const button = world.getComponent(entityId, UIButtonComponent);
11140
+ if (!button) continue;
11141
+ live.add(entityId);
11142
+ this.syncButton(world, entityId, button);
11143
+ }
11144
+ for (const entityId of world.query(UIToggleComponent, Object3DRef)) {
11145
+ const toggle = world.getComponent(entityId, UIToggleComponent);
11146
+ if (!toggle) continue;
11147
+ live.add(entityId);
11148
+ this.syncToggle(world, entityId, toggle, deltaTime);
11149
+ }
11150
+ for (const entityId of world.query(UISliderComponent, Object3DRef)) {
11151
+ const slider = world.getComponent(entityId, UISliderComponent);
11152
+ if (!slider) continue;
11153
+ live.add(entityId);
11154
+ this.syncSlider(world, entityId, slider);
11155
+ }
11156
+ for (const [entityId, entry] of this.entries) {
11157
+ if (!live.has(entityId)) {
11158
+ this.destroyEntry(entry);
11159
+ this.entries.delete(entityId);
11160
+ }
11161
+ }
11162
+ }
11163
+ /** All GPU/canvas resources; call on world teardown. */
11164
+ dispose() {
11165
+ for (const entry of this.entries.values()) this.destroyEntry(entry);
11166
+ this.entries.clear();
11167
+ }
11168
+ // ── Widgets ────────────────────────────────────────────────────────────────
11169
+ syncPanel(world, entityId, panel) {
11170
+ const size = resolveCanvasSize(panel.width, panel.height, panel.pixelDensity);
11171
+ const style = {
11172
+ backgroundColor: panel.backgroundColor,
11173
+ backgroundOpacity: panel.backgroundOpacity,
11174
+ cornerRadiusMeters: panel.cornerRadius,
11175
+ borderWidthMeters: panel.borderWidth,
11176
+ borderColor: panel.borderColor
11177
+ };
11178
+ const signature = panelSignature(panel.width, panel.height, size, style);
11179
+ this.rebuildIfChanged(world, entityId, signature, panel.width, panel.height, size, (ctx) => {
11180
+ drawChrome(ctx, size.pxWidth, size.pxHeight, size.density, style);
11181
+ });
11182
+ }
11183
+ syncText(world, entityId, text) {
11184
+ const size = resolveCanvasSize(text.width, text.height);
11185
+ const style = {
11186
+ fontSizeMeters: text.fontSize,
11187
+ fontWeight: text.fontWeight,
11188
+ color: text.color,
11189
+ align: text.align,
11190
+ valign: text.valign,
11191
+ lineHeight: text.lineHeight,
11192
+ paddingMeters: text.padding
11193
+ };
11194
+ const signature = textSignature(text.width, text.height, size, text.text, style);
11195
+ this.rebuildIfChanged(world, entityId, signature, text.width, text.height, size, (ctx) => {
11196
+ ctx.clearRect(0, 0, size.pxWidth, size.pxHeight);
11197
+ drawTextBlock(ctx, size.pxWidth, size.pxHeight, size.density, text.text, style);
11198
+ });
11199
+ }
11200
+ syncButton(world, entityId, button) {
11201
+ const size = resolveCanvasSize(button.width, button.height);
11202
+ const chrome = {
11203
+ backgroundColor: button.backgroundColor,
11204
+ backgroundOpacity: button.backgroundOpacity,
11205
+ cornerRadiusMeters: button.cornerRadius,
11206
+ borderWidthMeters: button.borderWidth,
11207
+ borderColor: button.borderColor
11208
+ };
11209
+ const label = {
11210
+ fontSizeMeters: button.fontSize,
11211
+ fontWeight: button.fontWeight,
11212
+ color: button.textColor,
11213
+ align: "center",
11214
+ valign: "middle",
11215
+ lineHeight: 1.2,
11216
+ paddingMeters: 0.01
11217
+ };
11218
+ const signature = buttonSignature(
11219
+ button.width,
11220
+ button.height,
11221
+ size,
11222
+ chrome,
11223
+ button.label,
11224
+ label
11225
+ );
11226
+ this.rebuildIfChanged(world, entityId, signature, button.width, button.height, size, (ctx) => {
11227
+ drawChrome(ctx, size.pxWidth, size.pxHeight, size.density, chrome);
11228
+ drawTextBlock(ctx, size.pxWidth, size.pxHeight, size.density, button.label, label);
11229
+ });
11230
+ }
11231
+ syncToggle(world, entityId, toggle, deltaTime) {
11232
+ const size = resolveCanvasSize(toggle.width, toggle.height);
11233
+ const chrome = {
11234
+ backgroundColor: toggle.value ? toggle.accentColor : toggle.trackColor,
11235
+ backgroundOpacity: 1,
11236
+ cornerRadiusMeters: toggle.height / 2,
11237
+ borderWidthMeters: 0,
11238
+ borderColor: "#000000"
11239
+ };
11240
+ const signature = JSON.stringify([
11241
+ trackSignature(
11242
+ toggle.width,
11243
+ toggle.height,
11244
+ size,
11245
+ chrome,
11246
+ toggle.accentColor,
11247
+ toggle.knobColor
11248
+ ),
11249
+ toggle.value
11250
+ ]);
11251
+ const entry = this.rebuildIfChanged(
11252
+ world,
11253
+ entityId,
11254
+ signature,
11255
+ toggle.width,
11256
+ toggle.height,
11257
+ size,
11258
+ (ctx) => {
11259
+ drawChrome(ctx, size.pxWidth, size.pxHeight, size.density, chrome);
11260
+ }
11261
+ );
11262
+ if (!entry) return;
11263
+ if (!entry.knob) {
11264
+ entry.knob = this.makeDisc(
11265
+ toggle.height * 0.4,
11266
+ toggle.knobColor,
11267
+ entry.face.parent,
11268
+ CHILD_Z
11269
+ );
11270
+ }
11271
+ const inset = toggle.height / 2;
11272
+ const target = toggle.value ? toggle.width / 2 - inset : -(toggle.width / 2 - inset);
11273
+ const knob = entry.knob;
11274
+ const ease = Math.min(1, deltaTime * 14);
11275
+ knob.position.x += (target - knob.position.x) * (deltaTime > 0 ? ease : 1);
11276
+ knob.position.z = CHILD_Z;
11277
+ }
11278
+ syncSlider(world, entityId, slider) {
11279
+ const size = resolveCanvasSize(slider.width, slider.height);
11280
+ const trackThickness = slider.height * 0.32;
11281
+ const knobRadius = slider.height * 0.45;
11282
+ const usable = Math.max(1e-3, slider.width - slider.height);
11283
+ const chrome = {
11284
+ backgroundColor: slider.trackColor,
11285
+ backgroundOpacity: 1,
11286
+ cornerRadiusMeters: trackThickness / 2,
11287
+ borderWidthMeters: 0,
11288
+ borderColor: "#000000"
11289
+ };
11290
+ const signature = trackSignature(
11291
+ slider.width,
11292
+ slider.height,
11293
+ size,
11294
+ chrome,
11295
+ slider.accentColor,
11296
+ slider.knobColor
11297
+ );
11298
+ const entry = this.rebuildIfChanged(
11299
+ world,
11300
+ entityId,
11301
+ signature,
11302
+ slider.width,
11303
+ slider.height,
11304
+ size,
11305
+ (ctx) => {
11306
+ ctx.clearRect(0, 0, size.pxWidth, size.pxHeight);
11307
+ const trackWidthM = usable + trackThickness;
11308
+ drawRoundedBar(
11309
+ ctx,
11310
+ (slider.width - trackWidthM) / 2 * size.density,
11311
+ (slider.height - trackThickness) / 2 * size.density,
11312
+ trackWidthM * size.density,
11313
+ trackThickness * size.density,
11314
+ slider.trackColor
11315
+ );
11316
+ }
11317
+ );
11318
+ if (!entry) return;
11319
+ if (!entry.fill) {
11320
+ entry.fill = this.makeQuad(
11321
+ 1,
11322
+ trackThickness,
11323
+ slider.accentColor,
11324
+ entry.face.parent,
11325
+ CHILD_Z / 2
11326
+ );
11327
+ entry.cap = this.makeDisc(
11328
+ trackThickness / 2,
11329
+ slider.accentColor,
11330
+ entry.face.parent,
11331
+ CHILD_Z / 2,
11332
+ Math.PI / 2,
11333
+ Math.PI
11334
+ );
11335
+ entry.cap.position.x = -usable / 2;
11336
+ }
11337
+ if (!entry.knob) {
11338
+ entry.knob = this.makeDisc(
11339
+ knobRadius,
11340
+ slider.knobColor,
11341
+ entry.face.parent,
11342
+ CHILD_Z
11343
+ );
11344
+ }
11345
+ const range = Math.max(1e-6, slider.max - slider.min);
11346
+ const t = Math.min(1, Math.max(0, (slider.value - slider.min) / range));
11347
+ entry.knob.position.x = -usable / 2 + t * usable;
11348
+ entry.knob.position.z = CHILD_Z;
11349
+ entry.fill.scale.x = Math.max(1e-4, t * usable);
11350
+ entry.fill.position.x = -usable / 2 + t * usable / 2;
11351
+ entry.fill.position.z = CHILD_Z / 2;
11352
+ }
11353
+ // ── Machinery ──────────────────────────────────────────────────────────────
11354
+ rebuildIfChanged(world, entityId, signature, widthM, heightM, size, draw) {
11355
+ const root = world.getComponent(entityId, Object3DRef)?.object;
11356
+ if (!root) return null;
11357
+ let entry = this.entries.get(entityId);
11358
+ if (entry && entry.signature === signature) return entry;
11359
+ if (entry) this.disposeMovingParts(entry);
11360
+ if (!entry) {
11361
+ const canvas2 = this.createCanvas();
11362
+ const texture = new THREE26__namespace.CanvasTexture(
11363
+ canvas2
11364
+ );
11365
+ this.configureTexture(world, texture);
11366
+ const material = new THREE26__namespace.MeshBasicMaterial({
11367
+ map: texture,
11368
+ transparent: true,
11369
+ depthWrite: false,
11370
+ // UI colours are authored absolutes; scene tone mapping would shift
11371
+ // them (the classic "why is my white UI beige" bug).
11372
+ toneMapped: false,
11373
+ side: THREE26__namespace.DoubleSide,
11374
+ premultipliedAlpha: true
11375
+ });
11376
+ const face = new THREE26__namespace.Mesh(
11377
+ new THREE26__namespace.PlaneGeometry(widthM, heightM),
11378
+ material
11379
+ );
11380
+ face.renderOrder = FACE_RENDER_ORDER;
11381
+ root.add(face);
11382
+ entry = {
11383
+ signature: "",
11384
+ face,
11385
+ texture,
11386
+ material,
11387
+ canvas: canvas2,
11388
+ knob: null,
11389
+ fill: null,
11390
+ cap: null
11391
+ };
11392
+ this.entries.set(entityId, entry);
11393
+ }
11394
+ const { canvas } = entry;
11395
+ if (canvas.width !== size.pxWidth || canvas.height !== size.pxHeight) {
11396
+ canvas.width = size.pxWidth;
11397
+ canvas.height = size.pxHeight;
11398
+ }
11399
+ const params = entry.face.geometry.parameters;
11400
+ if (params.width !== widthM || params.height !== heightM) {
11401
+ entry.face.geometry.dispose();
11402
+ entry.face.geometry = new THREE26__namespace.PlaneGeometry(widthM, heightM);
11403
+ }
11404
+ const ctx = canvas.getContext("2d");
11405
+ if (ctx) draw(ctx);
11406
+ entry.texture.needsUpdate = true;
11407
+ entry.signature = signature;
11408
+ return entry;
11409
+ }
11410
+ configureTexture(world, texture) {
11411
+ texture.colorSpace = THREE26__namespace.SRGBColorSpace;
11412
+ texture.generateMipmaps = true;
11413
+ texture.minFilter = THREE26__namespace.LinearMipmapLinearFilter;
11414
+ texture.magFilter = THREE26__namespace.LinearFilter;
11415
+ texture.premultiplyAlpha = true;
11416
+ try {
11417
+ const max = world.getRenderer().capabilities?.getMaxAnisotropy?.();
11418
+ if (typeof max === "number") texture.anisotropy = Math.min(8, max);
11419
+ } catch {
11420
+ }
11421
+ }
11422
+ makeQuad(widthM, heightM, color, parent, z) {
11423
+ return this.addPart(
11424
+ new THREE26__namespace.PlaneGeometry(widthM, heightM),
11425
+ color,
11426
+ parent,
11427
+ z
11428
+ );
11429
+ }
11430
+ /** A filled disc (or arc segment, for the fill's rounded cap). */
11431
+ makeDisc(radiusM, color, parent, z, thetaStart = 0, thetaLength = Math.PI * 2) {
11432
+ return this.addPart(
11433
+ new THREE26__namespace.CircleGeometry(radiusM, 32, thetaStart, thetaLength),
11434
+ color,
11435
+ parent,
11436
+ z
11437
+ );
11438
+ }
11439
+ addPart(geometry, color, parent, z) {
11440
+ const mesh = new THREE26__namespace.Mesh(
11441
+ geometry,
11442
+ new THREE26__namespace.MeshBasicMaterial({
11443
+ color,
11444
+ transparent: true,
11445
+ depthWrite: false,
11446
+ toneMapped: false,
11447
+ side: THREE26__namespace.DoubleSide
11448
+ })
11449
+ );
11450
+ mesh.renderOrder = FACE_RENDER_ORDER + 1;
11451
+ mesh.position.z = z;
11452
+ mesh.raycast = () => {
11453
+ };
11454
+ parent?.add(mesh);
11455
+ return mesh;
11456
+ }
11457
+ /** Remove + dispose knob/fill/cap so a sync pass rebuilds them fresh. */
11458
+ disposeMovingParts(entry) {
11459
+ for (const key of ["knob", "fill", "cap"]) {
11460
+ const mesh = entry[key];
11461
+ if (!mesh) continue;
11462
+ mesh.removeFromParent();
11463
+ mesh.geometry.dispose();
11464
+ mesh.material.dispose();
11465
+ entry[key] = null;
11466
+ }
11467
+ }
11468
+ /**
11469
+ * Billboard: yaw the ENTITY TRANSFORM toward the camera. Writing the
11470
+ * Object3D directly would be overwritten by `TransformSyncSystem` (which
11471
+ * runs after this system, before render) — the same reason locomotion
11472
+ * writes the rig transform. Skipped in the editor so the gizmo and the
11473
+ * authored rotation stay in charge while authoring.
11474
+ */
11475
+ applyFollow(world, entityId, panel) {
11476
+ if (panel.follow !== "billboard" || world.isEditorMode()) return;
11477
+ const transform = world.getComponent(entityId, TransformComponent);
11478
+ const object = world.getComponent(entityId, Object3DRef)?.object;
11479
+ if (!transform || !object) return;
11480
+ const camera = world.getCamera();
11481
+ const cam = new THREE26__namespace.Vector3();
11482
+ const self = new THREE26__namespace.Vector3();
11483
+ camera.getWorldPosition(cam);
11484
+ object.getWorldPosition(self);
11485
+ transform.rotation.y = Math.atan2(cam.x - self.x, cam.z - self.z);
11486
+ }
11487
+ destroyEntry(entry) {
11488
+ this.disposeMovingParts(entry);
11489
+ entry.face.removeFromParent();
11490
+ entry.face.geometry.dispose();
11491
+ entry.face.material.dispose();
11492
+ entry.texture.dispose();
11493
+ }
11494
+ };
11495
+ __name(_UIPanelSystem, "UIPanelSystem");
11496
+ var UIPanelSystem = _UIPanelSystem;
10255
11497
  var _XRHitTestSystem = class _XRHitTestSystem {
10256
11498
  constructor() {
10257
11499
  this.sources = /* @__PURE__ */ new Map();
@@ -10364,9 +11606,13 @@ var _XRAnchorSystem = class _XRAnchorSystem {
10364
11606
  this.anchors = /* @__PURE__ */ new Map();
10365
11607
  /** In-flight creations, so one entity is not anchored twice. */
10366
11608
  this.pending = /* @__PURE__ */ new Set();
11609
+ /** Per-anchor head-glue gates — see XREnvironmentSystem's TrackedEntry. */
11610
+ this.gates = /* @__PURE__ */ new Map();
10367
11611
  this.presenting = false;
11612
+ this.tmpViewerMatrix = new THREE26__namespace.Matrix4();
11613
+ this.tmpViewPos = new THREE26__namespace.Vector3();
10368
11614
  }
10369
- update(world) {
11615
+ update(world, deltaTime = 0) {
10370
11616
  const presenting = world.isXrPresenting();
10371
11617
  if (!presenting) {
10372
11618
  if (this.presenting) {
@@ -10386,8 +11632,11 @@ var _XRAnchorSystem = class _XRAnchorSystem {
10386
11632
  if (!live.has(entityId)) {
10387
11633
  anchor.delete?.();
10388
11634
  this.anchors.delete(entityId);
11635
+ this.gates.delete(entityId);
10389
11636
  }
10390
11637
  }
11638
+ const viewer = frame.getViewerPose?.(referenceSpace);
11639
+ const viewerInverse = viewer ? this.tmpViewerMatrix.fromArray(viewer.transform.matrix).invert() : null;
10391
11640
  for (const entityId of entityIds) {
10392
11641
  const component = world.getComponent(entityId, XRAnchorComponent);
10393
11642
  const transform = world.getComponent(entityId, TransformComponent);
@@ -10397,6 +11646,7 @@ var _XRAnchorSystem = class _XRAnchorSystem {
10397
11646
  if (existing) {
10398
11647
  existing.delete?.();
10399
11648
  this.anchors.delete(entityId);
11649
+ this.gates.delete(entityId);
10400
11650
  }
10401
11651
  continue;
10402
11652
  }
@@ -10407,7 +11657,21 @@ var _XRAnchorSystem = class _XRAnchorSystem {
10407
11657
  }
10408
11658
  const pose = frame.getPose(anchor.anchorSpace, referenceSpace);
10409
11659
  if (pose && pose.emulatedPosition !== true) {
10410
- decomposeXrPose(pose.transform.matrix, transform);
11660
+ let gate = this.gates.get(entityId);
11661
+ if (!gate) {
11662
+ gate = new SurfacePoseGate();
11663
+ this.gates.set(entityId, gate);
11664
+ }
11665
+ const m = pose.transform.matrix;
11666
+ const refPos = { x: m[12], y: m[13], z: m[14] };
11667
+ let viewPos = null;
11668
+ if (viewerInverse) {
11669
+ const v = this.tmpViewPos.set(refPos.x, refPos.y, refPos.z).applyMatrix4(viewerInverse);
11670
+ viewPos = { x: v.x, y: v.y, z: v.z };
11671
+ }
11672
+ if (gate.update(refPos, viewPos, deltaTime)) {
11673
+ decomposeXrPose(pose.transform.matrix, transform);
11674
+ }
10411
11675
  }
10412
11676
  }
10413
11677
  }
@@ -10452,6 +11716,7 @@ var _XRAnchorSystem = class _XRAnchorSystem {
10452
11716
  }).finally(() => this.pending.delete(entityId));
10453
11717
  }
10454
11718
  releaseAll() {
11719
+ this.gates.clear();
10455
11720
  for (const anchor of this.anchors.values()) anchor.delete?.();
10456
11721
  this.anchors.clear();
10457
11722
  this.pending.clear();
@@ -11716,6 +12981,8 @@ exports.SCRIPT_OBJECT_ROOT_FLAG = SCRIPT_OBJECT_ROOT_FLAG;
11716
12981
  exports.ScriptBehaviourSystem = ScriptBehaviourSystem;
11717
12982
  exports.ScriptObjectSystem = ScriptObjectSystem;
11718
12983
  exports.TransformSyncSystem = TransformSyncSystem;
12984
+ exports.UIInteractionSystem = UIInteractionSystem;
12985
+ exports.UIPanelSystem = UIPanelSystem;
11719
12986
  exports.VRTeleportAreaSystem = VRTeleportAreaSystem;
11720
12987
  exports.VideoSystem = VideoSystem;
11721
12988
  exports.XRAnchorSystem = XRAnchorSystem;