@terraware/web-components 5.0.1 → 5.0.2

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.
@@ -37,6 +37,17 @@ export interface VirtualWalkthroughViewerProps {
37
37
  * `rotation`, `scaleFactor` and `revealRain` — take precedence over anything set here.
38
38
  */
39
39
  splatModelProps?: Partial<SplatModelProps>;
40
+ /**
41
+ * Requests a VR session when this goes from false to true, and when the viewer mounts with it
42
+ * already true. Never re-requests on its own, so exiting VR leaves the user out. Browsers only
43
+ * grant an immersive session inside a user gesture, so the caller must raise this from a click.
44
+ */
45
+ autoStartVr?: boolean;
46
+ /**
47
+ * Called whenever the viewer ends up out of XR: when a session ends, and when one was requested
48
+ * but couldn't be started, in which case the error says why.
49
+ */
50
+ onXrExit?: (error?: Error) => void;
40
51
  annotations: AnnotationProps[];
41
52
  onSaveAnnotations: (annotations: AnnotationProps[]) => void | Promise<void>;
42
53
  editable?: boolean;
@@ -45,6 +56,6 @@ export interface VirtualWalkthroughViewerProps {
45
56
  onToggleFullScreen?: () => void;
46
57
  maxImagesPerAnnotation?: number;
47
58
  }
48
- declare const VirtualWalkthroughViewer: ({ splatSrc, splatFormat, origin, cameraPosition, sceneBounds, groundPlane: groundPlaneProp, skyColor, groundColor, averageCameraHeight, scaleFactor, splatModelProps, annotations, onSaveAnnotations, editable, showFreeFly, isFullScreen, onToggleFullScreen, maxImagesPerAnnotation, }: VirtualWalkthroughViewerProps) => import("react/jsx-runtime").JSX.Element;
59
+ declare const VirtualWalkthroughViewer: ({ splatSrc, splatFormat, origin, cameraPosition, sceneBounds, groundPlane: groundPlaneProp, skyColor, groundColor, averageCameraHeight, scaleFactor, splatModelProps, autoStartVr, onXrExit, annotations, onSaveAnnotations, editable, showFreeFly, isFullScreen, onToggleFullScreen, maxImagesPerAnnotation, }: VirtualWalkthroughViewerProps) => import("react/jsx-runtime").JSX.Element;
49
60
  export default VirtualWalkthroughViewer;
50
61
  //# sourceMappingURL=VirtualWalkthroughViewer.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"VirtualWalkthroughViewer.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/VirtualWalkthroughViewer.tsx"],"names":[],"mappings":"AAYA,OAAmB,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAO3D,OAAmB,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAS3D,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAmB5C;;;;GAIG;AACH,MAAM,WAAW,6BAA6B;IAC5C,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,6CAA6C;IAC7C,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;IAClC,8CAA8C;IAC9C,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;IAC1C,2FAA2F;IAC3F,WAAW,CAAC,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC7D,uEAAuE;IACvE,WAAW,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,CAAC;IACzC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6CAA6C;IAC7C,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC,eAAe,CAAC,CAAC;IAC3C,WAAW,EAAE,eAAe,EAAE,CAAC;IAC/B,iBAAiB,EAAE,CAAC,WAAW,EAAE,eAAe,EAAE,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5E,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,kBAAkB,CAAC,EAAE,MAAM,IAAI,CAAC;IAChC,sBAAsB,CAAC,EAAE,MAAM,CAAC;CACjC;AAED,QAAA,MAAM,wBAAwB,GAAI,0RAmB/B,6BAA6B,4CAgY/B,CAAC;AAEF,eAAe,wBAAwB,CAAC"}
1
+ {"version":3,"file":"VirtualWalkthroughViewer.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/VirtualWalkthroughViewer.tsx"],"names":[],"mappings":"AAYA,OAAmB,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAO3D,OAAmB,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAS3D,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAmB5C;;;;GAIG;AACH,MAAM,WAAW,6BAA6B;IAC5C,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,6CAA6C;IAC7C,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;IAClC,8CAA8C;IAC9C,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;IAC1C,2FAA2F;IAC3F,WAAW,CAAC,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC7D,uEAAuE;IACvE,WAAW,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,CAAC;IACzC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6CAA6C;IAC7C,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC,eAAe,CAAC,CAAC;IAC3C;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;OAGG;IACH,QAAQ,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,KAAK,KAAK,IAAI,CAAC;IACnC,WAAW,EAAE,eAAe,EAAE,CAAC;IAC/B,iBAAiB,EAAE,CAAC,WAAW,EAAE,eAAe,EAAE,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5E,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,kBAAkB,CAAC,EAAE,MAAM,IAAI,CAAC;IAChC,sBAAsB,CAAC,EAAE,MAAM,CAAC;CACjC;AAED,QAAA,MAAM,wBAAwB,GAAI,iTAqB/B,6BAA6B,4CAqa/B,CAAC;AAEF,eAAe,wBAAwB,CAAC"}
@@ -1,4 +1,4 @@
1
- import { useState, useMemo, useEffect, useCallback } from 'react';
1
+ import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
2
2
  import { useTheme } from '@mui/material';
3
3
  import { Entity } from '@playcanvas/react';
4
4
  import { Camera, Script } from '@playcanvas/react/components';
@@ -61,6 +61,8 @@ const VirtualWalkthroughViewer = ({
61
61
  averageCameraHeight = 0,
62
62
  scaleFactor = 1,
63
63
  splatModelProps,
64
+ autoStartVr = false,
65
+ onXrExit,
64
66
  annotations,
65
67
  onSaveAnnotations,
66
68
  editable = false,
@@ -87,12 +89,46 @@ const VirtualWalkthroughViewer = ({
87
89
  const [viewingAnnotation, setViewingAnnotation] = useState(null);
88
90
  const [viewingAnnotationIndex, setViewingAnnotationIndex] = useState(-1);
89
91
  const [viewedScreenPos, setViewedScreenPos] = useState(null);
92
+ const reportXrError = useCallback(error => {
93
+ console.warn(error.message, error);
94
+ onXrExit?.(error);
95
+ }, [onXrExit]);
90
96
  const {
91
97
  isCurrentlyInXr,
92
98
  isCurrentlyInVr,
93
- isCurrentlyInAr
94
- } = useXr();
99
+ isCurrentlyInAr,
100
+ isXrAvailable,
101
+ startXr
102
+ } = useXr({
103
+ onError: reportXrError
104
+ });
95
105
  useXrRenderTuning();
106
+ const autoStartRequested = useRef(false);
107
+
108
+ // Edge-triggered so that ending a session doesn't pull the user straight back into one. The ref
109
+ // is written before the early returns, so lowering the prop re-arms the next rising edge.
110
+ useEffect(() => {
111
+ const isRisingEdge = autoStartVr && !autoStartRequested.current;
112
+ autoStartRequested.current = autoStartVr;
113
+ if (!isRisingEdge || isCurrentlyInXr) {
114
+ return;
115
+ }
116
+ if (!isXrAvailable('VR')) {
117
+ reportXrError(new Error('A VR session was requested, but this device reports no immersive VR support'));
118
+ return;
119
+ }
120
+ startXr('VR');
121
+ }, [autoStartVr, isCurrentlyInXr, isXrAvailable, startXr, reportXrError]);
122
+ const wasInXr = useRef(false);
123
+
124
+ // Edge-detected rather than reported whenever the viewer is out of XR, which would fire on mount
125
+ // and tell a caller a session had ended before one ever began.
126
+ useEffect(() => {
127
+ if (wasInXr.current && !isCurrentlyInXr) {
128
+ onXrExit?.();
129
+ }
130
+ wasInXr.current = isCurrentlyInXr;
131
+ }, [isCurrentlyInXr, onXrExit]);
96
132
  const sceneBoundsRadius = useMemo(() => {
97
133
  if (sceneBounds?.m !== undefined) {
98
134
  return sceneBounds.m;
@@ -287,7 +323,7 @@ const VirtualWalkthroughViewer = ({
287
323
  }), /*#__PURE__*/jsx(XrPointerRay, {}), isCurrentlyInVr && viewingAnnotation && /*#__PURE__*/jsx(VrAnnotationPanel, {
288
324
  annotation: viewingAnnotation,
289
325
  annotationIndex: viewingAnnotationIndex
290
- }, viewingAnnotationIndex), isCurrentlyInXr && /*#__PURE__*/jsx(XrGazeDwell, {
326
+ }, viewingAnnotationIndex), /*#__PURE__*/jsx(XrGazeDwell, {
291
327
  activeIndex: viewingAnnotationIndex
292
328
  }), /*#__PURE__*/jsx(Script, {
293
329
  script: XrStartPose,
@@ -370,7 +406,8 @@ const VirtualWalkthroughViewer = ({
370
406
  onToggleFullScreen: onToggleFullScreen,
371
407
  isFreeFly: isFreeFly,
372
408
  onToggleFreeFly: handleToggleFreeFly,
373
- showFreeFly: showFreeFly
409
+ showFreeFly: showFreeFly,
410
+ onError: reportXrError
374
411
  }), /*#__PURE__*/jsx(AnnotationPanel, {
375
412
  annotation: viewingAnnotation,
376
413
  hotspotPosition: viewedScreenPos,
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Bakes the exit button's static artwork into a two-channel mask: red covers the backing disc, green
3
+ * the X glyph. The shader composites the progress sweep between the two, which is what keeps the
4
+ * glyph legible over the fill.
5
+ *
6
+ * The channels have to stay independent, so the glyph is drawn additively rather than over the disc,
7
+ * and an opaque base goes down first. Uniform full alpha means the mask survives the texture upload
8
+ * whether or not alpha ends up premultiplied, and antialiased edges keep their coverage.
9
+ */
10
+ export declare const drawExitButtonMask: (ctx: CanvasRenderingContext2D, size: number) => void;
11
+ //# sourceMappingURL=xr-exit-button-mask.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"xr-exit-button-mask.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/xr-exit-button-mask.ts"],"names":[],"mappings":"AAOA;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,GAAI,KAAK,wBAAwB,EAAE,MAAM,MAAM,SA4B7E,CAAC"}
@@ -0,0 +1,41 @@
1
+ /** Backing disc radius as a fraction of the button's half-size. Matches the shader's pie radius. */
2
+ const DISC_RADIUS = 0.92;
3
+
4
+ /** Glyph arm reach and stroke width, as fractions of the drawn size. */
5
+ const GLYPH_ARM = 0.24;
6
+ const GLYPH_WIDTH = 0.09;
7
+
8
+ /**
9
+ * Bakes the exit button's static artwork into a two-channel mask: red covers the backing disc, green
10
+ * the X glyph. The shader composites the progress sweep between the two, which is what keeps the
11
+ * glyph legible over the fill.
12
+ *
13
+ * The channels have to stay independent, so the glyph is drawn additively rather than over the disc,
14
+ * and an opaque base goes down first. Uniform full alpha means the mask survives the texture upload
15
+ * whether or not alpha ends up premultiplied, and antialiased edges keep their coverage.
16
+ */
17
+ const drawExitButtonMask = (ctx, size) => {
18
+ const c = size / 2;
19
+ ctx.save();
20
+ ctx.globalCompositeOperation = 'source-over';
21
+ ctx.fillStyle = '#000000';
22
+ ctx.fillRect(0, 0, size, size);
23
+ ctx.globalCompositeOperation = 'lighter';
24
+ ctx.fillStyle = '#ff0000';
25
+ ctx.beginPath();
26
+ ctx.arc(c, c, c * DISC_RADIUS, 0, Math.PI * 2);
27
+ ctx.fill();
28
+ const arm = size * GLYPH_ARM;
29
+ ctx.strokeStyle = '#00ff00';
30
+ ctx.lineWidth = size * GLYPH_WIDTH;
31
+ ctx.lineCap = 'round';
32
+ ctx.beginPath();
33
+ ctx.moveTo(c - arm, c - arm);
34
+ ctx.lineTo(c + arm, c + arm);
35
+ ctx.moveTo(c + arm, c - arm);
36
+ ctx.lineTo(c - arm, c + arm);
37
+ ctx.stroke();
38
+ ctx.restore();
39
+ };
40
+
41
+ export { drawExitButtonMask };
@@ -20,9 +20,6 @@ export declare class XrExitButton extends Script {
20
20
  private _headRotation;
21
21
  private _worldOffset;
22
22
  private _faceButtons;
23
- private _canvas?;
24
- private _context?;
25
- private _drawnProgress;
26
23
  private _hold;
27
24
  private _arming;
28
25
  private _isVrActive;
@@ -31,20 +28,10 @@ export declare class XrExitButton extends Script {
31
28
  private _onSelect;
32
29
  /** True when the ray points at the button's hit sphere. False while the button entity is disabled. */
33
30
  rayHitsButton(origin: Vec3, direction: Vec3): boolean;
34
- private _createCanvas;
35
- private _createTexture;
36
- /**
37
- * Repaints the button: dark disc, then the hold progress wedge, then the X arms on top so the
38
- * glyph stays legible over the fill. The pie is skipped entirely at zero progress so the idle
39
- * button doesn't wear the pie's faint track. Returns whether it painted, so callers can tell a
40
- * missing context from a successful repaint.
41
- */
42
- private _drawButton;
43
- /** Repaints only when the drawn wedge would visibly move. Only records the paint on success, so a
44
- * missing context doesn't fool the dedupe cache into thinking a repaint already happened. */
45
- private _setProgress;
31
+ /** The button's static artwork, painted once. Only the sweep changes per frame, and that lives in
32
+ * the shader. */
33
+ private _createMaskTexture;
46
34
  private _resetProgress;
47
- private _createMesh;
48
35
  initialize(): void;
49
36
  update(dt: number): void;
50
37
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"xr-exit-button.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/xr-exit-button.ts"],"names":[],"mappings":"AAAA,OAAO,EAUL,MAAM,EAGN,IAAI,EAGL,MAAM,YAAY,CAAC;AAOpB;;;GAGG;AACH,eAAO,MAAM,kBAAkB,GAAI,QAAQ,IAAI,EAAE,WAAW,IAAI,EAAE,QAAQ,IAAI,EAAE,QAAQ,MAAM,KAAG,OAUhG,CAAC;AAOF,qBAAa,YAAa,SAAQ,MAAM;IACtC,MAAM,CAAC,UAAU,SAAkB;IAEnC,yGAAyG;IACzG,MAAM,OAA8B;IAEpC,oFAAoF;IACpF,QAAQ,SAAQ;IAEhB,sGAAsG;IACtG,SAAS,SAAQ;IAEjB,OAAO,CAAC,SAAS,CAAC,CAAmB;IACrC,OAAO,CAAC,QAAQ,CAAC,CAAU;IAC3B,OAAO,CAAC,KAAK,CAAC,CAAO;IACrB,OAAO,CAAC,QAAQ,CAAS;IAEzB,OAAO,CAAC,aAAa,CAAc;IACnC,OAAO,CAAC,aAAa,CAAc;IACnC,OAAO,CAAC,YAAY,CAAc;IAClC,OAAO,CAAC,YAAY,CAAgC;IAEpD,OAAO,CAAC,OAAO,CAAC,CAAoB;IACpC,OAAO,CAAC,QAAQ,CAAC,CAA2B;IAC5C,OAAO,CAAC,cAAc,CAAM;IAC5B,OAAO,CAAC,KAAK,CAAiC;IAC9C,OAAO,CAAC,OAAO,CAA2B;IAE1C,OAAO,CAAC,WAAW,CAAyE;IAE5F,OAAO,CAAC,UAAU,CAGhB;IAEF,OAAO,CAAC,QAAQ,CASd;IAEF,OAAO,CAAC,SAAS,CAOf;IAEF,sGAAsG;IACtG,aAAa,CAAC,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,GAAG,OAAO;IAQrD,OAAO,CAAC,aAAa;IAQrB,OAAO,CAAC,cAAc;IAgBtB;;;;;OAKG;IACH,OAAO,CAAC,WAAW;IAiCnB;iGAC6F;IAC7F,OAAO,CAAC,YAAY;IAUpB,OAAO,CAAC,cAAc;IAMtB,OAAO,CAAC,WAAW;IAYnB,UAAU;IAgCV,MAAM,CAAC,EAAE,EAAE,MAAM;IAoDjB;;;;;OAKG;IACH,OAAO,CAAC,UAAU;IAuBlB,OAAO;CAgBR"}
1
+ {"version":3,"file":"xr-exit-button.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/xr-exit-button.ts"],"names":[],"mappings":"AAAA,OAAO,EAOL,MAAM,EAGN,IAAI,EAGL,MAAM,YAAY,CAAC;AASpB;;;GAGG;AACH,eAAO,MAAM,kBAAkB,GAAI,QAAQ,IAAI,EAAE,WAAW,IAAI,EAAE,QAAQ,IAAI,EAAE,QAAQ,MAAM,KAAG,OAUhG,CAAC;AAUF,qBAAa,YAAa,SAAQ,MAAM;IACtC,MAAM,CAAC,UAAU,SAAkB;IAEnC,yGAAyG;IACzG,MAAM,OAA8B;IAEpC,oFAAoF;IACpF,QAAQ,SAAQ;IAEhB,sGAAsG;IACtG,SAAS,SAAQ;IAEjB,OAAO,CAAC,SAAS,CAAC,CAAiB;IACnC,OAAO,CAAC,QAAQ,CAAC,CAAU;IAC3B,OAAO,CAAC,KAAK,CAAC,CAAO;IACrB,OAAO,CAAC,QAAQ,CAAS;IAEzB,OAAO,CAAC,aAAa,CAAc;IACnC,OAAO,CAAC,aAAa,CAAc;IACnC,OAAO,CAAC,YAAY,CAAc;IAClC,OAAO,CAAC,YAAY,CAAgC;IAEpD,OAAO,CAAC,KAAK,CAAiC;IAC9C,OAAO,CAAC,OAAO,CAA2B;IAE1C,OAAO,CAAC,WAAW,CAAyE;IAE5F,OAAO,CAAC,UAAU,CAGhB;IAEF,OAAO,CAAC,QAAQ,CAMd;IAEF,OAAO,CAAC,SAAS,CAOf;IAEF,sGAAsG;IACtG,aAAa,CAAC,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,GAAG,OAAO;IAQrD;qBACiB;IACjB,OAAO,CAAC,kBAAkB;IAyB1B,OAAO,CAAC,cAAc;IAKtB,UAAU;IAwBV,MAAM,CAAC,EAAE,EAAE,MAAM;IAiDjB;;;;;OAKG;IACH,OAAO,CAAC,UAAU;IAuBlB,OAAO;CAcR"}
@@ -1,8 +1,10 @@
1
- import { Script, Vec3, Quat, XRTYPE_VR, Texture, FILTER_LINEAR, ADDRESS_CLAMP_TO_EDGE, Mesh, StandardMaterial, Color, BLEND_NORMAL, CULLFACE_NONE, MeshInstance, LAYERID_IMMEDIATE } from 'playcanvas';
1
+ import { Script, Vec3, Quat, XRTYPE_VR, Texture, FILTER_LINEAR, ADDRESS_CLAMP_TO_EDGE, MeshInstance, LAYERID_IMMEDIATE } from 'playcanvas';
2
2
  import { ButtonArmingLatch } from './xr-button-arming.js';
3
3
  import { INITIAL_HOLD_STATE, advanceHold } from './xr-button-hold.js';
4
+ import { drawExitButtonMask } from './xr-exit-button-mask.js';
4
5
  import { FaceButtonPressTracker, secondaryFaceButtonPressed } from './xr-face-buttons.js';
5
- import { drawProgressPie } from './xr-progress-pie.js';
6
+ import { pieShaderProgress } from './xr-progress-pie.js';
7
+ import { createProgressPieMaterial, progressPieQuadMesh } from './xr-progress-pie-material.js';
6
8
 
7
9
  /**
8
10
  * Boolean ray-sphere hit test. Treats the ray as a half-line (t >= 0) and returns true when it
@@ -20,6 +22,9 @@ const raySphereIntersect = (origin, direction, center, radius) => {
20
22
  };
21
23
  const TEXTURE_SIZE = 256;
22
24
 
25
+ /** Opacity of the button while it is not being aimed at. */
26
+ const IDLE_OPACITY = 0.9;
27
+
23
28
  /** Seconds of continuous B/Y hold required to leave the session. Matches the gaze-dwell threshold. */
24
29
  const EXIT_HOLD_SECONDS = 1.25;
25
30
  class XrExitButton extends Script {
@@ -38,7 +43,6 @@ class XrExitButton extends Script {
38
43
  _headRotation = new Quat();
39
44
  _worldOffset = new Vec3();
40
45
  _faceButtons = new FaceButtonPressTracker();
41
- _drawnProgress = -1;
42
46
  _hold = INITIAL_HOLD_STATE;
43
47
  _arming = new ButtonArmingLatch();
44
48
  _isVrActive = () => this.app.xr?.active === true && this.app.xr?.type === XRTYPE_VR;
@@ -51,10 +55,7 @@ class XrExitButton extends Script {
51
55
  this._hovered = false;
52
56
  this._resetProgress();
53
57
  this.entity.setLocalScale(1, 1, 1);
54
- if (this._material) {
55
- this._material.opacity = 0.9;
56
- this._material.update();
57
- }
58
+ this._material?.setParameter('uOpacity', IDLE_OPACITY);
58
59
  };
59
60
  _onSelect = inputSource => {
60
61
  if (!this.entity.enabled) {
@@ -72,15 +73,19 @@ class XrExitButton extends Script {
72
73
  }
73
74
  return raySphereIntersect(origin, direction, this.entity.getPosition(), this.hitRadius);
74
75
  }
75
- _createCanvas() {
76
+
77
+ /** The button's static artwork, painted once. Only the sweep changes per frame, and that lives in
78
+ * the shader. */
79
+ _createMaskTexture() {
76
80
  const canvas = document.createElement('canvas');
77
81
  canvas.width = TEXTURE_SIZE;
78
82
  canvas.height = TEXTURE_SIZE;
79
- return canvas;
80
- }
81
- _createTexture(canvas) {
83
+ const ctx = canvas.getContext('2d');
84
+ if (ctx) {
85
+ drawExitButtonMask(ctx, TEXTURE_SIZE);
86
+ }
82
87
  const texture = new Texture(this.app.graphicsDevice, {
83
- name: 'xr-exit-button',
88
+ name: 'xr-exit-button-mask',
84
89
  width: TEXTURE_SIZE,
85
90
  height: TEXTURE_SIZE,
86
91
  addressU: ADDRESS_CLAMP_TO_EDGE,
@@ -92,85 +97,22 @@ class XrExitButton extends Script {
92
97
  texture.setSource(canvas);
93
98
  return texture;
94
99
  }
95
-
96
- /**
97
- * Repaints the button: dark disc, then the hold progress wedge, then the X arms on top so the
98
- * glyph stays legible over the fill. The pie is skipped entirely at zero progress so the idle
99
- * button doesn't wear the pie's faint track. Returns whether it painted, so callers can tell a
100
- * missing context from a successful repaint.
101
- */
102
- _drawButton(progress) {
103
- const ctx = this._context;
104
- if (!ctx) {
105
- return false;
106
- }
107
- const c = TEXTURE_SIZE / 2;
108
- ctx.clearRect(0, 0, TEXTURE_SIZE, TEXTURE_SIZE);
109
- ctx.fillStyle = 'rgba(20, 20, 20, 0.75)';
110
- ctx.beginPath();
111
- ctx.arc(c, c, c * 0.92, 0, Math.PI * 2);
112
- ctx.fill();
113
- if (progress > 0) {
114
- drawProgressPie(ctx, TEXTURE_SIZE, progress);
115
- }
116
- const arm = TEXTURE_SIZE * 0.24;
117
- ctx.strokeStyle = '#ffffff';
118
- ctx.lineWidth = TEXTURE_SIZE * 0.09;
119
- ctx.lineCap = 'round';
120
- ctx.beginPath();
121
- ctx.moveTo(c - arm, c - arm);
122
- ctx.lineTo(c + arm, c + arm);
123
- ctx.moveTo(c + arm, c - arm);
124
- ctx.lineTo(c - arm, c + arm);
125
- ctx.stroke();
126
- this._texture?.upload();
127
- return true;
128
- }
129
-
130
- /** Repaints only when the drawn wedge would visibly move. Only records the paint on success, so a
131
- * missing context doesn't fool the dedupe cache into thinking a repaint already happened. */
132
- _setProgress(progress) {
133
- if (Math.abs(progress - this._drawnProgress) <= 0.001) {
134
- return;
135
- }
136
- if (this._drawButton(progress)) {
137
- this._drawnProgress = progress;
138
- }
139
- }
140
100
  _resetProgress() {
141
101
  this._hold = INITIAL_HOLD_STATE;
142
- this._drawnProgress = -1;
143
- this._setProgress(0);
144
- }
145
- _createMesh() {
146
- const h = this.halfSize;
147
- const mesh = new Mesh(this.app.graphicsDevice);
148
- mesh.setPositions([-h, -h, 0, h, -h, 0, h, h, 0, -h, h, 0]);
149
- mesh.setNormals([0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1]);
150
- mesh.setUvs(0, [0, 1, 1, 1, 1, 0, 0, 0]);
151
- mesh.setIndices([0, 1, 2, 0, 2, 3]);
152
- mesh.update();
153
- return mesh;
102
+ this._material?.setParameter('uProgress', 0);
154
103
  }
155
104
  initialize() {
156
- this._canvas = this._createCanvas();
157
- this._context = this._canvas.getContext('2d') ?? undefined;
158
- this._texture = this._createTexture(this._canvas);
105
+ this._texture = this._createMaskTexture();
106
+ this._material = createProgressPieMaterial({
107
+ uniqueName: 'xr-exit-button',
108
+ maskMap: this._texture,
109
+ discColor: [20 / 255, 20 / 255, 20 / 255],
110
+ discAlpha: 0.75
111
+ });
112
+ this._material.setParameter('uOpacity', IDLE_OPACITY);
159
113
  this._resetProgress();
160
- const material = new StandardMaterial();
161
- material.useLighting = false;
162
- material.emissive = new Color(1, 1, 1);
163
- material.emissiveMap = this._texture;
164
- material.opacityMap = this._texture;
165
- material.opacityMapChannel = 'a';
166
- material.blendType = BLEND_NORMAL;
167
- material.depthTest = false;
168
- material.depthWrite = false;
169
- material.cull = CULLFACE_NONE;
170
- material.update();
171
- this._material = material;
172
- this._mesh = this._createMesh();
173
- const meshInstance = new MeshInstance(this._mesh, material);
114
+ this._mesh = progressPieQuadMesh(this.app.graphicsDevice, this.halfSize);
115
+ const meshInstance = new MeshInstance(this._mesh, this._material);
174
116
 
175
117
  // Immediate layer draws after the World layer (where the splats render) so the button is never
176
118
  // composited behind them; depthTest:false keeps it on top within the layer.
@@ -211,7 +153,7 @@ class XrExitButton extends Script {
211
153
  }
212
154
  const hold = advanceHold(this._hold, holdActive, dt, EXIT_HOLD_SECONDS);
213
155
  this._hold = hold.state;
214
- this._setProgress(hold.progress);
156
+ this._material?.setParameter('uProgress', pieShaderProgress(hold.progress));
215
157
  if (hold.justFired) {
216
158
  this.app.xr?.end();
217
159
  return;
@@ -220,10 +162,7 @@ class XrExitButton extends Script {
220
162
  this._hovered = hovered;
221
163
  const scale = hovered ? 1.15 : 1;
222
164
  this.entity.setLocalScale(scale, scale, scale);
223
- if (this._material) {
224
- this._material.opacity = hovered ? 1 : 0.9;
225
- this._material.update();
226
- }
165
+ this._material?.setParameter('uOpacity', hovered ? 1 : IDLE_OPACITY);
227
166
  }
228
167
  }
229
168
 
@@ -262,8 +201,6 @@ class XrExitButton extends Script {
262
201
  this._mesh = undefined;
263
202
  this._material = undefined;
264
203
  this._texture = undefined;
265
- this._canvas = undefined;
266
- this._context = undefined;
267
204
  }
268
205
  }
269
206
 
@@ -6,25 +6,29 @@ export declare class XrGazeDwell extends Script {
6
6
  activeIndex: number;
7
7
  private _dwell;
8
8
  private _pieEntity?;
9
- private _pieCanvas?;
10
- private _pieTexture?;
11
9
  private _pieMaterial?;
12
10
  private _pieMesh?;
13
- private _drawnProgress;
11
+ private _pieMask?;
14
12
  private _headPos;
15
13
  private _headRot;
16
14
  private _viewOffset;
17
15
  private _forward;
18
16
  private _scratchScale;
19
17
  private _isVrActive;
18
+ /**
19
+ * A render component hands its mesh instances to a layer only when it flips from disabled to
20
+ * enabled, and hands over nothing if the layer cannot be resolved yet. The session events are the
21
+ * points where the layers are known to exist, so the pie flips there: start registers it, and end
22
+ * restores the transition for the next session.
23
+ */
24
+ private _onXrStart;
25
+ private _onXrEnd;
26
+ private _setPieEnabled;
20
27
  initialize(): void;
21
- private _createQuad;
22
- /** Redraws the pie fill for the given dwell progress (0..1) onto the texture canvas. */
23
- private _drawPie;
24
- private _trackHead;
25
28
  update(dt: number): void;
26
- private _showPie;
27
- private _hidePie;
29
+ private _trackHead;
30
+ /** Sits the pie on the hotspot, square to the head. */
31
+ private _placePie;
28
32
  destroy(): void;
29
33
  }
30
34
  //# sourceMappingURL=xr-gaze-dwell.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"xr-gaze-dwell.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/xr-gaze-dwell.ts"],"names":[],"mappings":"AAAA,OAAO,EAWL,MAAM,EAKP,MAAM,YAAY,CAAC;AAqBpB,qBAAa,WAAY,SAAQ,MAAM;IACrC,MAAM,CAAC,UAAU,SAAiB;IAElC;6FACyF;IACzF,WAAW,SAAM;IAEjB,OAAO,CAAC,MAAM,CAAmC;IAEjD,OAAO,CAAC,UAAU,CAAC,CAAS;IAC5B,OAAO,CAAC,UAAU,CAAC,CAAoB;IACvC,OAAO,CAAC,WAAW,CAAC,CAAU;IAC9B,OAAO,CAAC,YAAY,CAAC,CAAmB;IACxC,OAAO,CAAC,QAAQ,CAAC,CAAO;IACxB,OAAO,CAAC,cAAc,CAAM;IAE5B,OAAO,CAAC,QAAQ,CAAc;IAC9B,OAAO,CAAC,QAAQ,CAAc;IAC9B,OAAO,CAAC,WAAW,CAAc;IACjC,OAAO,CAAC,QAAQ,CAAc;IAC9B,OAAO,CAAC,aAAa,CAAc;IAEnC,OAAO,CAAC,WAAW,CAAyE;IAE5F,UAAU;IAuCV,OAAO,CAAC,WAAW;IAYnB,wFAAwF;IACxF,OAAO,CAAC,QAAQ;IAWhB,OAAO,CAAC,UAAU;IAgBlB,MAAM,CAAC,EAAE,EAAE,MAAM;IAmCjB,OAAO,CAAC,QAAQ;IAmBhB,OAAO,CAAC,QAAQ;IAOhB,OAAO;CAeR"}
1
+ {"version":3,"file":"xr-gaze-dwell.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/xr-gaze-dwell.ts"],"names":[],"mappings":"AAAA,OAAO,EAML,MAAM,EAKP,MAAM,YAAY,CAAC;AAmBpB,qBAAa,WAAY,SAAQ,MAAM;IACrC,MAAM,CAAC,UAAU,SAAiB;IAElC;6FACyF;IACzF,WAAW,SAAM;IAEjB,OAAO,CAAC,MAAM,CAAmC;IAEjD,OAAO,CAAC,UAAU,CAAC,CAAS;IAC5B,OAAO,CAAC,YAAY,CAAC,CAAiB;IACtC,OAAO,CAAC,QAAQ,CAAC,CAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,CAAU;IAE3B,OAAO,CAAC,QAAQ,CAAc;IAC9B,OAAO,CAAC,QAAQ,CAAc;IAC9B,OAAO,CAAC,WAAW,CAAc;IACjC,OAAO,CAAC,QAAQ,CAAc;IAC9B,OAAO,CAAC,aAAa,CAAc;IAEnC,OAAO,CAAC,WAAW,CAAyE;IAE5F;;;;;OAKG;IACH,OAAO,CAAC,UAAU,CAGhB;IAEF,OAAO,CAAC,QAAQ,CAId;IAEF,OAAO,CAAC,cAAc;IAMtB,UAAU;IA4BV,MAAM,CAAC,EAAE,EAAE,MAAM;IAmCjB,OAAO,CAAC,UAAU;IAgBlB,uDAAuD;IACvD,OAAO,CAAC,SAAS;IAcjB,OAAO;CAiBR"}
@@ -1,8 +1,9 @@
1
- import { Vec3, Script, Quat, XRTYPE_VR, Texture, FILTER_LINEAR, ADDRESS_CLAMP_TO_EDGE, StandardMaterial, Color, BLEND_NORMAL, CULLFACE_NONE, MeshInstance, Entity, LAYERID_IMMEDIATE, Mesh } from 'playcanvas';
2
- import { HOTSPOT_HALF_EXTENT, collectAnnotationHitCandidates } from './xr-annotation-candidates.js';
1
+ import { Vec3, Script, Quat, XRTYPE_VR, MeshInstance, Entity, LAYERID_IMMEDIATE } from 'playcanvas';
2
+ import { collectAnnotationHitCandidates, HOTSPOT_HALF_EXTENT } from './xr-annotation-candidates.js';
3
3
  import { nearestAnnotationHit } from './xr-annotation-targeting.js';
4
4
  import { INITIAL_DWELL_STATE, advanceDwell } from './xr-gaze-dwell-state.js';
5
- import { drawProgressPie } from './xr-progress-pie.js';
5
+ import { pieShaderProgress } from './xr-progress-pie.js';
6
+ import { emptyMaskTexture, createProgressPieMaterial, progressPieQuadMesh } from './xr-progress-pie-material.js';
6
7
 
7
8
  /** Multiplier on the hotspot's world radius for gaze targeting. */
8
9
  const GAZE_HIT_RADIUS_PAD = 5;
@@ -12,9 +13,6 @@ const DWELL_SECONDS = 1.25;
12
13
 
13
14
  /** Progress-pie quad radius as a multiple of the hotspot's rendered half-extent (covers the hotspot). */
14
15
  const PIE_RADIUS_SCALE = 1.25;
15
-
16
- /** Progress-pie texture resolution. */
17
- const PIE_TEXTURE_SIZE = 128;
18
16
  const LOCAL_FORWARD = new Vec3(0, 0, -1);
19
17
  class XrGazeDwell extends Script {
20
18
  static scriptName = 'xrGazeDwell';
@@ -23,42 +21,47 @@ class XrGazeDwell extends Script {
23
21
  * dwell opens OTHER annotations while one is open (and never re-dwells the open one). */
24
22
  activeIndex = -1;
25
23
  _dwell = INITIAL_DWELL_STATE;
26
- _drawnProgress = -1;
27
24
  _headPos = new Vec3();
28
25
  _headRot = new Quat();
29
26
  _viewOffset = new Vec3();
30
27
  _forward = new Vec3();
31
28
  _scratchScale = new Vec3();
32
29
  _isVrActive = () => this.app.xr?.active === true && this.app.xr?.type === XRTYPE_VR;
30
+
31
+ /**
32
+ * A render component hands its mesh instances to a layer only when it flips from disabled to
33
+ * enabled, and hands over nothing if the layer cannot be resolved yet. The session events are the
34
+ * points where the layers are known to exist, so the pie flips there: start registers it, and end
35
+ * restores the transition for the next session.
36
+ */
37
+ _onXrStart = () => {
38
+ this._dwell = INITIAL_DWELL_STATE;
39
+ this._setPieEnabled(this._isVrActive());
40
+ };
41
+ _onXrEnd = () => {
42
+ this._dwell = INITIAL_DWELL_STATE;
43
+ this._pieMaterial?.setParameter('uProgress', 0);
44
+ this._setPieEnabled(false);
45
+ };
46
+ _setPieEnabled(enabled) {
47
+ if (this._pieEntity) {
48
+ this._pieEntity.enabled = enabled;
49
+ }
50
+ }
33
51
  initialize() {
34
- this._pieCanvas = document.createElement('canvas');
35
- this._pieCanvas.width = PIE_TEXTURE_SIZE;
36
- this._pieCanvas.height = PIE_TEXTURE_SIZE;
37
- this._pieTexture = new Texture(this.app.graphicsDevice, {
38
- name: 'xr-gaze-dwell-pie',
39
- width: PIE_TEXTURE_SIZE,
40
- height: PIE_TEXTURE_SIZE,
41
- addressU: ADDRESS_CLAMP_TO_EDGE,
42
- addressV: ADDRESS_CLAMP_TO_EDGE,
43
- minFilter: FILTER_LINEAR,
44
- magFilter: FILTER_LINEAR,
45
- mipmaps: true
52
+ this._pieMask = emptyMaskTexture(this.app.graphicsDevice);
53
+ this._pieMaterial = createProgressPieMaterial({
54
+ uniqueName: 'xr-gaze-dwell-pie',
55
+ maskMap: this._pieMask
46
56
  });
47
- this._pieTexture.setSource(this._pieCanvas);
48
- const material = new StandardMaterial();
49
- material.useLighting = false;
50
- material.emissive = new Color(1, 1, 1);
51
- material.emissiveMap = this._pieTexture;
52
- material.opacityMap = this._pieTexture;
53
- material.opacityMapChannel = 'a';
54
- material.blendType = BLEND_NORMAL;
55
- material.depthTest = false;
56
- material.depthWrite = false;
57
- material.cull = CULLFACE_NONE;
58
- material.update();
59
- this._pieMaterial = material;
60
- this._pieMesh = this._createQuad();
61
- const meshInstance = new MeshInstance(this._pieMesh, material);
57
+ this._pieMesh = progressPieQuadMesh(this.app.graphicsDevice, HOTSPOT_HALF_EXTENT);
58
+ const meshInstance = new MeshInstance(this._pieMesh, this._pieMaterial);
59
+
60
+ // Never frustum-culled. The quad only takes a position once a sweep starts, and until then sits
61
+ // at the origin where culling would drop it from the render list - taking the shader compile it
62
+ // stays enabled for along with it. An always-submitted quad that discards every fragment costs
63
+ // less than that compile landing mid-gaze.
64
+ meshInstance.cull = false;
62
65
  this._pieEntity = new Entity('xr-gaze-dwell-pie');
63
66
  this._pieEntity.addComponent('render', {
64
67
  meshInstances: [meshInstance],
@@ -66,49 +69,20 @@ class XrGazeDwell extends Script {
66
69
  });
67
70
  this._pieEntity.enabled = false;
68
71
  this.entity.addChild(this._pieEntity);
69
- }
70
- _createQuad() {
71
- const h = HOTSPOT_HALF_EXTENT;
72
- const mesh = new Mesh(this.app.graphicsDevice);
73
- mesh.setPositions([-h, -h, 0, h, -h, 0, h, h, 0, -h, h, 0]);
74
- mesh.setNormals([0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1]);
75
- mesh.setUvs(0, [0, 1, 1, 1, 1, 0, 0, 0]);
76
- mesh.setIndices([0, 1, 2, 0, 2, 3]);
77
- mesh.update();
78
- return mesh;
79
- }
80
72
 
81
- /** Redraws the pie fill for the given dwell progress (0..1) onto the texture canvas. */
82
- _drawPie(progress) {
83
- const ctx = this._pieCanvas?.getContext('2d');
84
- if (!ctx) {
85
- return;
86
- }
87
- ctx.clearRect(0, 0, PIE_TEXTURE_SIZE, PIE_TEXTURE_SIZE);
88
- drawProgressPie(ctx, PIE_TEXTURE_SIZE, progress);
89
- this._pieTexture?.upload();
90
- }
91
- _trackHead() {
92
- const views = this.app.xr?.views?.list;
93
- if (!views || views.length === 0) {
94
- return false;
95
- }
96
- this._headPos.set(0, 0, 0);
97
- for (const view of views) {
98
- view.viewInvOffMat.getTranslation(this._viewOffset);
99
- this._headPos.add(this._viewOffset);
100
- }
101
- this._headPos.mulScalar(1 / views.length);
102
- this._headRot.setFromMat4(views[0].viewInvOffMat);
103
- return true;
73
+ // Both orders happen: the walkthrough can mount into a session that has already started (as it
74
+ // does when VR is entered from outside the canvas), or mount first and wait for one.
75
+ this._setPieEnabled(this._isVrActive());
76
+ this.app.xr?.on('start', this._onXrStart);
77
+ this.app.xr?.on('end', this._onXrEnd);
104
78
  }
105
79
  update(dt) {
106
- if (!this._pieEntity) {
80
+ if (!this._pieEntity || !this._pieMaterial) {
107
81
  return;
108
82
  }
109
83
  if (!this._isVrActive() || !this._trackHead()) {
110
84
  this._dwell = INITIAL_DWELL_STATE;
111
- this._hidePie();
85
+ this._pieMaterial.setParameter('uProgress', 0);
112
86
  return;
113
87
  }
114
88
  const activeName = `annotation-${this.activeIndex}`;
@@ -117,10 +91,10 @@ class XrGazeDwell extends Script {
117
91
  const target = nearestAnnotationHit(this._headPos, this._forward, candidates);
118
92
  const result = advanceDwell(this._dwell, target, dt, DWELL_SECONDS);
119
93
  this._dwell = result.state;
120
- if (target !== null && result.progress > 0 && result.progress < 1) {
121
- this._showPie(candidates[target].entity, result.progress);
122
- } else {
123
- this._hidePie();
94
+ const progress = pieShaderProgress(result.progress);
95
+ this._pieMaterial.setParameter('uProgress', progress);
96
+ if (progress > 0 && target !== null) {
97
+ this._placePie(candidates[target].entity);
124
98
  }
125
99
  if (result.justOpened && target !== null) {
126
100
  const {
@@ -132,30 +106,37 @@ class XrGazeDwell extends Script {
132
106
  script.onVrOpenCallback(screen?.x ?? 0, screen?.y ?? 0);
133
107
  }
134
108
  }
135
- _showPie(entity, progress) {
109
+ _trackHead() {
110
+ const views = this.app.xr?.views?.list;
111
+ if (!views || views.length === 0) {
112
+ return false;
113
+ }
114
+ this._headPos.set(0, 0, 0);
115
+ for (const view of views) {
116
+ view.viewInvOffMat.getTranslation(this._viewOffset);
117
+ this._headPos.add(this._viewOffset);
118
+ }
119
+ this._headPos.mulScalar(1 / views.length);
120
+ this._headRot.setFromMat4(views[0].viewInvOffMat);
121
+ return true;
122
+ }
123
+
124
+ /** Sits the pie on the hotspot, square to the head. */
125
+ _placePie(entity) {
136
126
  if (!this._pieEntity) {
137
127
  return;
138
128
  }
139
- if (Math.abs(progress - this._drawnProgress) > 0.001) {
140
- this._drawnProgress = progress;
141
- this._drawPie(progress);
142
- }
143
129
 
144
130
  // The unit quad has half-extent HOTSPOT_HALF_EXTENT, so scale it to reach the desired world size.
145
131
  entity.getWorldTransform().getScale(this._scratchScale);
146
132
  const scale = this._scratchScale.x * PIE_RADIUS_SCALE;
147
- this._pieEntity.enabled = true;
148
133
  this._pieEntity.setLocalScale(scale, scale, scale);
149
134
  this._pieEntity.setPosition(entity.getPosition());
150
135
  this._pieEntity.setRotation(this._headRot);
151
136
  }
152
- _hidePie() {
153
- if (this._pieEntity && this._pieEntity.enabled) {
154
- this._pieEntity.enabled = false;
155
- this._drawnProgress = -1;
156
- }
157
- }
158
137
  destroy() {
138
+ this.app.xr?.off('start', this._onXrStart);
139
+ this.app.xr?.off('end', this._onXrEnd);
159
140
  if (this._pieEntity) {
160
141
  if (this._pieEntity.render) {
161
142
  this._pieEntity.render.meshInstances = [];
@@ -165,10 +146,10 @@ class XrGazeDwell extends Script {
165
146
  }
166
147
  this._pieMesh?.destroy();
167
148
  this._pieMaterial?.destroy();
168
- this._pieTexture?.destroy();
149
+ this._pieMask?.destroy();
169
150
  this._pieMesh = undefined;
170
151
  this._pieMaterial = undefined;
171
- this._pieTexture = undefined;
152
+ this._pieMask = undefined;
172
153
  }
173
154
  }
174
155
 
@@ -0,0 +1,24 @@
1
+ import { GraphicsDevice, Mesh, ShaderMaterial, Texture } from 'playcanvas';
2
+ /** A camera-facing unit quad with y-down UVs, sized to `halfExtent` in local units. */
3
+ export declare const progressPieQuadMesh: (device: GraphicsDevice, halfExtent: number) => Mesh;
4
+ /**
5
+ * A single transparent texel, for pies that carry no static artwork of their own.
6
+ *
7
+ * The filters are set explicitly because the default minification filter samples a mipmap chain,
8
+ * which a single-level texture does not have. That combination is incomplete in WebGL, and what a
9
+ * shader reads from an incomplete texture is not something to rely on.
10
+ */
11
+ export declare const emptyMaskTexture: (device: GraphicsDevice) => Texture;
12
+ export interface ProgressPieMaterialOptions {
13
+ uniqueName: string;
14
+ /** Static artwork baked once: red masks a disc under the sweep, green a glyph over it. */
15
+ maskMap: Texture;
16
+ discColor?: [number, number, number];
17
+ discAlpha?: number;
18
+ }
19
+ /**
20
+ * Material for a progress pie. Everything that animates rides on the `uProgress` uniform, so a
21
+ * running sweep costs one uniform write per frame and no texture work at all.
22
+ */
23
+ export declare const createProgressPieMaterial: ({ uniqueName, maskMap, discColor, discAlpha, }: ProgressPieMaterialOptions) => ShaderMaterial;
24
+ //# sourceMappingURL=xr-progress-pie-material.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"xr-progress-pie-material.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/xr-progress-pie-material.ts"],"names":[],"mappings":"AAAA,OAAO,EAKL,cAAc,EACd,IAAI,EAIJ,cAAc,EACd,OAAO,EACR,MAAM,YAAY,CAAC;AAoGpB,uFAAuF;AACvF,eAAO,MAAM,mBAAmB,GAAI,QAAQ,cAAc,EAAE,YAAY,MAAM,KAAG,IAUhF,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,gBAAgB,GAAI,QAAQ,cAAc,KAAG,OAiBzD,CAAC;AAEF,MAAM,WAAW,0BAA0B;IACzC,UAAU,EAAE,MAAM,CAAC;IACnB,0FAA0F;IAC1F,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;IACrC,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;GAGG;AACH,eAAO,MAAM,yBAAyB,GAAI,gDAKvC,0BAA0B,KAAG,cA0B/B,CAAC"}
@@ -0,0 +1,174 @@
1
+ import { ShaderMaterial, SEMANTIC_TEXCOORD0, SEMANTIC_POSITION, BLEND_NORMAL, CULLFACE_NONE, Texture, ADDRESS_CLAMP_TO_EDGE, FILTER_NEAREST, PIXELFORMAT_RGBA8, Mesh } from 'playcanvas';
2
+
3
+ /** Pie radius as a fraction of the quad's half-extent. */
4
+ const PIE_RADIUS = 0.92;
5
+
6
+ // GLSL only, for the same reason as BoundaryWallScript: @playcanvas/react's Application defaults to
7
+ // deviceTypes [DEVICETYPE_WEBGL2], so WGSL variants would never be compiled here.
8
+ const pieVertexGLSL = /* glsl */`
9
+ attribute vec3 vertex_position;
10
+ attribute vec2 aUv0;
11
+
12
+ uniform mat4 matrix_model;
13
+ uniform mat4 matrix_viewProjection;
14
+
15
+ varying vec2 uv0;
16
+
17
+ void main(void) {
18
+ uv0 = aUv0;
19
+ gl_Position = matrix_viewProjection * matrix_model * vec4(vertex_position, 1.0);
20
+ }
21
+ `;
22
+
23
+ /**
24
+ * Draws the progress sweep analytically from `uProgress`, so animating it costs one uniform write
25
+ * per frame.
26
+ *
27
+ * `uMaskMap` carries whatever static artwork sits around the pie, baked once by the caller: red is
28
+ * a mask for a backing disc drawn under the sweep, green a mask for a glyph drawn over it. Callers
29
+ * with no such artwork bind a transparent pixel and get the bare pie.
30
+ */
31
+ const pieFragmentGLSL = /* glsl */`
32
+ uniform float uProgress;
33
+ uniform vec3 uTrackColor;
34
+ uniform float uTrackAlpha;
35
+ uniform vec3 uWedgeColor;
36
+ uniform float uWedgeAlpha;
37
+ uniform vec3 uDiscColor;
38
+ uniform float uDiscAlpha;
39
+ uniform vec3 uGlyphColor;
40
+ uniform float uOpacity;
41
+ uniform sampler2D uMaskMap;
42
+
43
+ varying vec2 uv0;
44
+
45
+ const float PI = 3.14159265359;
46
+ const float TWO_PI = 6.28318530718;
47
+ const float HALF_PI = 1.57079632679;
48
+ const float PIE_RADIUS = ${PIE_RADIUS};
49
+
50
+ /** Source-over composite of a straight-alpha source onto a straight-alpha destination. */
51
+ vec4 over(vec4 dst, vec4 src) {
52
+ float a = src.a + dst.a * (1.0 - src.a);
53
+ if (a <= 0.0) {
54
+ return vec4(0.0);
55
+ }
56
+
57
+ return vec4((src.rgb * src.a + dst.rgb * dst.a * (1.0 - src.a)) / a, a);
58
+ }
59
+
60
+ void main(void) {
61
+ // The quad's UVs run y-down (v = 0 along the top edge), so centring them gives a coordinate
62
+ // system with +y downward and angles increasing clockwise.
63
+ vec2 p = uv0 * 2.0 - 1.0;
64
+ float d = length(p);
65
+
66
+ // Derivatives are undefined in non-uniform control flow, so every fwidth runs before the
67
+ // discard at the end.
68
+ float radiusFeather = fwidth(d);
69
+ float insideDisc = 1.0 - smoothstep(PIE_RADIUS - radiusFeather, PIE_RADIUS + radiusFeather, d);
70
+
71
+ // Fraction of a full turn, measured clockwise from 12 o'clock.
72
+ float turn = mod(atan(p.y, p.x) + HALF_PI + TWO_PI, TWO_PI) / TWO_PI;
73
+
74
+ // Signed arc-length to the sweep's leading edge, which is smooth either side of that edge and
75
+ // so antialiases cleanly. It jumps at the 12 o'clock seam, where fwidth would blow up and
76
+ // wash the edge out to half coverage, so the feather is capped to keep the smoothstep
77
+ // saturated there instead.
78
+ float edgeDistance = (turn - uProgress) * TWO_PI * d;
79
+ float edgeFeather = min(fwidth(edgeDistance), 0.05);
80
+ float swept = 1.0 - smoothstep(-edgeFeather, edgeFeather, edgeDistance);
81
+
82
+ // A pie at zero progress is not started, and should not show even its track.
83
+ float pie = insideDisc * step(0.0001, uProgress);
84
+
85
+ vec4 mask = texture2D(uMaskMap, uv0);
86
+
87
+ vec4 color = over(vec4(0.0), vec4(uDiscColor, uDiscAlpha * mask.r));
88
+ color = over(color, vec4(uTrackColor, uTrackAlpha * pie));
89
+ color = over(color, vec4(uWedgeColor, uWedgeAlpha * pie * swept));
90
+ color = over(color, vec4(uGlyphColor, mask.g));
91
+ color.a *= uOpacity;
92
+
93
+ if (color.a <= 0.0) {
94
+ discard;
95
+ }
96
+
97
+ gl_FragColor = color;
98
+ }
99
+ `;
100
+
101
+ /** A camera-facing unit quad with y-down UVs, sized to `halfExtent` in local units. */
102
+ const progressPieQuadMesh = (device, halfExtent) => {
103
+ const h = halfExtent;
104
+ const mesh = new Mesh(device);
105
+ mesh.setPositions([-h, -h, 0, h, -h, 0, h, h, 0, -h, h, 0]);
106
+ mesh.setNormals([0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1]);
107
+ mesh.setUvs(0, [0, 1, 1, 1, 1, 0, 0, 0]);
108
+ mesh.setIndices([0, 1, 2, 0, 2, 3]);
109
+ mesh.update();
110
+ return mesh;
111
+ };
112
+
113
+ /**
114
+ * A single transparent texel, for pies that carry no static artwork of their own.
115
+ *
116
+ * The filters are set explicitly because the default minification filter samples a mipmap chain,
117
+ * which a single-level texture does not have. That combination is incomplete in WebGL, and what a
118
+ * shader reads from an incomplete texture is not something to rely on.
119
+ */
120
+ const emptyMaskTexture = device => {
121
+ const texture = new Texture(device, {
122
+ name: 'xr-progress-pie-empty-mask',
123
+ width: 1,
124
+ height: 1,
125
+ format: PIXELFORMAT_RGBA8,
126
+ mipmaps: false,
127
+ minFilter: FILTER_NEAREST,
128
+ magFilter: FILTER_NEAREST,
129
+ addressU: ADDRESS_CLAMP_TO_EDGE,
130
+ addressV: ADDRESS_CLAMP_TO_EDGE
131
+ });
132
+ const pixels = texture.lock();
133
+ pixels.set([0, 0, 0, 0]);
134
+ texture.unlock();
135
+ return texture;
136
+ };
137
+ /**
138
+ * Material for a progress pie. Everything that animates rides on the `uProgress` uniform, so a
139
+ * running sweep costs one uniform write per frame and no texture work at all.
140
+ */
141
+ const createProgressPieMaterial = ({
142
+ uniqueName,
143
+ maskMap,
144
+ discColor = [0.08, 0.08, 0.08],
145
+ discAlpha = 0
146
+ }) => {
147
+ const material = new ShaderMaterial({
148
+ uniqueName,
149
+ vertexGLSL: pieVertexGLSL,
150
+ fragmentGLSL: pieFragmentGLSL,
151
+ attributes: {
152
+ vertex_position: SEMANTIC_POSITION,
153
+ aUv0: SEMANTIC_TEXCOORD0
154
+ }
155
+ });
156
+ material.blendType = BLEND_NORMAL;
157
+ material.cull = CULLFACE_NONE;
158
+ material.depthTest = false;
159
+ material.depthWrite = false;
160
+ material.setParameter('uProgress', 0);
161
+ material.setParameter('uTrackColor', [1, 1, 1]);
162
+ material.setParameter('uTrackAlpha', 0.18);
163
+ material.setParameter('uWedgeColor', [64 / 255, 200 / 255, 1]);
164
+ material.setParameter('uWedgeAlpha', 0.85);
165
+ material.setParameter('uDiscColor', discColor);
166
+ material.setParameter('uDiscAlpha', discAlpha);
167
+ material.setParameter('uGlyphColor', [1, 1, 1]);
168
+ material.setParameter('uOpacity', 1);
169
+ material.setParameter('uMaskMap', maskMap);
170
+ material.update();
171
+ return material;
172
+ };
173
+
174
+ export { createProgressPieMaterial, emptyMaskTexture, progressPieQuadMesh };
@@ -1,10 +1,10 @@
1
1
  /**
2
- * Draws a circular progress pie into the `size` x `size` region at the context origin: a faint
3
- * full-circle track under a wedge that sweeps clockwise as `progress` goes 0 -> 1. A `progress`
4
- * above 1 simply sweeps a full circle. Neither clears the context nor uploads a texture, so callers
5
- * can composite it between their own layers. Saves and restores the drawing state it touches and
6
- * leaves the path clean on exit, so callers can stroke or fill right after without an explicit
7
- * `beginPath()` of their own.
2
+ * Progress to hand the pie shader, where 0 draws nothing at all: an untouched target wears no faint
3
+ * track, and a completed one has already opened its annotation or ended the session.
4
+ *
5
+ * Visibility rides on this value so the quad can stay enabled for the whole session. A mesh
6
+ * instance compiles its shader variant the first time it is drawn, and that compile has to land
7
+ * somewhere other than the frame a pie first appears.
8
8
  */
9
- export declare const drawProgressPie: (ctx: CanvasRenderingContext2D, size: number, progress: number) => void;
9
+ export declare const pieShaderProgress: (progress: number) => number;
10
10
  //# sourceMappingURL=xr-progress-pie.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"xr-progress-pie.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/xr-progress-pie.ts"],"names":[],"mappings":"AAGA;;;;;;;GAOG;AACH,eAAO,MAAM,eAAe,GAAI,KAAK,wBAAwB,EAAE,MAAM,MAAM,EAAE,UAAU,MAAM,SAsB5F,CAAC"}
1
+ {"version":3,"file":"xr-progress-pie.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/xr-progress-pie.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,eAAO,MAAM,iBAAiB,GAAI,UAAU,MAAM,KAAG,MAAuD,CAAC"}
@@ -1,32 +1,11 @@
1
- /** Sweep starts at 12 o'clock. */
2
- const PIE_START_ANGLE = -Math.PI / 2;
3
-
4
1
  /**
5
- * Draws a circular progress pie into the `size` x `size` region at the context origin: a faint
6
- * full-circle track under a wedge that sweeps clockwise as `progress` goes 0 -> 1. A `progress`
7
- * above 1 simply sweeps a full circle. Neither clears the context nor uploads a texture, so callers
8
- * can composite it between their own layers. Saves and restores the drawing state it touches and
9
- * leaves the path clean on exit, so callers can stroke or fill right after without an explicit
10
- * `beginPath()` of their own.
2
+ * Progress to hand the pie shader, where 0 draws nothing at all: an untouched target wears no faint
3
+ * track, and a completed one has already opened its annotation or ended the session.
4
+ *
5
+ * Visibility rides on this value so the quad can stay enabled for the whole session. A mesh
6
+ * instance compiles its shader variant the first time it is drawn, and that compile has to land
7
+ * somewhere other than the frame a pie first appears.
11
8
  */
12
- const drawProgressPie = (ctx, size, progress) => {
13
- const c = size / 2;
14
- const r = c * 0.92;
15
- ctx.save();
16
- ctx.beginPath();
17
- ctx.arc(c, c, r, 0, Math.PI * 2);
18
- ctx.fillStyle = 'rgba(255, 255, 255, 0.18)';
19
- ctx.fill();
20
- if (progress > 0) {
21
- ctx.beginPath();
22
- ctx.moveTo(c, c);
23
- ctx.arc(c, c, r, PIE_START_ANGLE, PIE_START_ANGLE + progress * Math.PI * 2);
24
- ctx.closePath();
25
- ctx.fillStyle = 'rgba(64, 200, 255, 0.85)';
26
- ctx.fill();
27
- }
28
- ctx.restore();
29
- ctx.beginPath();
30
- };
9
+ const pieShaderProgress = progress => progress > 0 && progress < 1 ? progress : 0;
31
10
 
32
- export { drawProgressPie };
11
+ export { pieShaderProgress };
@@ -1 +1 @@
1
- {"version":3,"file":"useXr.d.ts","sourceRoot":"","sources":["../../src/hooks/useXr.ts"],"names":[],"mappings":"AAKA,MAAM,MAAM,MAAM,GAAG,IAAI,GAAG,IAAI,CAAC;AAEjC,UAAU,YAAY;IACpB,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;CAClC;AAgBD,eAAO,MAAM,KAAK,GAAI,cAAa,YAAiB;0BA4CT,MAAM;oBAGtC,MAAM;;;;;CA8BhB,CAAC;AAEF,eAAe,KAAK,CAAC"}
1
+ {"version":3,"file":"useXr.d.ts","sourceRoot":"","sources":["../../src/hooks/useXr.ts"],"names":[],"mappings":"AAKA,MAAM,MAAM,MAAM,GAAG,IAAI,GAAG,IAAI,CAAC;AAEjC,UAAU,YAAY;IACpB,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;CAClC;AAgBD,eAAO,MAAM,KAAK,GAAI,cAAa,YAAiB;0BAiDT,MAAM;oBAGtC,MAAM;;;;;CA8BhB,CAAC;AAEF,eAAe,KAAK,CAAC"}
package/hooks/useXr.js CHANGED
@@ -19,10 +19,12 @@ const useXr = ({
19
19
  onError
20
20
  } = {}) => {
21
21
  const app = useApp();
22
- const [available, setAvailable] = useState({
23
- VR: false,
24
- AR: false
25
- });
22
+ // Seeded from the manager rather than defaulting to unavailable, so a caller acting in a mount
23
+ // effect isn't told VR is unsupported while the effect below is still waiting to run.
24
+ const [available, setAvailable] = useState(() => ({
25
+ VR: app.xr?.isAvailable(XRTYPE_VR) ?? false,
26
+ AR: app.xr?.isAvailable(XRTYPE_AR) ?? false
27
+ }));
26
28
  // `type` is set as soon as a session is requested, so gate on `active` to ignore pending sessions.
27
29
  const [currentXrType, setCurrentXrType] = useState(app.xr?.active ? app.xr.type : null);
28
30
  useEffect(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terraware/web-components",
3
- "version": "5.0.1",
3
+ "version": "5.0.2",
4
4
  "author": "Terraformation Inc.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {