@terraware/web-components 5.0.2 → 5.0.4

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 (30) hide show
  1. package/components/VirtualWalkthrough/AnnotationPanel.d.ts.map +1 -1
  2. package/components/VirtualWalkthrough/AnnotationPanel.js +24 -10
  3. package/components/VirtualWalkthrough/SplatModel.js +2 -1
  4. package/components/VirtualWalkthrough/SplatRevealRain.d.ts +3 -1
  5. package/components/VirtualWalkthrough/SplatRevealRain.d.ts.map +1 -1
  6. package/components/VirtualWalkthrough/SplatRevealRain.js +15 -8
  7. package/components/VirtualWalkthrough/useRestartOnVr.d.ts +16 -0
  8. package/components/VirtualWalkthrough/useRestartOnVr.d.ts.map +1 -0
  9. package/components/VirtualWalkthrough/useRestartOnVr.js +38 -0
  10. package/components/VirtualWalkthrough/vr-annotation-panel-layout.d.ts +65 -0
  11. package/components/VirtualWalkthrough/vr-annotation-panel-layout.d.ts.map +1 -1
  12. package/components/VirtualWalkthrough/vr-annotation-panel-layout.js +99 -1
  13. package/components/VirtualWalkthrough/vr-annotation-panel.d.ts +40 -7
  14. package/components/VirtualWalkthrough/vr-annotation-panel.d.ts.map +1 -1
  15. package/components/VirtualWalkthrough/vr-annotation-panel.js +265 -104
  16. package/components/VirtualWalkthrough/vr-annotation-scroll.d.ts +23 -0
  17. package/components/VirtualWalkthrough/vr-annotation-scroll.d.ts.map +1 -0
  18. package/components/VirtualWalkthrough/vr-annotation-scroll.js +33 -0
  19. package/components/VirtualWalkthrough/xr-annotation-candidate-buffer.d.ts +32 -0
  20. package/components/VirtualWalkthrough/xr-annotation-candidate-buffer.d.ts.map +1 -0
  21. package/components/VirtualWalkthrough/xr-annotation-candidate-buffer.js +88 -0
  22. package/components/VirtualWalkthrough/xr-annotation-candidates.d.ts +8 -12
  23. package/components/VirtualWalkthrough/xr-annotation-candidates.d.ts.map +1 -1
  24. package/components/VirtualWalkthrough/xr-annotation-candidates.js +11 -32
  25. package/components/VirtualWalkthrough/xr-annotation-targeting.d.ts.map +1 -1
  26. package/components/VirtualWalkthrough/xr-annotation-targeting.js +9 -4
  27. package/components/VirtualWalkthrough/xr-gaze-dwell.d.ts +1 -0
  28. package/components/VirtualWalkthrough/xr-gaze-dwell.d.ts.map +1 -1
  29. package/components/VirtualWalkthrough/xr-gaze-dwell.js +4 -3
  30. package/package.json +1 -1
@@ -1,19 +1,22 @@
1
- import { Vec3, Script, Quat, Texture, FILTER_LINEAR, ADDRESS_CLAMP_TO_EDGE, StandardMaterial, Color, BLEND_NORMAL, CULLFACE_NONE, MeshInstance, LAYERID_IMMEDIATE, Mesh } from 'playcanvas';
2
- import { wrapText } from './vr-annotation-panel-layout.js';
1
+ import { Vec3, Script, Quat, StandardMaterial, MeshInstance, Color, BLEND_NORMAL, CULLFACE_NONE, LAYERID_IMMEDIATE, Texture, FILTER_LINEAR, ADDRESS_CLAMP_TO_EDGE, Mesh } from 'playcanvas';
2
+ import { PANEL_MAX_HEIGHT, PANEL_CANVAS_WIDTH, layoutPanel, PANEL_PAD_X, PANEL_FONTS, TITLE_LINE_HEIGHT, BODY_LINE_HEIGHT } from './vr-annotation-panel-layout.js';
3
+ import { readScrollAxis, nextScrollY } from './vr-annotation-scroll.js';
3
4
  import { rayQuadHit } from './xr-ray-quad.js';
4
5
 
5
- const CANVAS_WIDTH = 1024;
6
- const CANVAS_HEIGHT = 768;
7
6
  const PANEL_WIDTH = 7.5;
8
- const PANEL_HEIGHT = PANEL_WIDTH * CANVAS_HEIGHT / CANVAS_WIDTH;
9
7
 
10
8
  /** Gap (m) between the top of the hotspot and the bottom edge of the panel. */
11
9
  const PANEL_GAP = 0.75;
10
+ const PX_TO_M = PANEL_WIDTH / PANEL_CANVAS_WIDTH;
12
11
 
13
- /** World-space offset from the anchored hotspot: raise the panel above it so the
14
- * (tall) panel clears the hotspot rather than covering it. */
15
- const PANEL_OFFSET = new Vec3(0, PANEL_HEIGHT / 2 + PANEL_GAP, 0);
16
- const PAD_X = 48;
12
+ /** Local +z faces the viewer, so the overlay quads sit just in front of the chrome quad. Their
13
+ * draw order would otherwise depend on how the transparent sort breaks a tie between coplanar
14
+ * quads, all of which have depth testing off. */
15
+ const TEXT_Z = 0.001;
16
+ const THUMB_Z = 0.002;
17
+ const SCROLLBAR_WIDTH = 10;
18
+ const SCROLLBAR_INSET = 18;
19
+ const SCROLLBAR_MIN_HEIGHT = 48;
17
20
 
18
21
  /** Local +z (the quad's front face) — used to derive the panel's yaw toward the viewer. */
19
22
  const FORWARD_Z = new Vec3(0, 0, 1);
@@ -22,10 +25,6 @@ const RIGHT_AXIS = new Vec3(1, 0, 0);
22
25
  const UP_AXIS = new Vec3(0, 1, 0);
23
26
  const RAD_TO_DEG = 180 / Math.PI;
24
27
 
25
- /** Fixed image band (canvas px) so arrows/dots keep a stable position while images load. */
26
- const IMAGE_Y = 40;
27
- const IMAGE_H = 360;
28
-
29
28
  /** Width (canvas px) of the left/right arrow hit + draw zones over the image band. */
30
29
  const ARROW_ZONE_W = 170;
31
30
  class VrAnnotationPanel extends Script {
@@ -37,6 +36,11 @@ class VrAnnotationPanel extends Script {
37
36
  _drawnSignature = null;
38
37
  _currentImage = 0;
39
38
  _loadToken = 0;
39
+ _scrollY = 0;
40
+ _maxScroll = 0;
41
+ /** Height (canvas px) the chrome occupies; the rest of its canvas is unused. */
42
+ _canvasHeight = PANEL_MAX_HEIGHT;
43
+ _panelHeight = PANEL_MAX_HEIGHT * PX_TO_M;
40
44
  _headRotation = new Quat();
41
45
  _anchor = new Vec3();
42
46
  _scratchDir = new Vec3();
@@ -49,73 +53,136 @@ class VrAnnotationPanel extends Script {
49
53
  return;
50
54
  }
51
55
  const images = this.imageUrls ?? [];
52
- if (images.length <= 1) {
56
+ const band = this._layout?.image;
57
+ if (images.length <= 1 || !band) {
53
58
  return;
54
59
  }
55
60
  const hit = this._rayQuadHit(inputSource.getOrigin(), inputSource.getDirection());
56
61
  if (!hit) {
57
62
  return;
58
63
  }
59
- const cx = (hit.u + 1) / 2 * CANVAS_WIDTH;
60
- const cy = (1 - hit.v) / 2 * CANVAS_HEIGHT;
61
- if (cy < IMAGE_Y || cy > IMAGE_Y + IMAGE_H) {
64
+ const cx = (hit.u + 1) / 2 * PANEL_CANVAS_WIDTH;
65
+ // The image sits on the chrome quad, which doesn't scroll, so this needs no scroll offset.
66
+ const cy = (1 - hit.v) / 2 * this._canvasHeight;
67
+ if (cy < band.y || cy > band.y + band.height) {
62
68
  return;
63
69
  }
64
70
  if (cx <= ARROW_ZONE_W) {
65
71
  this._setImage((this._currentImage - 1 + images.length) % images.length);
66
- } else if (cx >= CANVAS_WIDTH - ARROW_ZONE_W) {
72
+ } else if (cx >= PANEL_CANVAS_WIDTH - ARROW_ZONE_W) {
67
73
  this._setImage((this._currentImage + 1) % images.length);
68
74
  }
69
75
  };
70
76
  initialize() {
71
- this._canvas = document.createElement('canvas');
72
- this._canvas.width = CANVAS_WIDTH;
73
- this._canvas.height = CANVAS_HEIGHT;
74
- this._texture = new Texture(this.app.graphicsDevice, {
75
- name: 'vr-annotation-panel',
76
- width: CANVAS_WIDTH,
77
- height: CANVAS_HEIGHT,
77
+ this._chromeCanvas = document.createElement('canvas');
78
+ this._chromeCanvas.width = PANEL_CANVAS_WIDTH;
79
+ this._chromeCanvas.height = PANEL_MAX_HEIGHT;
80
+ this._chromeTexture = this._createTexture('vr-annotation-panel', this._chromeCanvas);
81
+ this._chromeMaterial = this._createTexturedMaterial(this._chromeTexture);
82
+ this._chromeMesh = this._createQuadMesh();
83
+
84
+ // Sized once the first layout runs; until then it has nothing to show.
85
+ this._textMesh = this._createQuadMesh();
86
+ this._textMaterial = new StandardMaterial();
87
+ this._textInstance = new MeshInstance(this._textMesh, this._textMaterial);
88
+ this._textInstance.visible = false;
89
+ this._thumbMaterial = new StandardMaterial();
90
+ this._thumbMaterial.useLighting = false;
91
+ this._thumbMaterial.emissive = new Color(0, 0, 0);
92
+ this._thumbMaterial.opacity = 0.3;
93
+ this._thumbMaterial.blendType = BLEND_NORMAL;
94
+ this._thumbMaterial.depthTest = false;
95
+ this._thumbMaterial.depthWrite = false;
96
+ this._thumbMaterial.cull = CULLFACE_NONE;
97
+ this._thumbMaterial.update();
98
+ this._thumbMesh = this._createQuadMesh();
99
+ this._thumbInstance = new MeshInstance(this._thumbMesh, this._thumbMaterial);
100
+ this._thumbInstance.visible = false;
101
+ this.entity.addComponent('render', {
102
+ meshInstances: [new MeshInstance(this._chromeMesh, this._chromeMaterial), this._textInstance, this._thumbInstance],
103
+ layers: [LAYERID_IMMEDIATE]
104
+ });
105
+ this.app.xr?.input?.on('select', this._onSelect);
106
+ // Script removal fires a 'destroy' event rather than calling a destroy() method, so the
107
+ // teardown has to be registered as a listener or the handler and GPU resources outlive us.
108
+ this.once('destroy', () => this._teardown());
109
+ }
110
+ _createTexture(name, source) {
111
+ const texture = new Texture(this.app.graphicsDevice, {
112
+ name,
113
+ width: source.width,
114
+ height: source.height,
78
115
  addressU: ADDRESS_CLAMP_TO_EDGE,
79
116
  addressV: ADDRESS_CLAMP_TO_EDGE,
80
117
  minFilter: FILTER_LINEAR,
81
118
  magFilter: FILTER_LINEAR,
82
119
  mipmaps: true
83
120
  });
84
- this._texture.setSource(this._canvas);
121
+ texture.setSource(source);
122
+ return texture;
123
+ }
124
+ _createTexturedMaterial(texture) {
85
125
  const material = new StandardMaterial();
86
126
  material.useLighting = false;
87
127
  material.emissive = new Color(1, 1, 1);
88
- material.emissiveMap = this._texture;
89
- material.opacityMap = this._texture;
128
+ material.emissiveMap = texture;
129
+ material.opacityMap = texture;
90
130
  material.opacityMapChannel = 'a';
91
131
  material.blendType = BLEND_NORMAL;
92
132
  material.depthTest = false;
93
133
  material.depthWrite = false;
94
134
  material.cull = CULLFACE_NONE;
95
135
  material.update();
96
- this._material = material;
97
- this._mesh = this._createMesh();
98
- const meshInstance = new MeshInstance(this._mesh, material);
99
- this.entity.addComponent('render', {
100
- meshInstances: [meshInstance],
101
- layers: [LAYERID_IMMEDIATE]
102
- });
103
- this.app.xr?.input?.on('select', this._onSelect);
104
- // Script removal fires a 'destroy' event rather than calling a destroy() method, so the
105
- // teardown has to be registered as a listener or the handler and GPU resources outlive us.
106
- this.once('destroy', () => this._teardown());
136
+ return material;
107
137
  }
108
- _createMesh() {
109
- const hw = PANEL_WIDTH / 2;
110
- const hh = PANEL_HEIGHT / 2;
138
+ _createQuadMesh() {
111
139
  const mesh = new Mesh(this.app.graphicsDevice);
112
- mesh.setPositions([-hw, -hh, 0, hw, -hh, 0, hw, hh, 0, -hw, hh, 0]);
113
140
  mesh.setNormals([0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1]);
114
- mesh.setUvs(0, [0, 1, 1, 1, 1, 0, 0, 0]);
115
141
  mesh.setIndices([0, 1, 2, 0, 2, 3]);
116
- mesh.update();
142
+ this._writeQuad(mesh, -PANEL_WIDTH / 2, PANEL_WIDTH / 2, 0, 0, 0, 0, 1);
117
143
  return mesh;
118
144
  }
145
+
146
+ /** Rewrites the quad's four corners and the slice of its texture they map to. */
147
+ _writeQuad(mesh, left, right, top, bottom, z, vTop, vBottom) {
148
+ mesh.setPositions([left, bottom, z, right, bottom, z, right, top, z, left, top, z]);
149
+ mesh.setUvs(0, [0, vBottom, 1, vBottom, 1, vTop, 0, vTop]);
150
+ mesh.update();
151
+ }
152
+
153
+ /** Converts a y in panel canvas coordinates to the entity's local space. */
154
+ _localY(canvasY) {
155
+ return this._panelHeight / 2 - canvasY * PX_TO_M;
156
+ }
157
+ _writeChromeQuad() {
158
+ if (!this._chromeMesh) {
159
+ return;
160
+ }
161
+ const hw = PANEL_WIDTH / 2;
162
+ const hh = this._panelHeight / 2;
163
+ this._writeQuad(this._chromeMesh, -hw, hw, hh, -hh, 0, 0, this._canvasHeight / PANEL_MAX_HEIGHT);
164
+ }
165
+
166
+ /** Maps the visible window of the (much taller) text texture onto the body quad. */
167
+ _writeTextQuad() {
168
+ const body = this._layout?.body;
169
+ if (!this._textMesh || !this._textInstance || !body || body.contentHeight === 0) {
170
+ return;
171
+ }
172
+ this._writeQuad(this._textMesh, -PANEL_WIDTH / 2, PANEL_WIDTH / 2, this._localY(body.y), this._localY(body.y + body.viewportHeight), TEXT_Z, this._scrollY / body.contentHeight, (this._scrollY + body.viewportHeight) / body.contentHeight);
173
+ }
174
+ _writeThumbQuad() {
175
+ const body = this._layout?.body;
176
+ if (!this._thumbMesh || !body?.scrollable) {
177
+ return;
178
+ }
179
+ // The minimum keeps the thumb grabbable on very long text, but never past the track itself.
180
+ const height = Math.min(body.viewportHeight, Math.max(SCROLLBAR_MIN_HEIGHT, body.viewportHeight * body.viewportHeight / body.contentHeight));
181
+ const travel = body.viewportHeight - height;
182
+ const top = body.y + (this._maxScroll > 0 ? this._scrollY / this._maxScroll * travel : 0);
183
+ const right = PANEL_CANVAS_WIDTH - SCROLLBAR_INSET;
184
+ this._writeQuad(this._thumbMesh, (right - SCROLLBAR_WIDTH) * PX_TO_M - PANEL_WIDTH / 2, right * PX_TO_M - PANEL_WIDTH / 2, this._localY(top), this._localY(top + height), THUMB_Z, 0, 1);
185
+ }
119
186
  _roundRect(ctx, x, y, w, h, r) {
120
187
  ctx.beginPath();
121
188
  ctx.moveTo(x + r, y);
@@ -143,7 +210,7 @@ class VrAnnotationPanel extends Script {
143
210
  }
144
211
  _drawDots(ctx, count, dotY) {
145
212
  const gap = 26;
146
- const startX = CANVAS_WIDTH / 2 - (count - 1) * gap / 2;
213
+ const startX = PANEL_CANVAS_WIDTH / 2 - (count - 1) * gap / 2;
147
214
  for (let i = 0; i < count; i++) {
148
215
  ctx.beginPath();
149
216
  ctx.arc(startX + i * gap, dotY, 7, 0, Math.PI * 2);
@@ -151,83 +218,144 @@ class VrAnnotationPanel extends Script {
151
218
  ctx.fill();
152
219
  }
153
220
  }
154
- _drawContent(image) {
155
- const ctx = this._canvas?.getContext('2d');
221
+
222
+ /** Re-measures the content, resizes the panel, and repaints both canvases. */
223
+ _rebuild() {
224
+ const ctx = this._chromeCanvas?.getContext('2d');
156
225
  if (!ctx) {
157
226
  return;
158
227
  }
159
- ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
228
+ const images = this.imageUrls ?? [];
229
+ this._layout = layoutPanel({
230
+ title: this.title,
231
+ label: this.label,
232
+ bodyText: this.bodyText,
233
+ imageCount: images.length,
234
+ contentWidth: PANEL_CANVAS_WIDTH - PANEL_PAD_X * 2,
235
+ measure: (text, style) => {
236
+ ctx.font = PANEL_FONTS[style];
237
+ return ctx.measureText(text).width;
238
+ }
239
+ });
240
+ const {
241
+ body
242
+ } = this._layout;
243
+ this._maxScroll = body.contentHeight - body.viewportHeight;
244
+ this._canvasHeight = this._layout.height;
245
+ this._panelHeight = this._layout.height * PX_TO_M;
246
+ this._drawChrome();
247
+ this._drawText();
248
+ this._writeChromeQuad();
249
+ this._writeTextQuad();
250
+ this._writeThumbQuad();
251
+ if (this._textInstance) {
252
+ this._textInstance.visible = body.contentHeight > 0;
253
+ }
254
+ if (this._thumbInstance) {
255
+ this._thumbInstance.visible = body.scrollable;
256
+ }
257
+ }
258
+ _drawChrome() {
259
+ const ctx = this._chromeCanvas?.getContext('2d');
260
+ const layout = this._layout;
261
+ if (!ctx || !layout) {
262
+ return;
263
+ }
264
+ const contentWidth = PANEL_CANVAS_WIDTH - PANEL_PAD_X * 2;
265
+ const images = this.imageUrls ?? [];
266
+ ctx.clearRect(0, 0, PANEL_CANVAS_WIDTH, PANEL_MAX_HEIGHT);
160
267
  ctx.fillStyle = 'rgba(255, 255, 255, 0.96)';
161
- this._roundRect(ctx, 0, 0, CANVAS_WIDTH, CANVAS_HEIGHT, 32);
268
+ this._roundRect(ctx, 0, 0, PANEL_CANVAS_WIDTH, layout.height, 32);
162
269
  ctx.fill();
163
- let y = IMAGE_Y;
164
- const contentWidth = CANVAS_WIDTH - PAD_X * 2;
165
- const images = this.imageUrls ?? [];
166
- if (images.length > 0) {
270
+ if (layout.image) {
271
+ const {
272
+ y: bandY,
273
+ height: bandH
274
+ } = layout.image;
167
275
  ctx.save();
168
- this._roundRect(ctx, PAD_X, IMAGE_Y, contentWidth, IMAGE_H, 16);
276
+ this._roundRect(ctx, PANEL_PAD_X, bandY, contentWidth, bandH, 16);
169
277
  ctx.clip();
278
+ const image = this._loadedImage;
170
279
  if (image) {
171
- const scale = Math.max(contentWidth / image.width, IMAGE_H / image.height);
280
+ const scale = Math.max(contentWidth / image.width, bandH / image.height);
172
281
  const dw = image.width * scale;
173
282
  const dh = image.height * scale;
174
- ctx.drawImage(image, PAD_X + (contentWidth - dw) / 2, IMAGE_Y + (IMAGE_H - dh) / 2, dw, dh);
283
+ ctx.drawImage(image, PANEL_PAD_X + (contentWidth - dw) / 2, bandY + (bandH - dh) / 2, dw, dh);
175
284
  } else {
176
285
  ctx.fillStyle = 'rgba(0, 0, 0, 0.06)';
177
- ctx.fillRect(PAD_X, IMAGE_Y, contentWidth, IMAGE_H);
286
+ ctx.fillRect(PANEL_PAD_X, bandY, contentWidth, bandH);
178
287
  }
179
288
  ctx.restore();
180
- y = IMAGE_Y + IMAGE_H + 16;
181
- if (images.length > 1) {
182
- const bandCenterY = IMAGE_Y + IMAGE_H / 2;
289
+ if (layout.dotsY !== null) {
290
+ const bandCenterY = bandY + bandH / 2;
183
291
  this._drawArrow(ctx, ARROW_ZONE_W / 2, bandCenterY, true);
184
- this._drawArrow(ctx, CANVAS_WIDTH - ARROW_ZONE_W / 2, bandCenterY, false);
185
- this._drawDots(ctx, images.length, y + 4);
186
- y += 32;
292
+ this._drawArrow(ctx, PANEL_CANVAS_WIDTH - ARROW_ZONE_W / 2, bandCenterY, false);
293
+ this._drawDots(ctx, images.length, layout.dotsY);
187
294
  }
188
- y += 16;
189
295
  }
190
- if (this.label) {
191
- ctx.font = '600 28px sans-serif';
296
+ if (layout.chip && this.label) {
297
+ ctx.font = PANEL_FONTS.label;
192
298
  ctx.textBaseline = 'middle';
193
- const chipW = ctx.measureText(this.label).width + 32;
194
- const chipH = 44;
195
299
  ctx.fillStyle = 'rgba(44, 134, 88, 0.95)';
196
- this._roundRect(ctx, PAD_X, y, chipW, chipH, 12);
300
+ this._roundRect(ctx, PANEL_PAD_X, layout.chip.y, layout.chip.width, layout.chip.height, 12);
197
301
  ctx.fill();
198
302
  ctx.fillStyle = '#ffffff';
199
- ctx.fillText(this.label, PAD_X + 16, y + chipH / 2);
200
- y += chipH + 24;
303
+ ctx.fillText(this.label, PANEL_PAD_X + 16, layout.chip.y + layout.chip.height / 2);
201
304
  }
202
305
  ctx.textBaseline = 'top';
203
306
  ctx.fillStyle = '#000000';
204
- ctx.font = '700 48px sans-serif';
205
- const titleLines = wrapText(this.title, contentWidth, s => ctx.measureText(s).width);
206
- for (const line of titleLines) {
207
- if (y > CANVAS_HEIGHT - 40) {
208
- break;
209
- }
210
- ctx.fillText(line, PAD_X, y);
211
- y += 56;
307
+ ctx.font = PANEL_FONTS.title;
308
+ layout.title.lines.forEach((line, index) => {
309
+ ctx.fillText(line, PANEL_PAD_X, layout.title.y + index * TITLE_LINE_HEIGHT);
310
+ });
311
+ if (layout.body.scrollable) {
312
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.08)';
313
+ const trackX = PANEL_CANVAS_WIDTH - SCROLLBAR_INSET - SCROLLBAR_WIDTH;
314
+ this._roundRect(ctx, trackX, layout.body.y, SCROLLBAR_WIDTH, layout.body.viewportHeight, SCROLLBAR_WIDTH / 2);
315
+ ctx.fill();
212
316
  }
213
- y += 16;
214
- if (this.bodyText) {
215
- ctx.font = '400 32px sans-serif';
216
- ctx.fillStyle = 'rgba(0, 0, 0, 0.85)';
217
- const lines = wrapText(this.bodyText, contentWidth, s => ctx.measureText(s).width);
218
- for (const line of lines) {
219
- if (y > CANVAS_HEIGHT - 40) {
220
- break;
221
- }
222
- ctx.fillText(line, PAD_X, y);
223
- y += 40;
317
+ this._chromeTexture?.upload();
318
+ }
319
+
320
+ /**
321
+ * Paints every body line onto its own canvas, tall enough to hold all of them. Scrolling then
322
+ * only moves the quad's UV window over this texture, so no repaint or upload happens per frame.
323
+ */
324
+ _drawText() {
325
+ const body = this._layout?.body;
326
+ if (!body) {
327
+ return;
328
+ }
329
+ const height = Math.max(1, body.contentHeight);
330
+ if (!this._textCanvas || this._textCanvas.height !== height) {
331
+ this._textCanvas = document.createElement('canvas');
332
+ this._textCanvas.width = PANEL_CANVAS_WIDTH;
333
+ this._textCanvas.height = height;
334
+ this._textTexture?.destroy();
335
+ this._textTexture = this._createTexture('vr-annotation-panel-text', this._textCanvas);
336
+ this._textMaterial?.destroy();
337
+ this._textMaterial = this._createTexturedMaterial(this._textTexture);
338
+ if (this._textInstance) {
339
+ this._textInstance.material = this._textMaterial;
224
340
  }
225
341
  }
226
- this._texture?.upload();
342
+ const ctx = this._textCanvas.getContext('2d');
343
+ if (!ctx) {
344
+ return;
345
+ }
346
+ ctx.clearRect(0, 0, PANEL_CANVAS_WIDTH, height);
347
+ ctx.textBaseline = 'top';
348
+ ctx.font = PANEL_FONTS.body;
349
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.85)';
350
+ body.lines.forEach((line, index) => {
351
+ ctx.fillText(line, PANEL_PAD_X, index * BODY_LINE_HEIGHT);
352
+ });
353
+ this._textTexture?.upload();
227
354
  }
228
355
  _setImage(index) {
229
356
  this._currentImage = index;
230
- this._drawContent();
357
+ this._loadedImage = undefined;
358
+ this._drawChrome();
231
359
  this._loadCurrentImage();
232
360
  }
233
361
  _loadCurrentImage() {
@@ -241,7 +369,8 @@ class VrAnnotationPanel extends Script {
241
369
  img.onload = () => {
242
370
  // Ignore a stale load that a newer navigation has superseded.
243
371
  if (token === this._loadToken) {
244
- this._drawContent(img);
372
+ this._loadedImage = img;
373
+ this._drawChrome();
245
374
  }
246
375
  };
247
376
  // onerror (e.g. a host without CORS headers): keep the placeholder + text already drawn.
@@ -253,7 +382,7 @@ class VrAnnotationPanel extends Script {
253
382
  rotation.transformVector(UP_AXIS, this._axisUp);
254
383
  this.entity.getWorldTransform().getScale(this._scratchScale);
255
384
  const halfWidth = PANEL_WIDTH / 2 * this._scratchScale.x;
256
- const halfHeight = PANEL_HEIGHT / 2 * this._scratchScale.y;
385
+ const halfHeight = this._panelHeight / 2 * this._scratchScale.y;
257
386
  return rayQuadHit(origin, direction, this.entity.getPosition(), this._axisRight, this._axisUp, halfWidth, halfHeight);
258
387
  }
259
388
 
@@ -261,7 +390,7 @@ class VrAnnotationPanel extends Script {
261
390
  rayHitsPanel(origin, direction) {
262
391
  return this._rayQuadHit(origin, direction) !== null;
263
392
  }
264
- update() {
393
+ update(dt) {
265
394
  if (this.app.xr?.active !== true) {
266
395
  return;
267
396
  }
@@ -269,16 +398,19 @@ class VrAnnotationPanel extends Script {
269
398
  if (signature !== this._drawnSignature) {
270
399
  this._drawnSignature = signature;
271
400
  this._currentImage = 0;
272
- this._drawContent();
401
+ this._scrollY = 0;
402
+ this._loadedImage = undefined;
403
+ this._rebuild();
273
404
  this._loadCurrentImage();
274
405
  }
406
+ this._scroll(dt);
275
407
  this._trackHead();
276
408
  const anchor = this.app.root.findByName(`annotation-${this.annotationIndex}`);
277
409
  if (!anchor) {
278
410
  return;
279
411
  }
280
412
  this._anchor.copy(anchor.getPosition());
281
- this.entity.setPosition(this._anchor.x + PANEL_OFFSET.x, this._anchor.y + PANEL_OFFSET.y, this._anchor.z + PANEL_OFFSET.z);
413
+ this.entity.setPosition(this._anchor.x, this._anchor.y + this._panelHeight / 2 + PANEL_GAP, this._anchor.z);
282
414
 
283
415
  // Billboard on yaw only: face the viewer horizontally but stay upright and level, so
284
416
  // tilting/rolling the headset does not tilt the panel. Derived from the head's forward
@@ -288,6 +420,25 @@ class VrAnnotationPanel extends Script {
288
420
  this._yawQuat.setFromEulerAngles(0, yaw, 0);
289
421
  this.entity.setRotation(this._yawQuat);
290
422
  }
423
+ _scroll(dt) {
424
+ if (this._maxScroll <= 0) {
425
+ return;
426
+ }
427
+ let axis = 0;
428
+ for (const inputSource of this.app.xr?.input?.inputSources ?? []) {
429
+ const sourceAxis = readScrollAxis(inputSource);
430
+ if (Math.abs(sourceAxis) > Math.abs(axis)) {
431
+ axis = sourceAxis;
432
+ }
433
+ }
434
+ const scrollY = nextScrollY(this._scrollY, axis, dt, this._maxScroll);
435
+ if (scrollY === this._scrollY) {
436
+ return;
437
+ }
438
+ this._scrollY = scrollY;
439
+ this._writeTextQuad();
440
+ this._writeThumbQuad();
441
+ }
291
442
  _trackHead() {
292
443
  const views = this.app.xr?.views?.list;
293
444
  if (!views || views.length === 0) {
@@ -300,12 +451,22 @@ class VrAnnotationPanel extends Script {
300
451
  if (this.entity.render) {
301
452
  this.entity.render.meshInstances = [];
302
453
  }
303
- this._mesh?.destroy();
304
- this._material?.destroy();
305
- this._texture?.destroy();
306
- this._mesh = undefined;
307
- this._material = undefined;
308
- this._texture = undefined;
454
+ this._chromeMesh?.destroy();
455
+ this._textMesh?.destroy();
456
+ this._thumbMesh?.destroy();
457
+ this._chromeMaterial?.destroy();
458
+ this._textMaterial?.destroy();
459
+ this._thumbMaterial?.destroy();
460
+ this._chromeTexture?.destroy();
461
+ this._textTexture?.destroy();
462
+ this._chromeMesh = undefined;
463
+ this._textMesh = undefined;
464
+ this._thumbMesh = undefined;
465
+ this._chromeMaterial = undefined;
466
+ this._textMaterial = undefined;
467
+ this._thumbMaterial = undefined;
468
+ this._chromeTexture = undefined;
469
+ this._textTexture = undefined;
309
470
  }
310
471
  }
311
472
 
@@ -0,0 +1,23 @@
1
+ /** Deflection the stick must pass before the text moves, so a resting thumb doesn't drift it. */
2
+ export declare const SCROLL_DEADZONE = 0.15;
3
+ /** Canvas px per second at full deflection. */
4
+ export declare const SCROLL_SPEED = 1200;
5
+ interface ThumbstickSource {
6
+ handedness: string;
7
+ gamepad?: {
8
+ axes: ArrayLike<number>;
9
+ } | null;
10
+ }
11
+ /**
12
+ * Thumbstick Y for a source that can scroll the panel. Only the right hand scrolls: the left
13
+ * stick drives locomotion, and right-stick Y is free because snap-vertical is turned off.
14
+ * Hand-tracked sources report a gamepad with no axes, which would read as NaN.
15
+ */
16
+ export declare const readScrollAxis: (inputSource: ThumbstickSource) => number;
17
+ /**
18
+ * Advances the scroll position for one frame of stick deflection, ramping from a standstill at
19
+ * the deadzone edge. A forward push reports negative Y and moves further into the text.
20
+ */
21
+ export declare const nextScrollY: (scrollY: number, axisY: number, dt: number, maxScroll: number) => number;
22
+ export {};
23
+ //# sourceMappingURL=vr-annotation-scroll.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vr-annotation-scroll.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/vr-annotation-scroll.ts"],"names":[],"mappings":"AAAA,iGAAiG;AACjG,eAAO,MAAM,eAAe,OAAO,CAAC;AAEpC,+CAA+C;AAC/C,eAAO,MAAM,YAAY,OAAO,CAAC;AAEjC,UAAU,gBAAgB;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE;QAAE,IAAI,EAAE,SAAS,CAAC,MAAM,CAAC,CAAA;KAAE,GAAG,IAAI,CAAC;CAC9C;AAED;;;;GAIG;AACH,eAAO,MAAM,cAAc,GAAI,aAAa,gBAAgB,KAAG,MAO9D,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,WAAW,GAAI,SAAS,MAAM,EAAE,OAAO,MAAM,EAAE,IAAI,MAAM,EAAE,WAAW,MAAM,KAAG,MAU3F,CAAC"}
@@ -0,0 +1,33 @@
1
+ /** Deflection the stick must pass before the text moves, so a resting thumb doesn't drift it. */
2
+ const SCROLL_DEADZONE = 0.15;
3
+
4
+ /** Canvas px per second at full deflection. */
5
+ const SCROLL_SPEED = 1200;
6
+ /**
7
+ * Thumbstick Y for a source that can scroll the panel. Only the right hand scrolls: the left
8
+ * stick drives locomotion, and right-stick Y is free because snap-vertical is turned off.
9
+ * Hand-tracked sources report a gamepad with no axes, which would read as NaN.
10
+ */
11
+ const readScrollAxis = inputSource => {
12
+ const axes = inputSource.gamepad?.axes;
13
+ if (inputSource.handedness !== 'right' || !axes || axes.length < 4) {
14
+ return 0;
15
+ }
16
+ return axes[3];
17
+ };
18
+
19
+ /**
20
+ * Advances the scroll position for one frame of stick deflection, ramping from a standstill at
21
+ * the deadzone edge. A forward push reports negative Y and moves further into the text.
22
+ */
23
+ const nextScrollY = (scrollY, axisY, dt, maxScroll) => {
24
+ const magnitude = Math.abs(axisY);
25
+ if (magnitude <= SCROLL_DEADZONE) {
26
+ return Math.min(Math.max(scrollY, 0), maxScroll);
27
+ }
28
+ const ramped = (magnitude - SCROLL_DEADZONE) / (1 - SCROLL_DEADZONE);
29
+ const delta = -Math.sign(axisY) * ramped * SCROLL_SPEED * dt;
30
+ return Math.min(Math.max(scrollY + delta, 0), maxScroll);
31
+ };
32
+
33
+ export { SCROLL_DEADZONE, SCROLL_SPEED, nextScrollY, readScrollAxis };
@@ -0,0 +1,32 @@
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
+ export interface AnnotationCandidate {
5
+ entity: any;
6
+ script: any;
7
+ position: Vec3;
8
+ radius: number;
9
+ }
10
+ /** Pulls the stock annotation script off a hotspot entity, or undefined if it carries none. */
11
+ export type AnnotationScriptLookup = (entity: any) => any;
12
+ /**
13
+ * Collects world-space hit candidates for the openable annotation hotspots into a buffer it owns and
14
+ * reuses, so a caller running every frame allocates nothing. The returned array and the candidate
15
+ * objects inside it are overwritten by the next `collect`, which is why each caller holds its own
16
+ * buffer rather than sharing one.
17
+ *
18
+ * Positions and radii are re-read on every call, so hotspots that move or resize stay targetable.
19
+ * That read is cheap; it is the allocation around it that a per-frame caller cannot afford.
20
+ */
21
+ export declare class AnnotationCandidateBuffer {
22
+ private readonly _getScript;
23
+ private readonly _candidates;
24
+ private _root;
25
+ constructor(_getScript: AnnotationScriptLookup);
26
+ /**
27
+ * Hit candidates for every openable hotspot except `excludeName`, with hit spheres derived from
28
+ * each hotspot's rendered world size and padded by `radiusPad` for easier aiming.
29
+ */
30
+ collect(app: any, radiusPad: number, excludeName?: string): AnnotationCandidate[];
31
+ }
32
+ //# sourceMappingURL=xr-annotation-candidate-buffer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"xr-annotation-candidate-buffer.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/xr-annotation-candidate-buffer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AAElC,wDAAwD;AACxD,eAAO,MAAM,mBAAmB,MAAM,CAAC;AAEvC,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,GAAG,CAAC;IACZ,MAAM,EAAE,GAAG,CAAC;IACZ,QAAQ,EAAE,IAAI,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,+FAA+F;AAC/F,MAAM,MAAM,sBAAsB,GAAG,CAAC,MAAM,EAAE,GAAG,KAAK,GAAG,CAAC;AAmB1D;;;;;;;;GAQG;AACH,qBAAa,yBAAyB;IAIxB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAHvC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA6B;IACzD,OAAO,CAAC,KAAK,CAAa;gBAEG,UAAU,EAAE,sBAAsB;IAE/D;;;OAGG;IACH,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,SAAS,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,mBAAmB,EAAE;CA6ClF"}