@terraware/web-components 5.0.3 → 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.
@@ -1 +1 @@
1
- {"version":3,"file":"AnnotationPanel.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/AnnotationPanel.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAEpD,UAAU,oBAAoB;IAC5B,UAAU,EAAE,eAAe,GAAG,IAAI,CAAC;IAInC,eAAe,CAAC,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IACjE,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAoDD,QAAA,MAAM,eAAe,GAAI,0CAA0C,oBAAoB,mDA8ItF,CAAC;AAEF,eAAe,eAAe,CAAC"}
1
+ {"version":3,"file":"AnnotationPanel.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/AnnotationPanel.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAEpD,UAAU,oBAAoB;IAC5B,UAAU,EAAE,eAAe,GAAG,IAAI,CAAC;IAInC,eAAe,CAAC,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IACjE,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAiED,QAAA,MAAM,eAAe,GAAI,0CAA0C,oBAAoB,mDAsItF,CAAC;AAEF,eAAe,eAAe,CAAC"}
@@ -1,5 +1,5 @@
1
1
  import { useRef, useState, useEffect, useLayoutEffect } from 'react';
2
- import { useTheme } from '@mui/material';
2
+ import { useTheme, Box } from '@mui/material';
3
3
  import PhotosCarousel from '../PhotosCarousel/index.js';
4
4
  import { jsxs, Fragment, jsx } from 'react/jsx-runtime';
5
5
 
@@ -36,17 +36,33 @@ const PANEL_STYLE = {
36
36
  display: 'flex',
37
37
  flexDirection: 'column'
38
38
  };
