@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,15 @@
1
+ import { XrInputSource } from 'playcanvas';
2
+ /** True while either face button is held. False for hand-tracked sources, which have no gamepad. */
3
+ export declare const faceButtonPressed: (inputSource: XrInputSource) => boolean;
4
+ /** True while B or Y is held, regardless of which hand holds the controller. */
5
+ export declare const secondaryFaceButtonPressed: (inputSource: XrInputSource) => boolean;
6
+ /**
7
+ * Edge-detects face-button presses per input source so one press fires once. Callers must poll every
8
+ * frame for every source they care about; a press and release between polls is missed.
9
+ */
10
+ export declare class FaceButtonPressTracker {
11
+ private _down;
12
+ /** True only on the poll where a face button goes from released to pressed. */
13
+ justPressed(inputSource: XrInputSource): boolean;
14
+ }
15
+ //# sourceMappingURL=xr-face-buttons.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"xr-face-buttons.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/xr-face-buttons.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAc3C,oGAAoG;AACpG,eAAO,MAAM,iBAAiB,GAAI,aAAa,aAAa,KAAG,OACX,CAAC;AAErD,gFAAgF;AAChF,eAAO,MAAM,0BAA0B,GAAI,aAAa,aAAa,KAAG,OACV,CAAC;AAE/D;;;GAGG;AACH,qBAAa,sBAAsB;IACjC,OAAO,CAAC,KAAK,CAAyC;IAEtD,+EAA+E;IAC/E,WAAW,CAAC,WAAW,EAAE,aAAa,GAAG,OAAO;CAOjD"}
@@ -0,0 +1,31 @@
1
+ /** xr-standard gamepad button indices for the face buttons: A/X (4) and B/Y (5). */
2
+ const FACE_BUTTON_INDICES = [4, 5];
3
+
4
+ /** xr-standard gamepad button indices for the secondary face button: B on the right hand, Y on the left. */
5
+ const SECONDARY_FACE_BUTTON_INDICES = [5];
6
+ const anyButtonPressed = (inputSource, indices) => {
7
+ const buttons = inputSource.gamepad?.buttons;
8
+ return buttons ? indices.some(index => buttons[index]?.pressed === true) : false;
9
+ };
10
+
11
+ /** True while either face button is held. False for hand-tracked sources, which have no gamepad. */
12
+ export const faceButtonPressed = inputSource => anyButtonPressed(inputSource, FACE_BUTTON_INDICES);
13
+
14
+ /** True while B or Y is held, regardless of which hand holds the controller. */
15
+ export const secondaryFaceButtonPressed = inputSource => anyButtonPressed(inputSource, SECONDARY_FACE_BUTTON_INDICES);
16
+
17
+ /**
18
+ * Edge-detects face-button presses per input source so one press fires once. Callers must poll every
19
+ * frame for every source they care about; a press and release between polls is missed.
20
+ */
21
+ export class FaceButtonPressTracker {
22
+ _down = new WeakMap();
23
+
24
+ /** True only on the poll where a face button goes from released to pressed. */
25
+ justPressed(inputSource) {
26
+ const pressed = faceButtonPressed(inputSource);
27
+ const wasPressed = this._down.get(inputSource) ?? false;
28
+ this._down.set(inputSource, pressed);
29
+ return pressed && !wasPressed;
30
+ }
31
+ }
@@ -0,0 +1,82 @@
1
+ import { FaceButtonPressTracker, faceButtonPressed, secondaryFaceButtonPressed } from './xr-face-buttons';
2
+
3
+ /** Fake input source whose gamepad reports `pressedIndices` as held. */
4
+ const source = (pressedIndices, buttonCount = 6) => ({
5
+ gamepad: {
6
+ buttons: Array.from({
7
+ length: buttonCount
8
+ }, (_, index) => ({
9
+ pressed: pressedIndices.includes(index)
10
+ }))
11
+ }
12
+ });
13
+ const handTrackedSource = () => ({});
14
+ describe('faceButtonPressed', () => {
15
+ it('is false for an input source with no gamepad', () => {
16
+ expect(faceButtonPressed(handTrackedSource())).toBe(false);
17
+ });
18
+ it('is false when the gamepad has fewer buttons than the face buttons', () => {
19
+ expect(faceButtonPressed(source([0], 2))).toBe(false);
20
+ });
21
+ it('is true when A/X is pressed', () => {
22
+ expect(faceButtonPressed(source([4]))).toBe(true);
23
+ });
24
+ it('is true when B/Y is pressed', () => {
25
+ expect(faceButtonPressed(source([5]))).toBe(true);
26
+ });
27
+ it('is false when only the trigger and grip are pressed', () => {
28
+ expect(faceButtonPressed(source([0, 1]))).toBe(false);
29
+ });
30
+ it('is false when nothing is pressed', () => {
31
+ expect(faceButtonPressed(source([]))).toBe(false);
32
+ });
33
+ });
34
+ describe('secondaryFaceButtonPressed', () => {
35
+ it('is true when B/Y is pressed', () => {
36
+ expect(secondaryFaceButtonPressed(source([5]))).toBe(true);
37
+ });
38
+ it('is false when only A/X is pressed', () => {
39
+ expect(secondaryFaceButtonPressed(source([4]))).toBe(false);
40
+ });
41
+ it('is false for an input source with no gamepad', () => {
42
+ expect(secondaryFaceButtonPressed(handTrackedSource())).toBe(false);
43
+ });
44
+ it('is false when the gamepad has fewer buttons than the face buttons', () => {
45
+ expect(secondaryFaceButtonPressed(source([0], 2))).toBe(false);
46
+ });
47
+ });
48
+ describe('FaceButtonPressTracker', () => {
49
+ it('fires once for a held press and re-arms after release', () => {
50
+ const tracker = new FaceButtonPressTracker();
51
+ // One persistent source whose button state mutates, as a real XrInputSource does across frames.
52
+ const buttons = Array.from({
53
+ length: 6
54
+ }, () => ({
55
+ pressed: false
56
+ }));
57
+ const controller = {
58
+ gamepad: {
59
+ buttons
60
+ }
61
+ };
62
+ const holdFaceButton = pressed => {
63
+ buttons[4].pressed = pressed;
64
+ };
65
+ expect(tracker.justPressed(controller)).toBe(false);
66
+ holdFaceButton(true);
67
+ expect(tracker.justPressed(controller)).toBe(true);
68
+ expect(tracker.justPressed(controller)).toBe(false);
69
+ holdFaceButton(false);
70
+ expect(tracker.justPressed(controller)).toBe(false);
71
+ holdFaceButton(true);
72
+ expect(tracker.justPressed(controller)).toBe(true);
73
+ });
74
+ it('tracks each input source independently', () => {
75
+ const tracker = new FaceButtonPressTracker();
76
+ const left = source([4]);
77
+ const right = source([]);
78
+ expect(tracker.justPressed(left)).toBe(true);
79
+ expect(tracker.justPressed(right)).toBe(false);
80
+ expect(tracker.justPressed(left)).toBe(false);
81
+ });
82
+ });
@@ -0,0 +1,17 @@
1
+ export interface DwellState {
2
+ targetIndex: number | null;
3
+ elapsed: number;
4
+ }
5
+ export interface DwellUpdate {
6
+ state: DwellState;
7
+ progress: number;
8
+ justOpened: boolean;
9
+ }
10
+ export declare const INITIAL_DWELL_STATE: DwellState;
11
+ /**
12
+ * Advances gaze-dwell timing for one frame. `target` is the annotation index currently under the
13
+ * gaze ray, or null. Dwell time accumulates while the same target is held and resets when it
14
+ * changes or is lost. `justOpened` is true only on the frame the dwell first reaches `threshold`.
15
+ */
16
+ export declare const advanceDwell: (prev: DwellState, target: number | null, dt: number, threshold: number) => DwellUpdate;
17
+ //# sourceMappingURL=xr-gaze-dwell-state.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"xr-gaze-dwell-state.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/xr-gaze-dwell-state.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,UAAU;IACzB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,UAAU,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,OAAO,CAAC;CACrB;AAED,eAAO,MAAM,mBAAmB,EAAE,UAA8C,CAAC;AAEjF;;;;GAIG;AACH,eAAO,MAAM,YAAY,GAAI,MAAM,UAAU,EAAE,QAAQ,MAAM,GAAG,IAAI,EAAE,IAAI,MAAM,EAAE,WAAW,MAAM,KAAG,WAYrG,CAAC"}
@@ -0,0 +1,39 @@
1
+ export const INITIAL_DWELL_STATE = {
2
+ targetIndex: null,
3
+ elapsed: 0
4
+ };
5
+
6
+ /**
7
+ * Advances gaze-dwell timing for one frame. `target` is the annotation index currently under the
8
+ * gaze ray, or null. Dwell time accumulates while the same target is held and resets when it
9
+ * changes or is lost. `justOpened` is true only on the frame the dwell first reaches `threshold`.
10
+ */
11
+ export const advanceDwell = (prev, target, dt, threshold) => {
12
+ if (target === null) {
13
+ return {
14
+ state: INITIAL_DWELL_STATE,
15
+ progress: 0,
16
+ justOpened: false
17
+ };
18
+ }
19
+ if (target !== prev.targetIndex) {
20
+ return {
21
+ state: {
22
+ targetIndex: target,
23
+ elapsed: 0
24
+ },
25
+ progress: 0,
26
+ justOpened: false
27
+ };
28
+ }
29
+ const elapsed = Math.min(prev.elapsed + dt, threshold);
30
+ const justOpened = prev.elapsed < threshold && elapsed >= threshold;
31
+ return {
32
+ state: {
33
+ targetIndex: target,
34
+ elapsed
35
+ },
36
+ progress: elapsed / threshold,
37
+ justOpened
38
+ };
39
+ };
@@ -0,0 +1,61 @@
1
+ import { INITIAL_DWELL_STATE, advanceDwell } from './xr-gaze-dwell-state';
2
+ const THRESHOLD = 1.5;
3
+ describe('advanceDwell', () => {
4
+ it('stays reset when there is no gaze target', () => {
5
+ const r = advanceDwell({
6
+ targetIndex: 2,
7
+ elapsed: 1.0
8
+ }, null, 0.1, THRESHOLD);
9
+ expect(r.state).toEqual(INITIAL_DWELL_STATE);
10
+ expect(r.progress).toBe(0);
11
+ expect(r.justOpened).toBe(false);
12
+ });
13
+ it('resets to zero when the target changes', () => {
14
+ const r = advanceDwell({
15
+ targetIndex: 0,
16
+ elapsed: 1.0
17
+ }, 1, 0.1, THRESHOLD);
18
+ expect(r.state).toEqual({
19
+ targetIndex: 1,
20
+ elapsed: 0
21
+ });
22
+ expect(r.progress).toBe(0);
23
+ expect(r.justOpened).toBe(false);
24
+ });
25
+ it('accumulates dwell time while the same target is held', () => {
26
+ const r = advanceDwell({
27
+ targetIndex: 3,
28
+ elapsed: 0.5
29
+ }, 3, 0.25, THRESHOLD);
30
+ expect(r.state).toEqual({
31
+ targetIndex: 3,
32
+ elapsed: 0.75
33
+ });
34
+ expect(r.progress).toBeCloseTo(0.5, 5);
35
+ expect(r.justOpened).toBe(false);
36
+ });
37
+ it('fires justOpened once when crossing the threshold and clamps elapsed', () => {
38
+ const r = advanceDwell({
39
+ targetIndex: 3,
40
+ elapsed: 1.4
41
+ }, 3, 0.5, THRESHOLD);
42
+ expect(r.justOpened).toBe(true);
43
+ expect(r.progress).toBe(1);
44
+ expect(r.state).toEqual({
45
+ targetIndex: 3,
46
+ elapsed: THRESHOLD
47
+ });
48
+ });
49
+ it('does not re-fire while the target stays held past the threshold', () => {
50
+ const r = advanceDwell({
51
+ targetIndex: 3,
52
+ elapsed: THRESHOLD
53
+ }, 3, 0.5, THRESHOLD);
54
+ expect(r.justOpened).toBe(false);
55
+ expect(r.progress).toBe(1);
56
+ expect(r.state).toEqual({
57
+ targetIndex: 3,
58
+ elapsed: THRESHOLD
59
+ });
60
+ });
61
+ });
@@ -0,0 +1,30 @@
1
+ import { Script } from 'playcanvas';
2
+ export declare class XrGazeDwell extends Script {
3
+ static scriptName: string;
4
+ /** Index of the annotation whose panel is currently open, or -1. Excluded from gaze targets so
5
+ * dwell opens OTHER annotations while one is open (and never re-dwells the open one). */
6
+ activeIndex: number;
7
+ private _dwell;
8
+ private _pieEntity?;
9
+ private _pieCanvas?;
10
+ private _pieTexture?;
11
+ private _pieMaterial?;
12
+ private _pieMesh?;
13
+ private _drawnProgress;
14
+ private _headPos;
15
+ private _headRot;
16
+ private _viewOffset;
17
+ private _forward;
18
+ private _scratchScale;
19
+ private _isVrActive;
20
+ initialize(): void;
21
+ private _createQuad;
22
+ /** Redraws the pie fill for the given dwell progress (0..1) onto the texture canvas. */
23
+ private _drawPie;
24
+ private _trackHead;
25
+ update(dt: number): void;
26
+ private _showPie;
27
+ private _hidePie;
28
+ destroy(): void;
29
+ }
30
+ //# sourceMappingURL=xr-gaze-dwell.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"xr-gaze-dwell.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/xr-gaze-dwell.ts"],"names":[],"mappings":"AAAA,OAAO,EAWL,MAAM,EAKP,MAAM,YAAY,CAAC;AAqBpB,qBAAa,WAAY,SAAQ,MAAM;IACrC,MAAM,CAAC,UAAU,SAAiB;IAElC;6FACyF;IACzF,WAAW,SAAM;IAEjB,OAAO,CAAC,MAAM,CAAmC;IAEjD,OAAO,CAAC,UAAU,CAAC,CAAS;IAC5B,OAAO,CAAC,UAAU,CAAC,CAAoB;IACvC,OAAO,CAAC,WAAW,CAAC,CAAU;IAC9B,OAAO,CAAC,YAAY,CAAC,CAAmB;IACxC,OAAO,CAAC,QAAQ,CAAC,CAAO;IACxB,OAAO,CAAC,cAAc,CAAM;IAE5B,OAAO,CAAC,QAAQ,CAAc;IAC9B,OAAO,CAAC,QAAQ,CAAc;IAC9B,OAAO,CAAC,WAAW,CAAc;IACjC,OAAO,CAAC,QAAQ,CAAc;IAC9B,OAAO,CAAC,aAAa,CAAc;IAEnC,OAAO,CAAC,WAAW,CAAyE;IAE5F,UAAU;IAuCV,OAAO,CAAC,WAAW;IAYnB,wFAAwF;IACxF,OAAO,CAAC,QAAQ;IAWhB,OAAO,CAAC,UAAU;IAgBlB,MAAM,CAAC,EAAE,EAAE,MAAM;IAmCjB,OAAO,CAAC,QAAQ;IAmBhB,OAAO,CAAC,QAAQ;IAOhB,OAAO;CAeR"}
@@ -0,0 +1,173 @@
1
+ import { ADDRESS_CLAMP_TO_EDGE, BLEND_NORMAL, CULLFACE_NONE, Color, Entity, FILTER_LINEAR, LAYERID_IMMEDIATE, Mesh, MeshInstance, Quat, Script, StandardMaterial, Texture, Vec3, XRTYPE_VR } from 'playcanvas';
2
+ import { HOTSPOT_HALF_EXTENT, collectAnnotationHitCandidates } from './xr-annotation-candidates';
3
+ import { nearestAnnotationHit } from './xr-annotation-targeting';
4
+ import { INITIAL_DWELL_STATE, advanceDwell } from './xr-gaze-dwell-state';
5
+ import { drawProgressPie } from './xr-progress-pie';
6
+
7
+ /** Multiplier on the hotspot's world radius for gaze targeting. */
8
+ const GAZE_HIT_RADIUS_PAD = 5;
9
+
10
+ /** Seconds of continuous gaze required to open an annotation. */
11
+ const DWELL_SECONDS = 1.25;
12
+
13
+ /** Progress-pie quad radius as a multiple of the hotspot's rendered half-extent (covers the hotspot). */
14
+ const PIE_RADIUS_SCALE = 1.25;
15
+
16
+ /** Progress-pie texture resolution. */
17
+ const PIE_TEXTURE_SIZE = 128;
18
+ const LOCAL_FORWARD = new Vec3(0, 0, -1);
19
+ export class XrGazeDwell extends Script {
20
+ static scriptName = 'xrGazeDwell';
21
+
22
+ /** Index of the annotation whose panel is currently open, or -1. Excluded from gaze targets so
23
+ * dwell opens OTHER annotations while one is open (and never re-dwells the open one). */
24
+ activeIndex = -1;
25
+ _dwell = INITIAL_DWELL_STATE;
26
+ _drawnProgress = -1;
27
+ _headPos = new Vec3();
28
+ _headRot = new Quat();
29
+ _viewOffset = new Vec3();
30
+ _forward = new Vec3();
31
+ _scratchScale = new Vec3();
32
+ _isVrActive = () => this.app.xr?.active === true && this.app.xr?.type === XRTYPE_VR;
33
+ initialize() {
34
+ this._pieCanvas = document.createElement('canvas');
35
+ this._pieCanvas.width = PIE_TEXTURE_SIZE;
36
+ this._pieCanvas.height = PIE_TEXTURE_SIZE;
37
+ this._pieTexture = new Texture(this.app.graphicsDevice, {
38
+ name: 'xr-gaze-dwell-pie',
39
+ width: PIE_TEXTURE_SIZE,
40
+ height: PIE_TEXTURE_SIZE,
41
+ addressU: ADDRESS_CLAMP_TO_EDGE,
42
+ addressV: ADDRESS_CLAMP_TO_EDGE,
43
+ minFilter: FILTER_LINEAR,
44
+ magFilter: FILTER_LINEAR,
45
+ mipmaps: true
46
+ });
47
+ this._pieTexture.setSource(this._pieCanvas);
48
+ const material = new StandardMaterial();
49
+ material.useLighting = false;
50
+ material.emissive = new Color(1, 1, 1);
51
+ material.emissiveMap = this._pieTexture;
52
+ material.opacityMap = this._pieTexture;
53
+ material.opacityMapChannel = 'a';
54
+ material.blendType = BLEND_NORMAL;
55
+ material.depthTest = false;
56
+ material.depthWrite = false;
57
+ material.cull = CULLFACE_NONE;
58
+ material.update();
59
+ this._pieMaterial = material;
60
+ this._pieMesh = this._createQuad();
61
+ const meshInstance = new MeshInstance(this._pieMesh, material);
62
+ this._pieEntity = new Entity('xr-gaze-dwell-pie');
63
+ this._pieEntity.addComponent('render', {
64
+ meshInstances: [meshInstance],
65
+ layers: [LAYERID_IMMEDIATE]
66
+ });
67
+ this._pieEntity.enabled = false;
68
+ this.entity.addChild(this._pieEntity);
69
+ }
70
+ _createQuad() {
71
+ const h = HOTSPOT_HALF_EXTENT;
72
+ const mesh = new Mesh(this.app.graphicsDevice);
73
+ mesh.setPositions([-h, -h, 0, h, -h, 0, h, h, 0, -h, h, 0]);
74
+ mesh.setNormals([0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1]);
75
+ mesh.setUvs(0, [0, 1, 1, 1, 1, 0, 0, 0]);
76
+ mesh.setIndices([0, 1, 2, 0, 2, 3]);
77
+ mesh.update();
78
+ return mesh;
79
+ }
80
+
81
+ /** Redraws the pie fill for the given dwell progress (0..1) onto the texture canvas. */
82
+ _drawPie(progress) {
83
+ const ctx = this._pieCanvas?.getContext('2d');
84
+ if (!ctx) {
85
+ return;
86
+ }
87
+ ctx.clearRect(0, 0, PIE_TEXTURE_SIZE, PIE_TEXTURE_SIZE);
88
+ drawProgressPie(ctx, PIE_TEXTURE_SIZE, progress);
89
+ this._pieTexture?.upload();
90
+ }
91
+ _trackHead() {
92
+ const views = this.app.xr?.views?.list;
93
+ if (!views || views.length === 0) {
94
+ return false;
95
+ }
96
+ this._headPos.set(0, 0, 0);
97
+ for (const view of views) {
98
+ view.viewInvOffMat.getTranslation(this._viewOffset);
99
+ this._headPos.add(this._viewOffset);
100
+ }
101
+ this._headPos.mulScalar(1 / views.length);
102
+ this._headRot.setFromMat4(views[0].viewInvOffMat);
103
+ return true;
104
+ }
105
+ update(dt) {
106
+ if (!this._pieEntity) {
107
+ return;
108
+ }
109
+ if (!this._isVrActive() || !this._trackHead()) {
110
+ this._dwell = INITIAL_DWELL_STATE;
111
+ this._hidePie();
112
+ return;
113
+ }
114
+ const activeName = `annotation-${this.activeIndex}`;
115
+ const candidates = collectAnnotationHitCandidates(this.app, GAZE_HIT_RADIUS_PAD).filter(candidate => candidate.entity.name !== activeName);
116
+ this._headRot.transformVector(LOCAL_FORWARD, this._forward);
117
+ const target = nearestAnnotationHit(this._headPos, this._forward, candidates);
118
+ const result = advanceDwell(this._dwell, target, dt, DWELL_SECONDS);
119
+ this._dwell = result.state;
120
+ if (target !== null && result.progress > 0 && result.progress < 1) {
121
+ this._showPie(candidates[target].entity, result.progress);
122
+ } else {
123
+ this._hidePie();
124
+ }
125
+ if (result.justOpened && target !== null) {
126
+ const {
127
+ entity,
128
+ script
129
+ } = candidates[target];
130
+ const camera = this.app.root.findByName('camera');
131
+ const screen = camera?.camera?.worldToScreen(entity.getPosition());
132
+ script.onVrOpenCallback(screen?.x ?? 0, screen?.y ?? 0);
133
+ }
134
+ }
135
+ _showPie(entity, progress) {
136
+ if (!this._pieEntity) {
137
+ return;
138
+ }
139
+ if (Math.abs(progress - this._drawnProgress) > 0.001) {
140
+ this._drawnProgress = progress;
141
+ this._drawPie(progress);
142
+ }
143
+
144
+ // The unit quad has half-extent HOTSPOT_HALF_EXTENT, so scale it to reach the desired world size.
145
+ entity.getWorldTransform().getScale(this._scratchScale);
146
+ const scale = this._scratchScale.x * PIE_RADIUS_SCALE;
147
+ this._pieEntity.enabled = true;
148
+ this._pieEntity.setLocalScale(scale, scale, scale);
149
+ this._pieEntity.setPosition(entity.getPosition());
150
+ this._pieEntity.setRotation(this._headRot);
151
+ }
152
+ _hidePie() {
153
+ if (this._pieEntity && this._pieEntity.enabled) {
154
+ this._pieEntity.enabled = false;
155
+ this._drawnProgress = -1;
156
+ }
157
+ }
158
+ destroy() {
159
+ if (this._pieEntity) {
160
+ if (this._pieEntity.render) {
161
+ this._pieEntity.render.meshInstances = [];
162
+ }
163
+ this._pieEntity.destroy();
164
+ this._pieEntity = undefined;
165
+ }
166
+ this._pieMesh?.destroy();
167
+ this._pieMaterial?.destroy();
168
+ this._pieTexture?.destroy();
169
+ this._pieMesh = undefined;
170
+ this._pieMaterial = undefined;
171
+ this._pieTexture = undefined;
172
+ }
173
+ }
@@ -0,0 +1,13 @@
1
+ import { Vec3 } from 'playcanvas';
2
+ /** True when the ray points at an openable annotation hotspot. */
3
+ export declare const rayHitsAnnotationHotspot: (app: any, origin: Vec3, direction: Vec3) => boolean;
4
+ /** True when the ray points at the open VR annotation panel. False when no panel is mounted. */
5
+ export declare const rayHitsAnnotationPanel: (app: any, origin: Vec3, direction: Vec3) => boolean;
6
+ /** True when the ray points at the VR exit button. */
7
+ export declare const rayHitsExitButton: (app: any, origin: Vec3, direction: Vec3) => boolean;
8
+ /**
9
+ * True when the ray points at anything the trigger is meant to operate. Teleport consults this so a
10
+ * select that lands on UI doesn't also move the rig.
11
+ */
12
+ export declare const rayHitsInteractiveUi: (app: any, origin: Vec3, direction: Vec3) => boolean;
13
+ //# sourceMappingURL=xr-interactive-ui.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"xr-interactive-ui.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/xr-interactive-ui.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AAOlC,kEAAkE;AAClE,eAAO,MAAM,wBAAwB,GAAI,KAAK,GAAG,EAAE,QAAQ,IAAI,EAAE,WAAW,IAAI,KAAG,OACoB,CAAC;AAExG,gGAAgG;AAChG,eAAO,MAAM,sBAAsB,GAAI,KAAK,GAAG,EAAE,QAAQ,IAAI,EAAE,WAAW,IAAI,KAAG,OAIhF,CAAC;AAEF,sDAAsD;AACtD,eAAO,MAAM,iBAAiB,GAAI,KAAK,GAAG,EAAE,QAAQ,IAAI,EAAE,WAAW,IAAI,KAAG,OAI3E,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,GAAG,EAAE,QAAQ,IAAI,EAAE,WAAW,IAAI,KAAG,OAGpC,CAAC"}
@@ -0,0 +1,25 @@
1
+ import { VrAnnotationPanel } from './vr-annotation-panel';
2
+ import { HIT_RADIUS_PAD, collectAnnotationHitCandidates } from './xr-annotation-candidates';
3
+ import { nearestAnnotationHit } from './xr-annotation-targeting';
4
+ import { XrExitButton } from './xr-exit-button';
5
+
6
+ /** True when the ray points at an openable annotation hotspot. */
7
+ export const rayHitsAnnotationHotspot = (app, origin, direction) => nearestAnnotationHit(origin, direction, collectAnnotationHitCandidates(app, HIT_RADIUS_PAD)) !== null;
8
+
9
+ /** True when the ray points at the open VR annotation panel. False when no panel is mounted. */
10
+ export const rayHitsAnnotationPanel = (app, origin, direction) => {
11
+ const script = app.root.findByName('vr-annotation-panel')?.script?.get(VrAnnotationPanel.scriptName);
12
+ return typeof script?.rayHitsPanel === 'function' && script.rayHitsPanel(origin, direction) === true;
13
+ };
14
+
15
+ /** True when the ray points at the VR exit button. */
16
+ export const rayHitsExitButton = (app, origin, direction) => {
17
+ const script = app.root.findByName('xr-exit-button')?.script?.get(XrExitButton.scriptName);
18
+ return typeof script?.rayHitsButton === 'function' && script.rayHitsButton(origin, direction) === true;
19
+ };
20
+
21
+ /**
22
+ * True when the ray points at anything the trigger is meant to operate. Teleport consults this so a
23
+ * select that lands on UI doesn't also move the rig.
24
+ */
25
+ export const rayHitsInteractiveUi = (app, origin, direction) => rayHitsAnnotationHotspot(app, origin, direction) || rayHitsAnnotationPanel(app, origin, direction) || rayHitsExitButton(app, origin, direction);
@@ -0,0 +1,165 @@
1
+ import { Vec3 } from 'playcanvas';
2
+ import { VrAnnotationPanel } from './vr-annotation-panel';
3
+ import { XrExitButton } from './xr-exit-button';
4
+ import { rayHitsAnnotationHotspot, rayHitsAnnotationPanel, rayHitsExitButton, rayHitsInteractiveUi } from './xr-interactive-ui';
5
+
6
+ // Jest cannot parse the PlayCanvas ESM scripts that xr-annotation-candidates imports.
7
+ jest.mock('playcanvas/scripts/esm/annotations.mjs', () => ({
8
+ Annotation: {
9
+ scriptName: 'annotation'
10
+ }
11
+ }));
12
+ const ORIGIN = new Vec3(0, 0, 5);
13
+ const FORWARD = new Vec3(0, 0, -1);
14
+
15
+ /** Fake annotation hotspot entity: unit quad at `position`, scale 1, with the stock open callback. */
16
+ const hotspot = position => ({
17
+ getPosition: () => position,
18
+ getWorldTransform: () => ({
19
+ getScale: out => out.set(1, 1, 1)
20
+ }),
21
+ script: {
22
+ get: () => ({
23
+ onVrOpenCallback: () => undefined
24
+ })
25
+ }
26
+ });
27
+ /**
28
+ * Fake app whose `findByName` serves only the entities the helpers look up. Each script stub returns
29
+ * its fake only for the matching `scriptName`, so a lookup by the wrong name misses - the way a real
30
+ * `Script#get` would - instead of silently succeeding.
31
+ */
32
+ const fakeApp = ({
33
+ hotspots,
34
+ panelHit,
35
+ panelHasScript = true,
36
+ exitHit
37
+ }) => ({
38
+ root: {
39
+ findByName: name => {
40
+ if (name === 'annotations-root') {
41
+ return hotspots ? {
42
+ children: hotspots
43
+ } : null;
44
+ }
45
+ if (name === 'vr-annotation-panel') {
46
+ if (panelHit === undefined) {
47
+ return null;
48
+ }
49
+ return {
50
+ script: {
51
+ get: scriptName => scriptName === VrAnnotationPanel.scriptName ? panelHasScript ? {
52
+ rayHitsPanel: () => panelHit
53
+ } : {} : undefined
54
+ }
55
+ };
56
+ }
57
+ if (name === 'xr-exit-button') {
58
+ return exitHit === undefined ? null : {
59
+ script: {
60
+ get: scriptName => scriptName === XrExitButton.scriptName ? {
61
+ rayHitsButton: () => exitHit
62
+ } : undefined
63
+ }
64
+ };
65
+ }
66
+ return null;
67
+ }
68
+ }
69
+ });
70
+ describe('rayHitsAnnotationHotspot', () => {
71
+ it('is true when the ray enters a hotspot sphere', () => {
72
+ const app = fakeApp({
73
+ hotspots: [hotspot(new Vec3(0, 0, 0))]
74
+ });
75
+ expect(rayHitsAnnotationHotspot(app, ORIGIN, FORWARD)).toBe(true);
76
+ });
77
+ it('is false when the ray misses every hotspot', () => {
78
+ const app = fakeApp({
79
+ hotspots: [hotspot(new Vec3(0, 8, 0))]
80
+ });
81
+ expect(rayHitsAnnotationHotspot(app, ORIGIN, FORWARD)).toBe(false);
82
+ });
83
+ it('is false when there is no annotations root', () => {
84
+ expect(rayHitsAnnotationHotspot(fakeApp({}), ORIGIN, FORWARD)).toBe(false);
85
+ });
86
+
87
+ // Pins the derived radius (HOTSPOT_HALF_EXTENT 0.5 * scale 1 * HIT_RADIUS_PAD 2.5 = 1.25) against
88
+ // drift: a hotspot at (0,0,0) or (0,8,0) would pass the two tests above for nearly any radius.
89
+ it('is true just inside the derived hit radius', () => {
90
+ const app = fakeApp({
91
+ hotspots: [hotspot(new Vec3(0, 1.2, 0))]
92
+ });
93
+ expect(rayHitsAnnotationHotspot(app, ORIGIN, FORWARD)).toBe(true);
94
+ });
95
+ it('is false just outside the derived hit radius', () => {
96
+ const app = fakeApp({
97
+ hotspots: [hotspot(new Vec3(0, 1.3, 0))]
98
+ });
99
+ expect(rayHitsAnnotationHotspot(app, ORIGIN, FORWARD)).toBe(false);
100
+ });
101
+ });
102
+ describe('rayHitsAnnotationPanel', () => {
103
+ it('delegates to the panel script', () => {
104
+ expect(rayHitsAnnotationPanel(fakeApp({
105
+ panelHit: true
106
+ }), ORIGIN, FORWARD)).toBe(true);
107
+ expect(rayHitsAnnotationPanel(fakeApp({
108
+ panelHit: false
109
+ }), ORIGIN, FORWARD)).toBe(false);
110
+ });
111
+ it('is false when no panel is mounted', () => {
112
+ expect(rayHitsAnnotationPanel(fakeApp({}), ORIGIN, FORWARD)).toBe(false);
113
+ });
114
+ it('is false when the panel entity has no hit-test method', () => {
115
+ const app = fakeApp({
116
+ panelHit: true,
117
+ panelHasScript: false
118
+ });
119
+ expect(rayHitsAnnotationPanel(app, ORIGIN, FORWARD)).toBe(false);
120
+ });
121
+ });
122
+ describe('rayHitsExitButton', () => {
123
+ it('delegates to the exit button script', () => {
124
+ expect(rayHitsExitButton(fakeApp({
125
+ exitHit: true
126
+ }), ORIGIN, FORWARD)).toBe(true);
127
+ expect(rayHitsExitButton(fakeApp({
128
+ exitHit: false
129
+ }), ORIGIN, FORWARD)).toBe(false);
130
+ });
131
+ it('is false when no exit button is mounted', () => {
132
+ expect(rayHitsExitButton(fakeApp({}), ORIGIN, FORWARD)).toBe(false);
133
+ });
134
+ });
135
+ describe('rayHitsInteractiveUi', () => {
136
+ it('is false when the ray hits nothing interactive', () => {
137
+ const app = fakeApp({
138
+ hotspots: [hotspot(new Vec3(0, 8, 0))],
139
+ panelHit: false,
140
+ exitHit: false
141
+ });
142
+ expect(rayHitsInteractiveUi(app, ORIGIN, FORWARD)).toBe(false);
143
+ });
144
+ it('is true for a hotspot hit', () => {
145
+ const app = fakeApp({
146
+ hotspots: [hotspot(new Vec3(0, 0, 0))],
147
+ panelHit: false,
148
+ exitHit: false
149
+ });
150
+ expect(rayHitsInteractiveUi(app, ORIGIN, FORWARD)).toBe(true);
151
+ });
152
+ it('is true for a panel hit', () => {
153
+ expect(rayHitsInteractiveUi(fakeApp({
154
+ panelHit: true
155
+ }), ORIGIN, FORWARD)).toBe(true);
156
+ });
157
+ it('is true for an exit button hit', () => {
158
+ expect(rayHitsInteractiveUi(fakeApp({
159
+ exitHit: true
160
+ }), ORIGIN, FORWARD)).toBe(true);
161
+ });
162
+ it('is false in an empty scene', () => {
163
+ expect(rayHitsInteractiveUi(fakeApp({}), ORIGIN, FORWARD)).toBe(false);
164
+ });
165
+ });