@precession/react-native
v0.9.1
Published
React Native SDK for embedding Retor 3D experiences
Downloads
19
Maintainers
Readme
@precession/react-native
Embed Retor 3D experiences in a React Native / Expo app — with composable bottom-sheet UI built on @gorhom/bottom-sheet.
Installation
# Expo
npx expo install react-native-webview react-native-gesture-handler react-native-reanimated react-native-svg expo-blur
npm install @gorhom/bottom-sheet lucide-react-native @precession/react-native
# bare React Native
npm install react-native-webview react-native-gesture-handler react-native-reanimated react-native-svg @gorhom/bottom-sheet lucide-react-native expo-blur @precession/react-native
cd ios && pod install
expo-bluris optional — it's used for the default blurred sheet background. Skip it if you don't want blur and pass a custombackgroundComponentto any sheet.
You also need to wrap your app root in a GestureHandlerRootView (per @gorhom/bottom-sheet requirements):
import { GestureHandlerRootView } from "react-native-gesture-handler";
export default function App() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<YourApp />
</GestureHandlerRootView>
);
}Quick Start — Default UI
The fastest way: drop in <Hud> with the three sheets and they'll work out of the box.
import { View } from "react-native";
import { Viewer, Hud, ProjectSheet, LineDetailSheet, AddNoteSheet } from "@precession/react-native";
export default function Scene() {
return (
<View style={{ flex: 1 }}>
<Viewer projectId="abc123" apiKey="pk_your_embed_key">
<Hud>
<ProjectSheet />
<LineDetailSheet />
<AddNoteSheet />
</Hud>
</Viewer>
</View>
);
}
apiKeyis required. Create a publishable embed key in your project's Share → SDK embed keys section. Without it the viewer won't load.
What you get:
- A bottom sheet showing the project name and a horizontal carousel of lines
- Tap a line → second sheet opens (minimized) with the tag list; tap the header to expand
- Tap a tag → camera scrolls to it
- Call
controls.exitLine()(e.g. from arenderFooterbutton) to return to the browse sheet
Concepts
<Viewer>wraps the WebView and exposes scene state via React context. Always vanilla — Retor itself shows no UI.<Hud>sets upBottomSheetModalProvider. Place sheets and other overlays inside.- Sheets auto-present based on bridge state:
<ProjectSheet>shows when no line is open<LineDetailSheet>shows when a line is open<AddNoteSheet>shows whenuseAddNote().open()is called
- Composition components (
<LinesCarousel>,<LineTagList>) take a render prop so you can replace the visuals while keeping the data wiring.
Customising the visuals
Each sheet accepts:
snapPoints— override the default snap pointsrenderHeader— replace the headerchildren— replace the body (typically a render-prop list)
<LineDetailSheet> additionally accepts:
renderFooter(line, controls)— render a fixed footer pinned to the bottom of the sheet (sticks above scrolling content). Wire it tocontrols.exitLine(),controls.toggleAutoplay(), etc. If omitted, no footer is shown — sheets now open minimized, so provide your own footer if you want a persistent action.
import { Pressable, Text } from "react-native";
import { ProjectSheet, LinesCarousel, LineDetailSheet, LineTagList, useViewer } from "@precession/react-native";
function MyLineCard({ line }: { line: RetorLine }) {
const { openLine } = useViewer();
return (
<Pressable
onPress={() => openLine(line._id)}
style={{ width: 200, padding: 16, backgroundColor: "#222", borderRadius: 16 }}
>
<Text style={{ color: "white", fontWeight: "600" }}>{line.name}</Text>
</Pressable>
);
}
<ProjectSheet snapPoints={["20%", "50%"]}>
<LinesCarousel>
{(line) => <MyLineCard line={line} />}
</LinesCarousel>
</ProjectSheet>
<LineDetailSheet>
<LineTagList>
{(tag, isActive) => (
<Pressable style={{ padding: 12 }}>
<Text style={{ color: isActive ? "white" : "gray" }}>{tag.name}</Text>
</Pressable>
)}
</LineTagList>
</LineDetailSheet>Notes
<AddNoteSheet> triggers when you call useAddNote().open(tagId?). It collects text + private/public, then either:
- calls
onNoteSubmiton the parent<Viewer>(if set) — persistence is your responsibility - or falls back to persisting via Retor's own backend (Convex) when no
onNoteSubmitis provided
Re-pass saved notes back to the 3D scene via <Notes>.
Enabling notes
Whether the add-note "+" button shows is gated per-line by each line's
notesSupported flag (toggled in the Retor editor). Override this for every
line with the notesEnabled prop on <Viewer>:
- omitted (default) — respect each line's own
notesSupportedflag notesEnabled={true}— force notes on for all linesnotesEnabled={false}— force notes off for all lines
The resolved value is available as useAddNote().enabled if you build your own
add-note affordance.
Note fields for proper rendering
When passing notes via <Notes>, each note should include:
| Field | Required | Purpose |
|-------|----------|---------|
| _id | yes | Unique identifier |
| name | yes | The note text (displayed in the tag list and 3D scene) |
| position | yes | { x, y, z } — where the note sits on the line |
| objectId | yes | Set to the lineId so the note associates with the correct line |
| progress | yes | 0..1 position along the line (from the submit payload) — used for sort order and scroll-to |
| avatarUrl | recommended | Profile image URL — renders as a circular avatar in the tag pill and list item |
| authorName | recommended | Display name — used as initial-letter fallback when no avatarUrl |
| tagType | recommended | Set to "icon" for the standard note pill appearance |
| userId | for deletion | The note author's user ID — compared against Viewer.userId to show the delete button |
Creating + deleting notes
import { useState } from "react";
import { Viewer, Hud, ProjectSheet, LineDetailSheet, AddNoteSheet, Notes, type RetorTag } from "@precession/react-native";
export default function Scene() {
const [notes, setNotes] = useState<RetorTag[]>([]);
const currentUserId = "user_abc"; // your auth system's user ID
return (
<Viewer
projectId="abc123"
userId={currentUserId}
onNoteSubmit={({ text, isPrivate, lineId, position, progress }) => {
if (!position) return;
setNotes((prev) => [
...prev,
{
_id: `note-${Date.now()}`,
name: text,
position,
progress,
objectId: lineId ?? undefined,
tagType: "icon",
avatarUrl: "https://example.com/avatar.jpg",
authorName: "Jane",
userId: currentUserId,
},
]);
}}
onNoteDelete={(noteId) => {
setNotes((prev) => prev.filter((n) => n._id !== noteId));
// also delete from your backend
}}
>
<Notes notes={notes} />
<Hud>
<ProjectSheet />
<LineDetailSheet />
<AddNoteSheet />
</Hud>
</Viewer>
);
}When onNoteSubmit is not provided, the SDK sends the note to Retor's backend automatically (using the signed-in Clerk session inside the WebView). No <Notes> re-injection needed in that case.
When a user deletes a note, the SDK:
- Optimistically removes it from the local tag list
- Fires
onNoteDelete(noteId)so you can delete from your backend
Cursors
<Cursors> is a sentinel (sibling to <Notes>) that renders avatar pins in the 3D scene at
arbitrary lat/lng — use it for "you are here" markers, teammates, ghosts, etc. It draws nothing
itself; it just pushes the data into the scene.
import { Viewer, Cursors, type CursorEntry } from "@precession/react-native";
const me: CursorEntry[] = [
{ id: "self", lat: 63.42, lng: 10.39, avatarUrl: meUrl, color: "#ef4444", pulse: true },
];
const team: CursorEntry[] = [
{ id: "u_jane", lat: 63.4205, lng: 10.391, initial: "J", color: "#22d3ee" },
];
<Viewer projectId="abc123">
<Cursors data={me} />
<Cursors data={team} onCursorTap={(id) => openTeammateSheet(id)} />
</Viewer>;- Multiple
<Cursors>coexist — entries are merged byidacross instances (later wins), so you can keep self / ghost / team in separate sets. elevationMis optional; when omitted the pin snaps to the scene-surface elevation at lat/lng.onCursorTap(id)fires when a pin is tapped —idis theCursorEntry.idyou set, so route it to your own data (open a sheet, focus the camera, etc.). Each<Cursors>instance's handler receives taps for its own entries.
Display mode
Switch what every object draws — its Gaussian splat, point cloud, or reconstructed surface mesh with a chosen material — via a single display-mode enum:
| Mode | Draws |
|------|-------|
| "splat" | Gaussian splat |
| "pointcloud" | point cloud |
| "mesh_normal" | surface mesh, matcap clay material |
| "mesh_sun" | surface mesh, sun/shade study (see below) |
| "mesh_elevation" | surface mesh, elevation isolines |
| "mesh_heatmap" | surface mesh, slope heatmap (flat = blue, steep = red) |
// Declarative
<Viewer projectId="abc123" displayMode={isMeshView ? "mesh_sun" : "splat"} />
// Imperative
const { setDisplayMode } = useViewer();
setDisplayMode("mesh_elevation"); // null → restore the project's saved visibility + materialIt's per-viewer and non-destructive — it overrides what this viewer draws without changing the
project's saved visibility/material, so other viewers and the editor are unaffected. mesh_* modes
need the object to actually have a mesh.
Sun position (shade material)
mesh_sun lights the terrain from the sun at a given date + time (computed from the project's GPS).
Override it for this viewer at runtime — handy for a time-of-day slider:
const { setSunDate } = useViewer();
setSunDate(new Date("2026-06-21T15:00:00")); // Date or epoch-ms; null → project's saved sun timeDate drives the seasonal sun height; the clock time is treated as local solar time (noon = highest).
Compass HUD
Drop a <HudCompass> over the viewer to show a tilted mini-map dial that points to GPS north
(red = north, white = south) and spins as the user rotates the scene:
import { Viewer, HudCompass } from "@precession/react-native";
<View style={{ flex: 1 }}>
<Viewer projectId="abc123" />
<HudCompass offset={{ x: 0, y: 80 }} />
</View>The heading is streamed from the viewer over the bridge; useHeading() exposes it directly if you'd
rather build your own indicator. Both need a georeferenced project (GPS reference tags).
Tap to toggle top-down. Tapping <HudCompass> switches the viewer between 3D and a top-down /
map camera that looks straight down the ground plane — and, while on a line, straight down over the
scroll cursor (so it follows as you scroll). The dial lies flat in top-down and tilts back in 3D. You
can also drive it directly with controls.setTopDown(true | false).
Location & elevation
useViewer().controls (and useViewer() directly) expose two async resolvers backed by the
project's reconstructed surface. Both return Promises (the work happens inside the embedded viewer
and comes back over the bridge).
const viewer = useViewer();
// Resolve a raw GPS reading to ranked candidate positions on the model surface.
const { candidates } = await viewer.getLocation({
lineId: activeLineId,
lat, lng, elev,
rawAccuracy: { horizontalM: 12 },
previousTagId, // optional prior — favours candidates near it
});
const best = candidates[0]; // highest confidence (educated guess)
// Surface elevation (meters) at a point — null when the surface has no coverage there.
const elevationM = await viewer.getElevationAt(lat, lng);getLocationtreats raw GPS as noisy: candidates are positions on the model surface, not snapped to the line. WhenlineIdis provided, line-adjacent candidates rank higher.candidates[0]is a sensible default; re-rank with your own heuristics (velocity, history) if you like. Each candidate carriesclosestTag,closestPointOnLine, per-axiserrors, and aconfidence(0..1).getElevationAtis overhang-safe (cave/overhang aware) and returnsnullwhere the surface isn't reconstructed.
These require the project to be georeferenced (coordinate tags) and to have a reconstructed surface mesh.
getLocationstill returns a degraded line-based guess when no mesh is available.
Hooks
All hooks read from the bridge context provided by the parent <Viewer>.
| Hook | Returns |
|------|---------|
| useProject() | The current RetorProject (or null) |
| useLines() | Array of RetorLine |
| useActiveLine() | The currently open line (or null) |
| useLineProgress() | { progress, closestTagId } |
| useAutoplay() | { isPlaying, toggle, play, pause } |
| useAddNote() | { isOpen, tagId, open, close, submit } |
| useHeading() | Live camera compass heading in degrees (0 = north, +clockwise), or null |
| useViewer() | Imperative controls (openLine, exitLine, scrollToTag, scrollToProgress, setDisplayMode, setSunDate, getLocation, getElevationAt, ...) |
Imperative API
The useViewer hook also supports controlling a specific viewer by ID:
<Viewer id="left" projectId="..." />
<Viewer id="right" projectId="..." />
const left = useViewer("left");
left.openLine("line-a");Or pass a ref directly:
const ref = useRef<ViewerHandle>(null);
<Viewer ref={ref} projectId="..." />
ref.current?.openLine("...");Cover photo
A static thumbnail of a project's start view — no 3D, no bridge:
import { CoverPhoto } from "@precession/react-native";
<CoverPhoto projectId="abc123" style={{ width: 200, height: 120 }} />License
MIT