39
- const CAROUSEL_STYLE = {
39
+ const CAROUSEL_SX = {
40
40
  width: 'min(60vw, 640px)',
41
41
  borderRadius: '12px 12px 0 0',
42
42
  overflow: 'hidden',
43
- flexShrink: 0
43
+ flexShrink: 0,
44
+ '& .photos-carousel-image': {
45
+ height: 'min(400px, 40vh)'
46
+ },
47
+ '& .photos-carousel-container': {
48
+ minHeight: 'min(200px, 20vh)'
49
+ }
44
50
  };
45
51
  const TEXT_BLOCK_STYLE = {
46
52
  padding: '24px',
47
53
  display: 'flex',
48
54
  flexDirection: 'column',
49
- gap: 8
55
+ gap: 8,
56
+ minHeight: 0
57
+ };
58
+ const BODY_STYLE = {
59
+ fontSize: 16,
60
+ lineHeight: '24px',
61
+ margin: 0,
62
+ minHeight: 0,
63
+ overflowY: 'auto',
64
+ overscrollBehavior: 'contain',
65
+ scrollbarWidth: 'thin'
50
66
  };
51
67
  const AnnotationPanel = ({
52
68
  annotation,
@@ -149,8 +165,8 @@ const AnnotationPanel = ({
149
165
  ref: panelRef,
150
166
  "data-testid": "annotation-panel",
151
167
  style: panelStyle,
152
- children: [annotation.imageUrls && annotation.imageUrls.length > 0 && /*#__PURE__*/jsx("div", {
153
- style: CAROUSEL_STYLE,
168
+ children: [annotation.imageUrls && annotation.imageUrls.length > 0 && /*#__PURE__*/jsx(Box, {
169
+ sx: CAROUSEL_SX,
154
170
  children: /*#__PURE__*/jsx(PhotosCarousel, {
155
171
  photos: annotation.imageUrls.map(url => ({
156
172
  url,
@@ -187,10 +203,8 @@ const AnnotationPanel = ({
187
203
  }), annotation.bodyText && /*#__PURE__*/jsx("p", {
188
204
  "data-testid": "annotation-panel-description",
189
205
  style: {
190
- fontSize: 16,
191
- color: theme.palette.TwClrTxt,
192
- lineHeight: '24px',
193
- margin: 0
206
+ ...BODY_STYLE,
207
+ color: theme.palette.TwClrTxt
194
208
  },
195
209
  children: annotation.bodyText
196
210
  })]
@@ -3,4 +3,69 @@
3
3
  * each candidate line. A single word wider than `maxWidth` still gets its own line.
4
4
  */
5
5
  export declare const wrapText: (text: string, maxWidth: number, measure: (segment: string) => number) => string[];
6
+ export declare const PANEL_CANVAS_WIDTH = 1024;
7
+ /** Tallest the panel can grow; also the canvas the content is painted into. */
8
+ export declare const PANEL_MAX_HEIGHT = 768;
9
+ /** Shortest the panel can shrink, so a bare title still reads as a panel rather than a sliver. */
10
+ export declare const PANEL_MIN_HEIGHT = 200;
11
+ export declare const PANEL_PAD_X = 48;
12
+ export declare const PANEL_PAD_Y = 40;
13
+ export declare const IMAGE_BAND_HEIGHT = 360;
14
+ export declare const CHIP_HEIGHT = 44;
15
+ export declare const TITLE_LINE_HEIGHT = 56;
16
+ export declare const BODY_LINE_HEIGHT = 40;
17
+ /**
18
+ * Tallest canvas the body text is rasterised into. The body scrolls as a window onto this canvas,
19
+ * so it bounds how much text an annotation can show — far more than the panel itself holds.
20
+ */
21
+ export declare const TEXT_CANVAS_MAX_HEIGHT = 2048;
22
+ export declare const PANEL_FONTS: {
23
+ readonly label: "600 28px sans-serif";
24
+ readonly title: "700 48px sans-serif";
25
+ readonly body: "400 32px sans-serif";
26
+ };
27
+ export type PanelTextStyle = keyof typeof PANEL_FONTS;
28
+ export interface PanelLayoutInput {
29
+ title: string;
30
+ label?: string;
31
+ bodyText?: string;
32
+ imageCount: number;
33
+ contentWidth: number;
34
+ measure: (text: string, style: PanelTextStyle) => number;
35
+ }
36
+ export interface PanelLayout {
37
+ /** Painted height of the panel, between the min and max bounds. */
38
+ height: number;
39
+ image: {
40
+ y: number;
41
+ height: number;
42
+ } | null;
43
+ dotsY: number | null;
44
+ chip: {
45
+ y: number;
46
+ width: number;
47
+ height: number;
48
+ } | null;
49
+ title: {
50
+ y: number;
51
+ lines: string[];
52
+ };
53
+ body: {
54
+ /** Top of the body viewport, in panel coordinates. */
55
+ y: number;
56
+ lines: string[];
57
+ /** Height of all the lines together, which is also the text canvas height. */
58
+ contentHeight: number;
59
+ /** Height of the visible window onto that canvas. */
60
+ viewportHeight: number;
61
+ scrollable: boolean;
62
+ };
63
+ }
64
+ /**
65
+ * Lays the panel's blocks out top-to-bottom and reports the height they need, so the quad
66
+ * can be sized to the content instead of a fixed aspect. Everything above the body is a header
67
+ * that has to fit within `PANEL_MAX_HEIGHT`; the body instead scrolls within whatever height
68
+ * the header leaves it.
69
+ */
70
+ export declare const layoutPanel: ({ title, label, bodyText, imageCount, contentWidth, measure, }: PanelLayoutInput) => PanelLayout;
6
71
  //# sourceMappingURL=vr-annotation-panel-layout.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"vr-annotation-panel-layout.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/vr-annotation-panel-layout.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,eAAO,MAAM,QAAQ,GAAI,MAAM,MAAM,EAAE,UAAU,MAAM,EAAE,SAAS,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,KAAG,MAAM,EAoBrG,CAAC"}
1
+ {"version":3,"file":"vr-annotation-panel-layout.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/vr-annotation-panel-layout.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,eAAO,MAAM,QAAQ,GAAI,MAAM,MAAM,EAAE,UAAU,MAAM,EAAE,SAAS,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,KAAG,MAAM,EAoBrG,CAAC;AAEF,eAAO,MAAM,kBAAkB,OAAO,CAAC;AACvC,+EAA+E;AAC/E,eAAO,MAAM,gBAAgB,MAAM,CAAC;AACpC,kGAAkG;AAClG,eAAO,MAAM,gBAAgB,MAAM,CAAC;AAEpC,eAAO,MAAM,WAAW,KAAK,CAAC;AAC9B,eAAO,MAAM,WAAW,KAAK,CAAC;AAE9B,eAAO,MAAM,iBAAiB,MAAM,CAAC;AAKrC,eAAO,MAAM,WAAW,KAAK,CAAC;AAI9B,eAAO,MAAM,iBAAiB,KAAK,CAAC;AAEpC,eAAO,MAAM,gBAAgB,KAAK,CAAC;AAEnC;;;GAGG;AACH,eAAO,MAAM,sBAAsB,OAAO,CAAC;AAE3C,eAAO,MAAM,WAAW;;;;CAId,CAAC;AAEX,MAAM,MAAM,cAAc,GAAG,MAAM,OAAO,WAAW,CAAC;AAEtD,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,cAAc,KAAK,MAAM,CAAC;CAC1D;AAED,MAAM,WAAW,WAAW;IAC1B,mEAAmE;IACnE,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAC5C,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,IAAI,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAC1D,KAAK,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC;IACtC,IAAI,EAAE;QACJ,sDAAsD;QACtD,CAAC,EAAE,MAAM,CAAC;QACV,KAAK,EAAE,MAAM,EAAE,CAAC;QAChB,8EAA8E;QAC9E,aAAa,EAAE,MAAM,CAAC;QACtB,qDAAqD;QACrD,cAAc,EAAE,MAAM,CAAC;QACvB,UAAU,EAAE,OAAO,CAAC;KACrB,CAAC;CACH;AASD;;;;;GAKG;AACH,eAAO,MAAM,WAAW,GAAI,gEAOzB,gBAAgB,KAAG,WA8CrB,CAAC"}
@@ -20,5 +20,103 @@ const wrapText = (text, maxWidth, measure) => {
20
20
  }
21
21
  return lines;
22
22
  };
23
+ const PANEL_CANVAS_WIDTH = 1024;
24
+ /** Tallest the panel can grow; also the canvas the content is painted into. */
25
+ const PANEL_MAX_HEIGHT = 768;
26
+ /** Shortest the panel can shrink, so a bare title still reads as a panel rather than a sliver. */
27
+ const PANEL_MIN_HEIGHT = 200;
28
+ const PANEL_PAD_X = 48;
29
+ const PANEL_PAD_Y = 40;
30
+ const IMAGE_BAND_HEIGHT = 360;
31
+ const IMAGE_GAP = 16;
32
+ const DOTS_BLOCK_HEIGHT = 32;
33
+ const DOTS_CENTER_OFFSET = 4;
34
+ const CHIP_HEIGHT = 44;
35
+ const CHIP_PAD_X = 16;
36
+ const CHIP_GAP = 24;
37
+ const TITLE_LINE_HEIGHT = 56;
38
+ const TITLE_GAP = 16;
39
+ const BODY_LINE_HEIGHT = 40;
23
40
 
24
- export { wrapText };
41
+ /**
42
+ * Tallest canvas the body text is rasterised into. The body scrolls as a window onto this canvas,
43
+ * so it bounds how much text an annotation can show — far more than the panel itself holds.
44
+ */
45
+ const TEXT_CANVAS_MAX_HEIGHT = 2048;
46
+ const PANEL_FONTS = {
47
+ label: '600 28px sans-serif',
48
+ title: '700 48px sans-serif',
49
+ body: '400 32px sans-serif'
50
+ };
51
+ /** Keeps the lines whose full line box fits above the bottom padding. */
52
+ const fitLines = (lines, y, lineHeight) => {
53
+ const room = Math.max(0, Math.floor((PANEL_MAX_HEIGHT - PANEL_PAD_Y - y) / lineHeight));
54
+ return lines.slice(0, room);
55
+ };
56
+
57
+ /**
58
+ * Lays the panel's blocks out top-to-bottom and reports the height they need, so the quad
59
+ * can be sized to the content instead of a fixed aspect. Everything above the body is a header
60
+ * that has to fit within `PANEL_MAX_HEIGHT`; the body instead scrolls within whatever height
61
+ * the header leaves it.
62
+ */
63
+ const layoutPanel = ({
64
+ title,
65
+ label,
66
+ bodyText,
67
+ imageCount,
68
+ contentWidth,
69
+ measure
70
+ }) => {
71
+ let y = PANEL_PAD_Y;
72
+ const image = imageCount > 0 ? {
73
+ y,
74
+ height: IMAGE_BAND_HEIGHT
75
+ } : null;
76
+ let dotsY = null;
77
+ if (image) {
78
+ y += IMAGE_BAND_HEIGHT + IMAGE_GAP;
79
+ if (imageCount > 1) {
80
+ dotsY = y + DOTS_CENTER_OFFSET;
81
+ y += DOTS_BLOCK_HEIGHT;
82
+ }
83
+ y += IMAGE_GAP;
84
+ }
85
+ const chip = label ? {
86
+ y,
87
+ width: measure(label, 'label') + CHIP_PAD_X * 2,
88
+ height: CHIP_HEIGHT
89
+ } : null;
90
+ if (chip) {
91
+ y += CHIP_HEIGHT + CHIP_GAP;
92
+ }
93
+ const titleLines = wrapText(title, contentWidth, segment => measure(segment, 'title'));
94
+ const fittedTitle = fitLines(titleLines, y, TITLE_LINE_HEIGHT);
95
+ const titleY = y;
96
+ y += fittedTitle.length * TITLE_LINE_HEIGHT + TITLE_GAP;
97
+ const bodyLines = bodyText ? wrapText(bodyText, contentWidth, segment => measure(segment, 'body')) : [];
98
+ const fittedBody = bodyLines.slice(0, Math.floor(TEXT_CANVAS_MAX_HEIGHT / BODY_LINE_HEIGHT));
99
+ const bodyY = y;
100
+ const contentHeight = fittedBody.length * BODY_LINE_HEIGHT;
101
+ const viewportHeight = Math.min(contentHeight, Math.max(0, PANEL_MAX_HEIGHT - PANEL_PAD_Y - bodyY));
102
+ const height = Math.max(PANEL_MIN_HEIGHT, Math.min(PANEL_MAX_HEIGHT, bodyY + viewportHeight + PANEL_PAD_Y));
103
+ return {
104
+ height,
105
+ image,
106
+ dotsY,
107
+ chip,
108
+ title: {
109
+ y: titleY,
110
+ lines: fittedTitle
111
+ },
112
+ body: {
113
+ y: bodyY,
114
+ lines: fittedBody,
115
+ contentHeight,
116
+ viewportHeight,
117
+ scrollable: viewportHeight > 0 && contentHeight > viewportHeight
118
+ }
119
+ };
120
+ };
121
+
122
+ export { BODY_LINE_HEIGHT, CHIP_HEIGHT, IMAGE_BAND_HEIGHT, PANEL_CANVAS_WIDTH, PANEL_FONTS, PANEL_MAX_HEIGHT, PANEL_MIN_HEIGHT, PANEL_PAD_X, PANEL_PAD_Y, TEXT_CANVAS_MAX_HEIGHT, TITLE_LINE_HEIGHT, layoutPanel, wrapText };
@@ -6,13 +6,28 @@ export declare class VrAnnotationPanel extends Script {
6
6
  bodyText?: string;
7
7
  imageUrls?: string[];
8
8
  annotationIndex: number;
9
- private _material?;
10
- private _texture?;
11
- private _mesh?;
12
- private _canvas?;
9
+ private _chromeMaterial?;
10
+ private _chromeTexture?;
11
+ private _chromeMesh?;
12
+ private _chromeCanvas?;
13
+ private _textMaterial?;
14
+ private _textTexture?;
15
+ private _textMesh?;
16
+ private _textCanvas?;
17
+ private _textInstance?;
18
+ private _thumbMaterial?;
19
+ private _thumbMesh?;
20
+ private _thumbInstance?;
13
21
  private _drawnSignature;
14
22
  private _currentImage;
15
23
  private _loadToken;
24
+ private _loadedImage?;
25
+ private _layout?;
26
+ private _scrollY;
27
+ private _maxScroll;
28
+ /** Height (canvas px) the chrome occupies; the rest of its canvas is unused. */
29
+ private _canvasHeight;
30
+ private _panelHeight;
16
31
  private _headRotation;
17
32
  private _anchor;
18
33
  private _scratchDir;
@@ -22,17 +37,35 @@ export declare class VrAnnotationPanel extends Script {
22
37
  private _scratchScale;
23
38
  private _onSelect;
24
39
  initialize(): void;
25
- private _createMesh;
40
+ private _createTexture;
41
+ private _createTexturedMaterial;
42
+ private _createQuadMesh;
43
+ /** Rewrites the quad's four corners and the slice of its texture they map to. */
44
+ private _writeQuad;
45
+ /** Converts a y in panel canvas coordinates to the entity's local space. */
46
+ private _localY;
47
+ private _writeChromeQuad;
48
+ /** Maps the visible window of the (much taller) text texture onto the body quad. */
49
+ private _writeTextQuad;
50
+ private _writeThumbQuad;
26
51
  private _roundRect;
27
52
  private _drawArrow;
28
53
  private _drawDots;
29
- private _drawContent;
54
+ /** Re-measures the content, resizes the panel, and repaints both canvases. */
55
+ private _rebuild;
56
+ private _drawChrome;
57
+ /**
58
+ * Paints every body line onto its own canvas, tall enough to hold all of them. Scrolling then
59
+ * only moves the quad's UV window over this texture, so no repaint or upload happens per frame.
60
+ */
61
+ private _drawText;
30
62
  private _setImage;
31
63
  private _loadCurrentImage;
32
64
  private _rayQuadHit;
33
65
  /** True when the ray points at the panel quad (used to suppress the empty-select dismiss). */
34
66
  rayHitsPanel(origin: Vec3, direction: Vec3): boolean;
35
- update(): void;
67
+ update(dt: number): void;
68
+ private _scroll;
36
69
  private _trackHead;
37
70
  private _teardown;
38
71
  }
@@ -1 +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;AAmCpB,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;IAErB,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;IAmCN,OAAO,CAAC,UAAU;IAQlB,OAAO,CAAC,SAAS;CAYlB"}
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;AA2CpB,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;IAErB,OAAO,CAAC,eAAe,CAAC,CAAmB;IAC3C,OAAO,CAAC,cAAc,CAAC,CAAU;IACjC,OAAO,CAAC,WAAW,CAAC,CAAO;IAC3B,OAAO,CAAC,aAAa,CAAC,CAAoB;IAE1C,OAAO,CAAC,aAAa,CAAC,CAAmB;IACzC,OAAO,CAAC,YAAY,CAAC,CAAU;IAC/B,OAAO,CAAC,SAAS,CAAC,CAAO;IACzB,OAAO,CAAC,WAAW,CAAC,CAAoB;IACxC,OAAO,CAAC,aAAa,CAAC,CAAe;IAErC,OAAO,CAAC,cAAc,CAAC,CAAmB;IAC1C,OAAO,CAAC,UAAU,CAAC,CAAO;IAC1B,OAAO,CAAC,cAAc,CAAC,CAAe;IAEtC,OAAO,CAAC,eAAe,CAAuB;IAC9C,OAAO,CAAC,aAAa,CAAK;IAC1B,OAAO,CAAC,UAAU,CAAK;IACvB,OAAO,CAAC,YAAY,CAAC,CAAmB;IACxC,OAAO,CAAC,OAAO,CAAC,CAAc;IAC9B,OAAO,CAAC,QAAQ,CAAK;IACrB,OAAO,CAAC,UAAU,CAAK;IACvB,gFAAgF;IAChF,OAAO,CAAC,aAAa,CAAoB;IACzC,OAAO,CAAC,YAAY,CAA8B;IAElD,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,CAwBf;IAEF,UAAU;IA0CV,OAAO,CAAC,cAAc;IAgBtB,OAAO,CAAC,uBAAuB;IAgB/B,OAAO,CAAC,eAAe;IASvB,iFAAiF;IACjF,OAAO,CAAC,UAAU;IAelB,4EAA4E;IAC5E,OAAO,CAAC,OAAO;IAIf,OAAO,CAAC,gBAAgB;IASxB,oFAAoF;IACpF,OAAO,CAAC,cAAc;IAiBtB,OAAO,CAAC,eAAe;IA0BvB,OAAO,CAAC,UAAU;IAUlB,OAAO,CAAC,UAAU;IAkBlB,OAAO,CAAC,SAAS;IAWjB,8EAA8E;IAC9E,OAAO,CAAC,QAAQ;IAuChB,OAAO,CAAC,WAAW;IAoEnB;;;OAGG;IACH,OAAO,CAAC,SAAS;IAmCjB,OAAO,CAAC,SAAS;IAOjB,OAAO,CAAC,iBAAiB;IAmBzB,OAAO,CAAC,WAAW;IAmBnB,8FAA8F;IAC9F,YAAY,CAAC,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,GAAG,OAAO;IAIpD,MAAM,CAAC,EAAE,EAAE,MAAM;IAkCjB,OAAO,CAAC,OAAO;IAsBf,OAAO,CAAC,UAAU;IAQlB,OAAO,CAAC,SAAS;CAsBlB"}
@@ -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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terraware/web-components",
3
- "version": "5.0.3",
3
+ "version": "5.0.4",
4
4
  "author": "Terraformation Inc.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {