@terraware/web-components 4.2.18 → 4.2.19

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.
@@ -8,6 +8,7 @@ import { AnnotationIconType } from './Annotation';
8
8
  export declare class TfAnnotationManager extends PcAnnotationManager {
9
9
  static scriptName: string;
10
10
  private _maxWorldSize;
11
+ private _scratchScale;
11
12
  private _customParentDom;
12
13
  private _clickHandlersAttached;
13
14
  private _hotspotBackgroundColor;
@@ -101,6 +102,12 @@ export declare class TfAnnotationManager extends PcAnnotationManager {
101
102
  * @private
102
103
  */
103
104
  _updateAnnotationRotationAndScale(annotation: any): void;
105
+ /**
106
+ * World-space uniform scale of the annotation entity's parent (e.g. a scaled
107
+ * content-root). Returns 1 when there is no parent or it has no scale.
108
+ * @private
109
+ */
110
+ _getParentWorldScale(entity: any): number;
104
111
  /**
105
112
  * Override to create hotspot texture with custom background color.
106
113
  * @private
@@ -1 +1 @@
1
- {"version":3,"file":"TfAnnotationManager.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/TfAnnotationManager.ts"],"names":[],"mappings":"AAGA,OAAO,EAAoC,OAAO,EAAE,MAAM,YAAY,CAAC;AACvE,OAAO,EAAE,iBAAiB,IAAI,mBAAmB,EAAE,MAAM,wCAAwC,CAAC;AAIlG,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAelD;;;GAGG;AACH,qBAAa,mBAAoB,SAAQ,mBAAmB;IAC1D,MAAM,CAAC,UAAU,SAAyB;IAE1C,OAAO,CAAC,aAAa,CAAO;IAC5B,OAAO,CAAC,gBAAgB,CAA4B;IACpD,OAAO,CAAC,sBAAsB,CAAkB;IAChD,OAAO,CAAC,uBAAuB,CAAa;IAC5C,OAAO,CAAC,WAAW,CAAmD;IAEtE;;;;;;;;OAQG;IACH,IAAI,YAAY,CAAC,KAAK,EAAE,MAAM,EAE7B;IAED,IAAI,YAAY,IAJQ,MAAM,CAM7B;IAED;;;;;;;;;;OAUG;IACH,IAAI,sBAAsB,CAAC,KAAK,EAAE,MAAM,EAEvC;IAED,IAAI,sBAAsB,IAJQ,MAAM,CAMvC;IAED;;OAEG;IACH,UAAU;IAgBV;;;;OAIG;IACH,eAAe;IAiBf;;;;OAIG;IACH,YAAY,CAAC,QAAQ,EAAE,kBAAkB;IAazC;;OAEG;IACH,MAAM;IAmCN;;;;OAIG;IACH,mBAAmB,CAAC,UAAU,EAAE,GAAG;IAKnC;;;OAGG;IACH,oBAAoB,CAAC,UAAU,EAAE,GAAG;IAiBpC;;;;;OAKG;IACH,SAAS,CAAC,GAAG,EAAE,wBAAwB,EAAE,IAAI,EAAE,kBAAkB,EAAE,IAAI,EAAE,MAAM;IAW/E;;;;OAIG;IACH,cAAc,CAAC,UAAU,EAAE,GAAG;IAI9B;;;;OAIG;IACH,sBAAsB,CAAC,OAAO,EAAE,GAAG,EAAE,OAAO,CAAC,EAAE,GAAG;IASlD,mBAAmB,CAAC,WAAW,EAAE,GAAG,EAAE,MAAM,EAAE,OAAO;IAKrD,0BAA0B;IAK1B,YAAY,CAAC,WAAW,EAAE,GAAG;IAK7B,aAAa,CAAC,WAAW,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM;IAI7C;;;OAGG;IACH,0BAA0B,CAAC,UAAU,EAAE,GAAG,EAAE,SAAS,EAAE,GAAG,EAAE,SAAS,EAAE,GAAG;IA6B1E;;;;OAIG;IACH,iCAAiC,CAAC,UAAU,EAAE,GAAG;IAmCjD;;;OAGG;IACH,qBAAqB,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,SAAuB,EAAE,WAAW,SAAI,EAAE,IAAI,CAAC,EAAE,kBAAkB;CAyD7G"}
1
+ {"version":3,"file":"TfAnnotationManager.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/TfAnnotationManager.ts"],"names":[],"mappings":"AAGA,OAAO,EAAoC,OAAO,EAAQ,MAAM,YAAY,CAAC;AAC7E,OAAO,EAAE,iBAAiB,IAAI,mBAAmB,EAAE,MAAM,wCAAwC,CAAC;AAIlG,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAelD;;;GAGG;AACH,qBAAa,mBAAoB,SAAQ,mBAAmB;IAC1D,MAAM,CAAC,UAAU,SAAyB;IAE1C,OAAO,CAAC,aAAa,CAAO;IAC5B,OAAO,CAAC,aAAa,CAAc;IACnC,OAAO,CAAC,gBAAgB,CAA4B;IACpD,OAAO,CAAC,sBAAsB,CAAkB;IAChD,OAAO,CAAC,uBAAuB,CAAa;IAC5C,OAAO,CAAC,WAAW,CAAmD;IAEtE;;;;;;;;OAQG;IACH,IAAI,YAAY,CAAC,KAAK,EAAE,MAAM,EAE7B;IAED,IAAI,YAAY,IAJQ,MAAM,CAM7B;IAED;;;;;;;;;;OAUG;IACH,IAAI,sBAAsB,CAAC,KAAK,EAAE,MAAM,EAEvC;IAED,IAAI,sBAAsB,IAJQ,MAAM,CAMvC;IAED;;OAEG;IACH,UAAU;IAgBV;;;;OAIG;IACH,eAAe;IAiBf;;;;OAIG;IACH,YAAY,CAAC,QAAQ,EAAE,kBAAkB;IAazC;;OAEG;IACH,MAAM;IAmCN;;;;OAIG;IACH,mBAAmB,CAAC,UAAU,EAAE,GAAG;IAKnC;;;OAGG;IACH,oBAAoB,CAAC,UAAU,EAAE,GAAG;IAiBpC;;;;;OAKG;IACH,SAAS,CAAC,GAAG,EAAE,wBAAwB,EAAE,IAAI,EAAE,kBAAkB,EAAE,IAAI,EAAE,MAAM;IAW/E;;;;OAIG;IACH,cAAc,CAAC,UAAU,EAAE,GAAG;IAI9B;;;;OAIG;IACH,sBAAsB,CAAC,OAAO,EAAE,GAAG,EAAE,OAAO,CAAC,EAAE,GAAG;IASlD,mBAAmB,CAAC,WAAW,EAAE,GAAG,EAAE,MAAM,EAAE,OAAO;IAKrD,0BAA0B;IAK1B,YAAY,CAAC,WAAW,EAAE,GAAG;IAK7B,aAAa,CAAC,WAAW,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM;IAI7C;;;OAGG;IACH,0BAA0B,CAAC,UAAU,EAAE,GAAG,EAAE,SAAS,EAAE,GAAG,EAAE,SAAS,EAAE,GAAG;IA6B1E;;;;OAIG;IACH,iCAAiC,CAAC,UAAU,EAAE,GAAG;IA2CjD;;;;OAIG;IACH,oBAAoB,CAAC,MAAM,EAAE,GAAG;IAUhC;;;OAGG;IACH,qBAAqB,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,SAAuB,EAAE,WAAW,SAAI,EAAE,IAAI,CAAC,EAAE,kBAAkB;CAyD7G"}
@@ -1,6 +1,6 @@
1
1
  import { createElement } from 'react';
2
2
  import { renderToStaticMarkup } from 'react-dom/server';
3
- import { FILTER_LINEAR, PIXELFORMAT_RGBA8, Texture } from 'playcanvas';
3
+ import { FILTER_LINEAR, PIXELFORMAT_RGBA8, Texture, Vec3 } from 'playcanvas';
4
4
  import { AnnotationManager as PcAnnotationManager } from 'playcanvas/scripts/esm/annotations.mjs';
5
5
  import Icon from '../Icon/Icon';
6
6
  // Maps each annotation icon type to a @terraware/web-components icon.
@@ -23,6 +23,7 @@ const HOTSPOT_HOVER_SCALE = 1.3;
23
23
  export class TfAnnotationManager extends PcAnnotationManager {
24
24
  static scriptName = 'tfAnnotationManager';
25
25
  _maxWorldSize = 1.0;
26
+ _scratchScale = new Vec3();
26
27
  _customParentDom = null;
27
28
  _clickHandlersAttached = new Set();
28
29
  _hotspotBackgroundColor = '#2C8658';
@@ -283,28 +284,50 @@ export class TfAnnotationManager extends PcAnnotationManager {
283
284
  const screenHeight = canvas.clientHeight;
284
285
  const projMatrix = this._camera.camera.projectionMatrix;
285
286
 
286
- // Calculate world size based on screen size and distance
287
- const unclamped = this._hotspotSize / screenHeight * (2 * distance / projMatrix.data[5]);
287
+ // The world size that projects to _hotspotSize screen pixels at this distance.
288
+ const targetWorldSize = this._hotspotSize / screenHeight * (2 * distance / projMatrix.data[5]);
288
289
 
289
- // Clamp to maximum world size
290
- const worldSize = Math.min(unclamped, this._maxWorldSize);
290
+ // setLocalScale is applied on top of the parent's world scale (e.g. a scaled
291
+ // content-root), so divide it out to keep the on-screen size independent of it.
292
+ const parentScale = this._getParentWorldScale(annotation.entity);
293
+ const unclampedScale = targetWorldSize / parentScale;
294
+
295
+ // Clamp in the annotation's own (parent-local) space so maxWorldSize keeps the
296
+ // same size relative to the model regardless of the content-root scale.
297
+ const clampedScale = Math.min(unclampedScale, this._maxWorldSize);
291
298
  const hovered = this._hoverAnnotation === annotation;
292
299
  const hoverScale = hovered ? HOTSPOT_HOVER_SCALE : 1;
293
- const entityScale = worldSize * hoverScale;
300
+ const entityScale = clampedScale * hoverScale;
294
301
  annotation.entity.setLocalScale(entityScale, entityScale, entityScale);
295
302
 
296
303
  // Scale the hotspot DOM element proportionally when clamped, including the
297
- // hover growth so it tracks the visible circle.
304
+ // hover growth so it tracks the visible circle. This is a pure screen-space
305
+ // size, so it uses the true _hotspotSize and the clamp ratio only (never the
306
+ // parent scale, which the DOM overlay does not inherit).
298
307
  const resources = this._annotationResources.get(annotation);
299
308
  if (resources && resources.hotspotDom) {
300
- const scaleFactor = worldSize / unclamped;
309
+ const clampRatio = clampedScale / unclampedScale;
301
310
  const baseSize = this._hotspotSize + 5; // Match the +5 from the stylesheet
302
- const scaledSize = baseSize * scaleFactor * hoverScale;
311
+ const scaledSize = baseSize * clampRatio * hoverScale;
303
312
  resources.hotspotDom.style.width = `${scaledSize}px`;
304
313
  resources.hotspotDom.style.height = `${scaledSize}px`;
305
314
  }
306
315
  }
307
316
 
317
+ /**
318
+ * World-space uniform scale of the annotation entity's parent (e.g. a scaled
319
+ * content-root). Returns 1 when there is no parent or it has no scale.
320
+ * @private
321
+ */
322
+ _getParentWorldScale(entity) {
323
+ const parent = entity.parent;
324
+ if (!parent) {
325
+ return 1;
326
+ }
327
+ parent.getWorldTransform().getScale(this._scratchScale);
328
+ return this._scratchScale.x || 1;
329
+ }
330
+
308
331
  /**
309
332
  * Override to create hotspot texture with custom background color.
310
333
  * @private
@@ -14,6 +14,7 @@ export interface VirtualWalkthroughViewerProps {
14
14
  skyColor?: string;
15
15
  groundColor?: string;
16
16
  averageCameraHeight?: number;
17
+ scaleFactor?: number;
17
18
  annotations: AnnotationProps[];
18
19
  onSaveAnnotations: (annotations: AnnotationProps[]) => void | Promise<void>;
19
20
  strings: SplatControlsStrings;
@@ -23,6 +24,6 @@ export interface VirtualWalkthroughViewerProps {
23
24
  onToggleFullScreen?: () => void;
24
25
  maxImagesPerAnnotation?: number;
25
26
  }
26
- declare const VirtualWalkthroughViewer: ({ splatSrc, origin, cameraPosition, sceneBounds, groundPlane: groundPlaneProp, skyColor, groundColor, averageCameraHeight, annotations, onSaveAnnotations, strings, editable, showFreeFly, isFullScreen, onToggleFullScreen, maxImagesPerAnnotation, }: VirtualWalkthroughViewerProps) => import("react/jsx-runtime").JSX.Element;
27
+ declare const VirtualWalkthroughViewer: ({ splatSrc, origin, cameraPosition, sceneBounds, groundPlane: groundPlaneProp, skyColor, groundColor, averageCameraHeight, scaleFactor, annotations, onSaveAnnotations, strings, editable, showFreeFly, isFullScreen, onToggleFullScreen, maxImagesPerAnnotation, }: VirtualWalkthroughViewerProps) => import("react/jsx-runtime").JSX.Element;
27
28
  export default VirtualWalkthroughViewer;
28
29
  //# sourceMappingURL=VirtualWalkthroughViewer.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"VirtualWalkthroughViewer.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/VirtualWalkthroughViewer.tsx"],"names":[],"mappings":"AAWA,OAAmB,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAK3D,OAAsB,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAStE,MAAM,WAAW,6BAA6B;IAC5C,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;IAClC,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;IAC1C,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,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,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,WAAW,EAAE,eAAe,EAAE,CAAC;IAC/B,iBAAiB,EAAE,CAAC,WAAW,EAAE,eAAe,EAAE,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5E,OAAO,EAAE,oBAAoB,CAAC;IAC9B,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,wPAiB/B,6BAA6B,4CAgS/B,CAAC;AAEF,eAAe,wBAAwB,CAAC"}
1
+ {"version":3,"file":"VirtualWalkthroughViewer.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/VirtualWalkthroughViewer.tsx"],"names":[],"mappings":"AAWA,OAAmB,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAK3D,OAAsB,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAUtE,MAAM,WAAW,6BAA6B;IAC5C,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;IAClC,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;IAC1C,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,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,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,eAAe,EAAE,CAAC;IAC/B,iBAAiB,EAAE,CAAC,WAAW,EAAE,eAAe,EAAE,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5E,OAAO,EAAE,oBAAoB,CAAC;IAC9B,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,qQAkB/B,6BAA6B,4CAqT/B,CAAC;AAEF,eAAe,wBAAwB,CAAC"}
@@ -16,6 +16,7 @@ import SplatControls from './SplatControls';
16
16
  import SplatModel from './SplatModel';
17
17
  import { TfAnnotationManager } from './TfAnnotationManager';
18
18
  import { TfXrNavigation } from './TfXrNavigation';
19
+ import XrExitButton from './XrExitButton';
19
20
  import { WalkthroughCamera } from './walkthrough-camera';
20
21
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
21
22
  const DEFAULT_FOCUS_POINT = [0, 0.1, 0];
@@ -29,6 +30,7 @@ const VirtualWalkthroughViewer = ({
29
30
  skyColor,
30
31
  groundColor,
31
32
  averageCameraHeight = 0,
33
+ scaleFactor = 1,
32
34
  annotations,
33
35
  onSaveAnnotations,
34
36
  strings,
@@ -66,21 +68,23 @@ const VirtualWalkthroughViewer = ({
66
68
  return Math.sqrt(dx * dx + dy * dy + dz * dz) * 0.5;
67
69
  }, [cameraPosition, sceneBounds, origin]);
68
70
  const sceneBoundsCenter = useMemo(() => sceneBounds ? new Vec3(sceneBounds.x, sceneBounds.y, sceneBounds.z) : new Vec3(origin[0], cameraPosition[1], origin[2]), [sceneBounds, origin, cameraPosition]);
71
+ const cameraBoundsCenter = useMemo(() => sceneBoundsCenter.clone().mulScalar(scaleFactor), [sceneBoundsCenter, scaleFactor]);
69
72
  const groundPlane = useMemo(() => groundPlaneProp?.length === 3 ? groundPlaneProp.map(p => new Vec3(p[0], p[1], p[2])) : [], [groundPlaneProp]);
73
+ const cameraGroundPlane = useMemo(() => groundPlane.map(p => p.clone().mulScalar(scaleFactor)), [groundPlane, scaleFactor]);
70
74
  useEffect(() => {
71
75
  setCamera(origin, cameraPosition);
72
76
  }, [origin, cameraPosition, setCamera]);
73
77
  useEffect(() => {
74
- if (!groundPlane.length) {
78
+ if (!cameraGroundPlane.length) {
75
79
  return;
76
80
  }
77
81
  // @ts-expect-error - scripts are added dynamically to the camera entity
78
82
  const walkthroughCam = app.root.findByName('camera')?.script?.walkthroughCamera;
79
83
  if (walkthroughCam) {
80
84
  // Should be changed to a react prop if shallowEquals in playcanvas/react is fixed (see https://github.com/playcanvas/react/pull/298)
81
- walkthroughCam.groundPlane = groundPlane;
85
+ walkthroughCam.groundPlane = cameraGroundPlane;
82
86
  }
83
- }, [groundPlane, app]);
87
+ }, [cameraGroundPlane, app]);
84
88
  const handleToggleFreeFly = useCallback(() => {
85
89
  const newFreeFly = !isFreeFly;
86
90
  // @ts-expect-error - scripts are added dynamically to the camera entity
@@ -204,12 +208,15 @@ const VirtualWalkthroughViewer = ({
204
208
  fov: 60
205
209
  }), /*#__PURE__*/_jsx(Script, {
206
210
  script: WalkthroughCamera,
207
- boundsCenter: sceneBoundsCenter,
208
- boundsRadius: sceneBoundsRadius,
211
+ boundsCenter: cameraBoundsCenter,
212
+ boundsRadius: sceneBoundsRadius * scaleFactor,
209
213
  enableFly: !isTextFieldFocused,
210
- averageCameraHeight: averageCameraHeight
214
+ averageCameraHeight: scaleFactor * averageCameraHeight,
215
+ moveSpeed: 0.3 * scaleFactor,
216
+ moveFastSpeed: 0.5 * scaleFactor,
217
+ moveSlowSpeed: 0.15 * scaleFactor
211
218
  })]
212
- }), /*#__PURE__*/_jsx(Script, {
219
+ }), /*#__PURE__*/_jsx(XrExitButton, {}), /*#__PURE__*/_jsx(Script, {
213
220
  script: XrControllers,
214
221
  enabled: !isEdit
215
222
  }), /*#__PURE__*/_jsx(Script, {
@@ -223,33 +230,37 @@ const VirtualWalkthroughViewer = ({
223
230
  restartDelay: 3,
224
231
  startFadeInTime: 0.5
225
232
  })]
226
- }), splatModel, sceneBounds?.m !== undefined && groundPlane.length === 3 && /*#__PURE__*/_jsx(BoundaryRing, {
227
- center: sceneBoundsCenter,
228
- radius: sceneBoundsRadius,
229
- groundPlane: groundPlane
230
- }), localAnnotations.length > 0 && /*#__PURE__*/_jsxs(Entity, {
231
- name: "annotations-root",
232
- children: [/*#__PURE__*/_jsx(Script, {
233
- script: TfAnnotationManager,
234
- enabled: true,
235
- hotspotSize: 30,
236
- maxWorldSize: 0.05,
237
- opacity: 1,
238
- hotspotColor: new Color().fromString(theme.palette.TwClrIcnBrand),
239
- hoverColor: new Color().fromString('#ffffff'),
240
- hotspotBackgroundColor: theme.palette.TwClrBaseWhite
241
- }), localAnnotations.map((annotation, index) => /*#__PURE__*/_jsx(Annotation, {
242
- ...annotation,
243
- index: index,
244
- visible: showAnnotations,
245
- isEdit: isEdit,
246
- isSelected: selectedAnnotationIndex === index,
247
- isViewed: viewingAnnotationIndex === index,
248
- onSelect: () => setSelectedAnnotationIndex(index),
249
- onPositionChange: handleAnnotationPositionChange,
250
- onView: (anno, screenX, screenY) => handleAnnotationView(anno, index, screenX, screenY),
251
- onScreenPositionUpdate: handleAnnotationScreenPositionUpdate
252
- }, `annotation-${index}`))]
233
+ }), /*#__PURE__*/_jsxs(Entity, {
234
+ name: "content-root",
235
+ scale: [scaleFactor, scaleFactor, scaleFactor],
236
+ children: [splatModel, sceneBounds?.m !== undefined && groundPlane.length === 3 && /*#__PURE__*/_jsx(BoundaryRing, {
237
+ center: sceneBoundsCenter,
238
+ radius: sceneBoundsRadius,
239
+ groundPlane: groundPlane
240
+ }), localAnnotations.length > 0 && /*#__PURE__*/_jsxs(Entity, {
241
+ name: "annotations-root",
242
+ children: [/*#__PURE__*/_jsx(Script, {
243
+ script: TfAnnotationManager,
244
+ enabled: true,
245
+ hotspotSize: 30,
246
+ maxWorldSize: 0.05,
247
+ opacity: 1,
248
+ hotspotColor: new Color().fromString(theme.palette.TwClrIcnBrand),
249
+ hoverColor: new Color().fromString('#ffffff'),
250
+ hotspotBackgroundColor: theme.palette.TwClrBaseWhite
251
+ }), localAnnotations.map((annotation, index) => /*#__PURE__*/_jsx(Annotation, {
252
+ ...annotation,
253
+ index: index,
254
+ visible: showAnnotations,
255
+ isEdit: isEdit,
256
+ isSelected: selectedAnnotationIndex === index,
257
+ isViewed: viewingAnnotationIndex === index,
258
+ onSelect: () => setSelectedAnnotationIndex(index),
259
+ onPositionChange: handleAnnotationPositionChange,
260
+ onView: (anno, screenX, screenY) => handleAnnotationView(anno, index, screenX, screenY),
261
+ onScreenPositionUpdate: handleAnnotationScreenPositionUpdate
262
+ }, `annotation-${index}`))]
263
+ })]
253
264
  }), /*#__PURE__*/_jsx(SplatControls, {
254
265
  defaultCameraFocus: origin,
255
266
  defaultCameraPosition: cameraPosition,
@@ -0,0 +1,3 @@
1
+ declare const XrExitButton: () => import("react/jsx-runtime").JSX.Element;
2
+ export default XrExitButton;
3
+ //# sourceMappingURL=XrExitButton.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"XrExitButton.d.ts","sourceRoot":"","sources":["../../../src/components/VirtualWalkthrough/XrExitButton.tsx"],"names":[],"mappings":"AAOA,QAAA,MAAM,YAAY,+CAIjB,CAAC;AAEF,eAAe,YAAY,CAAC"}
@@ -0,0 +1,12 @@
1
+ import React from 'react';
2
+ import { Entity } from '@playcanvas/react';
3
+ import { Script } from '@playcanvas/react/components';
4
+ import { XrExitButton as XrExitButtonScript } from './xr-exit-button';
5
+ import { jsx as _jsx } from "react/jsx-runtime";
6
+ const XrExitButton = () => /*#__PURE__*/_jsx(Entity, {
7
+ name: "xr-exit-button",
8
+ children: /*#__PURE__*/_jsx(Script, {
9
+ script: XrExitButtonScript
10
+ })
11
+ });
12
+ export default XrExitButton;
@@ -0,0 +1,42 @@
1
+ import { Script, Vec3 } from 'playcanvas';
2
+ /**
3
+ * Boolean ray-sphere hit test. Treats the ray as a half-line (t >= 0) and returns true when it
4
+ * intersects the sphere or starts inside it. `direction` is normalized internally.
5
+ */
6
+ export declare const raySphereIntersect: (origin: Vec3, direction: Vec3, center: Vec3, radius: number) => boolean;
7
+ export declare class XrExitButton extends Script {
8
+ static scriptName: string;
9
+ /** Offset from the head, in head space: right, up, and forward (-z) into the upper-right of the view. */
10
+ offset: Vec3;
11
+ /** Half-extent of the square button quad, in world units at the offset distance. */
12
+ halfSize: number;
13
+ /** World-space radius of the hover/hit sphere. Slightly larger than halfSize for easier targeting. */
14
+ hitRadius: number;
15
+ private _material?;
16
+ private _texture?;
17
+ private _mesh?;
18
+ private _hovered;
19
+ private _headPosition;
20
+ private _headRotation;
21
+ private _worldOffset;
22
+ private _faceButtonDown;
23
+ private _isVrActive;
24
+ private _onXrStart;
25
+ private _onXrEnd;
26
+ private _onSelect;
27
+ private _rayHitsButton;
28
+ private _faceButtonPressed;
29
+ private _createTexture;
30
+ private _createMesh;
31
+ initialize(): void;
32
+ update(): void;
33
+ /**
34
+ * Pin the button to the upper-right of the headset view. The camera entity's transform is
35
+ * overwritten every frame by WalkthroughCamera and is not the rendered head pose, so the head
36
+ * pose is read from the XR views (each view's inverse-view matrix is that eye's world transform)
37
+ * and the button places itself in world space, offset in head space.
38
+ */
39
+ private _trackHead;
40
+ destroy(): void;
41
+ }
42
+ //# sourceMappingURL=xr-exit-button.d.ts.map
@@ -0,0 +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;AAEpB;;;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,eAAe,CAAyC;IAEhE,OAAO,CAAC,WAAW,CAAyE;IAE5F,OAAO,CAAC,UAAU,CAEhB;IAEF,OAAO,CAAC,QAAQ,CAQd;IAEF,OAAO,CAAC,SAAS,CAOf;IAEF,OAAO,CAAC,cAAc;IAStB,OAAO,CAAC,kBAAkB;IAM1B,OAAO,CAAC,cAAc;IAyCtB,OAAO,CAAC,WAAW;IAYnB,UAAU;IA6BV,MAAM;IAqCN;;;;;OAKG;IACH,OAAO,CAAC,UAAU;IAuBlB,OAAO;CAcR"}
@@ -0,0 +1,207 @@
1
+ import { ADDRESS_CLAMP_TO_EDGE, BLEND_NORMAL, CULLFACE_NONE, Color, FILTER_LINEAR, LAYERID_IMMEDIATE, Mesh, MeshInstance, Quat, Script, StandardMaterial, Texture, Vec3, XRTYPE_VR } from 'playcanvas';
2
+
3
+ /**
4
+ * Boolean ray-sphere hit test. Treats the ray as a half-line (t >= 0) and returns true when it
5
+ * intersects the sphere or starts inside it. `direction` is normalized internally.
6
+ */
7
+ export const raySphereIntersect = (origin, direction, center, radius) => {
8
+ const dir = direction.clone().normalize();
9
+ const m = new Vec3().sub2(origin, center);
10
+ const b = m.dot(dir);
11
+ const c = m.dot(m) - radius * radius;
12
+ if (c > 0 && b > 0) {
13
+ return false;
14
+ }
15
+ return b * b - c >= 0;
16
+ };
17
+ const TEXTURE_SIZE = 256;
18
+
19
+ /** xr-standard gamepad button indices for the face buttons: A/X (4) and B/Y (5). */
20
+ const FACE_BUTTON_INDICES = [4, 5];
21
+ export class XrExitButton extends Script {
22
+ static scriptName = 'xrExitButton';
23
+
24
+ /** Offset from the head, in head space: right, up, and forward (-z) into the upper-right of the view. */
25
+ offset = new Vec3(0.45, 0.35, -1.2);
26
+
27
+ /** Half-extent of the square button quad, in world units at the offset distance. */
28
+ halfSize = 0.12;
29
+
30
+ /** World-space radius of the hover/hit sphere. Slightly larger than halfSize for easier targeting. */
31
+ hitRadius = 0.15;
32
+ _hovered = false;
33
+ _headPosition = new Vec3();
34
+ _headRotation = new Quat();
35
+ _worldOffset = new Vec3();
36
+ _faceButtonDown = new WeakMap();
37
+ _isVrActive = () => this.app.xr?.active === true && this.app.xr?.type === XRTYPE_VR;
38
+ _onXrStart = () => {
39
+ this.entity.enabled = this._isVrActive();
40
+ };
41
+ _onXrEnd = () => {
42
+ this.entity.enabled = false;
43
+ this._hovered = false;
44
+ this.entity.setLocalScale(1, 1, 1);
45
+ if (this._material) {
46
+ this._material.opacity = 0.9;
47
+ this._material.update();
48
+ }
49
+ };
50
+ _onSelect = inputSource => {
51
+ if (!this.entity.enabled) {
52
+ return;
53
+ }
54
+ if (this._rayHitsButton(inputSource)) {
55
+ this.app.xr?.end();
56
+ }
57
+ };
58
+ _rayHitsButton(inputSource) {
59
+ return raySphereIntersect(inputSource.getOrigin(), inputSource.getDirection(), this.entity.getPosition(), this.hitRadius);
60
+ }
61
+ _faceButtonPressed(inputSource) {
62
+ const buttons = inputSource.gamepad?.buttons;
63
+ return buttons ? FACE_BUTTON_INDICES.some(index => buttons[index]?.pressed === true) : false;
64
+ }
65
+ _createTexture() {
66
+ const canvas = document.createElement('canvas');
67
+ canvas.width = TEXTURE_SIZE;
68
+ canvas.height = TEXTURE_SIZE;
69
+ const ctx = canvas.getContext('2d');
70
+ const c = TEXTURE_SIZE / 2;
71
+ if (ctx) {
72
+ ctx.clearRect(0, 0, TEXTURE_SIZE, TEXTURE_SIZE);
73
+ ctx.fillStyle = 'rgba(20, 20, 20, 0.75)';
74
+ ctx.beginPath();
75
+ ctx.arc(c, c, c * 0.92, 0, Math.PI * 2);
76
+ ctx.fill();
77
+ const arm = TEXTURE_SIZE * 0.24;
78
+ ctx.strokeStyle = '#ffffff';
79
+ ctx.lineWidth = TEXTURE_SIZE * 0.09;
80
+ ctx.lineCap = 'round';
81
+ ctx.beginPath();
82
+ ctx.moveTo(c - arm, c - arm);
83
+ ctx.lineTo(c + arm, c + arm);
84
+ ctx.moveTo(c + arm, c - arm);
85
+ ctx.lineTo(c - arm, c + arm);
86
+ ctx.stroke();
87
+ }
88
+ const texture = new Texture(this.app.graphicsDevice, {
89
+ name: 'xr-exit-button',
90
+ width: TEXTURE_SIZE,
91
+ height: TEXTURE_SIZE,
92
+ addressU: ADDRESS_CLAMP_TO_EDGE,
93
+ addressV: ADDRESS_CLAMP_TO_EDGE,
94
+ minFilter: FILTER_LINEAR,
95
+ magFilter: FILTER_LINEAR,
96
+ mipmaps: true
97
+ });
98
+ texture.setSource(canvas);
99
+ return texture;
100
+ }
101
+ _createMesh() {
102
+ const h = this.halfSize;
103
+ const mesh = new Mesh(this.app.graphicsDevice);
104
+ mesh.setPositions([-h, -h, 0, h, -h, 0, h, h, 0, -h, h, 0]);
105
+ mesh.setNormals([0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1]);
106
+ mesh.setUvs(0, [0, 1, 1, 1, 1, 0, 0, 0]);
107
+ mesh.setIndices([0, 1, 2, 0, 2, 3]);
108
+ mesh.update();
109
+ return mesh;
110
+ }
111
+ initialize() {
112
+ this._texture = this._createTexture();
113
+ const material = new StandardMaterial();
114
+ material.useLighting = false;
115
+ material.emissive = new Color(1, 1, 1);
116
+ material.emissiveMap = this._texture;
117
+ material.opacityMap = this._texture;
118
+ material.opacityMapChannel = 'a';
119
+ material.blendType = BLEND_NORMAL;
120
+ material.depthTest = false;
121
+ material.depthWrite = false;
122
+ material.cull = CULLFACE_NONE;
123
+ material.update();
124
+ this._material = material;
125
+ this._mesh = this._createMesh();
126
+ const meshInstance = new MeshInstance(this._mesh, material);
127
+
128
+ // Immediate layer draws after the World layer (where the splats render) so the button is never
129
+ // composited behind them; depthTest:false keeps it on top within the layer.
130
+ this.entity.addComponent('render', {
131
+ meshInstances: [meshInstance],
132
+ layers: [LAYERID_IMMEDIATE]
133
+ });
134
+ this.entity.enabled = this._isVrActive();
135
+ this.app.xr?.on('start', this._onXrStart);
136
+ this.app.xr?.on('end', this._onXrEnd);
137
+ this.app.xr?.input?.on('select', this._onSelect);
138
+ }
139
+ update() {
140
+ if (!this.entity.enabled) {
141
+ return;
142
+ }
143
+ this._trackHead();
144
+ const sources = this.app.xr?.input?.inputSources ?? [];
145
+ let hovered = false;
146
+ for (const source of sources) {
147
+ const sourceHovers = this._rayHitsButton(source);
148
+ hovered = hovered || sourceHovers;
149
+
150
+ // A face-button press exits only while that controller is aimed at the button, matching the
151
+ // trigger. Edge-detected so holding a button and then aiming onto it doesn't fire.
152
+ const facePressed = this._faceButtonPressed(source);
153
+ const faceWasDown = this._faceButtonDown.get(source) ?? false;
154
+ this._faceButtonDown.set(source, facePressed);
155
+ if (sourceHovers && facePressed && !faceWasDown) {
156
+ this.app.xr?.end();
157
+ return;
158
+ }
159
+ }
160
+ if (hovered !== this._hovered) {
161
+ this._hovered = hovered;
162
+ const scale = hovered ? 1.15 : 1;
163
+ this.entity.setLocalScale(scale, scale, scale);
164
+ if (this._material) {
165
+ this._material.opacity = hovered ? 1 : 0.9;
166
+ this._material.update();
167
+ }
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Pin the button to the upper-right of the headset view. The camera entity's transform is
173
+ * overwritten every frame by WalkthroughCamera and is not the rendered head pose, so the head
174
+ * pose is read from the XR views (each view's inverse-view matrix is that eye's world transform)
175
+ * and the button places itself in world space, offset in head space.
176
+ */
177
+ _trackHead() {
178
+ const views = this.app.xr?.views?.list;
179
+ if (!views || views.length === 0) {
180
+ return;
181
+ }
182
+ this._headPosition.set(0, 0, 0);
183
+ for (const view of views) {
184
+ view.viewInvOffMat.getTranslation(this._worldOffset);
185
+ this._headPosition.add(this._worldOffset);
186
+ }
187
+ this._headPosition.mulScalar(1 / views.length);
188
+ this._headRotation.setFromMat4(views[0].viewInvOffMat);
189
+ this._headRotation.transformVector(this.offset, this._worldOffset);
190
+ this.entity.setPosition(this._headPosition.x + this._worldOffset.x, this._headPosition.y + this._worldOffset.y, this._headPosition.z + this._worldOffset.z);
191
+ this.entity.setRotation(this._headRotation);
192
+ }
193
+ destroy() {
194
+ this.app.xr?.off('start', this._onXrStart);
195
+ this.app.xr?.off('end', this._onXrEnd);
196
+ this.app.xr?.input?.off('select', this._onSelect);
197
+ if (this.entity.render) {
198
+ this.entity.render.meshInstances = [];
199
+ }
200
+ this._mesh?.destroy();
201
+ this._material?.destroy();
202
+ this._texture?.destroy();
203
+ this._mesh = undefined;
204
+ this._material = undefined;
205
+ this._texture = undefined;
206
+ }
207
+ }
@@ -0,0 +1,23 @@
1
+ import { Vec3 } from 'playcanvas';
2
+ import { raySphereIntersect } from './xr-exit-button';
3
+ describe('raySphereIntersect', () => {
4
+ const CENTER = new Vec3(0, 0, 0);
5
+ it('hits a sphere straight ahead of the ray', () => {
6
+ expect(raySphereIntersect(new Vec3(0, 0, 5), new Vec3(0, 0, -1), CENTER, 1)).toBe(true);
7
+ });
8
+ it('misses a sphere off to the side', () => {
9
+ expect(raySphereIntersect(new Vec3(0, 5, 5), new Vec3(0, 0, -1), CENTER, 1)).toBe(false);
10
+ });
11
+ it('misses when the sphere is behind the ray origin', () => {
12
+ expect(raySphereIntersect(new Vec3(0, 0, 5), new Vec3(0, 0, 1), CENTER, 1)).toBe(false);
13
+ });
14
+ it('hits when the ray origin is inside the sphere', () => {
15
+ expect(raySphereIntersect(new Vec3(0, 0, 0), new Vec3(1, 0, 0), CENTER, 1)).toBe(true);
16
+ });
17
+ it('normalizes a non-unit direction', () => {
18
+ expect(raySphereIntersect(new Vec3(0, 0, 5), new Vec3(0, 0, -3), CENTER, 1)).toBe(true);
19
+ });
20
+ it('grazes a tangent sphere', () => {
21
+ expect(raySphereIntersect(new Vec3(0, 1, 5), new Vec3(0, 0, -1), CENTER, 1)).toBe(true);
22
+ });
23
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terraware/web-components",
3
- "version": "4.2.18",
3
+ "version": "4.2.19",
4
4
  "author": "Terraformation Inc.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {