@terraware/web-components 4.2.21 → 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 (93) 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/TfXrNavigation.d.ts +42 -6
  7. package/components/VirtualWalkthrough/TfXrNavigation.d.ts.map +1 -1
  8. package/components/VirtualWalkthrough/TfXrNavigation.js +109 -9
  9. package/components/VirtualWalkthrough/TfXrNavigation.test.js +110 -0
  10. package/components/VirtualWalkthrough/VirtualWalkthroughViewer.d.ts.map +1 -1
  11. package/components/VirtualWalkthrough/VirtualWalkthroughViewer.js +50 -3
  12. package/components/VirtualWalkthrough/VrAnnotationPanel.d.ts +9 -0
  13. package/components/VirtualWalkthrough/VrAnnotationPanel.d.ts.map +1 -0
  14. package/components/VirtualWalkthrough/VrAnnotationPanel.js +22 -0
  15. package/components/VirtualWalkthrough/XrAnnotationInteraction.d.ts +6 -0
  16. package/components/VirtualWalkthrough/XrAnnotationInteraction.d.ts.map +1 -0
  17. package/components/VirtualWalkthrough/XrAnnotationInteraction.js +15 -0
  18. package/components/VirtualWalkthrough/XrGazeDwell.d.ts +6 -0
  19. package/components/VirtualWalkthrough/XrGazeDwell.d.ts.map +1 -0
  20. package/components/VirtualWalkthrough/XrGazeDwell.js +15 -0
  21. package/components/VirtualWalkthrough/XrPointerRay.d.ts +7 -0
  22. package/components/VirtualWalkthrough/XrPointerRay.d.ts.map +1 -0
  23. package/components/VirtualWalkthrough/XrPointerRay.js +15 -0
  24. package/components/VirtualWalkthrough/boundary-wall.d.ts +86 -0
  25. package/components/VirtualWalkthrough/boundary-wall.d.ts.map +1 -0
  26. package/components/VirtualWalkthrough/boundary-wall.js +331 -0
  27. package/components/VirtualWalkthrough/boundary-wall.test.js +203 -0
  28. package/components/VirtualWalkthrough/vr-annotation-panel-layout.d.ts +6 -0
  29. package/components/VirtualWalkthrough/vr-annotation-panel-layout.d.ts.map +1 -0
  30. package/components/VirtualWalkthrough/vr-annotation-panel-layout.js +22 -0
  31. package/components/VirtualWalkthrough/vr-annotation-panel-layout.test.js +26 -0
  32. package/components/VirtualWalkthrough/vr-annotation-panel.d.ts +40 -0
  33. package/components/VirtualWalkthrough/vr-annotation-panel.d.ts.map +1 -0
  34. package/components/VirtualWalkthrough/vr-annotation-panel.js +312 -0
  35. package/components/VirtualWalkthrough/xr-annotation-candidates.d.ts +18 -0
  36. package/components/VirtualWalkthrough/xr-annotation-candidates.d.ts.map +1 -0
  37. package/components/VirtualWalkthrough/xr-annotation-candidates.js +38 -0
  38. package/components/VirtualWalkthrough/xr-annotation-interaction.d.ts +13 -0
  39. package/components/VirtualWalkthrough/xr-annotation-interaction.d.ts.map +1 -0
  40. package/components/VirtualWalkthrough/xr-annotation-interaction.js +59 -0
  41. package/components/VirtualWalkthrough/xr-annotation-targeting.d.ts +10 -0
  42. package/components/VirtualWalkthrough/xr-annotation-targeting.d.ts.map +1 -0
  43. package/components/VirtualWalkthrough/xr-annotation-targeting.js +36 -0
  44. package/components/VirtualWalkthrough/xr-annotation-targeting.test.js +66 -0
  45. package/components/VirtualWalkthrough/xr-button-arming.d.ts +14 -0
  46. package/components/VirtualWalkthrough/xr-button-arming.d.ts.map +1 -0
  47. package/components/VirtualWalkthrough/xr-button-arming.js +19 -0
  48. package/components/VirtualWalkthrough/xr-button-arming.test.js +35 -0
  49. package/components/VirtualWalkthrough/xr-button-hold.d.ts +20 -0
  50. package/components/VirtualWalkthrough/xr-button-hold.d.ts.map +1 -0
  51. package/components/VirtualWalkthrough/xr-button-hold.js +43 -0
  52. package/components/VirtualWalkthrough/xr-button-hold.test.js +57 -0
  53. package/components/VirtualWalkthrough/xr-exit-button.d.ts +21 -4
  54. package/components/VirtualWalkthrough/xr-exit-button.d.ts.map +1 -1
  55. package/components/VirtualWalkthrough/xr-exit-button.js +98 -37
  56. package/components/VirtualWalkthrough/xr-face-buttons.d.ts +15 -0
  57. package/components/VirtualWalkthrough/xr-face-buttons.d.ts.map +1 -0
  58. package/components/VirtualWalkthrough/xr-face-buttons.js +31 -0
  59. package/components/VirtualWalkthrough/xr-face-buttons.test.js +82 -0
  60. package/components/VirtualWalkthrough/xr-gaze-dwell-state.d.ts +17 -0
  61. package/components/VirtualWalkthrough/xr-gaze-dwell-state.d.ts.map +1 -0
  62. package/components/VirtualWalkthrough/xr-gaze-dwell-state.js +39 -0
  63. package/components/VirtualWalkthrough/xr-gaze-dwell-state.test.js +61 -0
  64. package/components/VirtualWalkthrough/xr-gaze-dwell.d.ts +30 -0
  65. package/components/VirtualWalkthrough/xr-gaze-dwell.d.ts.map +1 -0
  66. package/components/VirtualWalkthrough/xr-gaze-dwell.js +173 -0
  67. package/components/VirtualWalkthrough/xr-interactive-ui.d.ts +13 -0
  68. package/components/VirtualWalkthrough/xr-interactive-ui.d.ts.map +1 -0
  69. package/components/VirtualWalkthrough/xr-interactive-ui.js +25 -0
  70. package/components/VirtualWalkthrough/xr-interactive-ui.test.js +165 -0
  71. package/components/VirtualWalkthrough/xr-pointer-ray.d.ts +10 -0
  72. package/components/VirtualWalkthrough/xr-pointer-ray.d.ts.map +1 -0
  73. package/components/VirtualWalkthrough/xr-pointer-ray.js +24 -0
  74. package/components/VirtualWalkthrough/xr-progress-pie.d.ts +10 -0
  75. package/components/VirtualWalkthrough/xr-progress-pie.d.ts.map +1 -0
  76. package/components/VirtualWalkthrough/xr-progress-pie.js +30 -0
  77. package/components/VirtualWalkthrough/xr-progress-pie.test.js +79 -0
  78. package/components/VirtualWalkthrough/xr-ray-quad.d.ts +13 -0
  79. package/components/VirtualWalkthrough/xr-ray-quad.d.ts.map +1 -0
  80. package/components/VirtualWalkthrough/xr-ray-quad.js +29 -0
  81. package/components/VirtualWalkthrough/xr-ray-quad.test.js +37 -0
  82. package/components/VirtualWalkthrough/xr-scene-bounds.d.ts +17 -0
  83. package/components/VirtualWalkthrough/xr-scene-bounds.d.ts.map +1 -0
  84. package/components/VirtualWalkthrough/xr-scene-bounds.js +41 -0
  85. package/components/VirtualWalkthrough/xr-scene-bounds.test.js +70 -0
  86. package/components/VirtualWalkthrough/xr-teleport-gesture.d.ts +30 -0
  87. package/components/VirtualWalkthrough/xr-teleport-gesture.d.ts.map +1 -0
  88. package/components/VirtualWalkthrough/xr-teleport-gesture.js +45 -0
  89. package/components/VirtualWalkthrough/xr-teleport-gesture.test.js +90 -0
  90. package/hooks/useXr.d.ts +2 -0
  91. package/hooks/useXr.d.ts.map +1 -1
  92. package/hooks/useXr.js +10 -6
  93. package/package.json +1 -1
@@ -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
+ };
@@ -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
+ }