@terraware/web-components 4.2.22-rc.0 → 4.2.22

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 (104) hide show
  1. package/components/VirtualWalkthrough/Annotation.d.ts.map +1 -1
  2. package/components/VirtualWalkthrough/Annotation.js +10 -0
  3. package/components/VirtualWalkthrough/BoundaryWall.d.ts +11 -0
  4. package/components/VirtualWalkthrough/BoundaryWall.d.ts.map +1 -0
  5. package/components/VirtualWalkthrough/BoundaryWall.js +35 -0
  6. package/components/VirtualWalkthrough/SplatModel.d.ts +1 -8
  7. package/components/VirtualWalkthrough/SplatModel.d.ts.map +1 -1
  8. package/components/VirtualWalkthrough/SplatModel.js +2 -6
  9. package/components/VirtualWalkthrough/TfXrNavigation.d.ts +42 -6
  10. package/components/VirtualWalkthrough/TfXrNavigation.d.ts.map +1 -1
  11. package/components/VirtualWalkthrough/TfXrNavigation.js +109 -9
  12. package/components/VirtualWalkthrough/TfXrNavigation.test.js +110 -0
  13. package/components/VirtualWalkthrough/VirtualWalkthroughViewer.d.ts +1 -3
  14. package/components/VirtualWalkthrough/VirtualWalkthroughViewer.d.ts.map +1 -1
  15. package/components/VirtualWalkthrough/VirtualWalkthroughViewer.js +51 -6
  16. package/components/VirtualWalkthrough/VrAnnotationPanel.d.ts +9 -0
  17. package/components/VirtualWalkthrough/VrAnnotationPanel.d.ts.map +1 -0
  18. package/components/VirtualWalkthrough/VrAnnotationPanel.js +22 -0
  19. package/components/VirtualWalkthrough/XrAnnotationInteraction.d.ts +6 -0
  20. package/components/VirtualWalkthrough/XrAnnotationInteraction.d.ts.map +1 -0
  21. package/components/VirtualWalkthrough/XrAnnotationInteraction.js +15 -0
  22. package/components/VirtualWalkthrough/XrGazeDwell.d.ts +6 -0
  23. package/components/VirtualWalkthrough/XrGazeDwell.d.ts.map +1 -0
  24. package/components/VirtualWalkthrough/XrGazeDwell.js +15 -0
  25. package/components/VirtualWalkthrough/XrPointerRay.d.ts +7 -0
  26. package/components/VirtualWalkthrough/XrPointerRay.d.ts.map +1 -0
  27. package/components/VirtualWalkthrough/XrPointerRay.js +15 -0
  28. package/components/VirtualWalkthrough/boundary-wall.d.ts +86 -0
  29. package/components/VirtualWalkthrough/boundary-wall.d.ts.map +1 -0
  30. package/components/VirtualWalkthrough/boundary-wall.js +331 -0
  31. package/components/VirtualWalkthrough/boundary-wall.test.js +203 -0
  32. package/components/VirtualWalkthrough/vr-annotation-panel-layout.d.ts +6 -0
  33. package/components/VirtualWalkthrough/vr-annotation-panel-layout.d.ts.map +1 -0
  34. package/components/VirtualWalkthrough/vr-annotation-panel-layout.js +22 -0
  35. package/components/VirtualWalkthrough/vr-annotation-panel-layout.test.js +26 -0
  36. package/components/VirtualWalkthrough/vr-annotation-panel.d.ts +40 -0
  37. package/components/VirtualWalkthrough/vr-annotation-panel.d.ts.map +1 -0
  38. package/components/VirtualWalkthrough/vr-annotation-panel.js +312 -0
  39. package/components/VirtualWalkthrough/xr-annotation-candidates.d.ts +18 -0
  40. package/components/VirtualWalkthrough/xr-annotation-candidates.d.ts.map +1 -0
  41. package/components/VirtualWalkthrough/xr-annotation-candidates.js +38 -0
  42. package/components/VirtualWalkthrough/xr-annotation-interaction.d.ts +13 -0
  43. package/components/VirtualWalkthrough/xr-annotation-interaction.d.ts.map +1 -0
  44. package/components/VirtualWalkthrough/xr-annotation-interaction.js +59 -0
  45. package/components/VirtualWalkthrough/xr-annotation-targeting.d.ts +10 -0
  46. package/components/VirtualWalkthrough/xr-annotation-targeting.d.ts.map +1 -0
  47. package/components/VirtualWalkthrough/xr-annotation-targeting.js +36 -0
  48. package/components/VirtualWalkthrough/xr-annotation-targeting.test.js +66 -0
  49. package/components/VirtualWalkthrough/xr-button-arming.d.ts +14 -0
  50. package/components/VirtualWalkthrough/xr-button-arming.d.ts.map +1 -0
  51. package/components/VirtualWalkthrough/xr-button-arming.js +19 -0
  52. package/components/VirtualWalkthrough/xr-button-arming.test.js +35 -0
  53. package/components/VirtualWalkthrough/xr-button-hold.d.ts +20 -0
  54. package/components/VirtualWalkthrough/xr-button-hold.d.ts.map +1 -0
  55. package/components/VirtualWalkthrough/xr-button-hold.js +43 -0
  56. package/components/VirtualWalkthrough/xr-button-hold.test.js +57 -0
  57. package/components/VirtualWalkthrough/xr-exit-button.d.ts +21 -4
  58. package/components/VirtualWalkthrough/xr-exit-button.d.ts.map +1 -1
  59. package/components/VirtualWalkthrough/xr-exit-button.js +98 -37
  60. package/components/VirtualWalkthrough/xr-face-buttons.d.ts +15 -0
  61. package/components/VirtualWalkthrough/xr-face-buttons.d.ts.map +1 -0
  62. package/components/VirtualWalkthrough/xr-face-buttons.js +31 -0
  63. package/components/VirtualWalkthrough/xr-face-buttons.test.js +82 -0
  64. package/components/VirtualWalkthrough/xr-gaze-dwell-state.d.ts +17 -0
  65. package/components/VirtualWalkthrough/xr-gaze-dwell-state.d.ts.map +1 -0
  66. package/components/VirtualWalkthrough/xr-gaze-dwell-state.js +39 -0
  67. package/components/VirtualWalkthrough/xr-gaze-dwell-state.test.js +61 -0
  68. package/components/VirtualWalkthrough/xr-gaze-dwell.d.ts +30 -0
  69. package/components/VirtualWalkthrough/xr-gaze-dwell.d.ts.map +1 -0
  70. package/components/VirtualWalkthrough/xr-gaze-dwell.js +173 -0
  71. package/components/VirtualWalkthrough/xr-interactive-ui.d.ts +13 -0
  72. package/components/VirtualWalkthrough/xr-interactive-ui.d.ts.map +1 -0
  73. package/components/VirtualWalkthrough/xr-interactive-ui.js +25 -0
  74. package/components/VirtualWalkthrough/xr-interactive-ui.test.js +165 -0
  75. package/components/VirtualWalkthrough/xr-pointer-ray.d.ts +10 -0
  76. package/components/VirtualWalkthrough/xr-pointer-ray.d.ts.map +1 -0
  77. package/components/VirtualWalkthrough/xr-pointer-ray.js +24 -0
  78. package/components/VirtualWalkthrough/xr-progress-pie.d.ts +10 -0
  79. package/components/VirtualWalkthrough/xr-progress-pie.d.ts.map +1 -0
  80. package/components/VirtualWalkthrough/xr-progress-pie.js +30 -0
  81. package/components/VirtualWalkthrough/xr-progress-pie.test.js +79 -0
  82. package/components/VirtualWalkthrough/xr-ray-quad.d.ts +13 -0
  83. package/components/VirtualWalkthrough/xr-ray-quad.d.ts.map +1 -0
  84. package/components/VirtualWalkthrough/xr-ray-quad.js +29 -0
  85. package/components/VirtualWalkthrough/xr-ray-quad.test.js +37 -0
  86. package/components/VirtualWalkthrough/xr-scene-bounds.d.ts +17 -0
  87. package/components/VirtualWalkthrough/xr-scene-bounds.d.ts.map +1 -0
  88. package/components/VirtualWalkthrough/xr-scene-bounds.js +41 -0
  89. package/components/VirtualWalkthrough/xr-scene-bounds.test.js +70 -0
  90. package/components/VirtualWalkthrough/xr-teleport-gesture.d.ts +30 -0
  91. package/components/VirtualWalkthrough/xr-teleport-gesture.d.ts.map +1 -0
  92. package/components/VirtualWalkthrough/xr-teleport-gesture.js +45 -0
  93. package/components/VirtualWalkthrough/xr-teleport-gesture.test.js +90 -0
  94. package/hooks/useXr.d.ts +2 -0
  95. package/hooks/useXr.d.ts.map +1 -1
  96. package/hooks/useXr.js +10 -6
  97. package/package.json +1 -1
  98. package/virtualWalkthrough.d.ts +1 -3
  99. package/virtualWalkthrough.d.ts.map +1 -1
  100. package/virtualWalkthrough.js +1 -2
  101. package/components/VirtualWalkthrough/splatFormat.d.ts +0 -17
  102. package/components/VirtualWalkthrough/splatFormat.d.ts.map +0 -1
  103. package/components/VirtualWalkthrough/splatFormat.js +0 -50
  104. package/components/VirtualWalkthrough/splatFormat.test.js +0 -51
@@ -0,0 +1,13 @@
1
+ import { Script } from 'playcanvas';
2
+ export declare class XrAnnotationInteraction extends Script {
3
+ static scriptName: string;
4
+ onDismissCallback?: () => void;
5
+ private _faceButtons;
6
+ private _isVrActive;
7
+ private _dismiss;
8
+ private _onSelect;
9
+ private _openAnnotationUnderRay;
10
+ initialize(): void;
11
+ update(): void;
12
+ }
13
+ //# sourceMappingURL=xr-annotation-interaction.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"xr-annotation-interaction.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/xr-annotation-interaction.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAkC,MAAM,YAAY,CAAC;AAOpE,qBAAa,uBAAwB,SAAQ,MAAM;IACjD,MAAM,CAAC,UAAU,SAA6B;IAE9C,iBAAiB,CAAC,EAAE,MAAM,IAAI,CAAC;IAE/B,OAAO,CAAC,YAAY,CAAgC;IAEpD,OAAO,CAAC,WAAW,CAAyE;IAE5F,OAAO,CAAC,QAAQ,CAId;IAEF,OAAO,CAAC,SAAS,CAKf;IAEF,OAAO,CAAC,uBAAuB;IAqB/B,UAAU;IAKV,MAAM;CAeP"}
@@ -0,0 +1,59 @@
1
+ import { Script, XRTYPE_VR } from 'playcanvas';
2
+ import { HIT_RADIUS_PAD, collectAnnotationHitCandidates } from './xr-annotation-candidates';
3
+ import { nearestAnnotationHit } from './xr-annotation-targeting';
4
+ import { FaceButtonPressTracker } from './xr-face-buttons';
5
+ import { rayHitsAnnotationPanel } from './xr-interactive-ui';
6
+ export class XrAnnotationInteraction extends Script {
7
+ static scriptName = 'xrAnnotationInteraction';
8
+ _faceButtons = new FaceButtonPressTracker();
9
+ _isVrActive = () => this.app.xr?.active === true && this.app.xr?.type === XRTYPE_VR;
10
+ _dismiss = () => {
11
+ if (typeof this.onDismissCallback === 'function') {
12
+ this.onDismissCallback();
13
+ }
14
+ };
15
+ _onSelect = inputSource => {
16
+ if (!this._isVrActive()) {
17
+ return;
18
+ }
19
+ this._openAnnotationUnderRay(inputSource.getOrigin(), inputSource.getDirection());
20
+ };
21
+ _openAnnotationUnderRay(origin, direction) {
22
+ // Checked before the hotspots so aiming at the panel (e.g. its carousel arrows) neither
23
+ // dismisses it nor opens an annotation whose padded hit sphere sits behind it.
24
+ if (rayHitsAnnotationPanel(this.app, origin, direction)) {
25
+ return;
26
+ }
27
+ const candidates = collectAnnotationHitCandidates(this.app, HIT_RADIUS_PAD);
28
+ const index = nearestAnnotationHit(origin, direction, candidates);
29
+ if (index === null) {
30
+ this._dismiss();
31
+ return;
32
+ }
33
+ const {
34
+ entity,
35
+ script
36
+ } = candidates[index];
37
+ const camera = this.app.root.findByName('camera');
38
+ const screen = camera?.camera?.worldToScreen(entity.getPosition());
39
+ script.onVrOpenCallback(screen?.x ?? 0, screen?.y ?? 0);
40
+ }
41
+ initialize() {
42
+ this.app.xr?.input?.on('select', this._onSelect);
43
+ this.once('destroy', () => this.app.xr?.input?.off('select', this._onSelect));
44
+ }
45
+ update() {
46
+ if (!this._isVrActive()) {
47
+ return;
48
+ }
49
+
50
+ // A face button dismisses from any aim direction, unlike the click-out select. Harmless when
51
+ // nothing is open: the React close handler re-sets state it already holds.
52
+ for (const inputSource of this.app.xr?.input?.inputSources ?? []) {
53
+ if (this._faceButtons.justPressed(inputSource)) {
54
+ this._dismiss();
55
+ return;
56
+ }
57
+ }
58
+ }
59
+ }
@@ -0,0 +1,10 @@
1
+ import { Vec3 } from 'playcanvas';
2
+ export interface AnnotationHitCandidate {
3
+ position: Vec3;
4
+ radius: number;
5
+ }
6
+ /**
7
+ * Index of the closest candidate sphere the ray enters, or null if it hits none.
8
+ */
9
+ export declare const nearestAnnotationHit: (origin: Vec3, direction: Vec3, candidates: AnnotationHitCandidate[]) => number | null;
10
+ //# sourceMappingURL=xr-annotation-targeting.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"xr-annotation-targeting.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/xr-annotation-targeting.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AAElC,MAAM,WAAW,sBAAsB;IACrC,QAAQ,EAAE,IAAI,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAsBD;;GAEG;AACH,eAAO,MAAM,oBAAoB,GAC/B,QAAQ,IAAI,EACZ,WAAW,IAAI,EACf,YAAY,sBAAsB,EAAE,KACnC,MAAM,GAAG,IAcX,CAAC"}
@@ -0,0 +1,36 @@
1
+ import { Vec3 } from 'playcanvas';
2
+ /**
3
+ * Distance along a (normalized) ray to where it first enters the sphere, or null if it never does.
4
+ * A ray starting inside the sphere returns 0.
5
+ */
6
+ const raySphereEntryDistance = (origin, dir, center, radius) => {
7
+ const m = new Vec3().sub2(origin, center);
8
+ const b = m.dot(dir);
9
+ const c = m.dot(m) - radius * radius;
10
+ if (c > 0 && b > 0) {
11
+ return null;
12
+ }
13
+ const disc = b * b - c;
14
+ if (disc < 0) {
15
+ return null;
16
+ }
17
+ const t = -b - Math.sqrt(disc);
18
+ return t < 0 ? 0 : t;
19
+ };
20
+
21
+ /**
22
+ * Index of the closest candidate sphere the ray enters, or null if it hits none.
23
+ */
24
+ export const nearestAnnotationHit = (origin, direction, candidates) => {
25
+ const dir = direction.clone().normalize();
26
+ let bestIndex = null;
27
+ let bestDistance = Infinity;
28
+ candidates.forEach((candidate, index) => {
29
+ const distance = raySphereEntryDistance(origin, dir, candidate.position, candidate.radius);
30
+ if (distance !== null && distance < bestDistance) {
31
+ bestDistance = distance;
32
+ bestIndex = index;
33
+ }
34
+ });
35
+ return bestIndex;
36
+ };
@@ -0,0 +1,66 @@
1
+ import { Vec3 } from 'playcanvas';
2
+ import { nearestAnnotationHit } from './xr-annotation-targeting';
3
+ describe('nearestAnnotationHit', () => {
4
+ const origin = new Vec3(0, 0, 5);
5
+ const forward = new Vec3(0, 0, -1);
6
+ it('returns null when there are no candidates', () => {
7
+ expect(nearestAnnotationHit(origin, forward, [])).toBeNull();
8
+ });
9
+ it('returns null when the ray misses every candidate', () => {
10
+ const candidates = [{
11
+ position: new Vec3(0, 5, 0),
12
+ radius: 1
13
+ }];
14
+ expect(nearestAnnotationHit(origin, forward, candidates)).toBeNull();
15
+ });
16
+ it('returns the index of a single hit candidate', () => {
17
+ const candidates = [{
18
+ position: new Vec3(0, 0, 0),
19
+ radius: 1
20
+ }];
21
+ expect(nearestAnnotationHit(origin, forward, candidates)).toBe(0);
22
+ });
23
+ it('returns the nearest candidate when several are hit, regardless of order', () => {
24
+ const far = {
25
+ position: new Vec3(0, 0, -5),
26
+ radius: 1
27
+ };
28
+ const near = {
29
+ position: new Vec3(0, 0, 0),
30
+ radius: 1
31
+ };
32
+ expect(nearestAnnotationHit(origin, forward, [far, near])).toBe(1);
33
+ });
34
+ it('treats a ray starting inside a sphere as the nearest hit', () => {
35
+ const inside = {
36
+ position: new Vec3(0, 0, 5),
37
+ radius: 1
38
+ };
39
+ const ahead = {
40
+ position: new Vec3(0, 0, 0),
41
+ radius: 1
42
+ };
43
+ expect(nearestAnnotationHit(origin, forward, [ahead, inside])).toBe(1);
44
+ });
45
+ it('normalizes a non-unit direction', () => {
46
+ const candidates = [{
47
+ position: new Vec3(0, 0, 0),
48
+ radius: 1
49
+ }];
50
+ expect(nearestAnnotationHit(origin, new Vec3(0, 0, -4), candidates)).toBe(0);
51
+ });
52
+ it('returns null when the only candidate is behind the ray origin', () => {
53
+ const candidates = [{
54
+ position: new Vec3(0, 0, 0),
55
+ radius: 1
56
+ }];
57
+ expect(nearestAnnotationHit(origin, new Vec3(0, 0, 1), candidates)).toBeNull();
58
+ });
59
+ it('registers a tangent graze as a hit', () => {
60
+ const candidates = [{
61
+ position: new Vec3(0, 0, 0),
62
+ radius: 1
63
+ }];
64
+ expect(nearestAnnotationHit(new Vec3(0, 1, 5), new Vec3(0, 0, -1), candidates)).toBe(0);
65
+ });
66
+ });
@@ -0,0 +1,14 @@
1
+ import { XrInputSource } from 'playcanvas';
2
+ /**
3
+ * Latches per input source whether a press may count toward a hold. A source arms itself the
4
+ * moment it is seen released, and stays armed from then on - so a button already down when
5
+ * tracking starts is ignored until it is released once, and one controller held down can't stop
6
+ * another from arming and counting. Callers must poll every frame for every source they care
7
+ * about; a press and release between polls is missed.
8
+ */
9
+ export declare class ButtonArmingLatch {
10
+ private _armed;
11
+ /** Records one frame for `inputSource` and returns whether its current press may count. */
12
+ trackPress(inputSource: XrInputSource, pressed: boolean): boolean;
13
+ }
14
+ //# sourceMappingURL=xr-button-arming.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"xr-button-arming.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/xr-button-arming.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAE3C;;;;;;GAMG;AACH,qBAAa,iBAAiB;IAC5B,OAAO,CAAC,MAAM,CAAgC;IAE9C,2FAA2F;IAC3F,UAAU,CAAC,WAAW,EAAE,aAAa,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO;CASlE"}
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Latches per input source whether a press may count toward a hold. A source arms itself the
3
+ * moment it is seen released, and stays armed from then on - so a button already down when
4
+ * tracking starts is ignored until it is released once, and one controller held down can't stop
5
+ * another from arming and counting. Callers must poll every frame for every source they care
6
+ * about; a press and release between polls is missed.
7
+ */
8
+ export class ButtonArmingLatch {
9
+ _armed = new WeakSet();
10
+
11
+ /** Records one frame for `inputSource` and returns whether its current press may count. */
12
+ trackPress(inputSource, pressed) {
13
+ if (!pressed) {
14
+ this._armed.add(inputSource);
15
+ return false;
16
+ }
17
+ return this._armed.has(inputSource);
18
+ }
19
+ }
@@ -0,0 +1,35 @@
1
+ import { ButtonArmingLatch } from './xr-button-arming';
2
+ const source = () => ({});
3
+ describe('ButtonArmingLatch', () => {
4
+ it('never counts a source pressed from the very first poll, while it stays down', () => {
5
+ const latch = new ButtonArmingLatch();
6
+ const src = source();
7
+ expect(latch.trackPress(src, true)).toBe(false);
8
+ expect(latch.trackPress(src, true)).toBe(false);
9
+ expect(latch.trackPress(src, true)).toBe(false);
10
+ });
11
+ it('counts a press that follows a release', () => {
12
+ const latch = new ButtonArmingLatch();
13
+ const src = source();
14
+ latch.trackPress(src, true);
15
+ latch.trackPress(src, false);
16
+ expect(latch.trackPress(src, true)).toBe(true);
17
+ });
18
+ it('tracks each input source independently, so one held from the first poll does not block another', () => {
19
+ const latch = new ButtonArmingLatch();
20
+ const held = source();
21
+ const other = source();
22
+ latch.trackPress(held, true);
23
+ latch.trackPress(other, false);
24
+ expect(latch.trackPress(held, true)).toBe(false);
25
+ expect(latch.trackPress(other, true)).toBe(true);
26
+ });
27
+ it('stays armed across later press/release cycles', () => {
28
+ const latch = new ButtonArmingLatch();
29
+ const src = source();
30
+ latch.trackPress(src, false);
31
+ expect(latch.trackPress(src, true)).toBe(true);
32
+ latch.trackPress(src, false);
33
+ expect(latch.trackPress(src, true)).toBe(true);
34
+ });
35
+ });
@@ -0,0 +1,20 @@
1
+ export interface HoldState {
2
+ elapsed: number;
3
+ armed: boolean;
4
+ }
5
+ export interface HoldUpdate {
6
+ state: HoldState;
7
+ progress: number;
8
+ justFired: boolean;
9
+ }
10
+ export declare const INITIAL_HOLD_STATE: HoldState;
11
+ /**
12
+ * Advances a press-and-hold timer by one frame. `held` is whether the button is down right now.
13
+ *
14
+ * A hold only counts once a release has been seen, so a button that is already down when tracking
15
+ * starts does nothing until the user lets go - otherwise entering a session with a thumb on the
16
+ * button would fire `threshold` seconds later. `justFired` is true only on the frame elapsed first
17
+ * reaches `threshold`, so continuing to hold fires once.
18
+ */
19
+ export declare const advanceHold: (prev: HoldState, held: boolean, dt: number, threshold: number) => HoldUpdate;
20
+ //# sourceMappingURL=xr-button-hold.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"xr-button-hold.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/xr-button-hold.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,SAAS;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,SAAS,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,OAAO,CAAC;CACpB;AAED,eAAO,MAAM,kBAAkB,EAAE,SAAwC,CAAC;AAI1E;;;;;;;GAOG;AACH,eAAO,MAAM,WAAW,GAAI,MAAM,SAAS,EAAE,MAAM,OAAO,EAAE,IAAI,MAAM,EAAE,WAAW,MAAM,KAAG,UAY3F,CAAC"}
@@ -0,0 +1,43 @@
1
+ export const INITIAL_HOLD_STATE = {
2
+ elapsed: 0,
3
+ armed: false
4
+ };
5
+ const RELEASED_STATE = {
6
+ elapsed: 0,
7
+ armed: true
8
+ };
9
+
10
+ /**
11
+ * Advances a press-and-hold timer by one frame. `held` is whether the button is down right now.
12
+ *
13
+ * A hold only counts once a release has been seen, so a button that is already down when tracking
14
+ * starts does nothing until the user lets go - otherwise entering a session with a thumb on the
15
+ * button would fire `threshold` seconds later. `justFired` is true only on the frame elapsed first
16
+ * reaches `threshold`, so continuing to hold fires once.
17
+ */
18
+ export const advanceHold = (prev, held, dt, threshold) => {
19
+ if (!held) {
20
+ return {
21
+ state: RELEASED_STATE,
22
+ progress: 0,
23
+ justFired: false
24
+ };
25
+ }
26
+ if (!prev.armed) {
27
+ return {
28
+ state: prev,
29
+ progress: 0,
30
+ justFired: false
31
+ };
32
+ }
33
+ const elapsed = Math.min(prev.elapsed + dt, threshold);
34
+ const justFired = prev.elapsed < threshold && elapsed >= threshold;
35
+ return {
36
+ state: {
37
+ elapsed,
38
+ armed: true
39
+ },
40
+ progress: elapsed / threshold,
41
+ justFired
42
+ };
43
+ };
@@ -0,0 +1,57 @@
1
+ import { INITIAL_HOLD_STATE, advanceHold } from './xr-button-hold';
2
+ const THRESHOLD = 1.25;
3
+
4
+ /** Arms the timer the way a real release does, so a press can start accumulating. */
5
+ const released = () => advanceHold(INITIAL_HOLD_STATE, false, 0.1, THRESHOLD).state;
6
+ describe('advanceHold', () => {
7
+ it('ignores a button that is already held when tracking starts', () => {
8
+ const result = advanceHold(INITIAL_HOLD_STATE, true, 2, THRESHOLD);
9
+ expect(result.progress).toBe(0);
10
+ expect(result.justFired).toBe(false);
11
+ });
12
+ it('stays unarmed for as long as a pre-held button is down', () => {
13
+ let state = INITIAL_HOLD_STATE;
14
+ for (let frame = 0; frame < 5; frame += 1) {
15
+ state = advanceHold(state, true, 1, THRESHOLD).state;
16
+ }
17
+ expect(advanceHold(state, true, 1, THRESHOLD).justFired).toBe(false);
18
+ });
19
+ it('arms on release so the next press accumulates', () => {
20
+ expect(advanceHold(released(), true, 0.5, THRESHOLD).progress).toBeCloseTo(0.4);
21
+ });
22
+ it('accumulates progress across frames', () => {
23
+ let state = released();
24
+ let progress = 0;
25
+ for (let frame = 0; frame < 3; frame += 1) {
26
+ const result = advanceHold(state, true, 0.25, THRESHOLD);
27
+ state = result.state;
28
+ progress = result.progress;
29
+ }
30
+ expect(progress).toBeCloseTo(0.6);
31
+ });
32
+ it('fires once when the hold reaches the threshold', () => {
33
+ const partial = advanceHold(released(), true, 1, THRESHOLD).state;
34
+ const firing = advanceHold(partial, true, 1, THRESHOLD);
35
+ expect(firing.justFired).toBe(true);
36
+ expect(firing.progress).toBe(1);
37
+ expect(advanceHold(firing.state, true, 1, THRESHOLD).justFired).toBe(false);
38
+ });
39
+ it('clamps progress to the threshold', () => {
40
+ const result = advanceHold(released(), true, 99, THRESHOLD);
41
+ expect(result.progress).toBe(1);
42
+ expect(result.justFired).toBe(true);
43
+ });
44
+ it('resets to zero on release', () => {
45
+ const partial = advanceHold(released(), true, 1, THRESHOLD).state;
46
+ const result = advanceHold(partial, false, 0.1, THRESHOLD);
47
+ expect(result.progress).toBe(0);
48
+ expect(result.state.elapsed).toBe(0);
49
+ });
50
+ it('fires again only after a fresh press', () => {
51
+ const fired = advanceHold(advanceHold(released(), true, 1, THRESHOLD).state, true, 1, THRESHOLD).state;
52
+ const afterRelease = advanceHold(fired, false, 0.1, THRESHOLD).state;
53
+ const rePressed = advanceHold(afterRelease, true, 1, THRESHOLD);
54
+ expect(rePressed.justFired).toBe(false);
55
+ expect(advanceHold(rePressed.state, true, 1, THRESHOLD).justFired).toBe(true);
56
+ });
57
+ });
@@ -19,17 +19,34 @@ export declare class XrExitButton extends Script {
19
19
  private _headPosition;
20
20
  private _headRotation;
21
21
  private _worldOffset;
22
- private _faceButtonDown;
22
+ private _faceButtons;
23
+ private _canvas?;
24
+ private _context?;
25
+ private _drawnProgress;
26
+ private _hold;
27
+ private _arming;
23
28
  private _isVrActive;
24
29
  private _onXrStart;
25
30
  private _onXrEnd;
26
31
  private _onSelect;
27
- private _rayHitsButton;
28
- private _faceButtonPressed;
32
+ /** True when the ray points at the button's hit sphere. False while the button entity is disabled. */
33
+ rayHitsButton(origin: Vec3, direction: Vec3): boolean;
34
+ private _createCanvas;
29
35
  private _createTexture;
36
+ /**
37
+ * Repaints the button: dark disc, then the hold progress wedge, then the X arms on top so the
38
+ * glyph stays legible over the fill. The pie is skipped entirely at zero progress so the idle
39
+ * button doesn't wear the pie's faint track. Returns whether it painted, so callers can tell a
40
+ * missing context from a successful repaint.
41
+ */
42
+ private _drawButton;
43
+ /** Repaints only when the drawn wedge would visibly move. Only records the paint on success, so a
44
+ * missing context doesn't fool the dedupe cache into thinking a repaint already happened. */
45
+ private _setProgress;
46
+ private _resetProgress;
30
47
  private _createMesh;
31
48
  initialize(): void;
32
- update(): void;
49
+ update(dt: number): void;
33
50
  /**
34
51
  * Pin the button to the upper-right of the headset view. The camera entity's transform is
35
52
  * overwritten every frame by WalkthroughCamera and is not the rendered head pose, so the head
@@ -1 +1 @@
1
- {"version":3,"file":"xr-exit-button.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/xr-exit-button.ts"],"names":[],"mappings":"AAAA,OAAO,EAUL,MAAM,EAGN,IAAI,EAGL,MAAM,YAAY,CAAC;AAEpB;;;GAGG;AACH,eAAO,MAAM,kBAAkB,GAAI,QAAQ,IAAI,EAAE,WAAW,IAAI,EAAE,QAAQ,IAAI,EAAE,QAAQ,MAAM,KAAG,OAUhG,CAAC;AAOF,qBAAa,YAAa,SAAQ,MAAM;IACtC,MAAM,CAAC,UAAU,SAAkB;IAEnC,yGAAyG;IACzG,MAAM,OAA8B;IAEpC,oFAAoF;IACpF,QAAQ,SAAQ;IAEhB,sGAAsG;IACtG,SAAS,SAAQ;IAEjB,OAAO,CAAC,SAAS,CAAC,CAAmB;IACrC,OAAO,CAAC,QAAQ,CAAC,CAAU;IAC3B,OAAO,CAAC,KAAK,CAAC,CAAO;IACrB,OAAO,CAAC,QAAQ,CAAS;IAEzB,OAAO,CAAC,aAAa,CAAc;IACnC,OAAO,CAAC,aAAa,CAAc;IACnC,OAAO,CAAC,YAAY,CAAc;IAClC,OAAO,CAAC,eAAe,CAAyC;IAEhE,OAAO,CAAC,WAAW,CAAyE;IAE5F,OAAO,CAAC,UAAU,CAEhB;IAEF,OAAO,CAAC,QAAQ,CAQd;IAEF,OAAO,CAAC,SAAS,CAOf;IAEF,OAAO,CAAC,cAAc;IAStB,OAAO,CAAC,kBAAkB;IAM1B,OAAO,CAAC,cAAc;IAyCtB,OAAO,CAAC,WAAW;IAYnB,UAAU;IA6BV,MAAM;IAqCN;;;;;OAKG;IACH,OAAO,CAAC,UAAU;IAuBlB,OAAO;CAcR"}
1
+ {"version":3,"file":"xr-exit-button.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/xr-exit-button.ts"],"names":[],"mappings":"AAAA,OAAO,EAUL,MAAM,EAGN,IAAI,EAGL,MAAM,YAAY,CAAC;AAOpB;;;GAGG;AACH,eAAO,MAAM,kBAAkB,GAAI,QAAQ,IAAI,EAAE,WAAW,IAAI,EAAE,QAAQ,IAAI,EAAE,QAAQ,MAAM,KAAG,OAUhG,CAAC;AAOF,qBAAa,YAAa,SAAQ,MAAM;IACtC,MAAM,CAAC,UAAU,SAAkB;IAEnC,yGAAyG;IACzG,MAAM,OAA8B;IAEpC,oFAAoF;IACpF,QAAQ,SAAQ;IAEhB,sGAAsG;IACtG,SAAS,SAAQ;IAEjB,OAAO,CAAC,SAAS,CAAC,CAAmB;IACrC,OAAO,CAAC,QAAQ,CAAC,CAAU;IAC3B,OAAO,CAAC,KAAK,CAAC,CAAO;IACrB,OAAO,CAAC,QAAQ,CAAS;IAEzB,OAAO,CAAC,aAAa,CAAc;IACnC,OAAO,CAAC,aAAa,CAAc;IACnC,OAAO,CAAC,YAAY,CAAc;IAClC,OAAO,CAAC,YAAY,CAAgC;IAEpD,OAAO,CAAC,OAAO,CAAC,CAAoB;IACpC,OAAO,CAAC,QAAQ,CAAC,CAA2B;IAC5C,OAAO,CAAC,cAAc,CAAM;IAC5B,OAAO,CAAC,KAAK,CAAiC;IAC9C,OAAO,CAAC,OAAO,CAA2B;IAE1C,OAAO,CAAC,WAAW,CAAyE;IAE5F,OAAO,CAAC,UAAU,CAGhB;IAEF,OAAO,CAAC,QAAQ,CASd;IAEF,OAAO,CAAC,SAAS,CAOf;IAEF,sGAAsG;IACtG,aAAa,CAAC,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,GAAG,OAAO;IAQrD,OAAO,CAAC,aAAa;IAQrB,OAAO,CAAC,cAAc;IAgBtB;;;;;OAKG;IACH,OAAO,CAAC,WAAW;IAiCnB;iGAC6F;IAC7F,OAAO,CAAC,YAAY;IAUpB,OAAO,CAAC,cAAc;IAMtB,OAAO,CAAC,WAAW;IAYnB,UAAU;IAgCV,MAAM,CAAC,EAAE,EAAE,MAAM;IAoDjB;;;;;OAKG;IACH,OAAO,CAAC,UAAU;IAuBlB,OAAO;CAgBR"}
@@ -1,4 +1,8 @@
1
1
  import { ADDRESS_CLAMP_TO_EDGE, BLEND_NORMAL, CULLFACE_NONE, Color, FILTER_LINEAR, LAYERID_IMMEDIATE, Mesh, MeshInstance, Quat, Script, StandardMaterial, Texture, Vec3, XRTYPE_VR } from 'playcanvas';
2
+ import { ButtonArmingLatch } from './xr-button-arming';
3
+ import { INITIAL_HOLD_STATE, advanceHold } from './xr-button-hold';
4
+ import { FaceButtonPressTracker, secondaryFaceButtonPressed } from './xr-face-buttons';
5
+ import { drawProgressPie } from './xr-progress-pie';
2
6
 
3
7
  /**
4
8
  * Boolean ray-sphere hit test. Treats the ray as a half-line (t >= 0) and returns true when it
@@ -16,8 +20,8 @@ export const raySphereIntersect = (origin, direction, center, radius) => {
16
20
  };
17
21
  const TEXTURE_SIZE = 256;
18
22
 
19
- /** xr-standard gamepad button indices for the face buttons: A/X (4) and B/Y (5). */
20
- const FACE_BUTTON_INDICES = [4, 5];
23
+ /** Seconds of continuous B/Y hold required to leave the session. Matches the gaze-dwell threshold. */
24
+ const EXIT_HOLD_SECONDS = 1.25;
21
25
  export class XrExitButton extends Script {
22
26
  static scriptName = 'xrExitButton';
23
27
 
@@ -33,14 +37,19 @@ export class XrExitButton extends Script {
33
37
  _headPosition = new Vec3();
34
38
  _headRotation = new Quat();
35
39
  _worldOffset = new Vec3();
36
- _faceButtonDown = new WeakMap();
40
+ _faceButtons = new FaceButtonPressTracker();
41
+ _drawnProgress = -1;
42
+ _hold = INITIAL_HOLD_STATE;
43
+ _arming = new ButtonArmingLatch();
37
44
  _isVrActive = () => this.app.xr?.active === true && this.app.xr?.type === XRTYPE_VR;
38
45
  _onXrStart = () => {
39
46
  this.entity.enabled = this._isVrActive();
47
+ this._resetProgress();
40
48
  };
41
49
  _onXrEnd = () => {
42
50
  this.entity.enabled = false;
43
51
  this._hovered = false;
52
+ this._resetProgress();
44
53
  this.entity.setLocalScale(1, 1, 1);
45
54
  if (this._material) {
46
55
  this._material.opacity = 0.9;
@@ -51,40 +60,25 @@ export class XrExitButton extends Script {
51
60
  if (!this.entity.enabled) {
52
61
  return;
53
62
  }
54
- if (this._rayHitsButton(inputSource)) {
63
+ if (this.rayHitsButton(inputSource.getOrigin(), inputSource.getDirection())) {
55
64
  this.app.xr?.end();
56
65
  }
57
66
  };
58
- _rayHitsButton(inputSource) {
59
- return raySphereIntersect(inputSource.getOrigin(), inputSource.getDirection(), this.entity.getPosition(), this.hitRadius);
60
- }
61
- _faceButtonPressed(inputSource) {
62
- const buttons = inputSource.gamepad?.buttons;
63
- return buttons ? FACE_BUTTON_INDICES.some(index => buttons[index]?.pressed === true) : false;
67
+
68
+ /** True when the ray points at the button's hit sphere. False while the button entity is disabled. */
69
+ rayHitsButton(origin, direction) {
70
+ if (!this.entity.enabled) {
71
+ return false;
72
+ }
73
+ return raySphereIntersect(origin, direction, this.entity.getPosition(), this.hitRadius);
64
74
  }
65
- _createTexture() {
75
+ _createCanvas() {
66
76
  const canvas = document.createElement('canvas');
67
77
  canvas.width = TEXTURE_SIZE;
68
78
  canvas.height = TEXTURE_SIZE;
69
- const ctx = canvas.getContext('2d');
70
- const c = TEXTURE_SIZE / 2;
71
- if (ctx) {
72
- ctx.clearRect(0, 0, TEXTURE_SIZE, TEXTURE_SIZE);
73
- ctx.fillStyle = 'rgba(20, 20, 20, 0.75)';
74
- ctx.beginPath();
75
- ctx.arc(c, c, c * 0.92, 0, Math.PI * 2);
76
- ctx.fill();
77
- const arm = TEXTURE_SIZE * 0.24;
78
- ctx.strokeStyle = '#ffffff';
79
- ctx.lineWidth = TEXTURE_SIZE * 0.09;
80
- ctx.lineCap = 'round';
81
- ctx.beginPath();
82
- ctx.moveTo(c - arm, c - arm);
83
- ctx.lineTo(c + arm, c + arm);
84
- ctx.moveTo(c + arm, c - arm);
85
- ctx.lineTo(c - arm, c + arm);
86
- ctx.stroke();
87
- }
79
+ return canvas;
80
+ }
81
+ _createTexture(canvas) {
88
82
  const texture = new Texture(this.app.graphicsDevice, {
89
83
  name: 'xr-exit-button',
90
84
  width: TEXTURE_SIZE,
@@ -98,6 +92,56 @@ export class XrExitButton extends Script {
98
92
  texture.setSource(canvas);
99
93
  return texture;
100
94
  }
95
+
96
+ /**
97
+ * Repaints the button: dark disc, then the hold progress wedge, then the X arms on top so the
98
+ * glyph stays legible over the fill. The pie is skipped entirely at zero progress so the idle
99
+ * button doesn't wear the pie's faint track. Returns whether it painted, so callers can tell a
100
+ * missing context from a successful repaint.
101
+ */
102
+ _drawButton(progress) {
103
+ const ctx = this._context;
104
+ if (!ctx) {
105
+ return false;
106
+ }
107
+ const c = TEXTURE_SIZE / 2;
108
+ ctx.clearRect(0, 0, TEXTURE_SIZE, TEXTURE_SIZE);
109
+ ctx.fillStyle = 'rgba(20, 20, 20, 0.75)';
110
+ ctx.beginPath();
111
+ ctx.arc(c, c, c * 0.92, 0, Math.PI * 2);
112
+ ctx.fill();
113
+ if (progress > 0) {
114
+ drawProgressPie(ctx, TEXTURE_SIZE, progress);
115
+ }
116
+ const arm = TEXTURE_SIZE * 0.24;
117
+ ctx.strokeStyle = '#ffffff';
118
+ ctx.lineWidth = TEXTURE_SIZE * 0.09;
119
+ ctx.lineCap = 'round';
120
+ ctx.beginPath();
121
+ ctx.moveTo(c - arm, c - arm);
122
+ ctx.lineTo(c + arm, c + arm);
123
+ ctx.moveTo(c + arm, c - arm);
124
+ ctx.lineTo(c - arm, c + arm);
125
+ ctx.stroke();
126
+ this._texture?.upload();
127
+ return true;
128
+ }
129
+
130
+ /** Repaints only when the drawn wedge would visibly move. Only records the paint on success, so a
131
+ * missing context doesn't fool the dedupe cache into thinking a repaint already happened. */
132
+ _setProgress(progress) {
133
+ if (Math.abs(progress - this._drawnProgress) <= 0.001) {
134
+ return;
135
+ }
136
+ if (this._drawButton(progress)) {
137
+ this._drawnProgress = progress;
138
+ }
139
+ }
140
+ _resetProgress() {
141
+ this._hold = INITIAL_HOLD_STATE;
142
+ this._drawnProgress = -1;
143
+ this._setProgress(0);
144
+ }
101
145
  _createMesh() {
102
146
  const h = this.halfSize;
103
147
  const mesh = new Mesh(this.app.graphicsDevice);
@@ -109,7 +153,10 @@ export class XrExitButton extends Script {
109
153
  return mesh;
110
154
  }
111
155
  initialize() {
112
- this._texture = this._createTexture();
156
+ this._canvas = this._createCanvas();
157
+ this._context = this._canvas.getContext('2d') ?? undefined;
158
+ this._texture = this._createTexture(this._canvas);
159
+ this._resetProgress();
113
160
  const material = new StandardMaterial();
114
161
  material.useLighting = false;
115
162
  material.emissive = new Color(1, 1, 1);
@@ -136,26 +183,38 @@ export class XrExitButton extends Script {
136
183
  this.app.xr?.on('end', this._onXrEnd);
137
184
  this.app.xr?.input?.on('select', this._onSelect);
138
185
  }
139
- update() {
186
+ update(dt) {
140
187
  if (!this.entity.enabled) {
141
188
  return;
142
189
  }
143
190
  this._trackHead();
144
191
  const sources = this.app.xr?.input?.inputSources ?? [];
145
192
  let hovered = false;
193
+ let holdActive = false;
146
194
  for (const source of sources) {
147
- const sourceHovers = this._rayHitsButton(source);
195
+ const sourceHovers = this.rayHitsButton(source.getOrigin(), source.getDirection());
148
196
  hovered = hovered || sourceHovers;
149
197
 
150
198
  // A face-button press exits only while that controller is aimed at the button, matching the
151
199
  // trigger. Edge-detected so holding a button and then aiming onto it doesn't fire.
152
- const facePressed = this._faceButtonPressed(source);
153
- const faceWasDown = this._faceButtonDown.get(source) ?? false;
154
- this._faceButtonDown.set(source, facePressed);
155
- if (sourceHovers && facePressed && !faceWasDown) {
200
+ const facePressed = this._faceButtons.justPressed(source);
201
+ if (sourceHovers && facePressed) {
156
202
  this.app.xr?.end();
157
203
  return;
158
204
  }
205
+
206
+ // Two statements, not `holdActive = holdActive || this._arming.trackPress(...)`: trackPress
207
+ // arms the source as a side effect and must run for every source every frame, but `||`
208
+ // short-circuits once holdActive is true and would skip arming later sources.
209
+ const armedPress = this._arming.trackPress(source, secondaryFaceButtonPressed(source));
210
+ holdActive = holdActive || armedPress;
211
+ }
212
+ const hold = advanceHold(this._hold, holdActive, dt, EXIT_HOLD_SECONDS);
213
+ this._hold = hold.state;
214
+ this._setProgress(hold.progress);
215
+ if (hold.justFired) {
216
+ this.app.xr?.end();
217
+ return;
159
218
  }
160
219
  if (hovered !== this._hovered) {
161
220
  this._hovered = hovered;
@@ -203,5 +262,7 @@ export class XrExitButton extends Script {
203
262
  this._mesh = undefined;
204
263
  this._material = undefined;
205
264
  this._texture = undefined;
265
+ this._canvas = undefined;
266
+ this._context = undefined;
206
267
  }
207
268
  }