@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,203 @@
1
+ import { Vec3 } from 'playcanvas';
2
+ import { boundaryWallMesh } from './boundary-wall';
3
+ const FLAT = [new Vec3(0, 0, 0), new Vec3(1, 0, 0), new Vec3(0, 0, 1)];
4
+ const BASE = {
5
+ center: new Vec3(0, 0, 0),
6
+ radius: 5,
7
+ groundPlane: FLAT,
8
+ baseY: 0,
9
+ topY: 2.5,
10
+ gridSpacing: 0.5
11
+ };
12
+ const toVertices = positions => {
13
+ const out = [];
14
+ for (let i = 0; i < positions.length; i += 3) {
15
+ out.push([positions[i], positions[i + 1], positions[i + 2]]);
16
+ }
17
+ return out;
18
+ };
19
+ describe('boundaryWallMesh', () => {
20
+ it('produces four vertices, four UVs and six indices per segment', () => {
21
+ const geom = boundaryWallMesh({
22
+ ...BASE,
23
+ segments: 16
24
+ });
25
+ expect(geom.positions).toHaveLength(16 * 4 * 3);
26
+ expect(geom.uvs).toHaveLength(16 * 4 * 2);
27
+ expect(geom.indices).toHaveLength(16 * 6);
28
+ });
29
+ it('places every vertex at the boundary radius in XZ', () => {
30
+ const center = new Vec3(2, 0, -3);
31
+ const geom = boundaryWallMesh({
32
+ ...BASE,
33
+ center,
34
+ radius: 4,
35
+ segments: 32
36
+ });
37
+ for (const [x,, z] of toVertices(geom.positions)) {
38
+ expect(Math.sqrt((x - center.x) ** 2 + (z - center.z) ** 2)).toBeCloseTo(4);
39
+ }
40
+ });
41
+ it('puts the top ring flat at topY for every column, even over tilted ground', () => {
42
+ // y = z, so the bottom sweeps -3..3 while the top has to stay pinned at 4.
43
+ const tilted = [new Vec3(0, 0, 0), new Vec3(1, 0, 0), new Vec3(0, 1, 1)];
44
+ const geom = boundaryWallMesh({
45
+ ...BASE,
46
+ groundPlane: tilted,
47
+ radius: 3,
48
+ topY: 4,
49
+ segments: 8
50
+ });
51
+ const verts = toVertices(geom.positions);
52
+ // Per segment the order is: bottom@a0, top@a0, bottom@a1, top@a1.
53
+ for (let i = 0; i < verts.length; i += 4) {
54
+ expect(verts[i + 1][1]).toBeCloseTo(4);
55
+ expect(verts[i + 3][1]).toBeCloseTo(4);
56
+ }
57
+ });
58
+ it('sits the base on a tilted ground plane (y = z)', () => {
59
+ const tilted = [new Vec3(0, 0, 0), new Vec3(1, 0, 0), new Vec3(0, 1, 1)];
60
+ const geom = boundaryWallMesh({
61
+ ...BASE,
62
+ groundPlane: tilted,
63
+ radius: 3,
64
+ topY: 4,
65
+ segments: 8
66
+ });
67
+ const verts = toVertices(geom.positions);
68
+ for (let i = 0; i < verts.length; i += 4) {
69
+ expect(verts[i][1]).toBeCloseTo(verts[i][2]);
70
+ expect(verts[i + 2][1]).toBeCloseTo(verts[i + 2][2]);
71
+ }
72
+ });
73
+ it('falls back to a flat base at baseY when no ground plane is supplied', () => {
74
+ const geom = boundaryWallMesh({
75
+ ...BASE,
76
+ groundPlane: [],
77
+ baseY: 1.5,
78
+ topY: 3.5,
79
+ segments: 8
80
+ });
81
+ const verts = toVertices(geom.positions);
82
+ expect(verts).not.toHaveLength(0);
83
+ for (let i = 0; i < verts.length; i += 4) {
84
+ expect(verts[i][1]).toBeCloseTo(1.5);
85
+ expect(verts[i + 1][1]).toBeCloseTo(3.5);
86
+ }
87
+ });
88
+ it('falls back to a flat base for a degenerate ground plane', () => {
89
+ const collinear = [new Vec3(0, 0, 0), new Vec3(1, 0, 0), new Vec3(2, 0, 0)];
90
+ const geom = boundaryWallMesh({
91
+ ...BASE,
92
+ groundPlane: collinear,
93
+ baseY: -2,
94
+ segments: 8
95
+ });
96
+ const verts = toVertices(geom.positions);
97
+ expect(verts).not.toHaveLength(0);
98
+ expect(verts[0][1]).toBeCloseTo(-2);
99
+ expect(verts[1][1]).toBeCloseTo(2.5);
100
+ });
101
+ it('reports whole-number grid cell counts derived from gridSpacing', () => {
102
+ // Circumference 2*pi*5 = 31.4159; at 0.5 m spacing that rounds to 63 columns.
103
+ // The ground is flat at y = 0, so rows = round((2.5 - 0) / 0.5) = 5.
104
+ const geom = boundaryWallMesh({
105
+ ...BASE,
106
+ radius: 5,
107
+ topY: 2.5,
108
+ gridSpacing: 0.5,
109
+ segments: 8
110
+ });
111
+ expect(geom.columns).toBe(63);
112
+ expect(geom.rows).toBe(5);
113
+ });
114
+ it('derives rows from the average bottom, not from any single column', () => {
115
+ // Plane y = x - 1, sampled on a radius-3 circle at 6 columns (0, 60, 120, 180, 240, 300 deg):
116
+ // bottoms are 2, 0.5, -2.5, -4, -2.5, 0.5 -> average -1, which no column matches.
117
+ // rows = round((2.5 - -1) / 0.5) = round(7) = 7.
118
+ const tilted = [new Vec3(1, 0, 0), new Vec3(2, 1, 0), new Vec3(1, 0, 1)];
119
+ const geom = boundaryWallMesh({
120
+ ...BASE,
121
+ groundPlane: tilted,
122
+ radius: 3,
123
+ topY: 2.5,
124
+ segments: 6
125
+ });
126
+ const bottoms = toVertices(geom.positions).filter((_, index) => index % 2 === 0).map(([, y]) => y);
127
+ expect(bottoms).toHaveLength(12);
128
+ for (const y of bottoms) {
129
+ expect(Math.abs(y + 1)).toBeGreaterThan(1);
130
+ }
131
+ expect(geom.rows).toBe(7);
132
+ });
133
+ it('emits u in cell units that wrap exactly at `columns`', () => {
134
+ const geom = boundaryWallMesh({
135
+ ...BASE,
136
+ segments: 8
137
+ });
138
+ const us = [];
139
+ for (let i = 0; i < geom.uvs.length; i += 2) {
140
+ us.push(geom.uvs[i]);
141
+ }
142
+ expect(us[0]).toBeCloseTo(0);
143
+ // Last vertex of the last segment closes the loop at exactly `columns`.
144
+ expect(us[us.length - 1]).toBeCloseTo(geom.columns);
145
+ });
146
+ it('emits v as 0 at the base and `rows` at the top', () => {
147
+ const geom = boundaryWallMesh({
148
+ ...BASE,
149
+ segments: 8
150
+ });
151
+ for (let i = 0; i < geom.uvs.length; i += 8) {
152
+ expect(geom.uvs[i + 1]).toBeCloseTo(0);
153
+ expect(geom.uvs[i + 3]).toBeCloseTo(geom.rows);
154
+ }
155
+ });
156
+ it('references only in-range vertices from the index buffer', () => {
157
+ const geom = boundaryWallMesh({
158
+ ...BASE,
159
+ segments: 8
160
+ });
161
+ const vertexCount = geom.positions.length / 3;
162
+ for (const idx of geom.indices) {
163
+ expect(idx).toBeGreaterThanOrEqual(0);
164
+ expect(idx).toBeLessThan(vertexCount);
165
+ }
166
+ });
167
+ it('returns empty geometry for a non-positive radius', () => {
168
+ const geom = boundaryWallMesh({
169
+ ...BASE,
170
+ radius: 0
171
+ });
172
+ expect(geom.positions).toHaveLength(0);
173
+ expect(geom.indices).toHaveLength(0);
174
+ });
175
+ it('returns empty geometry for a non-positive segment count', () => {
176
+ expect(boundaryWallMesh({
177
+ ...BASE,
178
+ segments: 0
179
+ }).positions).toHaveLength(0);
180
+ });
181
+ it('returns empty geometry for a non-positive grid spacing', () => {
182
+ expect(boundaryWallMesh({
183
+ ...BASE,
184
+ gridSpacing: 0
185
+ }).positions).toHaveLength(0);
186
+ });
187
+ it('returns empty geometry when topY is level with the base', () => {
188
+ expect(boundaryWallMesh({
189
+ ...BASE,
190
+ topY: 0
191
+ }).positions).toHaveLength(0);
192
+ });
193
+ it('returns empty geometry when topY clears only part of a tilted base', () => {
194
+ // y = z on a radius-3 circle reaches 3, so a top at 2 is below the far half of the wall.
195
+ const tilted = [new Vec3(0, 0, 0), new Vec3(1, 0, 0), new Vec3(0, 1, 1)];
196
+ expect(boundaryWallMesh({
197
+ ...BASE,
198
+ groundPlane: tilted,
199
+ radius: 3,
200
+ topY: 2
201
+ }).positions).toHaveLength(0);
202
+ });
203
+ });
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Greedily wraps `text` into lines no wider than `maxWidth`, using `measure` to size
3
+ * each candidate line. A single word wider than `maxWidth` still gets its own line.
4
+ */
5
+ export declare const wrapText: (text: string, maxWidth: number, measure: (segment: string) => number) => string[];
6
+ //# sourceMappingURL=vr-annotation-panel-layout.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vr-annotation-panel-layout.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/vr-annotation-panel-layout.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,eAAO,MAAM,QAAQ,GAAI,MAAM,MAAM,EAAE,UAAU,MAAM,EAAE,SAAS,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,KAAG,MAAM,EAoBrG,CAAC"}
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Greedily wraps `text` into lines no wider than `maxWidth`, using `measure` to size
3
+ * each candidate line. A single word wider than `maxWidth` still gets its own line.
4
+ */
5
+ export const wrapText = (text, maxWidth, measure) => {
6
+ const words = text.split(/\s+/).filter(Boolean);
7
+ const lines = [];
8
+ let current = '';
9
+ words.forEach(word => {
10
+ const candidate = current ? `${current} ${word}` : word;
11
+ if (!current || measure(candidate) <= maxWidth) {
12
+ current = candidate;
13
+ } else {
14
+ lines.push(current);
15
+ current = word;
16
+ }
17
+ });
18
+ if (current) {
19
+ lines.push(current);
20
+ }
21
+ return lines;
22
+ };
@@ -0,0 +1,26 @@
1
+ import { wrapText } from './vr-annotation-panel-layout';
2
+
3
+ // Each character is 1 unit wide; a space counts as a character within a line.
4
+ const measure = s => s.length;
5
+ describe('wrapText', () => {
6
+ it('returns an empty array for empty or whitespace-only input', () => {
7
+ expect(wrapText('', 10, measure)).toEqual([]);
8
+ expect(wrapText(' ', 10, measure)).toEqual([]);
9
+ });
10
+ it('keeps text on one line when it fits', () => {
11
+ expect(wrapText('hello world', 11, measure)).toEqual(['hello world']);
12
+ });
13
+ it('wraps to a new line when the next word would exceed the width', () => {
14
+ // 'hello world' is 11 wide; width 10 forces a break before 'world'.
15
+ expect(wrapText('hello world', 10, measure)).toEqual(['hello', 'world']);
16
+ });
17
+ it('packs as many words as fit per line greedily', () => {
18
+ expect(wrapText('a bb cc d', 5, measure)).toEqual(['a bb', 'cc d']);
19
+ });
20
+ it('puts an over-long word on its own line rather than dropping it', () => {
21
+ expect(wrapText('tiny enormouslylongword end', 8, measure)).toEqual(['tiny', 'enormouslylongword', 'end']);
22
+ });
23
+ it('collapses runs of whitespace', () => {
24
+ expect(wrapText('a b', 10, measure)).toEqual(['a b']);
25
+ });
26
+ });
@@ -0,0 +1,40 @@
1
+ import { Script, Vec3 } from 'playcanvas';
2
+ export declare class VrAnnotationPanel extends Script {
3
+ static scriptName: string;
4
+ title: string;
5
+ label?: string;
6
+ bodyText?: string;
7
+ imageUrls?: string[];
8
+ annotationIndex: number;
9
+ scaleFactor: number;
10
+ private _material?;
11
+ private _texture?;
12
+ private _mesh?;
13
+ private _canvas?;
14
+ private _drawnSignature;
15
+ private _currentImage;
16
+ private _loadToken;
17
+ private _headRotation;
18
+ private _anchor;
19
+ private _scratchDir;
20
+ private _yawQuat;
21
+ private _axisRight;
22
+ private _axisUp;
23
+ private _scratchScale;
24
+ private _onSelect;
25
+ initialize(): void;
26
+ private _createMesh;
27
+ private _roundRect;
28
+ private _drawArrow;
29
+ private _drawDots;
30
+ private _drawContent;
31
+ private _setImage;
32
+ private _loadCurrentImage;
33
+ private _rayQuadHit;
34
+ /** True when the ray points at the panel quad (used to suppress the empty-select dismiss). */
35
+ rayHitsPanel(origin: Vec3, direction: Vec3): boolean;
36
+ update(): void;
37
+ private _trackHead;
38
+ private _teardown;
39
+ }
40
+ //# sourceMappingURL=vr-annotation-panel.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vr-annotation-panel.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/vr-annotation-panel.ts"],"names":[],"mappings":"AAAA,OAAO,EAUL,MAAM,EAGN,IAAI,EAEL,MAAM,YAAY,CAAC;AAiCpB,qBAAa,iBAAkB,SAAQ,MAAM;IAC3C,MAAM,CAAC,UAAU,SAAuB;IAGxC,KAAK,SAAM;IACX,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;IACrB,eAAe,SAAM;IACrB,WAAW,SAAK;IAEhB,OAAO,CAAC,SAAS,CAAC,CAAmB;IACrC,OAAO,CAAC,QAAQ,CAAC,CAAU;IAC3B,OAAO,CAAC,KAAK,CAAC,CAAO;IACrB,OAAO,CAAC,OAAO,CAAC,CAAoB;IACpC,OAAO,CAAC,eAAe,CAAuB;IAC9C,OAAO,CAAC,aAAa,CAAK;IAC1B,OAAO,CAAC,UAAU,CAAK;IAEvB,OAAO,CAAC,aAAa,CAAc;IACnC,OAAO,CAAC,OAAO,CAAc;IAC7B,OAAO,CAAC,WAAW,CAAc;IACjC,OAAO,CAAC,QAAQ,CAAc;IAC9B,OAAO,CAAC,UAAU,CAAc;IAChC,OAAO,CAAC,OAAO,CAAc;IAC7B,OAAO,CAAC,aAAa,CAAc;IAEnC,OAAO,CAAC,SAAS,CAsBf;IAEF,UAAU;IAwCV,OAAO,CAAC,WAAW;IAanB,OAAO,CAAC,UAAU;IAUlB,OAAO,CAAC,UAAU;IAkBlB,OAAO,CAAC,SAAS;IAWjB,OAAO,CAAC,YAAY;IAqFpB,OAAO,CAAC,SAAS;IAMjB,OAAO,CAAC,iBAAiB;IAkBzB,OAAO,CAAC,WAAW;IAmBnB,8FAA8F;IAC9F,YAAY,CAAC,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,GAAG,OAAO;IAIpD,MAAM;IAsCN,OAAO,CAAC,UAAU;IAQlB,OAAO,CAAC,SAAS;CAYlB"}
@@ -0,0 +1,312 @@
1
+ import { ADDRESS_CLAMP_TO_EDGE, BLEND_NORMAL, CULLFACE_NONE, Color, FILTER_LINEAR, LAYERID_IMMEDIATE, Mesh, MeshInstance, Quat, Script, StandardMaterial, Texture, Vec3 } from 'playcanvas';
2
+ import { wrapText } from './vr-annotation-panel-layout';
3
+ import { rayQuadHit } from './xr-ray-quad';
4
+ const CANVAS_WIDTH = 1024;
5
+ const CANVAS_HEIGHT = 768;
6
+
7
+ /** Panel width in world meters; height derived from the canvas aspect ratio. */
8
+ const PANEL_WIDTH = 0.5;
9
+ const PANEL_HEIGHT = PANEL_WIDTH * CANVAS_HEIGHT / CANVAS_WIDTH;
10
+
11
+ /** World-space offset from the anchored hotspot: raise the panel above it so the
12
+ * (tall) panel clears the hotspot rather than covering it. */
13
+ const PANEL_OFFSET = new Vec3(0, PANEL_HEIGHT / 2 + 0.05, 0);
14
+ const PAD_X = 48;
15
+
16
+ /** Local +z (the quad's front face) — used to derive the panel's yaw toward the viewer. */
17
+ const FORWARD_Z = new Vec3(0, 0, 1);
18
+ /** Quad in-plane axes in local space, transformed to world for ray hit-testing. */
19
+ const RIGHT_AXIS = new Vec3(1, 0, 0);
20
+ const UP_AXIS = new Vec3(0, 1, 0);
21
+ const RAD_TO_DEG = 180 / Math.PI;
22
+
23
+ /** Fixed image band (canvas px) so arrows/dots keep a stable position while images load. */
24
+ const IMAGE_Y = 40;
25
+ const IMAGE_H = 360;
26
+
27
+ /** Width (canvas px) of the left/right arrow hit + draw zones over the image band. */
28
+ const ARROW_ZONE_W = 170;
29
+ export class VrAnnotationPanel extends Script {
30
+ static scriptName = 'vrAnnotationPanel';
31
+
32
+ // Props assigned by the React wrapper.
33
+ title = '';
34
+ annotationIndex = -1;
35
+ scaleFactor = 1;
36
+ _drawnSignature = null;
37
+ _currentImage = 0;
38
+ _loadToken = 0;
39
+ _headRotation = new Quat();
40
+ _anchor = new Vec3();
41
+ _scratchDir = new Vec3();
42
+ _yawQuat = new Quat();
43
+ _axisRight = new Vec3();
44
+ _axisUp = new Vec3();
45
+ _scratchScale = new Vec3();
46
+ _onSelect = inputSource => {
47
+ if (this.app.xr?.active !== true) {
48
+ return;
49
+ }
50
+ const images = this.imageUrls ?? [];
51
+ if (images.length <= 1) {
52
+ return;
53
+ }
54
+ const hit = this._rayQuadHit(inputSource.getOrigin(), inputSource.getDirection());
55
+ if (!hit) {
56
+ return;
57
+ }
58
+ const cx = (hit.u + 1) / 2 * CANVAS_WIDTH;
59
+ const cy = (1 - hit.v) / 2 * CANVAS_HEIGHT;
60
+ if (cy < IMAGE_Y || cy > IMAGE_Y + IMAGE_H) {
61
+ return;
62
+ }
63
+ if (cx <= ARROW_ZONE_W) {
64
+ this._setImage((this._currentImage - 1 + images.length) % images.length);
65
+ } else if (cx >= CANVAS_WIDTH - ARROW_ZONE_W) {
66
+ this._setImage((this._currentImage + 1) % images.length);
67
+ }
68
+ };
69
+ initialize() {
70
+ this._canvas = document.createElement('canvas');
71
+ this._canvas.width = CANVAS_WIDTH;
72
+ this._canvas.height = CANVAS_HEIGHT;
73
+ this._texture = new Texture(this.app.graphicsDevice, {
74
+ name: 'vr-annotation-panel',
75
+ width: CANVAS_WIDTH,
76
+ height: CANVAS_HEIGHT,
77
+ addressU: ADDRESS_CLAMP_TO_EDGE,
78
+ addressV: ADDRESS_CLAMP_TO_EDGE,
79
+ minFilter: FILTER_LINEAR,
80
+ magFilter: FILTER_LINEAR,
81
+ mipmaps: true
82
+ });
83
+ this._texture.setSource(this._canvas);
84
+ const material = new StandardMaterial();
85
+ material.useLighting = false;
86
+ material.emissive = new Color(1, 1, 1);
87
+ material.emissiveMap = this._texture;
88
+ material.opacityMap = this._texture;
89
+ material.opacityMapChannel = 'a';
90
+ material.blendType = BLEND_NORMAL;
91
+ material.depthTest = false;
92
+ material.depthWrite = false;
93
+ material.cull = CULLFACE_NONE;
94
+ material.update();
95
+ this._material = material;
96
+ this._mesh = this._createMesh();
97
+ const meshInstance = new MeshInstance(this._mesh, material);
98
+ this.entity.addComponent('render', {
99
+ meshInstances: [meshInstance],
100
+ layers: [LAYERID_IMMEDIATE]
101
+ });
102
+ this.app.xr?.input?.on('select', this._onSelect);
103
+ // Script removal fires a 'destroy' event rather than calling a destroy() method, so the
104
+ // teardown has to be registered as a listener or the handler and GPU resources outlive us.
105
+ this.once('destroy', () => this._teardown());
106
+ }
107
+ _createMesh() {
108
+ const hw = PANEL_WIDTH / 2;
109
+ const hh = PANEL_HEIGHT / 2;
110
+ const mesh = new Mesh(this.app.graphicsDevice);
111
+ mesh.setPositions([-hw, -hh, 0, hw, -hh, 0, hw, hh, 0, -hw, hh, 0]);
112
+ mesh.setNormals([0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1]);
113
+ mesh.setUvs(0, [0, 1, 1, 1, 1, 0, 0, 0]);
114
+ mesh.setIndices([0, 1, 2, 0, 2, 3]);
115
+ mesh.update();
116
+ return mesh;
117
+ }
118
+ _roundRect(ctx, x, y, w, h, r) {
119
+ ctx.beginPath();
120
+ ctx.moveTo(x + r, y);
121
+ ctx.arcTo(x + w, y, x + w, y + h, r);
122
+ ctx.arcTo(x + w, y + h, x, y + h, r);
123
+ ctx.arcTo(x, y + h, x, y, r);
124
+ ctx.arcTo(x, y, x + w, y, r);
125
+ ctx.closePath();
126
+ }
127
+ _drawArrow(ctx, cx, cy, pointsLeft) {
128
+ ctx.beginPath();
129
+ ctx.arc(cx, cy, 40, 0, Math.PI * 2);
130
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.45)';
131
+ ctx.fill();
132
+ const s = 16;
133
+ const tip = pointsLeft ? cx - s * 0.6 : cx + s * 0.6;
134
+ const base = pointsLeft ? cx + s * 0.6 : cx - s * 0.6;
135
+ ctx.beginPath();
136
+ ctx.moveTo(base, cy - s);
137
+ ctx.lineTo(tip, cy);
138
+ ctx.lineTo(base, cy + s);
139
+ ctx.closePath();
140
+ ctx.fillStyle = '#ffffff';
141
+ ctx.fill();
142
+ }
143
+ _drawDots(ctx, count, dotY) {
144
+ const gap = 26;
145
+ const startX = CANVAS_WIDTH / 2 - (count - 1) * gap / 2;
146
+ for (let i = 0; i < count; i++) {
147
+ ctx.beginPath();
148
+ ctx.arc(startX + i * gap, dotY, 7, 0, Math.PI * 2);
149
+ ctx.fillStyle = i === this._currentImage ? 'rgba(0, 0, 0, 0.85)' : 'rgba(0, 0, 0, 0.25)';
150
+ ctx.fill();
151
+ }
152
+ }
153
+ _drawContent(image) {
154
+ const ctx = this._canvas?.getContext('2d');
155
+ if (!ctx) {
156
+ return;
157
+ }
158
+ ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
159
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.96)';
160
+ this._roundRect(ctx, 0, 0, CANVAS_WIDTH, CANVAS_HEIGHT, 32);
161
+ ctx.fill();
162
+ let y = IMAGE_Y;
163
+ const contentWidth = CANVAS_WIDTH - PAD_X * 2;
164
+ const images = this.imageUrls ?? [];
165
+ if (images.length > 0) {
166
+ ctx.save();
167
+ this._roundRect(ctx, PAD_X, IMAGE_Y, contentWidth, IMAGE_H, 16);
168
+ ctx.clip();
169
+ if (image) {
170
+ const scale = Math.max(contentWidth / image.width, IMAGE_H / image.height);
171
+ const dw = image.width * scale;
172
+ const dh = image.height * scale;
173
+ ctx.drawImage(image, PAD_X + (contentWidth - dw) / 2, IMAGE_Y + (IMAGE_H - dh) / 2, dw, dh);
174
+ } else {
175
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.06)';
176
+ ctx.fillRect(PAD_X, IMAGE_Y, contentWidth, IMAGE_H);
177
+ }
178
+ ctx.restore();
179
+ y = IMAGE_Y + IMAGE_H + 16;
180
+ if (images.length > 1) {
181
+ const bandCenterY = IMAGE_Y + IMAGE_H / 2;
182
+ this._drawArrow(ctx, ARROW_ZONE_W / 2, bandCenterY, true);
183
+ this._drawArrow(ctx, CANVAS_WIDTH - ARROW_ZONE_W / 2, bandCenterY, false);
184
+ this._drawDots(ctx, images.length, y + 4);
185
+ y += 32;
186
+ }
187
+ y += 16;
188
+ }
189
+ if (this.label) {
190
+ ctx.font = '600 28px sans-serif';
191
+ ctx.textBaseline = 'middle';
192
+ const chipW = ctx.measureText(this.label).width + 32;
193
+ const chipH = 44;
194
+ ctx.fillStyle = 'rgba(44, 134, 88, 0.95)';
195
+ this._roundRect(ctx, PAD_X, y, chipW, chipH, 12);
196
+ ctx.fill();
197
+ ctx.fillStyle = '#ffffff';
198
+ ctx.fillText(this.label, PAD_X + 16, y + chipH / 2);
199
+ y += chipH + 24;
200
+ }
201
+ ctx.textBaseline = 'top';
202
+ ctx.fillStyle = '#000000';
203
+ ctx.font = '700 48px sans-serif';
204
+ const titleLines = wrapText(this.title, contentWidth, s => ctx.measureText(s).width);
205
+ for (const line of titleLines) {
206
+ if (y > CANVAS_HEIGHT - 40) {
207
+ break;
208
+ }
209
+ ctx.fillText(line, PAD_X, y);
210
+ y += 56;
211
+ }
212
+ y += 16;
213
+ if (this.bodyText) {
214
+ ctx.font = '400 32px sans-serif';
215
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.85)';
216
+ const lines = wrapText(this.bodyText, contentWidth, s => ctx.measureText(s).width);
217
+ for (const line of lines) {
218
+ if (y > CANVAS_HEIGHT - 40) {
219
+ break;
220
+ }
221
+ ctx.fillText(line, PAD_X, y);
222
+ y += 40;
223
+ }
224
+ }
225
+ this._texture?.upload();
226
+ }
227
+ _setImage(index) {
228
+ this._currentImage = index;
229
+ this._drawContent();
230
+ this._loadCurrentImage();
231
+ }
232
+ _loadCurrentImage() {
233
+ const url = this.imageUrls?.[this._currentImage];
234
+ if (!url) {
235
+ return;
236
+ }
237
+ const token = ++this._loadToken;
238
+ const img = new Image();
239
+ img.crossOrigin = 'anonymous';
240
+ img.onload = () => {
241
+ // Ignore a stale load that a newer navigation has superseded.
242
+ if (token === this._loadToken) {
243
+ this._drawContent(img);
244
+ }
245
+ };
246
+ // onerror (e.g. a host without CORS headers): keep the placeholder + text already drawn.
247
+ img.src = url;
248
+ }
249
+ _rayQuadHit(origin, direction) {
250
+ const rotation = this.entity.getRotation();
251
+ rotation.transformVector(RIGHT_AXIS, this._axisRight);
252
+ rotation.transformVector(UP_AXIS, this._axisUp);
253
+ this.entity.getWorldTransform().getScale(this._scratchScale);
254
+ const halfWidth = PANEL_WIDTH / 2 * this._scratchScale.x;
255
+ const halfHeight = PANEL_HEIGHT / 2 * this._scratchScale.y;
256
+ return rayQuadHit(origin, direction, this.entity.getPosition(), this._axisRight, this._axisUp, halfWidth, halfHeight);
257
+ }
258
+
259
+ /** True when the ray points at the panel quad (used to suppress the empty-select dismiss). */
260
+ rayHitsPanel(origin, direction) {
261
+ return this._rayQuadHit(origin, direction) !== null;
262
+ }
263
+ update() {
264
+ if (this.app.xr?.active !== true) {
265
+ return;
266
+ }
267
+ const signature = `${this.title}|${this.label ?? ''}|${this.bodyText ?? ''}|${(this.imageUrls ?? []).join(',')}`;
268
+ if (signature !== this._drawnSignature) {
269
+ this._drawnSignature = signature;
270
+ this._currentImage = 0;
271
+ this._drawContent();
272
+ this._loadCurrentImage();
273
+ }
274
+ this._trackHead();
275
+ const anchor = this.app.root.findByName(`annotation-${this.annotationIndex}`);
276
+ if (!anchor) {
277
+ return;
278
+ }
279
+ this._anchor.copy(anchor.getPosition());
280
+ // Panel size and its offset from the hotspot scale with the scene so the panel keeps its
281
+ // proportions relative to the (content-root-scaled) annotations across scenes.
282
+ this.entity.setLocalScale(this.scaleFactor, this.scaleFactor, this.scaleFactor);
283
+ this.entity.setPosition(this._anchor.x + PANEL_OFFSET.x * this.scaleFactor, this._anchor.y + PANEL_OFFSET.y * this.scaleFactor, this._anchor.z + PANEL_OFFSET.z * this.scaleFactor);
284
+
285
+ // Billboard on yaw only: face the viewer horizontally but stay upright and level, so
286
+ // tilting/rolling the headset does not tilt the panel. Derived from the head's forward
287
+ // heading, so it matches the level head-rotation orientation but with pitch/roll removed.
288
+ this._headRotation.transformVector(FORWARD_Z, this._scratchDir);
289
+ const yaw = Math.atan2(this._scratchDir.x, this._scratchDir.z) * RAD_TO_DEG;
290
+ this._yawQuat.setFromEulerAngles(0, yaw, 0);
291
+ this.entity.setRotation(this._yawQuat);
292
+ }
293
+ _trackHead() {
294
+ const views = this.app.xr?.views?.list;
295
+ if (!views || views.length === 0) {
296
+ return;
297
+ }
298
+ this._headRotation.setFromMat4(views[0].viewInvOffMat);
299
+ }
300
+ _teardown() {
301
+ this.app.xr?.input?.off('select', this._onSelect);
302
+ if (this.entity.render) {
303
+ this.entity.render.meshInstances = [];
304
+ }
305
+ this._mesh?.destroy();
306
+ this._material?.destroy();
307
+ this._texture?.destroy();
308
+ this._mesh = undefined;
309
+ this._material = undefined;
310
+ this._texture = undefined;
311
+ }
312
+ }
@@ -0,0 +1,18 @@
1
+ import { Vec3 } from 'playcanvas';
2
+ /** Local half-extent of the unit-plane hotspot quad. */
3
+ export declare const HOTSPOT_HALF_EXTENT = 0.5;
4
+ /** Multiplier on the hotspot's world radius to make controller targeting forgiving. */
5
+ export declare const HIT_RADIUS_PAD = 2.5;
6
+ export interface AnnotationCandidate {
7
+ entity: any;
8
+ script: any;
9
+ position: Vec3;
10
+ radius: number;
11
+ }
12
+ /**
13
+ * World-space hit candidates for every openable annotation hotspot: its entity, its stock
14
+ * annotation script (which carries `onVrOpenCallback`), its world position, and a hit-sphere
15
+ * radius derived from the hotspot's rendered world size, padded by `radiusPad` for easier aiming.
16
+ */
17
+ export declare const collectAnnotationHitCandidates: (app: any, radiusPad: number) => AnnotationCandidate[];
18
+ //# sourceMappingURL=xr-annotation-candidates.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"xr-annotation-candidates.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/xr-annotation-candidates.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AAGlC,wDAAwD;AACxD,eAAO,MAAM,mBAAmB,MAAM,CAAC;AAEvC,uFAAuF;AACvF,eAAO,MAAM,cAAc,MAAM,CAAC;AAElC,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,GAAG,CAAC;IACZ,MAAM,EAAE,GAAG,CAAC;IACZ,QAAQ,EAAE,IAAI,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAID;;;;GAIG;AACH,eAAO,MAAM,8BAA8B,GAAI,KAAK,GAAG,EAAE,WAAW,MAAM,KAAG,mBAAmB,EAmB/F,CAAC"}
@@ -0,0 +1,38 @@
1
+ import { Vec3 } from 'playcanvas';
2
+ import { Annotation as PcAnnotation } from 'playcanvas/scripts/esm/annotations.mjs';
3
+
4
+ /** Local half-extent of the unit-plane hotspot quad. */
5
+ export const HOTSPOT_HALF_EXTENT = 0.5;
6
+
7
+ /** Multiplier on the hotspot's world radius to make controller targeting forgiving. */
8
+ export const HIT_RADIUS_PAD = 2.5;
9
+ const scratchScale = new Vec3();
10
+
11
+ /**
12
+ * World-space hit candidates for every openable annotation hotspot: its entity, its stock
13
+ * annotation script (which carries `onVrOpenCallback`), its world position, and a hit-sphere
14
+ * radius derived from the hotspot's rendered world size, padded by `radiusPad` for easier aiming.
15
+ */
16
+ export const collectAnnotationHitCandidates = (app, radiusPad) => {
17
+ const root = app.root.findByName('annotations-root');
18
+ if (!root) {
19
+ return [];
20
+ }
21
+ return root.children.map(entity => ({
22
+ entity,
23
+ script: entity.script?.get(PcAnnotation.scriptName)
24
+ })).filter(({
25
+ script
26
+ }) => script && script.enabled !== false && typeof script.onVrOpenCallback === 'function').map(({
27
+ entity,
28
+ script
29
+ }) => {
30
+ entity.getWorldTransform().getScale(scratchScale);
31
+ return {
32
+ entity,
33
+ script,
34
+ position: entity.getPosition(),
35
+ radius: HOTSPOT_HALF_EXTENT * scratchScale.x * radiusPad
36
+ };
37
+ });
38
+ };