@reactvision/react-viro 2.57.2 → 2.57.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +8 -0
  2. package/android/react_viro/react_viro-release.aar +0 -0
  3. package/android/viro_renderer/viro_renderer-release.aar +0 -0
  4. package/components/AR/ViroARSceneNavigator.tsx +11 -3
  5. package/components/Studio/StudioARScene.tsx +153 -4
  6. package/components/Studio/StudioSceneErrorBoundary.tsx +48 -0
  7. package/components/Studio/StudioSceneNavigator.tsx +186 -41
  8. package/components/Studio/VRTStudioModule.ts +26 -0
  9. package/components/Studio/domain/dragConfiguration.ts +6 -4
  10. package/components/Studio/domain/materialConfig.ts +75 -42
  11. package/components/Studio/domain/physicsConfig.ts +75 -32
  12. package/components/Studio/domain/viroNodeFactory.tsx +53 -14
  13. package/components/Studio/index.ts +4 -0
  14. package/components/Studio/types.ts +3 -8
  15. package/components/Utilities/ViroVersion.ts +1 -1
  16. package/components/ViroXRSceneNavigator.tsx +2 -0
  17. package/dist/components/AR/ViroARSceneNavigator.d.ts +11 -3
  18. package/dist/components/Studio/StudioARScene.d.ts +6 -0
  19. package/dist/components/Studio/StudioARScene.js +101 -6
  20. package/dist/components/Studio/StudioSceneErrorBoundary.d.ts +28 -0
  21. package/dist/components/Studio/StudioSceneErrorBoundary.js +31 -0
  22. package/dist/components/Studio/StudioSceneNavigator.d.ts +28 -3
  23. package/dist/components/Studio/StudioSceneNavigator.js +84 -20
  24. package/dist/components/Studio/VRTStudioModule.d.ts +17 -0
  25. package/dist/components/Studio/VRTStudioModule.js +14 -0
  26. package/dist/components/Studio/domain/dragConfiguration.d.ts +5 -3
  27. package/dist/components/Studio/domain/dragConfiguration.js +5 -3
  28. package/dist/components/Studio/domain/materialConfig.d.ts +0 -1
  29. package/dist/components/Studio/domain/materialConfig.js +12 -5
  30. package/dist/components/Studio/domain/physicsConfig.d.ts +0 -1
  31. package/dist/components/Studio/domain/physicsConfig.js +18 -6
  32. package/dist/components/Studio/domain/viroNodeFactory.d.ts +2 -2
  33. package/dist/components/Studio/domain/viroNodeFactory.js +31 -18
  34. package/dist/components/Studio/index.d.ts +1 -0
  35. package/dist/components/Studio/types.d.ts +1 -0
  36. package/dist/components/Utilities/ViroVersion.d.ts +1 -1
  37. package/dist/components/Utilities/ViroVersion.js +1 -1
  38. package/dist/components/ViroXRSceneNavigator.d.ts +2 -0
  39. package/dist/index.d.ts +1 -1
  40. package/index.ts +2 -0
  41. package/ios/dist/ViroRenderer/ViroKit.framework/ARCoreCoreMLSemanticsResources.bundle/Info.plist +0 -0
  42. package/ios/dist/ViroRenderer/ViroKit.framework/ARCoreResources.bundle/Info.plist +0 -0
  43. package/ios/dist/ViroRenderer/ViroKit.framework/Headers/VROARFrameiOS.h +8 -0
  44. package/ios/dist/ViroRenderer/ViroKit.framework/Headers/VROARSessioniOS.h +14 -3
  45. package/ios/dist/ViroRenderer/ViroKit.framework/Headers/VROFrontCameraProvider.h +34 -0
  46. package/ios/dist/ViroRenderer/ViroKit.framework/Headers/VROViewAR.h +3 -1
  47. package/ios/dist/ViroRenderer/ViroKit.framework/Shaders.dat +1 -1
  48. package/ios/dist/ViroRenderer/ViroKit.framework/ViroKit +0 -0
  49. package/ios/dist/include/RVStudioWatermarkState.h +25 -0
  50. package/ios/dist/lib/libViroReact.a +0 -0
  51. package/package.json +11 -11
package/README.md CHANGED
@@ -92,6 +92,14 @@ You can get a Studio account, which includes Platform access, for free at <https
92
92
  - Cloud Anchors guide: <https://viro-community.readme.io/docs/cloud-anchors>
93
93
  - Geospatial Anchors guide: <https://viro-community.readme.io/docs/geospatial-anchors>
94
94
 
95
+ ## Optional Packages
96
+
97
+ Two capabilities are shipped as separate, opt-in packages rather than in core — install them only if you need them:
98
+
99
+ - **[`@reactvision/react-viro-onnx`](https://www.npmjs.com/package/@reactvision/react-viro-onnx)** — on-device object detection. It provides the ONNX Runtime inference engine behind [`ViroObjectDetector`](https://github.com/ReactVision/viro/blob/main/docs/ViroObjectDetector.md) (model inference, NMS, class-name decoding). Without it, `ViroObjectDetector` returns no detections. Keeping it separate avoids bundling ONNX Runtime (~tens of MB) into apps that don't use detection.
100
+
101
+ - **[`@reactvision/react-viro-face-tracking`](https://www.npmjs.com/package/@reactvision/react-viro-face-tracking)** — front-camera (ARKit face-tracking) AR. It backs the `frontCameraEnabled` prop on `ViroARSceneNavigator`. On iOS ARKit reaches the front camera only through the **TrueDepth** API, which Apple statically scans for under App Store review Guideline 2.5.1 — so it's kept out of the core binary. Apps that don't use front-camera AR stay TrueDepth-free and pass review with no configuration; install this package (and declare TrueDepth) only when you need it. For a selfie *feed* without face tracking, use [`ViroCameraTexture`](https://github.com/ReactVision/viro/blob/main/docs/ViroCameraTexture.md) with `cameraPosition="front"` — that's in core and uses AVFoundation, no TrueDepth.
102
+
95
103
  ## Studio
96
104
 
97
105
  [ReactVision Studio](https://studio.reactvision.xyz) is our browser-based visual scene editor for AR and VR. Build scenes visually in Studio, then embed them inside your own ViroReact app with a single component. Designers and developers can iterate on a scene in Studio and have it update in production without a new app build.
@@ -208,9 +208,17 @@ type Props = ViewProps & {
208
208
 
209
209
  /**
210
210
  * Use the front (selfie) camera as the AR session background.
211
- * On iOS uses ARFaceTrackingConfiguration (requires TrueDepth camera, iPhone X+).
212
- * On Android uses ARCore Augmented Faces mode (front camera).
213
- * World tracking, plane detection, and LiDAR are unavailable in this mode.
211
+ *
212
+ * Requires the optional `@reactvision/react-viro-face-tracking` package to be
213
+ * installed it provides the native front-camera AR configuration and, on
214
+ * iOS, declares TrueDepth usage. Without it, this prop has no effect.
215
+ *
216
+ * On iOS the package uses the front TrueDepth camera; on Android it uses
217
+ * ARCore Augmented Faces mode. World tracking, plane detection, and LiDAR are
218
+ * unavailable in this mode.
219
+ *
220
+ * For a plain selfie feed without face tracking (no TrueDepth), use
221
+ * `ViroCameraTexture` with `cameraPosition="front"` instead.
214
222
  *
215
223
  * @default false
216
224
  * @platform ios, android
@@ -10,6 +10,7 @@ import { ViroScene } from "../ViroScene";
10
10
  import { ViroText } from "../ViroText";
11
11
  import { ViroController } from "../ViroController";
12
12
  import { isQuest } from "../Utilities/ViroPlatform";
13
+ import type { ViroAnchor } from "../Types/ViroEvents";
13
14
  import { registerSceneAnimations } from "./domain/animationRegistry";
14
15
  import { createPlacementCollisionHandler } from "./domain/collisionBindingsRuntime";
15
16
  import { collisionPairKey } from "./domain/collisionPairKey";
@@ -51,6 +52,12 @@ interface StudioARSceneProps {
51
52
  onReady?: () => void;
52
53
  onError?: (err: Error) => void;
53
54
  onSceneChange?: (sceneId: string, sceneName: string) => void;
55
+ /** Fired on first AR plane detection (AUTOMATIC) / plane accept (MANUAL). */
56
+ onPlaneDetected?: () => void;
57
+ /** Fired when the user taps to select a plane (MANUAL mode). */
58
+ onPlaneSelected?: () => void;
59
+ /** Text shown when the scene has no assets. Defaults to "No assets to display". */
60
+ noAssetsMessage?: string;
54
61
  /** Session-scoped store owned by the navigator; survives scene pushes. */
55
62
  variableStore?: StudioVariableStore;
56
63
  }
@@ -73,8 +80,16 @@ interface StudioARSceneInnerProps extends StudioARSceneProps {
73
80
  }
74
81
 
75
82
  const StudioARSceneInner: React.FC<StudioARSceneInnerProps> = (props) => {
76
- const { sceneNavigator, sceneData, onReady, onSceneChange, variableStore } =
77
- props;
83
+ const {
84
+ sceneNavigator,
85
+ sceneData,
86
+ onReady,
87
+ onSceneChange,
88
+ onPlaneDetected,
89
+ onPlaneSelected,
90
+ noAssetsMessage,
91
+ variableStore,
92
+ } = props;
78
93
  const { scene, assets, animations, collision_bindings, functions } =
79
94
  sceneData;
80
95
 
@@ -193,6 +208,49 @@ const StudioARSceneInner: React.FC<StudioARSceneInnerProps> = (props) => {
193
208
  {}
194
209
  );
195
210
 
211
+ // ─── Drag-active state (debounced) ────────────────────────────────────────
212
+ // Viro's onDrag fires per-frame. Track a Record<assetId, true> cleared 220ms
213
+ // after the last drag event; the node factory reads isDragActive to pass
214
+ // kinematicDragOverride so Dynamic-physics bodies don't fight the gesture.
215
+ // The onDrag callback's mere presence is also what unlocks native drag.
216
+ const [dragActiveByAssetId, setDragActiveByAssetId] = useState<
217
+ Record<string, true>
218
+ >({});
219
+ const dragTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
220
+ new Map()
221
+ );
222
+
223
+ const notifyPhysicsDrag = useCallback((assetId: string) => {
224
+ setDragActiveByAssetId((prev) =>
225
+ prev[assetId] ? prev : { ...prev, [assetId]: true }
226
+ );
227
+ const existing = dragTimersRef.current.get(assetId);
228
+ if (existing) clearTimeout(existing);
229
+ const t = setTimeout(() => {
230
+ setDragActiveByAssetId((prev) => {
231
+ if (!prev[assetId]) return prev;
232
+ const next = { ...prev };
233
+ delete next[assetId];
234
+ return next;
235
+ });
236
+ dragTimersRef.current.delete(assetId);
237
+ }, 220);
238
+ dragTimersRef.current.set(assetId, t);
239
+ }, []);
240
+
241
+ const isDragActive = useCallback(
242
+ (assetId: string) => !!dragActiveByAssetId[assetId],
243
+ [dragActiveByAssetId]
244
+ );
245
+
246
+ useEffect(() => {
247
+ const timers = dragTimersRef.current;
248
+ return () => {
249
+ timers.forEach((timer) => clearTimeout(timer));
250
+ timers.clear();
251
+ };
252
+ }, []);
253
+
196
254
  const handleAssetLoaded = useCallback((assetId: string) => {
197
255
  setLoadedAssetIds((prev) =>
198
256
  prev[assetId] ? prev : { ...prev, [assetId]: true }
@@ -429,6 +487,8 @@ const StudioARSceneInner: React.FC<StudioARSceneInnerProps> = (props) => {
429
487
  animationStates,
430
488
  handleAssetLoaded,
431
489
  getCollisionHandler(asset.id),
490
+ isDragActive,
491
+ notifyPhysicsDrag,
432
492
  handleSceneChange,
433
493
  runtimeCtx
434
494
  );
@@ -441,6 +501,8 @@ const StudioARSceneInner: React.FC<StudioARSceneInnerProps> = (props) => {
441
501
  animationStates,
442
502
  handleAssetLoaded,
443
503
  getCollisionHandler,
504
+ isDragActive,
505
+ notifyPhysicsDrag,
444
506
  maxModels,
445
507
  handleSceneChange,
446
508
  runtimeCtx,
@@ -461,6 +523,8 @@ const StudioARSceneInner: React.FC<StudioARSceneInnerProps> = (props) => {
461
523
  animationStates,
462
524
  handleAssetLoaded,
463
525
  getCollisionHandler(asset.id),
526
+ isDragActive,
527
+ notifyPhysicsDrag,
464
528
  handleSceneChange,
465
529
  runtimeCtx
466
530
  );
@@ -480,6 +544,8 @@ const StudioARSceneInner: React.FC<StudioARSceneInnerProps> = (props) => {
480
544
  animationStates,
481
545
  handleAssetLoaded,
482
546
  getCollisionHandler,
547
+ isDragActive,
548
+ notifyPhysicsDrag,
483
549
  handleSceneChange,
484
550
  runtimeCtx,
485
551
  ]);
@@ -490,6 +556,76 @@ const StudioARSceneInner: React.FC<StudioARSceneInnerProps> = (props) => {
490
556
  ).toUpperCase();
491
557
  const planeAlignment = (scene.plane_direction ?? "Horizontal") as any;
492
558
 
559
+ // Native plane anchor types for ViroARScene (lowercase matches Viro defaults).
560
+ const anchorDetectionTypes = useMemo((): string[] | undefined => {
561
+ if (planeDetectionMode !== "AUTOMATIC" && planeDetectionMode !== "MANUAL") {
562
+ return undefined;
563
+ }
564
+ const dir = (scene.plane_direction ?? "Horizontal").toLowerCase();
565
+ if (dir === "vertical") return ["planesVertical"];
566
+ if (dir.includes("horizontal")) return ["planesHorizontal"];
567
+ return ["planesHorizontal", "planesVertical"];
568
+ }, [planeDetectionMode, scene.plane_direction]);
569
+
570
+ // ViroARPlaneSelector (react-viro 2.54+) no longer receives scene anchors
571
+ // automatically; ViroARScene forwards them here via ref. Also surfaces
572
+ // onPlaneDetected / onPlaneSelected to the host.
573
+ const planeSelectorRef = useRef<InstanceType<
574
+ typeof ViroARPlaneSelector
575
+ > | null>(null);
576
+
577
+ const handleAnchorFound = useCallback(
578
+ (anchor: ViroAnchor) => {
579
+ try {
580
+ if (planeDetectionMode === "MANUAL") {
581
+ planeSelectorRef.current?.handleAnchorFound(anchor);
582
+ }
583
+ if (planeDetectionMode === "AUTOMATIC" && anchor?.type === "plane") {
584
+ onPlaneDetected?.();
585
+ }
586
+ } catch (error) {
587
+ console.error("[Studio] handleAnchorFound failed:", error);
588
+ }
589
+ },
590
+ [planeDetectionMode, onPlaneDetected]
591
+ );
592
+
593
+ const handleAnchorUpdated = useCallback(
594
+ (anchor: ViroAnchor) => {
595
+ try {
596
+ if (planeDetectionMode === "MANUAL") {
597
+ planeSelectorRef.current?.handleAnchorUpdated(anchor);
598
+ }
599
+ } catch (error) {
600
+ console.error("[Studio] handleAnchorUpdated failed:", error);
601
+ }
602
+ },
603
+ [planeDetectionMode]
604
+ );
605
+
606
+ const handleAnchorRemoved = useCallback(
607
+ (anchor?: ViroAnchor) => {
608
+ try {
609
+ if (planeDetectionMode === "MANUAL" && anchor) {
610
+ planeSelectorRef.current?.handleAnchorRemoved(anchor);
611
+ }
612
+ } catch (error) {
613
+ console.error("[Studio] handleAnchorRemoved failed:", error);
614
+ }
615
+ },
616
+ [planeDetectionMode]
617
+ );
618
+
619
+ const handlePlaneSelected = useCallback(() => {
620
+ onPlaneSelected?.();
621
+ }, [onPlaneSelected]);
622
+
623
+ // ViroARPlaneSelector.onPlaneDetected must return a boolean (accept the plane).
624
+ const handlePlaneDetectedForSelector = useCallback(() => {
625
+ onPlaneDetected?.();
626
+ return true;
627
+ }, [onPlaneDetected]);
628
+
493
629
  const renderAssets = () => {
494
630
  if (isQuest) {
495
631
  if (planeDetectionMode !== "NONE") {
@@ -510,9 +646,12 @@ const StudioARSceneInner: React.FC<StudioARSceneInnerProps> = (props) => {
510
646
  if (planeDetectionMode === "MANUAL") {
511
647
  return (
512
648
  <ViroARPlaneSelector
649
+ ref={planeSelectorRef}
513
650
  minHeight={0.1}
514
651
  minWidth={0.1}
515
652
  alignment={planeAlignment}
653
+ onPlaneDetected={handlePlaneDetectedForSelector}
654
+ onPlaneSelected={handlePlaneSelected}
516
655
  >
517
656
  {renderedPlaneAssets}
518
657
  </ViroARPlaneSelector>
@@ -543,7 +682,7 @@ const StudioARSceneInner: React.FC<StudioARSceneInnerProps> = (props) => {
543
682
  <StudioSounds manager={soundManagerRef.current!} />
544
683
  {assets.length === 0 && (
545
684
  <ViroText
546
- text="No assets to display"
685
+ text={noAssetsMessage ?? "No assets to display"}
547
686
  position={[0, 0, -2]}
548
687
  style={{
549
688
  fontFamily: "Arial",
@@ -559,5 +698,15 @@ const StudioARSceneInner: React.FC<StudioARSceneInnerProps> = (props) => {
559
698
  if (isQuest) {
560
699
  return <ViroScene {...physicsProps}>{children}</ViroScene>;
561
700
  }
562
- return <ViroARScene {...physicsProps}>{children}</ViroARScene>;
701
+ return (
702
+ <ViroARScene
703
+ {...physicsProps}
704
+ {...(anchorDetectionTypes != null ? { anchorDetectionTypes } : {})}
705
+ onAnchorFound={handleAnchorFound}
706
+ onAnchorUpdated={handleAnchorUpdated}
707
+ onAnchorRemoved={handleAnchorRemoved}
708
+ >
709
+ {children}
710
+ </ViroARScene>
711
+ );
563
712
  };
@@ -0,0 +1,48 @@
1
+ import { Component, ErrorInfo, ReactNode } from "react";
2
+
3
+ interface Props {
4
+ children: ReactNode;
5
+ onError?: (error: Error) => void;
6
+ renderError?: (error: Error) => ReactNode;
7
+ sceneId?: string;
8
+ }
9
+
10
+ interface State {
11
+ hasError: boolean;
12
+ error: Error | null;
13
+ }
14
+
15
+ /**
16
+ * Catches render/lifecycle errors in the Studio scene tree so a bad asset or a
17
+ * node-factory throw can't take down the host app. Always reports via onError;
18
+ * renders renderError(error) if provided, else nothing (no built-in UI).
19
+ *
20
+ * AR-only in effect: on Quest, ViroXRSceneNavigator forwards to a separate
21
+ * React root (VRActivity), so scene errors there are outside this boundary.
22
+ * Native and async errors are also out of a React boundary's reach; async
23
+ * scene-load failures route through onError in StudioSceneNavigator instead.
24
+ */
25
+ export class StudioSceneErrorBoundary extends Component<Props, State> {
26
+ public state: State = { hasError: false, error: null };
27
+
28
+ public static getDerivedStateFromError(error: Error): State {
29
+ return { hasError: true, error };
30
+ }
31
+
32
+ public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
33
+ this.props.onError?.(error);
34
+ console.error(
35
+ "[Studio] Scene render error:",
36
+ error,
37
+ this.props.sceneId,
38
+ errorInfo.componentStack
39
+ );
40
+ }
41
+
42
+ public render() {
43
+ if (this.state.hasError && this.state.error) {
44
+ return this.props.renderError?.(this.state.error) ?? null;
45
+ }
46
+ return this.props.children;
47
+ }
48
+ }
@@ -1,5 +1,12 @@
1
1
  import * as React from "react";
2
- import { useCallback, useEffect, useRef, useState } from "react";
2
+ import {
3
+ forwardRef,
4
+ useCallback,
5
+ useEffect,
6
+ useImperativeHandle,
7
+ useRef,
8
+ useState,
9
+ } from "react";
3
10
  import { ActivityIndicator, StyleSheet, View, ViewStyle } from "react-native";
4
11
  import { ViroARScene } from "../AR/ViroARScene";
5
12
  import { ViroScene } from "../ViroScene";
@@ -9,23 +16,55 @@ import { registerSceneAnimations } from "./domain/animationRegistry";
9
16
  import { registerStudioMaterialsForAssets } from "./domain/studioMaterials";
10
17
  import { StudioVariableStore } from "./domain/variableStore";
11
18
  import { StudioARScene } from "./StudioARScene";
19
+ import { StudioSceneErrorBoundary } from "./StudioSceneErrorBoundary";
12
20
  import { StudioProjectApiResponse, StudioSceneResponse } from "./types";
13
21
  import { VRTStudioModule } from "./VRTStudioModule";
14
22
 
15
- function LoadingARScene() { return <ViroARScene />; }
16
- function LoadingVRScene() { return <ViroScene />; }
23
+ function LoadingARScene() {
24
+ return <ViroARScene />;
25
+ }
26
+ function LoadingVRScene() {
27
+ return <ViroScene />;
28
+ }
29
+
30
+ type ViroOcclusionMode = "peopleOnly" | "depthBased" | undefined;
31
+
32
+ function mapOcclusionMode(
33
+ dbValue: string | null | undefined
34
+ ): ViroOcclusionMode {
35
+ switch (dbValue) {
36
+ case "PEOPLEONLY":
37
+ return "peopleOnly";
38
+ case "DEPTHBASED":
39
+ return "depthBased";
40
+ default:
41
+ return undefined;
42
+ }
43
+ }
17
44
 
18
45
  const styles = StyleSheet.create({
19
46
  loader: {
20
47
  position: "absolute",
21
- top: 0, left: 0, right: 0, bottom: 0,
48
+ top: 0,
49
+ left: 0,
50
+ right: 0,
51
+ bottom: 0,
22
52
  justifyContent: "center",
23
53
  alignItems: "center",
24
54
  backgroundColor: "#000000",
25
55
  },
26
56
  });
27
57
 
28
- interface StudioSceneNavigatorProps {
58
+ /** Imperative handle exposed via ref. */
59
+ export interface StudioSceneNavigatorHandle {
60
+ /** Screenshots the AR renderer. Resolves `{ success: false }` (no-op) on Quest. */
61
+ takeScreenshot: (
62
+ fileName: string,
63
+ saveToCameraRoll: boolean
64
+ ) => Promise<{ success: boolean; url?: string; errorCode?: string }>;
65
+ }
66
+
67
+ export interface StudioSceneNavigatorProps {
29
68
  /**
30
69
  * UUID of a specific scene to load. If omitted, the navigator fetches the
31
70
  * project configured in the app manifest and uses its opening scene.
@@ -38,6 +77,22 @@ interface StudioSceneNavigatorProps {
38
77
  onError?: (err: Error) => void;
39
78
  onSceneChange?: (sceneId: string, sceneName: string) => void;
40
79
  onExitViro?: () => void;
80
+ /** Fired after the scene is fetched and parsed, before it is pushed. */
81
+ onSceneLoaded?: (sceneData: StudioSceneResponse) => void;
82
+ /** Threaded to the initial scene's StudioARScene (initial scene only). */
83
+ onPlaneDetected?: () => void;
84
+ onPlaneSelected?: () => void;
85
+ noAssetsMessage?: string;
86
+ /**
87
+ * Opt-in overlay shown until the scene mounts. Omit to render nothing on AR
88
+ * during load (the camera feed); Quest falls back to a built-in spinner.
89
+ */
90
+ loadingView?: React.ReactNode;
91
+ /**
92
+ * Opt-in UI for a caught render error. The boundary always catches and calls
93
+ * `onError`; when this is omitted it renders nothing.
94
+ */
95
+ renderError?: (error: Error) => React.ReactNode;
41
96
  }
42
97
 
43
98
  /**
@@ -53,19 +108,33 @@ interface StudioSceneNavigatorProps {
53
108
  * ready. This means VRActivity always launches with the actual content scene
54
109
  * as its initial scene, avoiding the LoadingVRScene → replace timing race.
55
110
  */
56
- export function StudioSceneNavigator({
57
- sceneId,
58
- worldAlignment = "Gravity",
59
- autofocus = true,
60
- style,
61
- onSceneReady,
62
- onError,
63
- onSceneChange,
64
- onExitViro,
65
- }: StudioSceneNavigatorProps) {
111
+ export const StudioSceneNavigator = forwardRef<
112
+ StudioSceneNavigatorHandle,
113
+ StudioSceneNavigatorProps
114
+ >(function StudioSceneNavigator(
115
+ {
116
+ sceneId,
117
+ worldAlignment = "Gravity",
118
+ autofocus = true,
119
+ style,
120
+ onSceneReady,
121
+ onError,
122
+ onSceneChange,
123
+ onExitViro,
124
+ onSceneLoaded,
125
+ onPlaneDetected,
126
+ onPlaneSelected,
127
+ noAssetsMessage,
128
+ loadingView,
129
+ renderError,
130
+ },
131
+ ref
132
+ ) {
66
133
  const navigatorRef = useRef<any>(null);
67
134
  const loadedSceneIdRef = useRef<string | null>(null);
68
135
 
136
+ const [isSceneReady, setIsSceneReady] = useState(false);
137
+
69
138
  // Session-scoped variable store: outlives every scene push, resets when the
70
139
  // navigator (= the AR/VR session) unmounts.
71
140
  const variableStoreRef = useRef<StudioVariableStore | null>(null);
@@ -82,13 +151,55 @@ export function StudioSceneNavigator({
82
151
  const onSceneReadyRef = useRef(onSceneReady);
83
152
  const onErrorRef = useRef(onError);
84
153
  const onSceneChangeRef = useRef(onSceneChange);
154
+ const onSceneLoadedRef = useRef(onSceneLoaded);
155
+ const onPlaneDetectedRef = useRef(onPlaneDetected);
156
+ const onPlaneSelectedRef = useRef(onPlaneSelected);
157
+ const noAssetsMessageRef = useRef(noAssetsMessage);
85
158
  onSceneReadyRef.current = onSceneReady;
86
159
  onErrorRef.current = onError;
87
160
  onSceneChangeRef.current = onSceneChange;
161
+ onSceneLoadedRef.current = onSceneLoaded;
162
+ onPlaneDetectedRef.current = onPlaneDetected;
163
+ onPlaneSelectedRef.current = onPlaneSelected;
164
+ noAssetsMessageRef.current = noAssetsMessage;
165
+
166
+ // Stable so passProps stays referentially steady across renders. Idempotent,
167
+ // so StrictMode's dev double-invoke of StudioARScene's onReady effect is safe.
168
+ const handleSceneReady = useCallback(() => {
169
+ setIsSceneReady(true);
170
+ onSceneReadyRef.current?.();
171
+ }, []);
88
172
 
89
173
  // On Quest: holds the resolved scene entry. ViroXRSceneNavigator is not
90
174
  // rendered until this is non-null, so VRActivity always launches into content.
91
- const [vrSceneEntry, setVrSceneEntry] = useState<{ scene: any; passProps?: any } | null>(null);
175
+ const [vrSceneEntry, setVrSceneEntry] = useState<{
176
+ scene: any;
177
+ passProps?: any;
178
+ } | null>(null);
179
+
180
+ // Host config derived from the loaded scene; native setters apply post-mount,
181
+ // so setting these after the navigator mounts is fine.
182
+ const [occlusionMode, setOcclusionMode] =
183
+ useState<ViroOcclusionMode>(undefined);
184
+ const [numberOfTrackedImages, setNumberOfTrackedImages] = useState<
185
+ number | undefined
186
+ >(undefined);
187
+
188
+ useImperativeHandle(
189
+ ref,
190
+ (): StudioSceneNavigatorHandle => ({
191
+ takeScreenshot: (fileName, saveToCameraRoll) => {
192
+ // On AR the handle is the ViroARSceneNavigator instance (has
193
+ // arSceneNavigator.takeScreenshot); on Quest it's a bridge without it.
194
+ const nav = navigatorRef.current?.arSceneNavigator;
195
+ if (typeof nav?.takeScreenshot !== "function") {
196
+ return Promise.resolve({ success: false });
197
+ }
198
+ return nav.takeScreenshot(fileName, saveToCameraRoll);
199
+ },
200
+ }),
201
+ []
202
+ );
92
203
 
93
204
  const resolveSceneId = useCallback(async (): Promise<string> => {
94
205
  if (sceneId) return sceneId;
@@ -101,7 +212,9 @@ export function StudioSceneNavigator({
101
212
  throw new Error("rvGetProject returned no data");
102
213
  }
103
214
 
104
- const { project } = JSON.parse(projectResult.data) as StudioProjectApiResponse;
215
+ const { project } = JSON.parse(
216
+ projectResult.data
217
+ ) as StudioProjectApiResponse;
105
218
 
106
219
  if (project.opening_scene?.id) {
107
220
  return project.opening_scene.id;
@@ -114,7 +227,9 @@ export function StudioSceneNavigator({
114
227
 
115
228
  const loadScene = useCallback(
116
229
  async (isCancelled: () => boolean) => {
117
- await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
230
+ await new Promise<void>((resolve) =>
231
+ requestAnimationFrame(() => resolve())
232
+ );
118
233
  if (isCancelled()) return;
119
234
 
120
235
  const resolvedSceneId = await resolveSceneId();
@@ -136,11 +251,20 @@ export function StudioSceneNavigator({
136
251
 
137
252
  loadedSceneIdRef.current = resolvedSceneId;
138
253
 
139
- // On Quest: pre-register animations and materials before VRActivity launches.
140
- // This mirrors the module-level registration pattern used by XRSceneContent —
141
- // native registrations complete before any Viro components mount, eliminating
142
- // the race between registerAnimations/createMaterials native calls and the
143
- // Fabric commit that creates those components.
254
+ const triggerImageCount = sceneData.assets.filter(
255
+ (a) => !!a.trigger_image_url
256
+ ).length;
257
+ setNumberOfTrackedImages(
258
+ triggerImageCount > 0 ? Math.min(triggerImageCount, 5) : undefined
259
+ );
260
+ setOcclusionMode(mapOcclusionMode(sceneData.project?.occlusion_mode));
261
+
262
+ onSceneLoadedRef.current?.(sceneData);
263
+
264
+ // On Quest, pre-register animations and materials before VRActivity
265
+ // launches so the native registrations land before any Viro component
266
+ // mounts; otherwise registerAnimations/createMaterials races the Fabric
267
+ // commit that creates those components.
144
268
  if (isQuest) {
145
269
  registerSceneAnimations(sceneData.animations);
146
270
  registerStudioMaterialsForAssets(sceneData.assets);
@@ -150,21 +274,24 @@ export function StudioSceneNavigator({
150
274
  scene: StudioARScene,
151
275
  passProps: {
152
276
  sceneData,
153
- onReady: onSceneReadyRef.current,
277
+ onReady: handleSceneReady,
154
278
  onSceneChange: onSceneChangeRef.current,
279
+ onPlaneDetected: onPlaneDetectedRef.current,
280
+ onPlaneSelected: onPlaneSelectedRef.current,
281
+ noAssetsMessage: noAssetsMessageRef.current,
155
282
  variableStore: variableStoreRef.current,
156
283
  },
157
284
  };
158
285
 
159
286
  if (isQuest) {
160
- // On Quest: setting vrSceneEntry triggers ViroXRSceneNavigator to mount
161
- // with StudioARScene as vrInitialScene VRActivity gets content immediately.
287
+ // Setting vrSceneEntry mounts ViroXRSceneNavigator with StudioARScene as
288
+ // vrInitialScene, so VRActivity launches straight into content.
162
289
  setVrSceneEntry(entry);
163
290
  } else {
164
291
  navigatorRef.current?.arSceneNavigator?.push(entry);
165
292
  }
166
293
  },
167
- [resolveSceneId]
294
+ [resolveSceneId, handleSceneReady]
168
295
  );
169
296
 
170
297
  useEffect(() => {
@@ -179,28 +306,46 @@ export function StudioSceneNavigator({
179
306
  else console.error("[Studio] Failed to load scene:", err);
180
307
  });
181
308
 
182
- return () => { cancelled = true; };
309
+ return () => {
310
+ cancelled = true;
311
+ };
183
312
  }, [sceneId, loadScene]);
184
313
 
185
- // On Quest: show a spinner until scene data is ready, then mount
186
- // ViroXRSceneNavigator (which launches VRActivity with content immediately).
314
+ // Quest has no camera passthrough, so during load it always needs something
315
+ // on screen: the caller's loadingView, else a built-in spinner. (AR shows the
316
+ // live camera, so its overlay stays opt-in.)
187
317
  if (isQuest && !vrSceneEntry) {
188
318
  return (
189
319
  <View style={styles.loader}>
190
- <ActivityIndicator size="large" color="#ffffff" />
320
+ {loadingView ?? <ActivityIndicator size="large" color="#ffffff" />}
191
321
  </View>
192
322
  );
193
323
  }
194
324
 
195
325
  return (
196
- <ViroXRSceneNavigator
197
- ref={navigatorRef}
198
- arInitialScene={{ scene: LoadingARScene }}
199
- vrInitialScene={vrSceneEntry ?? { scene: LoadingVRScene }}
200
- worldAlignment={worldAlignment}
201
- autofocus={autofocus}
202
- onExitViro={onExitViro}
203
- style={style ?? StyleSheet.absoluteFill}
204
- />
326
+ <StudioSceneErrorBoundary
327
+ sceneId={sceneId}
328
+ onError={onError}
329
+ renderError={renderError}
330
+ >
331
+ <View style={style ?? StyleSheet.absoluteFill}>
332
+ <ViroXRSceneNavigator
333
+ ref={navigatorRef}
334
+ arInitialScene={{ scene: LoadingARScene }}
335
+ vrInitialScene={vrSceneEntry ?? { scene: LoadingVRScene }}
336
+ worldAlignment={worldAlignment}
337
+ autofocus={autofocus}
338
+ numberOfTrackedImages={numberOfTrackedImages}
339
+ occlusionMode={occlusionMode}
340
+ onExitViro={onExitViro}
341
+ style={StyleSheet.absoluteFill}
342
+ />
343
+ {/* Absolutely filled so the overlay covers the navigator instead of
344
+ taking flow space beneath it. */}
345
+ {!isSceneReady && loadingView && (
346
+ <View style={StyleSheet.absoluteFill}>{loadingView}</View>
347
+ )}
348
+ </View>
349
+ </StudioSceneErrorBoundary>
205
350
  );
206
- }
351
+ });