@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.
- package/components/VirtualWalkthrough/AnnotationPanel.d.ts.map +1 -1
- package/components/VirtualWalkthrough/AnnotationPanel.js +24 -10
- package/components/VirtualWalkthrough/SplatModel.js +2 -1
- package/components/VirtualWalkthrough/SplatRevealRain.d.ts +3 -1
- package/components/VirtualWalkthrough/SplatRevealRain.d.ts.map +1 -1
- package/components/VirtualWalkthrough/SplatRevealRain.js +15 -8
- package/components/VirtualWalkthrough/useRestartOnVr.d.ts +16 -0
- package/components/VirtualWalkthrough/useRestartOnVr.d.ts.map +1 -0
- package/components/VirtualWalkthrough/useRestartOnVr.js +38 -0
- package/components/VirtualWalkthrough/vr-annotation-panel-layout.d.ts +65 -0
- package/components/VirtualWalkthrough/vr-annotation-panel-layout.d.ts.map +1 -1
- package/components/VirtualWalkthrough/vr-annotation-panel-layout.js +99 -1
- package/components/VirtualWalkthrough/vr-annotation-panel.d.ts +40 -7
- package/components/VirtualWalkthrough/vr-annotation-panel.d.ts.map +1 -1
- package/components/VirtualWalkthrough/vr-annotation-panel.js +265 -104
- package/components/VirtualWalkthrough/vr-annotation-scroll.d.ts +23 -0
- package/components/VirtualWalkthrough/vr-annotation-scroll.d.ts.map +1 -0
- package/components/VirtualWalkthrough/vr-annotation-scroll.js +33 -0
- package/components/VirtualWalkthrough/xr-annotation-candidate-buffer.d.ts +32 -0
- package/components/VirtualWalkthrough/xr-annotation-candidate-buffer.d.ts.map +1 -0
- package/components/VirtualWalkthrough/xr-annotation-candidate-buffer.js +88 -0
- package/components/VirtualWalkthrough/xr-annotation-candidates.d.ts +8 -12
- package/components/VirtualWalkthrough/xr-annotation-candidates.d.ts.map +1 -1
- package/components/VirtualWalkthrough/xr-annotation-candidates.js +11 -32
- package/components/VirtualWalkthrough/xr-annotation-targeting.d.ts.map +1 -1
- package/components/VirtualWalkthrough/xr-annotation-targeting.js +9 -4
- package/components/VirtualWalkthrough/xr-gaze-dwell.d.ts +1 -0
- package/components/VirtualWalkthrough/xr-gaze-dwell.d.ts.map +1 -1
- package/components/VirtualWalkthrough/xr-gaze-dwell.js +4 -3
- package/package.json +1 -1
|
@@ -1,19 +1,22 @@
|
|
|
1
|
-
import { Vec3, Script, Quat,
|
|
2
|
-
import {
|
|
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
|
-
/**
|
|
14
|
-
*
|
|
15
|
-
|
|
16
|
-
const
|
|
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
|
-
|
|
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 *
|
|
60
|
-
|
|
61
|
-
|
|
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 >=
|
|
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.
|
|
72
|
-
this.
|
|
73
|
-
this.
|
|
74
|
-
this.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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 =
|
|
89
|
-
material.opacityMap =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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,
|
|
268
|
+
this._roundRect(ctx, 0, 0, PANEL_CANVAS_WIDTH, layout.height, 32);
|
|
162
269
|
ctx.fill();
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
270
|
+
if (layout.image) {
|
|
271
|
+
const {
|
|
272
|
+
y: bandY,
|
|
273
|
+
height: bandH
|
|
274
|
+
} = layout.image;
|
|
167
275
|
ctx.save();
|
|
168
|
-
this._roundRect(ctx,
|
|
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,
|
|
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,
|
|
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(
|
|
286
|
+
ctx.fillRect(PANEL_PAD_X, bandY, contentWidth, bandH);
|
|
178
287
|
}
|
|
179
288
|
ctx.restore();
|
|
180
|
-
|
|
181
|
-
|
|
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,
|
|
185
|
-
this._drawDots(ctx, images.length,
|
|
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 =
|
|
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,
|
|
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,
|
|
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 =
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
y
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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 =
|
|
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.
|
|
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
|
|
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.
|
|
304
|
-
this.
|
|
305
|
-
this.
|
|
306
|
-
this.
|
|
307
|
-
this.
|
|
308
|
-
this.
|
|
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"}
|