Canonical: https://react-immersive.liveroom.dev/docs/agent-guide Package: @liveroom-tech/react-immersive@5.2.0 # Build with React Immersive This guide targets `@liveroom-tech/react-immersive` **5.2.0**. Use React and React DOM **19**, Three.js **0.184**, and React Three Fiber **9.6.1 or a compatible 9.x release**. ## Read only what the task needs - [Compact agent index](https://react-immersive.liveroom.dev/llms.txt) - [This guide as Markdown](https://react-immersive.liveroom.dev/docs/agent-guide.md) - [Version, peers, page and section URLs](https://react-immersive.liveroom.dev/docs/manifest.json) - [Full documentation bundle](https://react-immersive.liveroom.dev/llms-full.txt), optional and large - [Public source and runnable starters](https://github.com/Liveroom-Technologies/react-immersive-docs) Every documentation page has a `.md` alternative. The manifest also lists small Markdown sections of the ModelViewer reference so you can fetch one topic without loading the entire API reference. HTML is the default response; `Accept: text/markdown` requests the Markdown alternative at the same docs URL. ## Choose a component | Goal | Import | Stylesheet | Runtime key prop | | -------------------------------------------------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------- | ---------------- | | Display a model, inspect meshes, toggle visibility | `SimpleModelViewer` from `@liveroom-tech/react-immersive/simple-model-viewer` | `@liveroom-tech/react-immersive/simple-model-viewer.css` | None | | Configure materials, actions, annotations, camera or app state | `ModelViewer` from `@liveroom-tech/react-immersive` | `@liveroom-tech/react-immersive/styles.css` | Required | | Author bindings and scene settings visually | `BindingBuilder` from `@liveroom-tech/react-immersive/binding-builder` | `@liveroom-tech/react-immersive/binding-builder.css` | Required | The package is proprietary. SimpleModelViewer needs no key or paid plan, including in production. For ModelViewer and BindingBuilder, follow the [licensing guide](https://react-immersive.liveroom.dev/docs/licensing) before deployment. Account sign-in, plan selection and key creation are manual steps in the [console](https://react-immersive.liveroom.dev/console). ## Get a first render Use the complete [Vite setup](https://react-immersive.liveroom.dev/docs/frameworks/vite) or [Next.js App Router setup](https://react-immersive.liveroom.dev/docs/frameworks/nextjs). Both start with SimpleModelViewer and an explicitly sized container. The public sample is [red_room.glb](https://react-immersive.liveroom.dev/red_room.glb); place it at `public/red_room.glb` in the consuming application. Success means the model is visible, the scene objects panel lists objects, and `onModelLoaded` fires. A successful TypeScript build alone does not verify that a model URL works in the browser. ## Add interaction Start with `ModelViewer` without `objectBindings` to generate bindings for all meshes. Use `useViewer` and spread `viewer.props` onto the component to connect its controllers. Use `onObjectCatalog` to discover actual object identifiers; do not invent node names. Duplicate authored names receive suffixed IDs. When using `defineObjectBindings`, preserve literal IDs for authoring but widen mutable React state to `Record`. Bindings are indexed by model node identifiers; `binding.id` is application metadata and can differ. | Task | Read | | --------------------------------------------- | ----------------------------------------------------------------------------------------------- | | Wire app state and controllers | [useViewer](https://react-immersive.liveroom.dev/docs/hooks/use-viewer.md) | | Materials, visibility, grouped edits | [useObjectBindings](https://react-immersive.liveroom.dev/docs/hooks/use-object-bindings.md) | | Camera focus and saved views | [useViewerCamera](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-camera.md) | | Lighting, environment and scene configuration | [useSceneConfig](https://react-immersive.liveroom.dev/docs/hooks/use-scene-config.md) | | Animations | [useViewerAnimations](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-animations.md) | | Annotation tours, exports, XR or performance | [ModelViewer section index](https://react-immersive.liveroom.dev/docs/manifest.json) | | Export/import scene files | [BindingBuilder](https://react-immersive.liveroom.dev/docs/reference/binding-builder.md) | | API shapes | [Types](https://react-immersive.liveroom.dev/docs/reference/types.md) | | A complete feature implementation | [Examples](https://react-immersive.liveroom.dev/docs/examples.md) | ## Verify the result 1. Run the application's typecheck and production build. 2. Open the page in a browser with WebGL support. 3. Confirm the model URL returns model data rather than an HTML error page. 4. Confirm the viewer has height and its own stylesheet is imported. 5. Confirm model-loaded/error callbacks and one intended interaction. 6. For licensed components, check the key and the deployed domain against the plan. If any step fails, use [Troubleshooting](https://react-immersive.liveroom.dev/docs/troubleshooting.md). For existing projects, read the [version and migration notes](https://react-immersive.liveroom.dev/docs/releases.md). --- Canonical: https://react-immersive.liveroom.dev/docs/examples Package: @liveroom-tech/react-immersive@5.2.0 # Examples ### `SimpleModelViewer` The lightweight inspection experience for quick model review: - Upload-first workflow - Scene objects panel - Environment and lighting controls - Click-to-select focus flow [Live Demo](https://react-immersive.liveroom.dev/examples/simple-model-viewer) · [Starter App](https://github.com/liveroom-technologies/react-immersive-docs/tree/main/examples/simple-model-viewer-demo) · [Open in CodeSandbox](https://codesandbox.io/s/github/liveroom-technologies/react-immersive-docs/tree/main/examples/simple-model-viewer-demo) ### `ModelViewer` The fuller `ModelViewer` experience used for the richer hosted demo: - Object bindings and per-object actions - Scene config wiring - UV Checker toolbar - Exploded-view controls - Measurement tools - Annotation navigation - XR flags and performance tuning [Live Demo](https://react-immersive.liveroom.dev/examples/model-viewer) · [Starter App](https://github.com/liveroom-technologies/react-immersive-docs/tree/main/examples/model-viewer-demo) · [Open in CodeSandbox](https://codesandbox.io/s/github/liveroom-technologies/react-immersive-docs/tree/main/examples/model-viewer-demo) ### `Guided Tour` A guided walkthrough demo that uses scene annotations as tour stops instead of per-object bindings: - SceneAnnotationMarker stops - Built-in annotation navigation - Camera flies to saved world positions - Position-only markers, no object binding needed [Live Demo](https://react-immersive.liveroom.dev/examples/guided-tour) · [Starter App](https://github.com/liveroom-technologies/react-immersive-docs/tree/main/examples/guided-tour-demo) · [Open in CodeSandbox](https://codesandbox.io/s/github/liveroom-technologies/react-immersive-docs/tree/main/examples/guided-tour-demo) ### `Shareable View / Deep Link` Create a client-ready link to an exact walkthrough state: - Pick an object to fly the camera to it - Hide selected objects from the walkthrough - Copy or open a link that restores camera, selection, and visibility state - Built with `useShareableViewerState` [Live Demo](https://react-immersive.liveroom.dev/examples/shareable-view) · [Starter App](https://github.com/liveroom-technologies/react-immersive-docs/tree/main/examples/shareable-view-demo) · [Open in CodeSandbox](https://codesandbox.io/s/github/liveroom-technologies/react-immersive-docs/tree/main/examples/shareable-view-demo) ### `AR Product Viewer` A standalone, mobile-first retail demo for placing a model in the customer's space: - WebXR via `enableXR` on supported Android devices - iOS Quick Look using `usdzUrl` - Real-world initial scale using `arScaleMode="real-world"` - Desktop-to-phone QR handoff for opening the same experience in mobile AR - Minimal viewer UI focused on the product and AR handoff [Live Demo](https://react-immersive.liveroom.dev/examples/ar-product-viewer) · [Starter App](https://github.com/liveroom-technologies/react-immersive-docs/tree/main/examples/ar-product-viewer-demo) · [Open in CodeSandbox](https://codesandbox.io/s/github/liveroom-technologies/react-immersive-docs/tree/main/examples/ar-product-viewer-demo) ### `Switch Demo` A focused room-level example where two switches control four lights through app state: - Start with exported `objectBindings`. - Add declarative `effects` to the switch actions. - Target each light circuit as a binding group instead of listing every light. - Pass the bindings into `ModelViewer` as React state. - Pass `useViewerActions().handleAction` to `ModelViewer` and call `runAction` from the panel buttons. - Use the `useSceneConfig` light controller to synchronize ambient and key-light intensity. - Attach each point light to its bound bulb object without writing R3F light components. [Live Demo](https://react-immersive.liveroom.dev/examples/switch-demo) · [Starter App](https://github.com/liveroom-technologies/react-immersive-docs/tree/main/examples/switch-room-demo) · [Open in CodeSandbox](https://codesandbox.io/s/github/liveroom-technologies/react-immersive-docs/tree/main/examples/switch-room-demo) ### `Light Flicker` An atmospheric lighting example that turns nine static bulb meshes into an unstable electrical circuit: - `useObjectBindings` applies the BindingBuilder-exported model setup - `useSceneConfig` changes the environment, bloom, and vignette by preset - `useViewerEffects` drives filament, glass, and point-light channels without Three.js access - `useViewerConnection` shares one viewer-ready callback across camera, effects, and light setup - Runtime point lights attach to their filament meshes and follow them automatically - Failing-circuit mode dims every bulb together from one shared signal - Possessed mode gives every bulb independent dim, flash, and blackout timing - Lit-up, failing-circuit, and possessed presets change the power behavior - BindingBuilder-exported bindings and scene configuration drive the model [Live Demo](https://react-immersive.liveroom.dev/examples/light-flicker) · [Starter App](https://github.com/liveroom-technologies/react-immersive-docs/tree/main/examples/light-flicker-demo) · [Open in CodeSandbox](https://codesandbox.io/s/github/liveroom-technologies/react-immersive-docs/tree/main/examples/light-flicker-demo) ### `Vehicle Configurator` A product-configurator example that shows how BindingBuilder-exported object bindings can drive grouped customization flows: - Binding `group` selectors patch body paint, wheels, trim, and other multi-mesh sets without manual ID arrays - Built-in `change-color` / `change-material` / `toggle-visibility` actions are reinterpreted as real configurator controls - An aero-kit toggle hides or restores a whole package of parts - Window tint and headlight state update the same binding map the viewer renders - Clickable parts and side-panel controls stay in sync through React state [Live Demo](https://react-immersive.liveroom.dev/examples/vehicle-configurator) · [Starter App](https://github.com/liveroom-technologies/react-immersive-docs/tree/main/examples/vehicle-configurator-demo) · [Open in CodeSandbox](https://codesandbox.io/s/github/liveroom-technologies/react-immersive-docs/tree/main/examples/vehicle-configurator-demo) ### `Exploded View` An assembly-inspection demo that separates a multi-part model into its components with a single slider: - `showExplodeControls` renders the built-in explode slider in the viewer - Every bound mesh slides outward from the model center along its own direction - Nested parts ride their exploding parent instead of being displaced twice - Fully reversible, dragging back to zero reassembles the model exactly - Best suited to static product / CAD models (engines, gadgets, vehicles) [Live Demo](https://react-immersive.liveroom.dev/examples/exploded-view) · [Starter App](https://github.com/liveroom-technologies/react-immersive-docs/tree/main/examples/exploded-view-demo) · [Open in CodeSandbox](https://codesandbox.io/s/github/liveroom-technologies/react-immersive-docs/tree/main/examples/exploded-view-demo) ### `Mouse Controller` An on-screen navigation control for touchscreen, kiosk, and guided viewing experiences: - Enable the built-in controller with `showMouseController` - Tune movement with `moveSensitivity` - Adjust its visibility with `mouseControllerOpacity` - Move it to a safe part of the interface with `mouseControllerPosition` [Live Demo](https://react-immersive.liveroom.dev/examples/mouse-controler-demo) · [Starter App](https://github.com/liveroom-technologies/react-immersive-docs/tree/main/examples/mouse-controller-demo) · [Open in CodeSandbox](https://codesandbox.io/s/github/liveroom-technologies/react-immersive-docs/tree/main/examples/mouse-controller-demo) ### `Cinematic Tour` An automatic camera that glides through a space on its own, like a film: - The `cinematic` prop takes a list of `waypoints` (camera position + look-at keyframes) - The camera follows a smooth Catmull-Rom spline through them at a constant on-screen speed, looping seamlessly - `autoPlay` starts the glide once the model has loaded and framed - A play / pause control appears in the viewer; grabbing the camera pauses it - Waypoints are authored visually in BindingBuilder's Cinematic tab and carried on `sceneConfig.cinematic` [Live Demo](https://react-immersive.liveroom.dev/examples/cinematic-tour) · [Starter App](https://github.com/liveroom-technologies/react-immersive-docs/tree/main/examples/cinematic-tour-demo) · [Open in CodeSandbox](https://codesandbox.io/s/github/liveroom-technologies/react-immersive-docs/tree/main/examples/cinematic-tour-demo) ### `Animation Studio` A playback studio for a rigged, animated GLB, driven entirely by `useViewerAnimations`: - Clip picker for every animation baked into the model - Play, pause, and stop transport controls - A scrubbable timeline bound to the live playback position - Playback speed presets - A loop-mode toggle (repeat vs. play once) driven through `sceneConfig.animations` [Live Demo](https://react-immersive.liveroom.dev/examples/animation-studio) · [Starter App](https://github.com/liveroom-technologies/react-immersive-docs/tree/main/examples/animation-studio-demo) · [Open in CodeSandbox](https://codesandbox.io/s/github/liveroom-technologies/react-immersive-docs/tree/main/examples/animation-studio-demo) ### `Texture Upload / Custom Print` A custom-apparel demo where shoppers upload their own artwork onto a product: - The built-in Upload Artwork action hands the file to `onTextureUpload` - A real app uploads it to storage and returns a durable URL (here a data URL) - The returned URL is committed to `style.material.texture.path` - `copyMaterial` and the `shirt` binding group broadcast the print without a manual ID array - `texturePositioning` lets shoppers drag, scale, and rotate the print; the result lands in `style.material.texture`, so it's broadcast the same way - `setBaseColor` and `clearTexture` drive the same grouped binding state - The viewer download menu exports the shirt's editable UV layout as SVG [Live Demo](https://react-immersive.liveroom.dev/examples/texture-upload) · [Starter App](https://github.com/liveroom-technologies/react-immersive-docs/tree/main/examples/texture-upload-demo) · [Open in CodeSandbox](https://codesandbox.io/s/github/liveroom-technologies/react-immersive-docs/tree/main/examples/texture-upload-demo) ### `BindingBuilder` The hosted `BindingBuilder` surface for authoring bindings in the browser: - Desktop-first editor shell - License-key based access - Full-height authoring workspace [Live Demo](https://react-immersive.liveroom.dev/editor) · [Starter App](https://github.com/liveroom-technologies/react-immersive-docs/tree/main/examples/binding-builder-demo) · [Open in CodeSandbox](https://codesandbox.io/s/github/liveroom-technologies/react-immersive-docs/tree/main/examples/binding-builder-demo) ## Public Code Examples If you want starter code, see the public community examples in the GitHub repo: - [SimpleModelViewer examples](https://github.com/liveroom-technologies/react-immersive-docs/tree/main/examples) - [ModelViewer examples](https://github.com/liveroom-technologies/react-immersive-docs/tree/main/examples) - [BindingBuilder examples](https://github.com/liveroom-technologies/react-immersive-docs/tree/main/examples) ## Running Examples For local starter apps, clone the public examples and run the example you want from its own folder. Some examples require a valid license key. Create a free key at [react-immersive.liveroom.dev/console](https://react-immersive.liveroom.dev/console) before running gated demos locally. --- Canonical: https://react-immersive.liveroom.dev/docs/frameworks/nextjs Package: @liveroom-tech/react-immersive@5.2.0 # Next.js App Router This setup targets React Immersive **5.2.0**, React **19.2.4**, and Next.js **16.2.7**. Use Node **22.12+** for the included starter. ## Install In a Next.js App Router TypeScript application: ```bash npm install next@16.2.7 react@19.2.4 react-dom@19.2.4 @liveroom-tech/react-immersive@5.2.0 three@^0.184.0 @react-three/fiber@^9.6.1 npm install -D typescript@^5.9.0 @types/node@^22 @types/react@~19.2.0 @types/react-dom@~19.2.0 @types/three@^0.184.0 ``` Download [red_room.glb](https://react-immersive.liveroom.dev/red_room.glb) to `public/red_room.glb`. It is [Red Room by LaisLH](https://skfb.ly/6VBJR), licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). ## Add these files For a new standalone app, explicitly set its root so Next.js does not inherit the surrounding documentation repository's configuration or lockfile. If your application already has a Next.js config, merge these options into that file. ```js filename="next.config.mjs" import { fileURLToPath } from "node:url"; const root = fileURLToPath(new URL(".", import.meta.url)); export default { turbopack: { root }, outputFileTracingRoot: root, }; ``` Import component CSS from the root layout. This layout remains a Server Component. ```tsx filename="app/layout.tsx" import type { ReactNode } from "react"; import "@liveroom-tech/react-immersive/simple-model-viewer.css"; export default function RootLayout({ children }: { children: ReactNode }) { return ( {children} ); } ``` Put browser state and callbacks behind a client boundary. The dynamic import keeps the WebGL viewer out of server rendering; use it inside the client file. ```tsx filename="app/Viewer.tsx" "use client"; import dynamic from "next/dynamic"; import { useState } from "react"; const SimpleModelViewer = dynamic( () => import("@liveroom-tech/react-immersive/simple-model-viewer").then((module) => module.SimpleModelViewer), { ssr: false, loading: () =>

Starting viewer…

}, ); export default function Viewer() { const [status, setStatus] = useState("Loading model…"); return (

{status}

setStatus(`Loaded ${model.objectCount} objects`)} onLoadError={() => setStatus("Model failed to load. Check the URL and browser console.")} />

Red Room by LaisLH, CC BY 4.0 — https://skfb.ly/6VBJR

); } ``` ```tsx filename="app/page.tsx" import Viewer from "./Viewer"; export default function Page() { return ; } ``` ```bash npm run dev npm run build ``` Expect a visible room, scene objects, and the loaded-object count. The [standalone Next.js starter](https://github.com/Liveroom-Technologies/react-immersive-docs/tree/main/examples/nextjs-model-viewer-demo) also includes `package.json`, TypeScript configuration and the sample asset. When nesting the app in another repository, scope `compilerOptions.types` to `node`, `react`, and `react-dom`, as the starter does, so unrelated ambient types from the parent repository are not loaded into the React 19 app. ## Use licensed components Create a key in the [console](https://react-immersive.liveroom.dev/console), then set `NEXT_PUBLIC_LICENSE_KEY=your-license-key` in an uncommitted `.env.local` and restart Next.js. Use `process.env.NEXT_PUBLIC_LICENSE_KEY` in client code. Import the matching component stylesheet from `app/layout.tsx` and put hooks and event handlers in a file beginning with `"use client"`. `NEXT_PUBLIC_` values are exposed in browser bundles. Domain-bound viewer keys are passed to the browser for validation; do not put account credentials, payment tokens, or server API secrets in these variables. See [Licensing](https://react-immersive.liveroom.dev/docs/licensing) and [Getting Started](https://react-immersive.liveroom.dev/docs/getting-started). --- Canonical: https://react-immersive.liveroom.dev/docs/frameworks/vite Package: @liveroom-tech/react-immersive@5.2.0 # Vite + React This complete first-render setup targets React Immersive **5.2.0** and React 19. Use Node **20.19+ in the 20.x line, or 22.12+** for the Vite starter. ## Create the application ```bash npm create vite@8 viewer-app -- --template react-ts cd viewer-app npm install react@19.2.4 react-dom@19.2.4 @liveroom-tech/react-immersive@5.2.0 three@^0.184.0 @react-three/fiber@^9.6.1 npm install -D @types/react@~19.2.0 @types/react-dom@~19.2.0 @types/three@^0.184.0 ``` Download [red_room.glb](https://react-immersive.liveroom.dev/red_room.glb) into `public/red_room.glb`. Keep the filename unchanged. This sample is [Red Room by LaisLH](https://skfb.ly/6VBJR), licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/); retain attribution when sharing a page that uses it. ## Replace these files ```tsx filename="src/main.tsx" import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "@liveroom-tech/react-immersive/simple-model-viewer.css"; import App from "./App"; createRoot(document.getElementById("root")!).render( , ); ``` ```tsx filename="src/App.tsx" import { useState } from "react"; import { SimpleModelViewer } from "@liveroom-tech/react-immersive/simple-model-viewer"; export default function App() { const [status, setStatus] = useState("Loading model…"); return (

{status}

setStatus(`Loaded ${model.objectCount} objects`)} onLoadError={() => setStatus("Model failed to load. Check the URL and browser console.")} />

Red Room by LaisLH, CC BY 4.0 — https://skfb.ly/6VBJR

); } ``` The generated `index.html` and TypeScript configuration can remain as scaffolded. The files above intentionally do not import the Vite template's `App.css` or `index.css`, whose layout rules can constrain the viewer. ```bash npm run dev npm run build ``` Open the local URL printed by Vite. Expect a visible room, a scene objects panel, and a loaded-object count. No `licenseKey` prop is needed for this SimpleModelViewer smoke test. It also needs no key in production. See [Licensing](https://react-immersive.liveroom.dev/docs/licensing) for the requirements of the other components. ## Use ModelViewer Follow [Getting Started](https://react-immersive.liveroom.dev/docs/getting-started) for the component, bindings and hook wiring. Import `styles.css` in `src/main.tsx` and use `import.meta.env.VITE_LICENSE_KEY` for the runtime key. Create an uncommitted `.env.local` containing `VITE_LICENSE_KEY=your-license-key`, then restart Vite. `VITE_` variables are included in browser code; they are not server secrets. Keep account credentials and billing tokens out of client code. The public [Vite starters](https://github.com/Liveroom-Technologies/react-immersive-docs/tree/main/examples) include complete feature implementations, sample assets, lockfiles and build scripts. --- Canonical: https://react-immersive.liveroom.dev/docs/getting-started Package: @liveroom-tech/react-immersive@5.2.0 # Getting Started This guide targets **5.2.0** and **React 19**. For a complete first-render app, start with [Vite + React](https://react-immersive.liveroom.dev/docs/frameworks/vite) or [Next.js App Router](https://react-immersive.liveroom.dev/docs/frameworks/nextjs). For a compact API/task map, see the [AI Agent Guide](https://react-immersive.liveroom.dev/docs/agent-guide). ## Installation ```bash npm install @liveroom-tech/react-immersive@5.2.0 three@^0.184.0 @react-three/fiber@^9.6.1 ``` ```bash pnpm add @liveroom-tech/react-immersive@5.2.0 three@^0.184.0 @react-three/fiber@^9.6.1 ``` ```bash yarn add @liveroom-tech/react-immersive@5.2.0 three@^0.184.0 @react-three/fiber@^9.6.1 ``` `three` and `@react-three/fiber` are peer dependencies, along with React 19, so your app has exactly one copy of each. Everything else installs with the package. ## Import Styles Import the stylesheet for each component you use, once, from your application root. In a Next.js App Router project, put these in `app/layout.tsx`: ```tsx import "@liveroom-tech/react-immersive/styles.css"; // ModelViewer import "@liveroom-tech/react-immersive/simple-model-viewer.css"; // SimpleModelViewer import "@liveroom-tech/react-immersive/binding-builder.css"; // BindingBuilder ``` `SimpleModelViewer` and `BindingBuilder` are imported from their own entry points, `@liveroom-tech/react-immersive/simple-model-viewer` and `@liveroom-tech/react-immersive/binding-builder`, so an app downloads only the components it uses. If a component's stylesheet is missing, it renders unstyled, and in development it logs a console warning naming the import to add. ## Get a License Key `ModelViewer` and `BindingBuilder` require a `licenseKey`. (`SimpleModelViewer` needs no key, including in production; see [Licensing](https://react-immersive.liveroom.dev/docs/licensing).) To get one: 1. Sign in (or sign up) at the [React Immersive Console](https://react-immersive.liveroom.dev/console) 2. Choose a plan 3. Generate a license key from your dashboard Keep keys out of public repositories. `NEXT_PUBLIC_LICENSE_KEY` (Next.js) and `VITE_LICENSE_KEY` (Vite) are browser-visible values, not server secrets. Use a key for the registered domain and follow the [licensing guide](https://react-immersive.liveroom.dev/docs/licensing). ## Quick Start This example is a Next.js client component. Import `styles.css` in your root layout, put the asset at `public/model.glb`, and set `NEXT_PUBLIC_LICENSE_KEY` in an uncommitted `.env.local`. In Vite, import CSS in `src/main.tsx` and use `import.meta.env.VITE_LICENSE_KEY` instead. Restart the dev server after setting the variable. For a first render without a runtime key prop, use the [SimpleModelViewer starter](https://react-immersive.liveroom.dev/docs/frameworks/vite). To see your model working before you write any bindings, let the viewer generate them: ```tsx "use client"; import { ModelViewer } from "@liveroom-tech/react-immersive"; export default function App() { return (
); } ``` Every mesh can then be hovered and selected, with Change Color, Change Material, and Toggle Visibility in the side panel (or light controls for meshes whose names contain "light"). See [generating bindings automatically](https://react-immersive.liveroom.dev/docs/reference/model-viewer#generating-bindings-automatically) to keep and refine them. The steps below write the bindings yourself, which gives you control over labels, actions, metadata, and camera views. ### Define your object bindings Create `objectBindings.ts` with the named export below. This sample targets the wall node in `red_room.glb`. For another asset, discover its identifiers with `onObjectCatalog` or BindingBuilder instead of copying the sample node name. Each key in `objectBindings` must match a model node identifier. Unique nodes use their authored name; duplicate names receive identifiers such as `Cube [1]` and `Cube [2]`. BindingBuilder generates the correct identifiers automatically. You can write the bindings by hand, or generate starter bindings in [`BindingBuilder`](https://react-immersive.liveroom.dev/docs/reference/binding-builder) and export them as `objectBindings.json` or a TypeScript file to import into your app. For a configured scene, export `sceneConfig.ts` from BindingBuilder's **Scene** tab and pass its named `sceneConfig` export to `ModelViewer` as well. The [`BindingBuilder export and import guide`](https://react-immersive.liveroom.dev/docs/reference/binding-builder#exporting-and-reusing-files) explains where each toolbar action lives and when to choose JSON versus TypeScript. If you want a ready-to-run starter, download the sample [`red_room.glb`](https://react-immersive.liveroom.dev/red_room.glb) model and [`objectBindings.ts`](https://react-immersive.liveroom.dev/objectBindings.ts) bindings file from these docs. Put `red_room.glb` in your app's `public/` folder, and place `objectBindings.ts` in your source tree so you can import it into your viewer page. Sample model credit: [“Red Room”](https://skfb.ly/6VBJR) by LaisLH, licensed under [Creative Commons Attribution 4.0](http://creativecommons.org/licenses/by/4.0/). ```ts filename="objectBindings.ts" import { defineObjectBindings } from "@liveroom-tech/react-immersive/utils"; export const objectBindings = defineObjectBindings({ Quarto_Wall_0: { id: "brick-wall", modelObjectId: "Quarto_Wall_0", type: "wall", label: "Room Walls", selectable: true, hoverable: true, visible: true, style: {}, metrics: {}, metadata: {}, cameraState: { position: [-0.91, 0.87, 0.42], target: [0.09, 0.98, 0.036], fov: 45, }, actions: [ { id: "change-color", label: "Change Color", type: "command" }, { id: "change-material", label: "Change Material", type: "command" }, { id: "toggle-visibility", label: "Toggle Visibility", type: "command" }, ], }, }); ``` ### Mount the viewer Wrap `ModelViewer` in a full-height container. The component owns its own layout. `defineObjectBindings` preserves literal IDs; widen mutable state to `Record` so the viewer can return an updated record. Replace the placeholder key below with the framework-specific environment value. ```tsx filename="Viewer.tsx" "use client"; import { useState } from "react"; import { ModelViewer, type ObjectBinding } from "@liveroom-tech/react-immersive"; import { objectBindings as initialBindings } from "./objectBindings"; export default function App() { const [objectBindings, setObjectBindings] = useState>(initialBindings); return (
); } ``` ### Control the viewer from your app `useViewer` connects every viewer hook to `ModelViewer` at once. Spread its `props` onto the viewer, then drive bindings, camera, selection, animations, and scene settings from your own UI. ```tsx filename="ControlledViewer.tsx" "use client"; import { ModelViewer, useViewer } from "@liveroom-tech/react-immersive"; import { objectBindings } from "./objectBindings"; export default function Example() { const viewer = useViewer({ bindings: objectBindings, onAction: (event) => console.log(event), }); const { toggleObjectVisibility } = viewer.bindings; const { resetView, focusObject } = viewer.camera; const { clips, play } = viewer.animations; return (
{clips.map((name) => ( ))}
); } ``` Leave out `bindings` and `useViewer` generates them from the model, like `objectBindings="auto"`. See [`useViewer`](https://react-immersive.liveroom.dev/docs/hooks/use-viewer) for everything it returns, and the other [hooks](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-selection) for wiring individual pieces yourself. --- Canonical: https://react-immersive.liveroom.dev/docs/hooks/use-object-binding-ids Package: @liveroom-tech/react-immersive@5.2.0 # `useObjectBindingIds` Returns a memoized array of every `modelObjectId` across the current `objectBindings` map. ## Usage ```tsx import { useObjectBindingIds } from "@liveroom-tech/react-immersive"; const ids = useObjectBindingIds(objectBindings); // → ["CarBody", "WheelFL", "WheelFR", ...] ``` ## Parameters | Name | Type | Description | | ---------------- | ------------------------------- | ------------------------ | | `objectBindings` | `Record` | The current bindings map | ## Returns `string[]`, a stable memoized array of `modelObjectId` values, one per binding entry. Re-computed only when `objectBindings` changes. ## Example ```tsx import { useState } from "react"; import { ModelViewer, useObjectBindingIds } from "@liveroom-tech/react-immersive"; export default function Example() { const [objectBindings, setObjectBindings] = useState(initialBindings); const modelObjectIds = useObjectBindingIds(objectBindings); return ( <>

Bound objects: {modelObjectIds.join(", ")}

); } ``` --- Canonical: https://react-immersive.liveroom.dev/docs/hooks/use-object-binding Package: @liveroom-tech/react-immersive@5.2.0 # `useObjectBinding` Resolves a single binding from the `objectBindings` map by any of its identifiers. ## Usage ```tsx import { useObjectBinding } from "@liveroom-tech/react-immersive"; const binding = useObjectBinding(objectBindings, "some-object-id"); ``` ## Resolution Order `useObjectBinding` checks, in order: 1. The object binding map **key** (e.g. `"CarBody"`) 2. `binding.id` (e.g. `"some-object-id"`) 3. `binding.modelObjectId` (e.g. `"CarBody"`) This lets you pass any of the three identifiers and get back the same binding. ## Parameters | Name | Type | Description | | ---------------- | ------------------------------- | ------------------------------------------------------------------------- | | `objectBindings` | `Record` | The current bindings map | | `id` | `string` | Any binding identifier: map key, `binding.id`, or `binding.modelObjectId` | ## Returns `ObjectBinding | undefined`, the resolved binding, or `undefined` if not found. ## Example ```tsx const focusedBinding = useObjectBinding(objectBindings, "some-object-id"); if (focusedBinding) { console.log(focusedBinding.label); } ``` --- Canonical: https://react-immersive.liveroom.dev/docs/hooks/use-object-bindings Package: @liveroom-tech/react-immersive@5.2.0 # `useObjectBindings` Own object-binding state and update general fields, materials, textures, transforms, metadata, and visibility from one hook. Every setter uses a functional React state update. Consecutive calls in the same tick compose, including calls across material and visibility concerns. ## Define typed bindings Use `defineObjectBindings` instead of annotating the object as `Record`. It validates every binding while preserving exact keys, IDs, action IDs, groups, and tags for autocomplete and reusable union types. ```tsx import { defineObjectBindings, type ObjectBindingActionId, type ObjectBindingKey, } from "@liveroom-tech/react-immersive/utils"; const initialBindings = defineObjectBindings({ switch_a: { id: "switch-a", modelObjectId: "switch_a", type: "switch", group: "controls", tags: ["interactive"], actions: [ { id: "turn-on", label: "Turn On", type: "command" }, { id: "turn-off", label: "Turn Off", type: "command" }, ], }, }); type BindingKey = ObjectBindingKey; // "switch_a" type SwitchAction = ObjectBindingActionId; // "turn-on" | "turn-off" ``` `defineObjectBindings` returns the same object without cloning or changing it at runtime. BindingBuilder uses this helper in exported TypeScript files automatically. ## Usage ```tsx import { ModelViewer, useObjectBindings, } from "@liveroom-tech/react-immersive"; import { patchBindings, selectBindings, selectBindingKeys, } from "@liveroom-tech/react-immersive/utils"; const { objectBindings, setObjectBindings, updateObjectBindings, selectObjectBindings, selectObjectBindingIds, setMaterial, setBaseColor, setTexture, clearTexture, copyMaterial, resetMaterial, getMaterial, setTransform, resetTransform, updateMetadata, addDecal, updateDecal, removeDecal, hiddenObjects, hiddenObjectIds, hideObject, showObject, toggleObjectVisibility, isObjectHidden, clearHiddenObjects, } = useObjectBindings(initialBindings); ``` ## Parameter | Name | Type | Description | | ----------------- | --------------------------------------------------------------------- | --------------------------- | | `initialBindings` | `Record \| () => Record` | Initial binding state or initializer | ## General patches Patch one object, a group, or an atomic edit batch: ```tsx updateObjectBindings("body", { visible: false }); updateObjectBindings({ group: "wheels" }, { selectable: false }); updateObjectBindings([ { ids: "body", patch: { metadata: { state: "active" } } }, { ids: { tag: "lighting" }, patch: { visible: true } }, ]); ``` Nested objects are merged, arrays and tuples are replaced, and explicit `undefined` values clear fields. Identifiers resolve by binding map key, `binding.id`, then `binding.modelObjectId`. ## Groups, tags, and selectors Store a single group and any number of tags on each binding. BindingBuilder provides fields for both and includes them in exported bindings. ```tsx const initialBindings = { WheelFL: { id: "wheel-front-left", modelObjectId: "WheelFL", type: "wheel", group: "wheels", tags: ["exterior", "configurable"], }, }; setBaseColor({ group: "paint" }, "#ef4444"); hideObject({ tag: "optional" }); updateObjectBindings({ type: "wheel" }, { selectable: false }); const exterior = selectObjectBindings({ tag: "exterior" }); const wheelIds = selectObjectBindingIds({ type: "wheel" }); ``` Group, tag, and type matching is exact and case-sensitive. All mutation helpers that previously accepted an identifier or identifier array also accept these selectors. ## Material operations ```tsx setMaterial("body", { baseColor: "#ef4444", metalness: 0.7, roughness: 0.25, }); setBaseColor("body", "#2563eb"); setTexture({ group: "screens" }, "/images/dashboard.png"); setTexture("panel", "/images/panel-normal.png", "normal"); clearTexture("panel", "normal"); copyMaterial("wheel-front", { group: "wheels" }); const currentMaterial = getMaterial("body"); resetMaterial("body"); ``` Material setters merge with existing material fields. `resetMaterial` removes the complete material override, while `copyMaterial` replaces each target's complete override with an independent copy of the source material. `setTexture` accepts either a URL or `{ path: url }`. It targets the base-color texture by default. Its optional channel supports material maps such as `normal`, `roughness`, `metalness`, `emissive`, `ao`, and `clearcoat`. ## Transform and metadata operations ```tsx setTransform("door-left", { position: [0, 1, 0], rotation: [0, Math.PI / 2, 0], scale: [1, 1, 1], }); resetTransform("door-left"); updateMetadata({ group: "lights" }, { state: "on", circuit: "a" }); ``` Transforms are persistent local-space binding state. Rotations use radians. Missing transform fields retain the model-authored value, and `resetTransform` restores the complete model-authored local transform. Metadata updates merge recursively with existing metadata. ## Visibility operations ```tsx hideObject("body"); showObject({ group: "wheels" }); toggleObjectVisibility("spoiler"); clearHiddenObjects(); const hidden = isObjectHidden("body"); ``` `hideObject`, `showObject`, and `toggleObjectVisibility` accept one identifier, an identifier array, or a group, tag, or type selector. `hiddenObjects` and `hiddenObjectIds` are derived from the current binding state. ## Returns | Name | Description | | ------------------------ | ------------------------------------------------ | | `objectBindings` | Current object-binding state | | `setObjectBindings` | React state dispatcher for controlled viewers | | `updateObjectBindings` | Update one object, a group, or an atomic edit batch | | `selectObjectBindings` | Return bindings matching an identifier or selector | | `selectObjectBindingIds` | Return binding IDs matching an identifier or selector | | `setMaterial` | Merge material fields | | `setBaseColor` | Set the material base color | | `setTexture` | Set a base-color or material-map texture | | `clearTexture` | Clear one texture channel | | `copyMaterial` | Copy one complete material override to targets | | `resetMaterial` | Remove the material override | | `getMaterial` | Read the current material override | | `setTransform` | Merge a persistent local transform | | `resetTransform` | Restore the model-authored local transform | | `updateMetadata` | Merge metadata fields | | `addDecal` | Print an image or text on the targets; returns the decal's id. See [Decals](https://react-immersive.liveroom.dev/docs/reference/model-viewer#decals) | | `updateDecal` | Change a decal by id (size, rotation, text, ...) | | `removeDecal` | Remove a decal by id | | `hiddenObjects` | Map of hidden binding keys | | `hiddenObjectIds` | IDs of currently hidden bindings | | `hideObject` | Hide one object or a group | | `showObject` | Show one object or a group | | `toggleObjectVisibility` | Toggle each target's current visibility | | `isObjectHidden` | Check whether an object is hidden | | `clearHiddenObjects` | Show every bound object | ## Externally owned state When a parent component or store owns the bindings, use the pure `patchBindings` utility instead: ```tsx const next = patchBindings(objectBindings, "body", { style: { material: { baseColor: "#ef4444" } }, }); const exterior = selectBindings(objectBindings, { tag: "exterior" }); const exteriorKeys = selectBindingKeys(objectBindings, { tag: "exterior" }); onObjectBindingsChange(next); ``` `selectBindings` and `selectBindingKeys` provide the same selector queries without React state ownership. --- Canonical: https://react-immersive.liveroom.dev/docs/hooks/use-scene-config Package: @liveroom-tech/react-immersive@5.2.0 # `useSceneConfig` Own scene-wide configuration state and update nested settings without repetitive object spreads. ## Usage ```tsx import { ModelViewer, useSceneConfig, } from "@liveroom-tech/react-immersive"; import { sceneConfig as initialSceneConfig } from "./sceneConfig"; const { sceneConfig, setSceneConfig, updateSceneConfig, resetSceneConfig, lights, } = useSceneConfig(initialSceneConfig); const enableUvChecker = () => { updateSceneConfig({ model: { renderer: "uv-checker" }, }); updateSceneConfig({ lighting: { ambient: { intensity: 0.5 }, }, }); }; ``` Every update runs through a functional React state updater, so consecutive calls in the same tick compose safely. ## Scene lights `lights` is a namespaced controller for persistent scene lights. Its operations use the same functional state pipeline as `updateSceneConfig`, so several light changes in one event compose safely. ```tsx lights.add({ id: "bulb-light", type: "point", color: "#ffd27d", intensity: 4, distance: 5, }); lights.setIntensity("bulb-light", 6); lights.setColor("bulb-light", "#fff1c2"); lights.toggle("bulb-light"); lights.attach("bulb-light", { objectId: "bulb-filament", offset: [0, -0.2, 0], }); ``` An attached light follows the model object's complete world transform. The `objectId` can be a binding key, binding id, `modelObjectId`, or raw model node name. The offset is measured in the object's local space. Use `lights.detach` to return to the light's stored world position. Pass `sceneConfig` to `ModelViewer`. To list lights in an initial config, `sceneLight` from `@liveroom-tech/react-immersive/utils` fills in each light's defaults. The controller provides: - `items` and `get` - `add`, `update`, and `remove` - `setIntensity` and `setColor` - `show`, `hide`, and `toggle` - `attach` and `detach` ## Updating from current state Pass a function when an update depends on the current configuration: ```tsx const speedUpWalk = () => updateSceneConfig((current) => ({ animations: { clips: current.animations.clips.map((clip) => clip.sourceName === "Walk" ? { ...clip, speed: 1.5 } : clip, ), }, })); ``` ## Merge behavior - Nested configuration objects are merged recursively. - Arrays and tuples are replaced wholesale. - The input configuration and patch are not mutated. - `resetSceneConfig` restores the configuration supplied when the hook mounted. - `setSceneConfig` remains available when the complete configuration must be replaced. Arrays include lights, animation clips, annotations, and cinematic waypoints. Supply the complete replacement array when updating one of these fields. ## Returns | Name | Description | | ------------------- | ------------------------------------------------ | | `sceneConfig` | Current scene configuration | | `setSceneConfig` | React state dispatcher for complete replacements | | `updateSceneConfig` | Merge a nested patch into the current state | | `resetSceneConfig` | Restore the initial scene configuration | | `lights` | CRUD, visibility, color, intensity, and attachment operations | ## Externally owned state Use the pure `patchSceneConfig` function when a store or parent owns the state: ```tsx import { patchSceneConfig } from "@liveroom-tech/react-immersive/utils"; const next = patchSceneConfig(sceneConfig, { background: { enabled: true, type: "color", color: "#111827", }, }); onSceneConfigChange(next); ``` The library also exports `DEFAULT_SCENE_CONFIG` and the `SceneConfigPatch`, `SceneConfigUpdate`, `SceneConfigController`, `SceneLightInput`, `SceneLightPatch`, and `SceneLightsController` types. --- Canonical: https://react-immersive.liveroom.dev/docs/hooks/use-shareable-viewer-state Package: @liveroom-tech/react-immersive@5.2.0 # `useShareableViewerState` Serializes the current camera angle, hidden objects, and selection into a URL hash so a configured view can be copied as a link and restored exactly when opened. Try the complete [Shareable View / Deep Link demo](https://react-immersive.liveroom.dev/examples/shareable-view) to create a walkthrough, copy its link, and open it in a new tab. The encoded state covers camera + visibility + selection for the **currently loaded model** only, it doesn't carry the model URL itself, so combine it with whatever query param already identifies which model to load. ## Usage ```tsx import { ModelViewer, useShareableViewerState, useViewerCamera, useViewerSelection, } from "@liveroom-tech/react-immersive"; const [objectBindings, setObjectBindings] = useState(initialBindings); const { cameraState, setCameraState, handleCameraChange, handleViewerReady } = useViewerCamera(); const { selectedObjectBinding, setSelectedObjectBinding, handleObjectSelect } = useViewerSelection(); const { getShareUrl, restoreFromUrl } = useShareableViewerState({ cameraState, setCameraState, objectBindings, onObjectBindingsChange: setObjectBindings, selectedObjectBinding, setSelectedObjectBinding, }); { handleViewerReady(viewer); restoreFromUrl(); }} refitOnResize={false} />; ; ``` `selectedObject={selectedObjectBinding}` is required for a restored selection to actually appear in the viewer. Without it, `ModelViewer` manages selection internally and `setSelectedObjectBinding` only updates state used for serialization, it has no visible effect on its own. ## Parameters | Name | Type | Description | | -------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------ | | `cameraState` | `ViewerCameraState \| null` | From `useViewerCamera()` | | `setCameraState` | `(state: ViewerCameraState, enableTransition?: boolean) => Promise` | From `useViewerCamera()`; used to restore a decoded camera state | | `objectBindings` | `Record` | The current bindings map (owned by consumer) | | `onObjectBindingsChange` | `(next: Record) => void` | State setter from `useState` | | `selectedObjectBinding` | `ObjectBinding \| null` | From `useViewerSelection()` | | `setSelectedObjectBinding` | `(binding: ObjectBinding \| null) => void` | From `useViewerSelection()` | | `paramKey` | `string` | Hash key the state is stored under. Default `"view"` | | `debounceMs` | `number` | Debounce before the URL hash updates after a state change. Default `400` | | `autoSync` | `boolean` | Auto-write state to the URL hash on change. Default `true` | ## Returns | Name | Type | Description | | ---------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | | `getShareUrl` | `() => string` | Builds the full share URL for the current state, without touching the address bar | | `syncUrl` | `() => void` | Writes the current state into the URL hash via `history.replaceState` (no navigation, no history entry) | | `restoreFromUrl` | `() => Promise` | Reads state from the current URL hash, if any, and applies it; resolves `true` once all of it is applied. Safe to call from `onViewerReady`; only a new hash snapshot re-applies | ## Notes - By default the hook keeps the URL hash in sync automatically (debounced) as the camera moves or visibility/selection changes, so the address bar is always a working link to the current view. Pass `autoSync: false` to only update the hash explicitly, e.g. from a "Copy link" button via `syncUrl` or `getShareUrl`. - **Auto-sync stays dormant until `restoreFromUrl` has been called at least once.** `restoreFromUrl` only resolves once the viewer is ready (after the model finishes loading), which takes far longer than the sync debounce, without this guard, auto-sync's first write would overwrite an incoming shared link with the app's default state before it was ever restored. If you use `autoSync` but don't need restore-on-load, still call `restoreFromUrl()` once (it's a no-op when there's nothing in the hash), otherwise auto-sync never activates. - `restoreFromUrl` depends on `viewer.raw.controls` being available, so call it from inside (or after) `onViewerReady`, not before the viewer has mounted. - When the bindings come from the model, as with `useViewer` or `objectBindings="auto"`, they exist only once it has loaded, after the first `onViewerReady`. A link that hides or selects objects then restores its camera at once and waits for them: `onViewerReady` runs again when the bindings arrive, and that call hides and selects them. The hash isn't updated while it waits. It's also safe to call from there even though `onViewerReady` fires repeatedly (camera changes, model load, binding changes): only the first call for a given hash snapshot does anything, so a newer permalink can still be applied later in the same tab. - The hook also listens for `hashchange`, so an already-open tab can respond when the URL hash is replaced with a newer shared link. - Camera restoration snaps instantly (no fly-in transition) by design, so opening a shared link doesn't make visitors wait through an animated camera move. --- Canonical: https://react-immersive.liveroom.dev/docs/hooks/use-viewer-actions Package: @liveroom-tech/react-immersive@5.2.0 # `useViewerActions` Executes declarative object-binding, scene, visibility, camera, and animation effects from an object's actions. Use the same `handleAction` callback for `ModelViewer` actions and `runAction` for controls elsewhere in your app. ## Usage ```tsx import { ModelViewer, useObjectBindings, useSceneConfig, useViewerActions, useViewerAnimations, useViewerCamera, } from "@liveroom-tech/react-immersive"; const bindings = useObjectBindings(initialBindings); const scene = useSceneConfig(initialSceneConfig); const camera = useViewerCamera(); const animations = useViewerAnimations(); const { getActionsForObject, handleAction, runAction } = useViewerActions({ bindings, scene, camera, animations, onAction: (event) => console.log("completed", event.action.id), }); ; // Run the same exported action from your own button. await runAction("switch-a", "turn-on"); ``` ## Declare effects Effects run in the order they appear. An action can combine any of the supported effect categories. ```tsx const initialBindings = { switch_a: { id: "switch-a", modelObjectId: "switch_a", actions: [ { id: "turn-on", label: "Turn On", type: "command", effects: [ { target: { group: "circuit-a" }, objectBindings: { style: { material: { emissive: "#fde047", emissiveIntensity: 3, }, }, metadata: { state: "on" }, }, }, { sceneConfig: { lighting: { ambient: { intensity: 0.2 } }, }, }, { target: { tag: "warning" }, visibility: "show" }, { camera: { type: "focus-object" } }, { animation: { type: "play", clip: "PowerOn" } }, ], }, ], }, }; ``` ### Supported effects | Category | Shape | Controller used | | --- | --- | --- | | Object bindings | `{ target, objectBindings: patch }` | `useObjectBindings` | | Scene | `{ sceneConfig: patch }` | `useSceneConfig` | | Visibility | `{ target, visibility: "show" \| "hide" \| "toggle" }` | `useObjectBindings` | | Camera | `{ camera: action }` | `useViewerCamera` | | Animation | `{ animation: action }` | `useViewerAnimations` | | Isolation | `{ isolation: ViewerIsolation \| null }` | `useViewerIsolation` | Object and visibility targets accept an object id, an array of ids, `{ group }`, `{ tag }`, or `{ type }`, just like `useObjectBindings`. Camera actions are `focus-object`, `fit-scene`, `reset`, `set-target`, and `set-state`. Animation actions are `play`, `pause`, `stop`, `set-speed`, and `seek`. `play` takes the same `alongside`, `fade`, and `reverse` options as `useViewerAnimations().play`, so an action can open a door without stopping other clips: `{ animation: { type: "play", clip: "DoorOpen", alongside: true } }`. The others take an optional `clip`, and act on every active clip without one. ## Parameters | Name | Required | Description | | --- | --- | --- | | `bindings` | Yes | The controller returned by `useObjectBindings` | | `scene` | No | The controller returned by `useSceneConfig` | | `camera` | No | The controller returned by `useViewerCamera` | | `animations` | No | The controller returned by `useViewerAnimations` | | `onAction` | No | Observer called after the declared effects have run | If an optional controller is omitted, effects for that category are skipped and the remaining effects still run. ## Returns | Name | Description | | --- | --- | | `getActionsForObject(id)` | Resolves actions by binding key, `binding.id`, or `binding.modelObjectId` | | `handleAction(event)` | Executes an action event. Pass it directly to `ModelViewer.onAction` | | `runAction(id, actionId)` | Finds and executes an action, then resolves to its event, or `null` when it cannot be found | ## Built-in viewer actions `ModelViewer` still owns the picker UI for `change-color` and `change-material`, plus its built-in `toggle-visibility` behavior. Declarative effects run in addition to that behavior when `handleAction` is connected. Do not declare an equivalent visibility effect on a built-in `toggle-visibility` action, because it would toggle twice. Calling `runAction` executes declarative effects only. It does not invoke viewer-owned behavior such as opening a picker or running the built-in visibility toggle. For programmatic visibility, call the bindings controller directly or declare a custom action with a visibility effect. --- Canonical: https://react-immersive.liveroom.dev/docs/hooks/use-viewer-animations Package: @liveroom-tech/react-immersive@5.2.0 # `useViewerAnimations` Controls animation playback for supported GLB/GLTF, FBX, and USDZ models that contain embedded animations. ## Usage ```tsx import { useViewerAnimations } from "@liveroom-tech/react-immersive"; const { clips, currentClip, isPlaying, speed, time, duration, play, pause, stop, setSpeed, seek, handleAnimationsReady, } = useViewerAnimations(); ``` ## Returns | Name | Type | Description | |------|------|-------------| | `clips` | `string[]` | Animation clip names found in the model file | | `currentClip` | `string \| null` | The clip played most recently, while it's active, or `null` | | `isPlaying` | `boolean` | `true` while any clip is playing (not paused, finished, or stopped) | | `speed` | `number` | Playback speed of `currentClip` (default `1`) | | `time` | `number` | Live playback position of `currentClip` in seconds (updates while playing) | | `duration` | `number` | Length of `currentClip` in seconds | | `active` | `AnimationClipState[]` | Every active clip (playing, paused, or finished) with its `status`, `time`, `duration`, `speed`, and `reverse`, most recently played last | | `play` | `(clipName: string, options?: AnimationPlayOptions) => void` | Starts a clip, replacing those already playing unless `alongside` is set; `fade` blends over that many seconds, `reverse` plays it backwards. A paused clip resumes | | `pause` | `(clipName?: string) => void` | Pauses one clip, or every active clip | | `stop` | `(clipName?: string) => void` | Stops one clip, or every active clip, and resets the pose | | `setSpeed` | `(speed: number, clipName?: string) => void` | Changes the speed of one clip, or of every active clip and those played later without their own | | `seek` | `(time: number, clipName?: string) => void` | Scrubs one clip, or every active clip, to an absolute time in seconds (works while playing or paused), clamped to the clip length | | `handleAnimationsReady` | `(controls: AnimationControls) => void` | Callback to pass to `onAnimationsReady` | ## Playback Controls Example ```tsx const { clips, currentClip, isPlaying, play, pause, stop, setSpeed } = useViewerAnimations(); // Play/pause toggle // Stop // Speed slider setSpeed(Number(e.target.value))} /> // Clip selector {clips.map((name) => ( ))} ``` ## Playing Several Clips Several clips can play at once. `play` replaces the clips already playing unless you pass `alongside: true`, and the other controls act on one clip when you name it, or on every active clip when you don't: ```tsx const animations = useViewerAnimations(); // Open both doors together, then close the left one. animations.play("DoorLeftOpen", { alongside: true }); animations.play("DoorRightOpen", { alongside: true }); animations.play("DoorLeftOpen", { alongside: true, reverse: true }); // Blend from walking to running over half a second. animations.play("Run", { fade: 0.5 }); animations.pause("Wheels"); // one clip animations.stop(); // every clip // What's active, and how far along each clip is animations.active.map(({ clip, status, time }) => `${clip}: ${status} at ${time}s`); ``` A clip whose `loopMode` is `"once"` holds its last pose when it finishes (`status: "finished"`) until it's stopped or played again, so an opened door stays open. `"ping-pong"` plays forwards then backwards, repeatedly. Playing a clip that's already playing in the same direction leaves it running; playing it the other way turns it around where it is. `onAnimationsReady` is called even when the model has no animations, with an empty `clips` array and no-op control functions. --- Canonical: https://react-immersive.liveroom.dev/docs/hooks/use-viewer-camera Package: @liveroom-tech/react-immersive@5.2.0 # `useViewerCamera` Provides declarative initial framing, live camera state, named presets, and camera movement helpers without direct `CameraControls` calls. ## Usage ```tsx import { ModelViewer, useViewerCamera } from "@liveroom-tech/react-immersive"; const camera = useViewerCamera({ initialPosition: [0, 2, 8], initialTarget: [0, 1.2, 0], presets: { detail: { position: [2, 1.2, 2.5], target: [0, 1.1, 0], fov: 35 }, }, }); const { cameraState, resetView, focusObject, fitScene, setCameraTarget, lookAt, orbitTo, dollyTo, savePreset, goToPreset, handleCameraChange, handleViewerReady, } = camera; ``` ## Returns | Name | Type | Description | | -------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------- | | `cameraState` | `ViewerCameraState \| null` | Latest camera `position`, `target`, `fov`, `zoom` | | `cameraPresets` | `Record` | Current named views | | `resetView` | `() => Promise` | Resets the controls to their initial saved state | | `focusObject` | `(id: string) => Promise` | Fits the camera to a binding by key, `binding.id`, or `binding.modelObjectId` | | `fitScene` | `() => Promise` | Fits the camera to the whole loaded scene | | `setCameraTarget` | `(target, transition?) => Promise` | Sets the camera target to a given `[x, y, z]` | | `setCameraState` | `(state, transition?) => Promise` | Restores a complete camera state | | `lookAt` | `(position, target, transition?) => Promise` | Moves the camera position and target together | | `orbitTo` | `(azimuth, polar, transition?) => Promise` | Orbits to angles in degrees (azimuth 0 = front, 90 = right; polar 0 = straight down, 90 = level) | | `dollyTo` | `(distance, transition?) => Promise` | Moves to a distance from the current target | | `savePreset` | `(name: string) => ViewerCameraState \| null` | Saves the current view under a name | | `goToPreset` | `(name, transition?) => Promise` | Moves to a named view | | `handleCameraChange` | `(state: ViewerCameraState) => void` | Callback to pass to `onCameraChange` | | `handleViewerReady` | `(viewer: ViewerReadyState) => void` | Pass to `onViewerReady`, or compose with `useViewerConnection` | ## Example ```tsx
{JSON.stringify(cameraState, null, 2)}
``` Each initial position or target is optional and is applied once for every loaded model. Set `initialTransition: true` to animate into it. The default is `false`. `cameraPresets` contains the current named preset record. ## Viewer-ready capture methods `ViewerReadyState` includes `captureImage`, `captureVideo`, and `invalidate`. Compose `camera` with [`useViewerConnection`](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-connection) to capture PNGs or record cinematic/turntable video while keeping camera controls connected. After [WebGL recovery](https://react-immersive.liveroom.dev/docs/reference/model-viewer#webgl-context-recovery), `handleViewerReady` reconnects to the rebuilt viewer. --- Canonical: https://react-immersive.liveroom.dev/docs/hooks/use-viewer-connection Package: @liveroom-tech/react-immersive@5.2.0 # `useViewerConnection` Combines every feature that needs `ModelViewer.onViewerReady` into one typed connection. Camera, runtime effects, screenshots, video export, and custom setup callbacks can share the same viewer without manual callback wiring. ## Usage ```tsx import { ModelViewer, useViewerCamera, useViewerConnection, useViewerEffects, } from "@liveroom-tech/react-immersive"; const camera = useViewerCamera({ initialTarget: [0, 1.2, 0], }); const effects = useViewerEffects(); const connection = useViewerConnection( camera, effects, (viewer) => { console.log("Loaded scene", viewer.raw.scene); }, ); ; ``` Consumers run in the order supplied. A consumer can be a controller with a `handleViewerReady` method, a callback, `null`, or `undefined`. ## Screenshots and direct access The connection keeps the latest viewer in a ref, so camera changes do not cause extra React renders. ```tsx const dataUrl = await connection.captureImage({ width: 1920, height: 1080, transparent: true, }); connection.invalidate(); const viewer = connection.getViewer(); ``` `captureImage` requires `preserveDrawingBuffer` on the connected `ModelViewer` and rejects with a clear error before the viewer is ready or when framebuffer preservation is disabled. `invalidate` returns `false` before connection and `true` after requesting a frame. ## Video recording `captureVideo(options?: CaptureVideoOptions): Promise` records one turntable revolution or cinematic pass from the connected viewer. It is also available through `useViewer().connection`. Call it from a user action after loading and initial framing, rather than from `onViewerReady`, which runs repeatedly as the camera moves. ```tsx const abort = new AbortController(); const clip = await connection.captureVideo({ mode: "turntable", format: "auto", duration: 12, fps: 30, signal: abort.signal, }); // Upload the Blob, or preview it with URL.createObjectURL(clip). // abort.abort() cancels and discards an in-progress recording. ``` Video recording does not require `preserveDrawingBuffer`. The browser must support `canvas.captureStream`, and `MediaRecorder` for WebM/MP4; Auto prefers WebM, then MP4. `format: "gif"` makes an animated GIF instead, `gifWidth` pixels wide (default 480) at 15 fps by default. Explicit unsupported formats reject. Video uses the canvas's current pixel resolution. Recording runs in real time, so keep the tab visible. It restores the previous camera and controls when it ends. Audio and HTML overlays are not included. Calls before framing, during XR, or during another recording reject. See [Video export](https://react-immersive.liveroom.dev/docs/reference/model-viewer#video-export) and [`CaptureVideoOptions`](https://react-immersive.liveroom.dev/docs/reference/types#capturevideooptions) for all options. ## WebGL recovery After context loss, the viewer can rebuild automatically or through **Reload viewer**. `handleViewerReady` receives the rebuilt viewer and replaces the connection's live controls and node references. Custom setup consumers run again; use those fresh references instead of retaining objects from the old scene. ## Returns | Name | Description | | --- | --- | | `handleViewerReady` | The single callback to pass to `ModelViewer.onViewerReady` | | `getViewer` | Return the latest `ViewerReadyState`, or `null` | | `captureImage` | Capture a PNG data URL from the connected viewer | | `captureVideo` | Record one cinematic pass or turntable revolution as a video `Blob` | | `invalidate` | Request a render frame from the connected viewer | --- Canonical: https://react-immersive.liveroom.dev/docs/hooks/use-viewer-effects Package: @liveroom-tech/react-immersive@5.2.0 # `useViewerEffects` Create high-frequency material, transform, and lighting effects without accessing Three.js objects or putting animation values in React state. The hook runs one request-animation-frame scheduler, invalidates demand-rendered viewers automatically, and restores runtime changes when it unmounts. ## Flicker example ```tsx import { useEffect } from "react"; import { ModelViewer, useViewerEffects, type ViewerEffectChannels, } from "@liveroom-tech/react-immersive"; const channels: ViewerEffectChannels = { materials: [ { targets: { group: "filaments" }, values: { emissiveIntensity: [0.02, 8] }, }, ], lights: [ { lights: ["light-left", "light-right"], intensity: [0, 180], }, ], }; function Bulbs() { const { addPointLight, flicker, handleViewerReady, overrideEffect, stopEffect, } = useViewerEffects(); useEffect(() => { const id = flicker(channels, { id: "bulbs", minLevel: 0.1, maxLevel: 1, blackoutChance: 0.1, synchronized: true, }); return () => stopEffect(id); }, [flicker, stopEffect]); return ( <> { handleViewerReady(viewer); addPointLight({ id: "light-left", target: "filament-left", color: "#ff9a3c", intensity: 0, distance: 30, }); }} /> ); } ``` Material and transform targets resolve by binding map key, `binding.id`, `modelObjectId`, or a group, tag, or type selector. Runtime point lights attach to the target object and follow its transform. ## Effect channels The same channel structure works with `transition`, `pulse`, and `flicker`: ```tsx const channels: ViewerEffectChannels = { materials: [ { targets: "warning-light", values: { emissiveIntensity: [0, 6], opacity: [0.4, 1], }, }, ], transforms: [ { targets: "door", values: { rotation: [[0, 0, 0], [0, 1.57, 0]], }, }, ], lights: [ { lights: "warning-point-light", intensity: [0, 120], }, ], }; transition(channels, { duration: 500, easing: "ease-out" }); pulse(channels, { duration: 900, repeat: Infinity }); flicker(channels, { synchronized: false }); timeline( [ { channels, duration: 500, hold: 200 }, { channels: closingChannels, duration: 500 }, ], { repeat: Infinity }, ); ``` When `synchronized` is `true`, every target receives the same level. When it is `false`, array items receive independent levels or phases while aligned channel arrays continue to describe the same logical object. ## Immediate runtime operations ```tsx setMaterial("screen", { emissive: "#22d3ee", emissiveIntensity: 4, }); setMaterial({ tag: "warning" }, { emissive: "#ef4444", emissiveIntensity: 4, }); setTransform("door", { rotation: [0, Math.PI / 2, 0], position: [0, 0.2, 0], }); const position = getWorldPosition("door"); ``` Selectors can target several bindings for material and transform operations. `getWorldPosition` remains a single-object lookup. Transform rotations use radians. Immediate changes are snapshotted the first time they are applied so `resetRuntime` can restore the original values. ## Point lights ```tsx addPointLight({ id: "status-light", target: "status-led", color: "#22c55e", intensity: 80, distance: 12, decay: 2, offset: [0, 0.02, 0], }); updatePointLight("status-light", { intensity: 140 }); removePointLight("status-light"); ``` These lights are created and disposed by react-immersive. Application code does not import `PointLight` or render React Three Fiber light elements. ## Returns | Name | Description | | --- | --- | | `handleViewerReady` | Connect the hook to `ModelViewer.onViewerReady` | | `setMaterial` | Apply an immediate runtime material patch | | `setTransform` | Apply position, rotation, scale, or visibility at runtime | | `getWorldPosition` | Read a bound object's world position as a tuple | | `addPointLight` | Create a point light attached to a bound object | | `updatePointLight` | Update or reattach a runtime point light | | `removePointLight` | Dispose a runtime point light | | `transition` | Run a finite eased channel transition | | `timeline` | Sequence transitions and holds, optionally repeating | | `pulse` | Run repeating channel pulses | | `flicker` | Run synchronized or independent randomized flicker | | `overrideEffect` | Temporarily force an effect level, such as a blackout or surge | | `stopEffect` | Stop one effect by ID | | `stopAllEffects` | Stop every active effect | | `resetRuntime` | Stop effects, restore snapshots, and remove runtime lights | ## Composing viewer-ready handlers Use `useViewerConnection` when camera or other viewer hooks also need the callback: ```tsx import { useViewerCamera, useViewerConnection, useViewerEffects, } from "@liveroom-tech/react-immersive"; const camera = useViewerCamera(); const effects = useViewerEffects(); const connection = useViewerConnection(camera, effects); ; ``` --- Canonical: https://react-immersive.liveroom.dev/docs/hooks/use-viewer-hover Package: @liveroom-tech/react-immersive@5.2.0 # `useViewerHover` Tracks which mesh the user is currently hovering in the viewer. ## Usage ```tsx import { useViewerHover } from "@liveroom-tech/react-immersive"; const { hoveredObjectBinding, handleHoveredObject } = useViewerHover(); // hoveredObjectBinding is the currently hovered binding or null ``` ## Returns | Name | Type | Description | |------|------|-------------| | `hoveredObjectBinding` | `ObjectBinding \| null` | The currently hovered binding, or `null` | | `handleHoveredObject` | `(binding: ObjectBinding \| null) => void` | Callback to pass to `onObjectHover` | --- Canonical: https://react-immersive.liveroom.dev/docs/hooks/use-viewer-isolation Package: @liveroom-tech/react-immersive@5.2.0 # `useViewerIsolation` State for focusing on some objects: `ModelViewer` ghosts (or hides) everything else, and clicks pass through to what's isolated. `useViewer` includes it as `viewer.isolation`. ## Usage ```tsx import { useViewerIsolation } from "@liveroom-tech/react-immersive"; const { isolation, isolate, clearIsolation, handleIsolationChange } = useViewerIsolation(); ``` ## Returns | Name | Type | Description | |------|------|-------------| | `isolation` | `ViewerIsolation \| null` | The current isolation, or `null` when everything shows normally | | `isolate` | `(target: ObjectBindingTarget, options?: { mode?, ghostOpacity? }) => void` | Isolates the target's objects: an id, a list of ids, `{ group }`, `{ tag }`, or `{ type }` | | `clearIsolation` | `() => void` | Shows everything again | | `handleIsolationChange` | `(isolation: ViewerIsolation \| null) => void` | Callback to pass to `onIsolationChange` | See [Isolating objects](https://react-immersive.liveroom.dev/docs/reference/model-viewer#isolating-objects) for how ghosting, hiding, and the built-in controls behave. --- Canonical: https://react-immersive.liveroom.dev/docs/hooks/use-viewer-model Package: @liveroom-tech/react-immersive@5.2.0 # `useViewerModel` Provides model lifecycle state and scene metadata: loading status, errors, scene bounds, object count, and the list of bindable objects. ## Usage ```tsx import { useViewerModel } from "@liveroom-tech/react-immersive"; const { isLoading, isReady, error, bounds, objectCount, materialVariants, materialVariantGroups, objects, handleModelLoaded, handleObjectCatalog, handleLoadError, } = useViewerModel(); ``` ## Returns | Name | Type | Description | |------|------|-------------| | `isLoading` | `boolean` | `true` until the model has loaded or failed | | `isReady` | `boolean` | `true` after a successful model load | | `error` | `unknown \| null` | The most recent load/render error, or `null` | | `bounds` | `SceneBounds \| null` | Scene bounds: `min`, `max`, `center`, `size` | | `objectCount` | `number` | Number of mesh objects in the loaded scene | | `materialVariants` | `string[]` | Names of the model's glTF material variants; empty when it has none. See [`showMaterialVariants`](https://react-immersive.liveroom.dev/docs/reference/model-viewer#showmaterialvariants) | | `materialVariantGroups` | `MaterialVariantGroup[]` | The same variants as option groups, each with a `name` (or `null`) and its `variants` (`name`, display `label`, and a `swatch` colour when the material is a plain colour) | | `objects` | `ViewerObjectInfo[]` | Every bindable object in the model; filled once `handleObjectCatalog` is passed to `onObjectCatalog` | | `handleModelLoaded` | `(model: ViewerModelInfo) => void` | Callback to pass to `onModelLoaded` | | `handleObjectCatalog` | `(objects: ViewerObjectInfo[]) => void` | Callback to pass to `onObjectCatalog` | | `handleLoadError` | `(error: unknown) => void` | Callback to pass to `onLoadError` | ## Example: Loading States ```tsx const { isLoading, isReady, error } = useViewerModel(); if (isLoading) return

Loading model…

; if (error) return

Failed to load: {String(error)}

; if (isReady) return

Model ready with {objectCount} objects

; ``` ## Example: Listing the model's objects ```tsx const { objects, handleObjectCatalog } = useViewerModel(); ;
    {objects.map((object) => (
  • {object.name} ({object.triangles} triangles)
  • ))}
; ``` --- Canonical: https://react-immersive.liveroom.dev/docs/hooks/use-viewer-selection Package: @liveroom-tech/react-immersive@5.2.0 # `useViewerSelection` Manages selection state for the viewer. Pass `handleObjectSelect` to `onObjectSelect` and consume `selectedObjectBinding` wherever you need to react to the user's selection. ## Usage ```tsx import { useViewerSelection } from "@liveroom-tech/react-immersive"; const { selectedObjectBinding, setSelectedObjectBinding, handleObjectSelect, clearSelection, } = useViewerSelection(); ``` ## Returns | Name | Type | Description | |------|------|-------------| | `selectedObjectBinding` | `ObjectBinding \| null` | The currently selected binding, or `null` | | `setSelectedObjectBinding` | `(binding: ObjectBinding \| null) => void` | Set selection imperatively | | `handleObjectSelect` | `(binding: ObjectBinding \| null) => void` | Callback to pass to `onObjectSelect` | | `clearSelection` | `() => void` | Clears the current selection | --- Canonical: https://react-immersive.liveroom.dev/docs/hooks/use-viewer Package: @liveroom-tech/react-immersive@5.2.0 # `useViewer` Every viewer hook in one, already connected to each other and to `ModelViewer`. Spread its `props` onto the viewer and control everything else by name. ## Usage ```tsx import { ModelViewer, useViewer } from "@liveroom-tech/react-immersive"; export default function Configurator() { const viewer = useViewer(); return ( <> ); } ``` Props you set on `ModelViewer` after the spread take precedence, so other props (`showViewGizmo`, `sceneConfig`, and so on) work as usual. ## Options All options are optional. | Name | Type | Description | |------|------|-------------| | `bindings` | `Record \| (() => Record) \| "auto"` | The starting bindings. Defaults to `"auto"`: a binding for every mesh, generated when the model loads | | `sceneConfig` | `SceneConfig \| (() => SceneConfig)` | The starting scene config | | `camera` | `UseViewerCameraOptions` | Initial camera position, target, and named presets | | `onAction` | `(event: ObjectActionEvent) => void` | Called for every action button, after built-in behavior and declared `effects` | | `onObjectSelect` | `(binding: ObjectBinding \| null) => void` | Called when the selection changes | | `onObjectHover` | `(binding: ObjectBinding \| null) => void` | Called when the hovered object changes | | `onLoadError` | `(error: unknown) => void` | Called if the model fails to load | With `"auto"` bindings, a binding the viewer already has for an object keeps its edits when the model reloads; objects with new names get fresh bindings. ## Returns | Name | Same as | Use it for | |------|---------|------------| | `props` | | Spread onto `ModelViewer` | | `bindings` | [`useObjectBindings`](https://react-immersive.liveroom.dev/docs/hooks/use-object-bindings) | Colors, textures, materials, visibility, transforms, metadata | | `selection` | [`useViewerSelection`](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-selection) | The selected object, and selecting from your own UI | | `hover` | [`useViewerHover`](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-hover) | The hovered object | | `camera` | [`useViewerCamera`](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-camera) | Focusing objects, resetting, saved views | | `model` | [`useViewerModel`](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-model) | Loading state, errors, bounds, and the model's `objects` | | `animations` | [`useViewerAnimations`](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-animations) | Playing the model's animation clips | | `actions` | [`useViewerActions`](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-actions) | Running binding actions from code | | `scene` | [`useSceneConfig`](https://react-immersive.liveroom.dev/docs/hooks/use-scene-config) | Lighting, environment, background, post-processing | | `isolation` | [`useViewerIsolation`](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-isolation) | Focusing on some objects, ghosting or hiding the rest | | `effects` | [`useViewerEffects`](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-effects) | Transitions, pulses, flicker, runtime lights | | `connection` | [`useViewerConnection`](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-connection) | The live viewer, `captureImage`, and `captureVideo` | Each part is that hook's full result, so everything the hook offers is available: `viewer.camera.focusObject("door")`, `viewer.model.objects`, `viewer.animations.play("Open")`, `viewer.scene.lights.setIntensity("key", 2)`. `useViewer` passes `sceneConfig` to `ModelViewer` only once you've supplied one or changed the scene through `viewer.scene`, so the default scene behaves exactly as if no scene config were passed. ## When to use the individual hooks Use the hooks one by one when state lives elsewhere: for example, when a store owns the bindings and passes them to `ModelViewer`, or when you only need one piece, such as the camera. --- Canonical: https://react-immersive.liveroom.dev/docs Package: @liveroom-tech/react-immersive@5.2.0 # `@liveroom-tech/react-immersive` Documentation baseline: **5.2.0 · React 19**. [Version and migration notes](https://react-immersive.liveroom.dev/docs/releases). [AI Agent Guide](https://react-immersive.liveroom.dev/docs/agent-guide) · [Markdown index](https://react-immersive.liveroom.dev/llms.txt) · [Vite setup](https://react-immersive.liveroom.dev/docs/frameworks/vite) · [Next.js setup](https://react-immersive.liveroom.dev/docs/frameworks/nextjs). A React 3D model viewer for interactive 3D assets, mesh selection, object bindings, built-in UI panels, and a full suite of hooks for camera, animations, visibility, and more. ## Features - **GLB/GLTF, OBJ/MTL, FBX, USDZ, STL, and PLY rendering** via React Three Fiber - **3D Tiles streaming** for large processed scenes, with object catalogs and configurable client caching - **Mesh selection** by object name with built-in side panels - **Object bindings**, drive transforms, color, texture, visibility, and actions from a plain data record - **Object move & rotate controls**, geometry-centered handles with World or Local axis alignment - **Shadows & post-processing**, soft shadows (auto-tuned for interiors), SSAO, bloom, and ACES tone mapping, toggleable via the `shadows` prop - **Orbit and pointer-lock camera modes**, camera movement limits, auto-rotation, and load-progress callbacks - **SimpleModelViewer**, a lightweight model inspection viewer with no bindings or actions - **BindingBuilder**, browser-based design-time tool for generating bindings and positioning model objects - **Hooks API**: - [`useViewer`](https://react-immersive.liveroom.dev/docs/hooks/use-viewer) - [`useViewerCamera`](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-camera) - [`useViewerConnection`](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-connection) - [`useViewerAnimations`](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-animations) - [`useViewerEffects`](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-effects) - [`useViewerSelection`](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-selection) - [`useViewerHover`](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-hover) - [`useViewerModel`](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-model) - [`useViewerIsolation`](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-isolation) - [`useViewerActions`](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-actions) - [`useObjectBinding`](https://react-immersive.liveroom.dev/docs/hooks/use-object-binding) - [`useObjectBindingIds`](https://react-immersive.liveroom.dev/docs/hooks/use-object-binding-ids) - [`useObjectBindings`](https://react-immersive.liveroom.dev/docs/hooks/use-object-bindings) - [`useSceneConfig`](https://react-immersive.liveroom.dev/docs/hooks/use-scene-config) - [`useShareableViewerState`](https://react-immersive.liveroom.dev/docs/hooks/use-shareable-viewer-state) ## Quick Install ```bash npm install @liveroom-tech/react-immersive@5.2.0 three@^0.184.0 @react-three/fiber@^9.6.1 ``` ```bash pnpm add @liveroom-tech/react-immersive@5.2.0 three@^0.184.0 @react-three/fiber@^9.6.1 ``` ```bash yarn add @liveroom-tech/react-immersive@5.2.0 three@^0.184.0 @react-three/fiber@^9.6.1 ``` Import the library stylesheet once from your application root: ```tsx import "@liveroom-tech/react-immersive/styles.css"; ``` ## Minimal Example ```tsx import objectBindings from "./objectBindings.json"; import { ModelViewer, type ObjectBinding, } from "@liveroom-tech/react-immersive"; const initialBindings = objectBindings as Record; export default function App() { return (
); } ``` Without `objectBindings`, the viewer generates a binding for every mesh when the model loads. To choose which objects are interactive and what they do, author the bindings by hand, or generate starter bindings in [`BindingBuilder`](https://react-immersive.liveroom.dev/docs/reference/binding-builder) and export them as `objectBindings.json` or a TypeScript file to import into your app. UI styles ship as an explicit static CSS import; the package does not inject runtime style tags. Tailwind is **not** required in the consuming app. ## Quick Inspection With `SimpleModelViewer` If you don't need `objectBindings`-driven styling and actions, [`SimpleModelViewer`](https://react-immersive.liveroom.dev/docs/reference/simple-model-viewer) gives you a lightweight viewer for inspecting GLB, GLTF, OBJ, FBX, USDZ, STL, or PLY assets. It can also run in an upload-first mode for ad-hoc local model inspection: ```tsx import "@liveroom-tech/react-immersive/simple-model-viewer.css"; import { SimpleModelViewer } from "@liveroom-tech/react-immersive/simple-model-viewer"; export default function App() { return (
); } ``` ## Exports ```ts filename="exports.ts" import { material, ModelViewer, patchBindings, patchSceneConfig, useObjectBinding, useObjectBindings, useSceneConfig, useViewerActions, useViewerAnimations, useViewerEffects, useViewerCamera, useViewerConnection, useViewerHover, useViewerModel, useViewerSelection, } from "@liveroom-tech/react-immersive"; import { SimpleModelViewer } from "@liveroom-tech/react-immersive/simple-model-viewer"; import { BindingBuilder } from "@liveroom-tech/react-immersive/binding-builder"; ``` Pure data helpers such as `defineObjectBindings`, `patchBindings`, `patchSceneConfig`, and `selectBindings` are also available from the server-safe `@liveroom-tech/react-immersive/utils` entry point. The main entry continues to export them for backwards compatibility. --- Canonical: https://react-immersive.liveroom.dev/docs/introduction Package: @liveroom-tech/react-immersive@5.2.0 # Introduction `@liveroom-tech/react-immersive` is a React-based 3D model viewer for interactive GLB, GLTF, OBJ, FBX, USDZ, STL, and PLY assets. It renders a model with React Three Fiber, lets users click named meshes, shows a built-in side panel for the selected object, and supports per-object actions such as color changes, texture uploads, and visibility toggles. ## Why This Library Exists Rendering a model in Three.js/React Three Fiber is the easy part. The hard part is everything layered on top once a GLB, GLTF, OBJ, FBX, USDZ, STL, or PLY asset needs to be **interactive and configurable**: - Click a part of the model to show details about it - Let a user change a part's color or swap its texture - Toggle parts on/off (e.g. show/hide furniture, accessories, room walls) - Save a "good viewing angle" for each part and snap the camera to it - Track hover/selection state, animations, loading state, and camera state, all the bookkeeping around the 3D scene Every project that does this ends up rebuilding the same scaffolding: material cloning so edits don't bleed across objects, texture caching/disposal, raycasting and highlighting, side panels, color pickers, popovers, and animation mixers. `react-immersive` packages all of that once so consuming apps don't have to reinvent it. ## Why `objectBindings` Exists `objectBindings` is the answer to one core problem: a GLB/GLTF file is just geometry, it has no concept of "this mesh is configurable, has these actions, and looks like this." Something has to bridge the gap between raw mesh node names (e.g. `"CarBody"`, `"BrickWall"`) and your application's domain model (a car's body panel, a room's wall, etc.). `objectBindings` is that bridge, a plain JS/TS record keyed by stable model node identifier. Unique node names stay unchanged; duplicate names receive traversal-order identifiers such as `Cube [1]` and `Cube [2]`. The record: - **Declares which meshes are interactive** (`selectable`, `hoverable`, `visible`), meshes not listed aren't part of the interactive flow at all - **Carries live render state** (`visible`, local `transform.position` / `transform.rotation`, plus `style.material.*` overrides like `baseColor`, `texture.path`, `opacity`, `metalness`, `roughness`, `emissive`, and the full PBR/texture-map set) so the viewer can apply overrides without you touching Three.js objects directly - **Defines per-object actions** (`change-color`, `change-material`, `toggle-visibility`, etc.) that drive the built-in side panel buttons - **Stores domain data** (`metrics`, `metadata`) for display alongside the 3D view - **Saves camera framing** (`cameraState`) so clicking an object can snap to a curated view instead of a generic "fit to box" Because it's just a serializable record, it can be authored by hand, generated automatically (via [`BindingBuilder`](https://react-immersive.liveroom.dev/docs/reference/binding-builder), which traverses a model and produces starter bindings), stored in a database, and round-tripped through `onObjectBindingsChange` to keep your app state and the 3D scene in sync. `objectBindings` is the single source of truth, so React's normal data flow ("state in, callbacks out") works for a 3D scene the same way it would for any other component. ## Why It Is Worth The Setup At first glance, `objectBindings` can look like extra work compared to simply loading a model and rendering it. That instinct is understandable, but it only holds if the model is purely visual. If the model is part of a real product experience, the "extra work" is actually the structure that prevents everything else from turning into ad-hoc scene mutation: - Without bindings, a model is just a rendered asset - With bindings, the model becomes part of your application state That distinction is what makes it possible to build maintainable 3D product flows. Instead of scattering logic across mesh refs, custom raycasters, material mutations, and one-off UI handlers, you keep the important decisions in a single serializable record. In other words, `objectBindings` is not extra work for rendering. It is the abstraction that makes interaction, customization, persistence, and app-level logic manageable after rendering. If you only need to display a model, you probably do not need objectBindings. If you need the model to behave like part of your app, bindings are the right tradeoff. ## Why `sceneConfig` Exists `objectBindings` describe what individual model nodes mean. `sceneConfig` describes the experience around the whole model: the initial camera view, lighting, environment, shadows, post-processing, animation behavior, and annotation markers. It keeps scene-wide decisions out of component props, ad-hoc Three.js setup, and one-off effects. That separation matters because not every interactive experience is about a specific mesh. A guided showroom tour, for example, can use annotation markers and saved camera positions to lead a visitor through a scene without binding a single exhibit. The [`Guided Tour`](https://react-immersive.liveroom.dev/examples/guided-tour) demo uses exactly this pattern. - **Guided tours and onboarding** - define named stops and camera positions for a repeatable walkthrough - **Showrooms and product launches** - set the intended lighting, environment, and opening view for a model - **Architecture and real estate** - ship curated viewpoints, daylight mood, and annotations with a building or room - **Training and safety flows** - combine markers, camera positions, and animation playback into a consistent lesson - **XR and device experiences** - keep scene-level presentation and interaction settings together as the experience evolves - **Reusable model presets** - save a serializable scene setup that can be versioned, stored, and reused across applications In short: `objectBindings` make parts of a model behave like application data; `sceneConfig` makes the model behave like a designed 3D experience. Use one or both depending on what the experience needs. Use sceneConfig whenever the default viewer presentation is not the product you want to ship. It is valuable on its own for tours and curated scenes, and it complements objectBindings when individual parts also need application behavior. ## Viewer tools and engagement - [Video export](https://react-immersive.liveroom.dev/docs/reference/model-viewer#video-export) records a cinematic pass or turntable revolution as browser-supported WebM/MP4, or as an animated GIF, for product media. - [Measurement tools](https://react-immersive.liveroom.dev/docs/reference/model-viewer#showmeasuretools) include distance, angle, polygon area, point-to-plane distance, vertex/edge snapping, and bounding-box dimensions. - [Fullscreen](https://react-immersive.liveroom.dev/docs/reference/model-viewer#showfullscreenbutton) expands the viewer, including its panels and overlays, through `ui={{ fullscreen: true }}`. - [WebGL recovery](https://react-immersive.liveroom.dev/docs/reference/model-viewer#webgl-context-recovery) shows a **Reload viewer** button after context loss and rebuilds when the context returns. - [Engagement analytics](https://react-immersive.liveroom.dev/docs/reference/model-viewer#onengagement--debugengagement) exposes part views, variant choices, AR session duration, and tour outcomes through `onEngagement`; `debugEngagement` logs events in the developer console. Both are opt-in, and the viewer sends no telemetry automatically. - [Accessibility](https://react-immersive.liveroom.dev/docs/reference/model-viewer#accessibility): keyboard users can move through and select objects, screen readers hear each selection, and motion the viewer starts on its own stops for visitors who ask their device to reduce motion. - [`CompareViewer`](https://react-immersive.liveroom.dev/docs/reference/compare-viewer) shows two configurations, or two models, with one camera: a before/after slider or side by side. - [WebGPU](https://react-immersive.liveroom.dev/docs/reference/model-viewer#webgpu) rendering is opt-in through `perf={{ webgpu: true }}`, with WebGL wherever the browser lacks it. ## When This Package Fits - **Product configurators** - let users recolor or retexture parts of a car, piece of furniture, or apparel - **Interactive room/scene tours** - click walls, furniture, or fixtures to see info or toggle visibility - **Asset inspection tools** - [`SimpleModelViewer`](https://react-immersive.liveroom.dev/docs/reference/simple-model-viewer) for quickly browsing a model hierarchy without any binding setup, including an optional local upload mode for ad-hoc inspection - **Anything needing "click a 3D part to show a panel with data and actions"** without hand-rolling raycasting, highlighting, and UI panels ## Example Applications Where Bindings Shine - **Product configurators** - cars, furniture, kitchens, appliances, apparel, or footwear where each part can be selected, recolored, retextured, upgraded, or hidden - **E-commerce customization flows** - "build your own" experiences where a mesh maps to a product option, add-on, or purchasable variant - **CPQ and sales tools** - enterprise configuration flows where 3D parts correspond to real options, compatibility rules, and pricing decisions - **Interior design and real estate tools** - rooms, walls, fixtures, finishes, and furniture that need labels, toggles, metadata, and saved viewpoints - **Industrial and engineering viewers** - machines, assemblies, equipment, and plant layouts where users isolate parts, inspect metadata, and follow maintenance flows - **Training and guided walkthroughs** - medical, hardware, aviation, or manufacturing education where each part can own annotations, steps, and camera presets - **Digital twins and operational dashboards** - buildings, assets, or devices where visual parts correspond to live entities with status, metrics, and alerts - **Smart building controls** - building models where light switches, room sensors, HVAC units, and doors are bound to real-time controls and telemetry - **Room-level control logic** - a room where one switch controls two lights and another switch controls two different lights, with the model reflecting application state instead of hard-coded mesh behavior The common pattern is simple: if a user needs to say "this exact part of the model does something special," bindings usually make sense. ## When It Probably Doesn't Fit - Pure visual/cinematic scenes with no interactivity, a plain `` is simpler - Highly custom 3D UIs where the built-in panels/popovers would just be ripped out anyway, though [`customObjectBindingDataPanel`](https://react-immersive.liveroom.dev/docs/reference/model-viewer#customobjectbindingdatapanel) and [`customSceneObjectsPanel`](https://react-immersive.liveroom.dev/docs/reference/model-viewer#customsceneobjectspanel) cover a lot of that middle ground ## How It Works At a high level, the viewer does the following: 1. Creates a Three.js canvas; `ModelViewer` only defaults the `fov` (`50`), the camera position falls back to React Three Fiber's own `Canvas` default (`[0, 0, 5]`) unless you pass `camera={{ position: [...] }}` 2. Loads the model with the format-appropriate loader, or streams a processed 3D Tiles hierarchy when `tilesetUrl` is supplied 3. Builds stable node identifiers, including suffixed identifiers for duplicate names, and resolves `objectBindings` through those identifiers 4. Clones each referenced material so edits stay isolated per object 5. Applies runtime color, texture, and style overrides from `objectBindings` in a `useEffect` 6. Manages a reference-counted texture cache so the same texture URL is decoded once and disposed when no longer referenced 7. Adds hover, selected, and panel-hover emissive highlighting 8. Creates an `AnimationMixer` for the loaded scene and exposes playback controls through `onAnimationsReady` 9. Renders side panels for the selected object and its configured actions Only objects listed in `objectBindings` are rendered interactively. ## Lighting & Shadows With the `shadows` prop enabled (the default), the viewer renders soft shadows and a post-processing stack (SSAO ambient occlusion, bloom, vignette, ACES filmic tone mapping). The default light rig uses ambient + hemisphere fill and a directional key light that casts shadows. Interior models (rooms, dollhouses) are a special case: the large mesh that forms the walls/ceiling enclosure is detected by its bounding-box size and excluded from **casting** shadows while still **receiving** them. This prevents a closed shell from blocking a top-down light and sealing the interior in darkness, so furniture and decor cast realistic contact shadows onto the floor. Pass `shadows={false}` to turn shadows off entirely, or set your own lights in `sceneConfig.lighting` to replace the default rig. ## UI Shell The component includes its own UI shell: - A full-height viewer layout styled by the package's explicit static CSS import - A sliding left side panel (~280 px wide) for the selected object's details and actions; on narrow screens the side panels collapse to half-width so the canvas keeps its other half - A canvas area that fills the remaining width - A color picker that expands inline beneath the **Change Color** action - A texture upload panel that expands inline beneath the **Change Material** action - A right scene objects panel with search and per-object visibility toggles `ModelViewer` works best when mounted in a container that owns the full viewport height. ## Model Requirements To use this library successfully, your model should follow these conventions: - Binding identifiers must match the model node IDs (`BindingBuilder` assigns stable suffixed IDs when authored names are duplicated) - Target nodes should have geometry and a `MeshStandardMaterial` - The file must be accessible from the browser at the path passed to `modelUrl`; hosted GLTF, OBJ, and FBX files must also serve external dependencies at their declared relative paths If a node name or material does not match, that object will not render through the interactive binding flow. ## Peer Dependencies | Package | Version | | ----------- | ------- | | `react` | `^19.0.0` | | `react-dom` | `^19.0.0` | | `three` | `^0.184.0` | | `@react-three/fiber` | `^9.6.1` | These peers match **5.2.0**. See [Versions and Migration](https://react-immersive.liveroom.dev/docs/releases) for starter tooling requirements and upgrade guidance. --- Canonical: https://react-immersive.liveroom.dev/docs/licensing Package: @liveroom-tech/react-immersive@5.2.0 # Licensing and runtime keys React Immersive is proprietary software. The [package license](https://react-immersive.liveroom.dev/LICENSE.txt) and your plan terms govern use. **SimpleModelViewer needs no license key in local or production applications.** ModelViewer and BindingBuilder require a key and the appropriate plan. The remaining proprietary package terms still apply. ## Component and environment requirements | Component | Runtime `licenseKey` prop | Local evaluation | Production or other non-local use | | ----------------- | ------------------------- | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | | SimpleModelViewer | None | No key required | No key or paid plan required | | ModelViewer | Required | Create a Free or paid key in the console; Free supports local development | A production-enabled plan and a registered domain are required | | BindingBuilder | Required | Create a Free or paid key; some authoring features depend on tier | A production-enabled plan and a registered domain are required; features depend on tier | SimpleModelViewer can be deployed to production without creating an account, generating a key, or choosing a paid plan. For ModelViewer and BindingBuilder, the Free plan is for local development. Starter supports one production domain; Growth supports up to five. Scale terms are arranged with Liveroom. Consult [current pricing](https://react-immersive.liveroom.dev/pricing) for the available plans and feature entitlements for those licensed components. ## Create and configure a key for ModelViewer or BindingBuilder 1. Sign in to the [console](https://react-immersive.liveroom.dev/console). 2. Select the plan appropriate for the environment and features. 3. Generate a license key. For production, register the application's domain. 4. Pass that key to ModelViewer or BindingBuilder. SimpleModelViewer does not accept this prop and does not need this account/key setup. An agent can prepare the integration without signing into your account. Account access, plan purchases and key issuance require the account holder. Do not reuse the hosted demo's key in a consuming application. ## Local and production domains for licensed components Local validation recognizes `localhost`, `127.0.0.1`, `0.0.0.0`, and hostnames ending in `.localhost` or `.local`. A hosted preview URL or LAN IP is not automatically a local-development domain. Production validation checks the request's browser origin against the registered domains and plan. ## Browser-visible configuration Viewer keys are passed to client components and can be inspected in browser requests or bundles. `VITE_LICENSE_KEY` and `NEXT_PUBLIC_LICENSE_KEY` are client-exposed environment variables, not mechanisms for hiding secrets. Use a domain-bound key for the intended application, avoid committing it to a public repository, and keep account credentials, billing tokens and server API secrets out of client code. Revoke or rotate keys through the console as needed. ## Package and documentation licenses The package LICENSE is included in the npm tarball, published as [LICENSE.txt](https://react-immersive.liveroom.dev/LICENSE.txt), and mirrored as [PACKAGE-LICENSE.txt](https://github.com/Liveroom-Technologies/react-immersive-docs/blob/main/PACKAGE-LICENSE.txt) in the public repository. That mirror describes the npm product's terms; it does not relicense third-party example models or apply a new blanket license to this documentation repository. For account-specific or contractual questions, contact [developers@liveroom.xyz](mailto:developers@liveroom.xyz). --- Canonical: https://react-immersive.liveroom.dev/docs/reference/binding-builder Package: @liveroom-tech/react-immersive@5.2.0 # `BindingBuilder` A design-time helper component for authoring `objectBindings` and `sceneConfig` in the browser. Load a GLB, GLTF, OBJ, FBX, USDZ, STL, or PLY asset, inspect its mesh nodes, edit binding fields, configure the scene, preview the result live, and export as JSON or TypeScript. Try the public editor at [react-immersive.liveroom.dev/editor](https://react-immersive.liveroom.dev/editor). ## Usage ```tsx import "@liveroom-tech/react-immersive/binding-builder.css"; import { BindingBuilder } from "@liveroom-tech/react-immersive/binding-builder"; export default function App() { return ; } ``` ## Props ```ts type BindingBuilderProps = { licenseKey: string; dracoDecoderPath?: string | false; ktx2TranscoderPath?: string | false; meshopt?: boolean; }; ``` `licenseKey` is **required** and is validated the same way as [`ModelViewer`](https://react-immersive.liveroom.dev/docs/reference/model-viewer)'s, see [Licensing & plan tiers](https://react-immersive.liveroom.dev/docs/reference/binding-builder#licensing--plan-tiers) below. While the key is being checked, `BindingBuilder` renders a "Verifying license…" placeholder; if the key is invalid, it renders an error message instead of the editor. The decoder props are forwarded to the editor preview. Built-in defaults support Meshopt, DRACO, and KTX2; override them with self-hosted directories or `false`. See the [`ModelViewer` decoder setup](https://react-immersive.liveroom.dev/docs/reference/model-viewer#dracodecoderpath--ktx2transcoderpath--meshopt). ## Capabilities ### Object tab - Upload a `.glb`, standalone/data-URI `.gltf`, `.obj`, `.fbx`, `.usdz`, `.stl`, `.ply`, or `.zip` bundle. ZIP bundles may contain GLTF dependencies, OBJ + MTL/textures, or FBX external textures; USDZ is uploaded directly - Traverse renderable mesh nodes and generate starter bindings automatically - For an object without texture coordinates (STL, most PLY), the **Material** image map fields are turned off with a note, and the starter actions leave out **Change Material** (see [STL and PLY](https://react-immersive.liveroom.dev/docs/reference/model-viewer#stl-and-ply)) - Edit binding fields: identity, label, type, status, booleans, style, actions, metrics, metadata, and saved camera state - Add decals from the **Decals** panel: upload an image or add text to place it automatically at the centre of the camera-facing surface. Set its size, rotation, and opacity; **Move** lets you drag it across the object's surface. Release to save, or press Escape to cancel. The **Position** arrows move the decal in small steps on the visible surface relative to the camera; click for a fine adjustment or hold an arrow to keep moving. Each step can be undone. Uploaded images are exported under `materials/` with the bindings - Move and rotate the selected object with a geometry-centered pivot, World or Local axes, numeric position/rotation fields, and restore-to-authored-transform support - Author a full per-object material in the **Material** panel, a one-click preset gallery (wood, metal, chrome, glass, plastic, fabric, ceramic, concrete, etc.) plus collapsible sub-panels for PBR maps, roughness, emission, opacity, normal & bump, displacement, ambient occlusion, clearcoat, sheen, anisotropy, transmission & volume, and faces rendering. Each sub-panel has an enable toggle, and every field maps to a `style.material` override (see [`ObjectBindingMaterial`](https://react-immersive.liveroom.dev/docs/reference/types#objectbindingmaterial)) - Undo/redo the current bindings (toolbar buttons, or `Cmd/Ctrl+Z` / `Cmd/Ctrl+Shift+Z` while the Object tab is active), rapid edits like slider drags coalesce into a single undo step - Import a previously exported `objectBindings.json` and merge it back onto the current model (matched by `modelObjectId`, so re-imports survive hand edits or partial files) - Export the current bindings as **JSON** or **TypeScript** Model ZIPs are limited to 128 MiB compressed, 1,024 entries, 128 MiB per expanded entry, 256 MiB expanded in total, and a 200:1 per-entry compression ratio. Every GLTF dependency must be included in the archive: absolute, root-relative, and other external asset URLs are rejected. `data:` URIs are allowed. ### Scene tab In **Annotations**, edit a marker's title, description, category, image URLs and alt text, and link URLs and labels. **Capture current view** saves the preview camera, **Preview** recalls it, and **Clear view** uses automatic tour framing. New markers use automatic close-up zoom until you explicitly capture a custom view. Optional minimum and maximum distances hide markers outside the chosen range (scene units); blank fields mean no limit. These fields persist in exported `sceneConfig.annotations`. - Configure scene-wide settings (lighting, environment, background, ground shadows, wireframe, post-processing, annotations, per-clip animation options, and a cinematic camera path), the same `SceneConfig` shape consumed by `ModelViewer`'s `sceneConfig` prop - Configure scene auto-rotation and speed. These values are exported as `sceneConfig.model.autoRotate` and `sceneConfig.model.autoRotateSpeed` - Isolate a node from the Scene Nodes list (the button at the end of its row) to see it on its own in the preview while you edit it. This is only for editing and isn't exported - Set up a section cut under **General → Section Cut** (axis, position, and which side stays), exported as `sceneConfig.section`. The preview has the section toolbar and a plane you can drag - Build style rules in the **Rules** sub-tab: pick the objects (all, a group, tag, type, or one object), the conditions (metric ranges, status, metadata values), and the look (colour, opacity, glow, a colour scale from a metric, or a pulse). The form suggests the model's own groups, tags, metric names, and metadata keys, shows how many objects each rule matches, and the preview updates as you edit. Exported as `sceneConfig.styleRules`; see [Style rules](https://react-immersive.liveroom.dev/docs/reference/model-viewer#style-rules) - Check your changes with **Compare** above the preview: a divider across the preview shows the project as it was opened on its left and as it is now on its right. Drag it (or focus it and use the arrow keys) to sweep between them; orbiting either side moves both. **Pin**, on the "Before" label, makes the current state the "before", to compare the next round of changes with it. The preview stays live while comparing, and the "before" side is view only. It is the same slider as [`CompareViewer`](https://react-immersive.liveroom.dev/docs/reference/compare-viewer) - Pick which of the model's glTF material variants (`KHR_materials_variants`) the scene starts with (**General**, one select per option group, such as **Paint** and **Wheels**), exported as `sceneConfig.model.activeVariants`. The preview's swatch rows change the same setting - Position and rotate directional, point, and spot lights with transform gizmos; spot lights also expose angle and penumbra controls - Switch the model renderer between **PBR**, **Matcap**, and **UV Checker**. UV Checker overlays a numbered directional chart to diagnose stretching, seams, rotation, texel-density differences, and missing UV0 coordinates - Author a **cinematic camera path** visually in the **Cinematic** sub-tab: frame the model in the preview, click **Add waypoint from current view** to capture the camera pose, then set its segment duration, reorder, fly-to-preview (📍), or delete waypoints. Toggle Enable / Loop / Auto-play and the overall glide Duration. The path is stored on `sceneConfig.cinematic` and replayed by [`ModelViewer`'s `cinematic` prop](https://react-immersive.liveroom.dev/docs/reference/model-viewer#cinematic) - Undo/redo the scene config independently of the Object tab's bindings history - Import a previously exported `sceneConfig.json`, merged by top-level section so a partial file doesn't wipe sections it doesn't include - Export the current scene config as **JSON** or **TypeScript** ### Preview - Live preview of the selected node in an embedded `ModelViewer`, including loading progress and orbit/pointer-lock camera modes ### Autosave and restoring a session `BindingBuilder` keeps your work in the browser, so a reload or a closed tab doesn't lose it: - Once you change something, the session is saved shortly after each edit, and again when the tab is hidden or closed. A session is the model, its object bindings, and the scene settings. An uploaded model file is saved with it, so it doesn't need uploading again. - **Restore last session** (in the "⋯" menu above the preview) reopens the saved session. One session is kept per browser, and each save replaces the last. - While there are edits on screen, leaving or reloading the page asks for confirmation first. Loading or restoring a model without changing it doesn't. - Sessions live in this browser's IndexedDB, in a database named `react-immersive:binding-builder`. They aren't shared across browsers or devices, and private browsing may discard them. If the browser refuses to store a session, usually for lack of space, `BindingBuilder` says so; export your work to keep it. Autosave is a safety net, not a way to keep or share work: export JSON or TypeScript to keep bindings and scene settings with your application. ### Moving and rotating objects Open the **Move & Rotate** panel in the Object tab, then: 1. Turn on **Transform gizmo**. Camera controls pause while editing is enabled. 2. Select a bound mesh in the preview or object list. 3. Drag an arrow to move on one axis, a plane handle to move on two axes, or a colored ring to rotate. Move and rotate handles are always shown together. 4. Choose **World** to keep the handles aligned with the scene, or **Local** to align them with the selected object's current orientation. 5. Use the horizontal X, Y, and Z fields for precise values. Rotation fields display degrees; the exported binding stores radians. The pivot is placed at the center of the selected object's renderable geometry, so rotation happens around the object itself even when the source node's authored origin is elsewhere. This authoring pivot does not rewrite the GLB/GLTF origin. Both gizmo and numeric edits are saved to the selected binding's local-space `transform.position` and `transform.rotation`. Changing World/Local axis space only changes the direction of the gizmo handles; it does not change the saved position. Use **Restore original position & rotation** to remove those overrides and return to the model-authored transform. ## Exporting and Reusing Files BindingBuilder has separate toolbars for bindings and scene settings, so export the data from the tab that owns it: 1. Load your model first. The import and export controls become available once BindingBuilder has inspected its nodes. 2. Open the **Object** tab to work with `objectBindings`. In that tab's toolbar, use **Export → JSON** or **Export → TypeScript**. 3. Open the **Scene** tab to work with `sceneConfig`. Its toolbar has the same **Export** menu, producing `sceneConfig.json` or `sceneConfig.ts`. 4. Choose **TypeScript** for a file you will import into your application. Choose **JSON** for a portable snapshot that you want to bring back into BindingBuilder later. We recommend keeping TypeScript exports alongside the viewer route, for example: ```text src/immersive/objectBindings.ts src/immersive/sceneConfig.ts public/model.glb public/materials/... (when the export includes uploaded textures) ``` The TypeScript bindings export already wraps the record in `defineObjectBindings`, so TypeScript validates the complete file without widening its keys or literal IDs. Import the named exports directly and pass them to `ModelViewer`: ```tsx import { useState } from "react"; import { ModelViewer, type ObjectBinding } from "@liveroom-tech/react-immersive"; import { objectBindings as initialObjectBindings } from "./objectBindings"; import { sceneConfig } from "./sceneConfig"; export default function ProductViewer() { const [objectBindings, setObjectBindings] = useState>(initialObjectBindings); return ( ); } ``` ### Importing Back into BindingBuilder Use the **Import** button in the same toolbar as the matching export: - **Object tab → Import** accepts an `objectBindings.json` file. Load the model first; incoming records are matched by `modelObjectId`, so bindings for nodes absent from the current model are safely skipped. - **Scene tab → Import** accepts a `sceneConfig.json` file. Included top-level sections are merged into the current scene config, which lets you restore a partial scene preset without resetting the other settings. TypeScript exports are deliberately for your application source code, not for the editor's import button. Keep a JSON export whenever you expect to reopen or share the configuration in BindingBuilder. **Picking a texture map** stores the durable path `materials/` in the binding (this is what's exported), and shows the image immediately in the live preview via an in-memory `blob:` URL, so you can author without first copying files into `public/`. **Exporting bundles the files automatically:** if any material field was filled by uploading a file, clicking **Export** downloads a `.zip` instead of a single `.json`/`.ts`, containing the export file plus a `materials/` folder with every uploaded image. Unzip it into your app's `public/` folder and the exported bindings resolve as-is. Two different uploads that happen to share a filename are kept distinct (`materials/wood.jpg`, `materials/wood-2.jpg`, ...) rather than one overwriting the other. If nothing was uploaded, Export downloads a plain `.json`/`.ts` file exactly as before. ## Licensing & plan tiers `BindingBuilder` validates `licenseKey` the same way `ModelViewer` does, and additionally gates some editor features by the license's plan tier: | Tier | Unlocks | | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `free` | Base color, metalness/roughness sliders, basic scene settings | | `starter` | + Texture maps, the specular workflow, reflectivity, environment lighting, environment backgrounds, wireframe preview, animation configuration, annotations, cinematic camera paths, style rules, decals | | `growth` | + Post-processing effects | A locked feature is shown in place (not hidden) with a message naming the required plan, so the feature stays discoverable while editing. Get a license key at the [React Immersive Console](https://react-immersive.liveroom.dev/console). ## Typical Workflow 1. Mount `` in a dev-only route (e.g. `/dev/bindings`) 2. Upload your `.glb`, `.gltf`, `.obj`, `.fbx`, `.usdz`, `.stl`, `.ply`, or `.zip` 3. Select mesh nodes in the sidebar, edit their binding fields, move or rotate them as needed, or apply a material preset 4. Configure scene-wide settings in the **Scene** tab 5. Preview the interactive result in the embedded viewer 6. Click **Export → JSON** or **Export → TypeScript** to download the bindings (and, if you uploaded any textures, a `.zip` with them included) 7. Drop the exported file (and any `materials/` folder) into your app as the initial `objectBindings` / `sceneConfig` state --- Canonical: https://react-immersive.liveroom.dev/docs/reference/compare-viewer Package: @liveroom-tech/react-immersive@5.2.0 # `CompareViewer` Shows two configurations next to each other with one camera: a before and after of a renovation, two paint options, the current part against its replacement. Each side is a full [`ModelViewer`](https://react-immersive.liveroom.dev/docs/reference/model-viewer), so a side can differ in anything a viewer takes: `objectBindings`, `sceneConfig`, material variants, even a different `modelUrl`. ```tsx import "@liveroom-tech/react-immersive/styles.css"; import { CompareViewer } from "@liveroom-tech/react-immersive";
; ``` ## Layouts - **`layout="slider"`** (default) lays the two views over each other. A divider splits them: the left of it shows `before`, the right shows `after`. Drag the divider, or focus it and use the arrow keys, Page Up/Down, Home, and End. It is a slider to screen readers, with each side's label and share. - **`layout="side-by-side"`** shows the two views next to each other, each half the width. ```tsx ``` ## One camera Orbiting, panning, or zooming either side moves both. The side you last grabbed leads and the other copies it every frame, so a fly-in, a zoom to a selected object, or a refit on one side shows on both. With two different models, the views line up on the same point in space, so the models should share a coordinate system. ## Props ```ts type CompareViewerProps = Partial & { licenseKey: string; before: CompareViewerSide; // the left side after: CompareViewerSide; // the right side layout?: "slider" | "side-by-side"; // default "slider" split?: number; // the divider, 0 (left edge) to 1; default 0.5 onSplitChange?: (split: number) => void; }; type CompareViewerSide = Partial & { label?: string; // shown over the side; default "Before" / "After" }; ``` Every other prop is shared by both sides, and a side's own props win. Callbacks run per side: give `before.onObjectSelect` and `after.onObjectSelect` to tell the sides apart. Both sides start from the `minimal` [preset](https://react-immersive.liveroom.dev/docs/reference/model-viewer#presets-and-grouped-props), with no panels or buttons, so their interfaces don't overlap; pass `preset` or `ui` to change that. Pass `split` to control the divider yourself; without it, `CompareViewer` keeps the position. Each side has its own WebGL (or [WebGPU](https://react-immersive.liveroom.dev/docs/reference/model-viewer#webgpu)) canvas. The two share one download and parse of the model file, but the GPU holds two copies of its geometry and textures. `poster`, `loading` and `reveal` work as on [`ModelViewer`](https://react-immersive.liveroom.dev/docs/reference/model-viewer#lazy-loading-and-posters). With `reveal="interaction"`, one **View in 3D** button starts both sides. BindingBuilder has the same slider over its preview (**Compare**), to check edits against the project as it was opened. The divider and the default side labels use the [`labels`](https://react-immersive.liveroom.dev/docs/reference/labels) keys `compareBefore`, `compareAfter`, `compareDivider`, and `compareDividerValue`. --- Canonical: https://react-immersive.liveroom.dev/docs/reference/labels Package: @liveroom-tech/react-immersive@5.2.0 # UI Labels `ModelViewer`, `SimpleModelViewer`, and `CompareViewer` accept a `labels` prop to override built-in text. Buttons, tooltips, accessible names, tool hints, loading/error messages, and AR placement controls all use this dictionary. ## Usage ```tsx import { ModelViewer, type ModelViewerLabels } from "@liveroom-tech/react-immersive"; const french: ModelViewerLabels = { guidedTour: "Visite guidée", startGuidedTour: "Commencer la visite", stopGuidedTour: "Terminer la visite", previous: "Précédent", next: "Suivant", tapToPlace: "Touchez pour placer", download: "Télécharger", recordingVideo: "Enregistrement : {progress}%", visibleEntries: "Éléments visibles : {count}", }; ; ``` Unspecified keys fall back to English. Empty strings are accepted. Labels are local to each viewer instance; switching dictionaries updates the UI without reloading the model. Values are plain text, not HTML or React nodes. ## Placeholders and application translations Dynamic text uses named placeholders such as `{count}`, `{progress}`, `{name}`, or `{status}`. Keep the placeholders you need in the translated string; you can reorder or repeat them. For example, `objectCount: "Objets : {count}"` displays `Objets : 3`. Placeholder values are substituted literally, and unsupported placeholders remain unchanged. This dictionary does not format numbers or choose plural forms automatically. Your application can supply a dictionary from its own localization system. Annotation titles/descriptions, image alt text, link labels, category names, model/object metadata, custom panel content, and raw server/loader diagnostics remain application data. Automatically generated object action buttons also use the dictionary; labels on caller-supplied actions remain application data. API values such as `"angle"`, `"vertex"`, and environment preset IDs stay the same when their displayed labels change. ## Default dictionary and types ```ts import { DEFAULT_MODEL_VIEWER_LABELS, type ModelViewerLabels, type ModelViewerLabelKey, } from "@liveroom-tech/react-immersive/utils"; // Also exported from the main package and /simple-model-viewer. type Labels = Partial>; const englishTour = DEFAULT_MODEL_VIEWER_LABELS.guidedTour; ``` ## Complete catalog Only labels for enabled controls appear in a viewer. The following list is shared by both components. Placeholder names in defaults show the values supplied by the viewer. | Key | English default | | --- | --- | | `actionChangeColor` | `Change Color` | | `actionChangeLightColor` | `Change Light Color` | | `actionChangeMaterial` | `Change Material` | | `actions` | `Actions` | | `actionSetBrightness` | `Set Brightness` | | `actionToggleLight` | `Toggle Light` | | `actionToggleVisibility` | `Toggle Visibility` | | `added` | `Added` | | `addText` | `Add text` | | `all` | `All` | | `ambientLightColor` | `Ambient Light Color` | | `ambientLightIntensity` | `Ambient Light Intensity` | | `angle` | `Angle` | | `angleFirstArmHint` | `Pick the first arm` | | `angleNewHint` | `Pick a point to start a new angle` | | `angleSecondArmHint` | `Pick the second arm` | | `angleVertexHint` | `Pick the angle's vertex` | | `annotationCategories` | `Annotation categories` | | `annotationMarker` | `Annotation {id}: {title}` | | `annotations` | `Annotations` | | `annotationTitlePlaceholder` | `Title *` | | `area` | `Area` | | `areaCornersHint` | `Pick polygon corners in order, then Finish area` | | `areaDistinctPoints` | `Area points must be distinct.` | | `areaEdgesCross` | `Area edges must not cross or touch each other.` | | `areaMinimumPoints` | `Pick at least three points to finish the area.` | | `areaNewHint` | `Pick a point to start a new area` | | `areaNotPlanar` | `Area points must lie on one plane.` | | `areaStraightLine` | `Area points must not lie on one straight line.` | | `areaZero` | `The polygon has no measurable area.` | | `arFindSurface` | `Move your device over a flat surface` | | `arGestureHint` | `Pinch to resize · Twist to rotate` | | `arReleaseToPlace` | `Release to place` | | `arRelocate` | `Relocate` | | `arScanCode` | `Scan the code and choose AR.` | | `auto` | `Auto` | | `autoRotate` | `Auto Rotate` | | `background` | `Background` | | `backgroundColor` | `Background Color` | | `bindingMismatchMany` | `{count} object bindings don't match this model` | | `bindingMismatchOne` | `1 object binding doesn't match this model` | | `cameraController` | `Camera controller` | | `cameraControllerSettings` | `Camera controller settings` | | `cameraKeyboardHelp` | `3D view. Arrow keys orbit, Shift with Up or Down zooms, and W, A, S, D, Space and C move the camera.` | | `cancel` | `Cancel` | | `checkingYourLicenseKey` | `Checking your license key...` | | `chooseImage` | `Choose image` | | `chooseModel` | `Choose model` | | `clear` | `Clear` | | `clearMeasurements` | `Clear measurements` | | `close` | `Close` | | `closeAnnotation` | `Close annotation` | | `closePanel` | `Close panel` | | `closePhoneHandoff` | `Close phone handoff` | | `closeSceneObjectsPanel` | `Close scene objects panel` | | `color` | `Color` | | `colour` | `Colour` | | `compareAfter` | `After` | | `compareBefore` | `Before` | | `compareDivider` | `Comparison divider` | | `compareDividerValue` | `{before} {percent}%, {after} {rest}%` | | `confirmTexturePosition` | `Confirm this texture position and exit positioning mode` | | `contextLostMessage` | `The graphics connection was interrupted. The viewer will rebuild when it returns, or you can reload it now.` | | `contextLostTitle` | `3D view interrupted` | | `contextRestoringMessage` | `Rebuilding the 3D view. Please wait…` | | `contextRestoringTitle` | `Reloading viewer…` | | `decalAdded` | `{count} added` | | `decalDirectionMissing` | `No visible surface in that direction. Try another arrow or rotate the view.` | | `decalDragHint` | `Drag the decal on the object. Release to save; Escape to cancel.` | | `decalImage` | `Image {index}` | | `decalNudgeControls` | `Move decal in small steps` | | `decalNudgeHint` | `Click to fine-tune · Hold to move` | | `decalSurfaceMissing` | `Could not find a surface on this object for the decal.` | | `decalText` | `Text {index}` | | `description` | `Description` | | `directLightColor` | `Direct Light Color` | | `directLightIntensity` | `Direct Light Intensity` | | `disableUVChecker` | `Disable UV checker` | | `dismiss` | `Dismiss` | | `distance` | `Distance` | | `distanceHint` | `Pick points on the model to measure distances` | | `done` | `Done` | | `donePositioning` | `Done positioning` | | `down` | `down` | | `download` | `Download` | | `downloadGLB` | `Download GLB` | | `downloadOptions` | `Download options` | | `downloadPNG` | `Download PNG` | | `downloadUVLayout` | `Download UV Layout` | | `dragTheDecalAcrossTheObjectsSurface` | `Drag the decal across the object's surface` | | `dropModelHint` | `Drag and drop {formats} here` | | `enableUVChecker` | `Enable UV checker` | | `enterAR` | `Enter AR` | | `enterFullscreen` | `Enter fullscreen` | | `enterVR` | `Enter VR` | | `environment` | `Environment` | | `environmentApartment` | `apartment` | | `environmentCity` | `city` | | `environmentDawn` | `dawn` | | `environmentForest` | `forest` | | `environmentLobby` | `lobby` | | `environmentNight` | `night` | | `environmentPark` | `park` | | `environmentStudio` | `studio` | | `environmentSunset` | `sunset` | | `environmentWarehouse` | `warehouse` | | `exitFullscreen` | `Exit fullscreen` | | `explodeAmount` | `Explode amount` | | `explodedView` | `Exploded view` | | `exporting` | `Exporting…` | | `exposure` | `Exposure` | | `failedToLoadPleaseContactTheSiteAdministrator` | `Failed to load. Please contact the site administrator.` | | `finishArea` | `Finish area` | | `fullscreenEnterFailed` | `Could not enter fullscreen. Check your browser or iframe fullscreen permissions and try again.` | | `fullscreenExitFailed` | `Could not exit fullscreen. Press Escape to exit.` | | `guidedTour` | `Guided Tour` | | `hide` | `Hide` | | `hideObject` | `Hide {name}` | | `image` | `Image` | | `imageToAdd` | `Image to add` | | `invalidLicense` | `Invalid License` | | `isolate` | `Isolate` | | `isolatedObject` | `Isolated: {name}` | | `isolatedObjects` | `Isolated: {count} objects` | | `isolateObject` | `Isolate {name}` | | `keepTheOtherSide` | `Keep the other side` | | `largeModelWarning` | `Very large model ({millions}M triangles) — it may render and move slowly on this device, and click/hover selection on the model is disabled. Use the objects panel to select parts.` | | `learnMore` | `Learn more` | | `left` | `left` | | `licenseHasBeenRevokedOrHasExpired` | `License has been revoked or has expired` | | `lighting` | `Lighting` | | `loadErrorDenied` | `The server refused access to the file ({status}). Check that the file is public, or that a signed URL hasn't expired.` | | `loadErrorHtml` | `The server sent back a web page instead of a model. There's probably no file at this path: many servers answer a missing file with their index.html.` | | `loadErrorInvalidExplicitFormat` | `The file couldn't be read as {format}. Check that it's a valid {format} file and that modelFormat matches it.` | | `loadErrorInvalidFile` | `The file couldn't be read as {format}. Check that it's a valid {format} file.` | | `loadErrorMissingFormat` | `The file couldn't be read as {format}. The URL has no file extension, so set modelFormat to the file's format.` | | `loadErrorNetwork` | `The download was blocked or failed. Check that the URL is correct and, if the file is on another domain, that its server allows cross-origin requests (CORS) from this site.` | | `loadErrorNotFound` | `Nothing was found at this address ({status}). Check that modelUrl points to the file.` | | `loadErrorPointCloud` | `This PLY file is a point cloud: it has points but no faces, and the viewer shows meshes. Turn it into a mesh first, for example with MeshLab or Blender.` | | `loadErrorStatus` | `The server responded with {status} when the file was requested.` | | `loadErrorTitle` | `This model couldn't be loaded` | | `loadingModel` | `Loading model...` | | `material` | `Material` | | `materialVariants` | `Material variants` | | `measurementAngleDistinct` | `Pick a point away from the angle's vertex.` | | `measurementReferenceFaceRequired` | `Pick a triangle face to define the reference plane.` | | `measurementSnapping` | `Measurement snapping` | | `measurementTool` | `Measurement tool` | | `measurementTooManyPoints` | `Finish or clear this measurement before adding more points.` | | `missingUVsAreShownInMagenta` | `Missing UVs are shown in magenta.` | | `move` | `Move` | | `moveBackward` | `Move backward` | | `moveDecal` | `Move decal {direction}` | | `moveDecalOnSurface` | `Move {direction} on the visible surface` | | `moveForward` | `Move forward` | | `moveLeft` | `Move left` | | `moveRight` | `Move right` | | `next` | `Next` | | `nextAnnotation` | `Next annotation` | | `noneAddedYet` | `None added yet` | | `noSnapping` | `No snapping` | | `noTextureApplied` | `No texture applied` | | `noTextureCoordinates` | `This part has no texture coordinates, so an image can't be placed on it.` | | `objectCount` | `{count} objects` | | `objectNavigator` | `Objects in the 3D view` | | `objectNavigatorHint` | `Arrow keys browse · Enter selects · Esc clears` | | `objectNavigatorPosition` | `{position} of {count}` | | `objectSelected` | `{name} selected` | | `offsetX` | `Offset X` | | `offsetY` | `Offset Y` | | `ok` | `OK` | | `openOnPhone` | `Open on phone` | | `openPanel` | `Open panel` | | `openSceneObjectsPanel` | `Open scene objects panel` | | `openThisExperienceOnAPhoneForAR` | `Open this experience on a phone for AR` | | `orbitControl` | `Orbit Control` | | `pauseCinematic` | `Pause cinematic` | | `playCinematic` | `Play cinematic` | | `pointLock` | `Point Lock` | | `pointToPlane` | `Point to plane` | | `pointToPlaneFaceHint` | `Pick the reference face` | | `pointToPlaneNewHint` | `Pick a face to start a new measurement` | | `pointToPlanePointHint` | `Pick a point to measure its perpendicular distance` | | `position` | `Position` | | `preparingLargeModel` | `Preparing large model ({millions}M triangles) — the first display can take a while on this device...` | | `previous` | `Prev` | | `previousAnnotation` | `Previous annotation` | | `qrCodeToOpenThisModelOnAPhone` | `QR code to open this model on a phone` | | `recordCinematicVideo` | `Record cinematic video` | | `recordingVideo` | `Recording video · {progress}%` | | `recordTurntableVideo` | `Record turntable video` | | `releaseToLoadYourModel` | `Release to load your model` | | `reloadViewer` | `Reload viewer` | | `remove` | `Remove` | | `removeSectionCut` | `Remove section cut` | | `removeTexture` | `Remove texture` | | `replaceImage` | `Replace image` | | `resetCameraView` | `Reset camera view` | | `right` | `right` | | `rotation` | `Rotation` | | `sceneObjects` | `SCENE OBJECTS` | | `sceneSettings` | `Scene Settings` | | `searchNodesLabels` | `Search nodes, labels` | | `searchObjects` | `Search objects` | | `sectionAxis` | `Section axis` | | `sectionCut` | `Section cut` | | `sectionPosition` | `Section position` | | `selectionCleared` | `Selection cleared` | | `show` | `Show` | | `showAll` | `Show all` | | `showAllObject` | `Show all {name}` | | `showObject` | `Show {name}` | | `simpleLargeModelWarning` | `Very large model detected ({millions}M triangles) — it may render and move slowly on this device, and click/hover selection on the model is disabled. Use the scene objects panel to select parts.` | | `size` | `Size` | | `snapAuto` | `Snap: auto` | | `snapEdge` | `Snap: edge` | | `snapVertex` | `Snap: vertex` | | `startGuidedTour` | `Start guided tour` | | `startMeasuring` | `Start measuring` | | `stopCinematicAndResetCamera` | `Stop cinematic and reset camera` | | `stopGuidedTour` | `Stop guided tour` | | `stopMeasuring` | `Stop measuring` | | `tapToPlace` | `Tap to place` | | `text` | `Text` | | `textColour` | `Text colour` | | `textToAdd` | `Text to add` | | `textureOffsetHint` | `Double-click the object in the preview to drag its texture directly (scroll to zoom), or edit its offset numerically.` | | `texturePosition` | `Texture position` | | `texturePositioningHint` | `Drag to move · Scroll to zoom · Click elsewhere to finish` | | `texturePositioningStartHint` | `Double-click to move or zoom` | | `texturePreview` | `texture preview` | | `toggleBoundingDimensions` | `Toggle bounding dimensions` | | `turn` | `Turn` | | `uncategorized` | `Uncategorized` | | `undoMeasurementPoint` | `Undo measurement point` | | `up` | `up` | | `uploadedModel` | `Uploaded model` | | `uploadImage` | `Upload image` | | `uvCoordinatesMissing` | `This model does not contain UV coordinates.` | | `uvExportFailed` | `Could not create the UV layout archive.` | | `verifyingLicense` | `Verifying License` | | `videoAlreadyRecording` | `A video export is already in progress.` | | `videoBitrateInvalid` | `Video bitrate must be a positive number.` | | `videoContextLost` | `Video export stopped because the WebGL context was lost.` | | `videoDidNotStart` | `Video recording did not start.` | | `videoDurationInvalid` | `Video duration must be greater than 0 and at most 300 seconds.` | | `videoEmpty` | `The browser produced an empty video.` | | `videoEncodingFailed` | `The browser could not encode the video.` | | `videoExitXR` | `Exit the XR session before recording a video.` | | `videoExportFailed` | `Could not export the video.` | | `videoFormat` | `Video format` | | `videoFrameRateInvalid` | `Video frame rate must be between 1 and 60.` | | `videoGIFUnsupported` | `Video recording as GIF is not supported in this browser.` | | `videoGifWidthInvalid` | `GIF width must be a whole number of pixels from 16 to 4096.` | | `videoMP4Unsupported` | `Video recording as MP4 is not supported in this browser.` | | `videoNotReady` | `Viewer not ready for video export.` | | `videoPathRequired` | `Enable a cinematic camera path before recording it.` | | `videoTimedOut` | `Video export timed out. Keep the viewer tab visible while recording.` | | `videoUnsupported` | `Video recording is not supported in this browser.` | | `videoWebMUnsupported` | `Video recording as WEBM is not supported in this browser.` | | `viewIn3D` | `View in 3D` | | `viewInAR` | `View in AR` | | `viewThisModelInARWithAppleQuickLook` | `View this model in AR with Apple Quick Look` | | `viewThisModelInVR` | `View this model in VR` | | `viewThisModelInYourSpace` | `View this model in your space` | | `visibleEntries` | `{count} visible entries` | | `yourText` | `Your text` | --- Canonical: https://react-immersive.liveroom.dev/docs/reference/model-viewer Package: @liveroom-tech/react-immersive@5.2.0 # `ModelViewer` The primary exported component. Renders a GLB, GLTF, OBJ, FBX, USDZ, STL, or PLY model with orbit/pan/zoom controls, built-in side panels, and a full callback API. ## Presets and grouped props `preset` sets `ModelViewer` up for a common kind of page: | Preset | For | What it sets | | --- | --- | --- | | `minimal` | Just the model | Hides both panels, the download and reset buttons, tour controls, and the material variant buttons | | `showcase` | A product on display | Everything `minimal` does, plus auto-rotation | | `configurator` | Customers changing parts | Shows the selected object's panel, the reset button, and the material variant buttons; hides the scene objects panel and download | | `inspector` | Checking a model | Every panel, the view gizmo, the measure, UV checker, exploded-view, section-cut, and isolate tools, and keyboard navigation | `MODEL_VIEWER_PRESETS` lists exactly which props each preset sets. Related props are also available as groups, which keeps a configured viewer readable: ```tsx ``` | Group | Fields, and the flat prop each one sets | | --- | --- | | `ui` | `objectPanel` (`showObjectBindingDataPanel`), `sceneObjects` (`showSceneObjectsPanel`), `download` (`showDownloadButton`; a string also sets `downloadFilename`), `reset` (`showResetButton`), `fullscreen` (`showFullscreenButton`), `loadingOverlay` (`showLoadingOverlay`), `viewGizmo` (`showViewGizmo`), `mouseController` (`showMouseController`; `{ position, opacity }` also sets `mouseControllerPosition` and `mouseControllerOpacity`), `annotationNavigation` (`showAnnotationNavigation`), `annotationOnHover` (`showAnnotationOnHover`), `annotationFilters` (`showAnnotationFilters`), `highlightOnHover`, `materialVariants` (`showMaterialVariants`), `objectNavigator` (`showObjectNavigator`) | | `tools` | `measure` (`showMeasureTools`; `{ unit }` also sets `measurementUnit`), `uvChecker` (`showUvCheckerButton`), `explode` (`showExplodeControls`), `section` (`showSectionTools`), `isolate` (`showIsolateControls`), `texturePositioning` (`texturePositioning`) | | `controls` | `enabled` (`enableCameraControls`), `keyboard` (`enableKeyboardNavigation`), `zoom` (the opposite of `disableZoom`), `zoomOnSelect` (`zoomOnSelected`), `autoRotate` (a number also sets `autoRotateSpeed`), `refitOnResize`, `limits` (`cameraLimits`), `moveSensitivity`, `zoomSensitivity`, `reducedMotion` | | `perf` | `renderMode`, `maxDpr`, `profile` (`performanceProfile`), `preserveDrawingBuffer`, `webgpu` | | `tiles` | `url` (`tilesetUrl`), `errorTarget` (`tilesErrorTarget`), `cacheSize` (`tilesCacheSize`) | | `xr` | `enabled` (`enableXR`), `usdzUrl`, `scaleMode` (`arScaleMode`), `mobileHandoffUrl` | | `decoders` | `draco` (`dracoDecoderPath`), `ktx2` (`ktx2TranscoderPath`), `meshopt` | A preset only fills in defaults: a flat prop overrides it, and a grouped prop overrides both. The flat props keep working exactly as before. ## Props ```ts type Props = { preset?: "minimal" | "showcase" | "configurator" | "inspector"; ui?: ModelViewerUiOptions; tools?: ModelViewerToolsOptions; controls?: ModelViewerControlsOptions; perf?: ModelViewerPerfOptions; tiles?: ModelViewerTilesOptions; xr?: ModelViewerXrOptions; decoders?: ModelViewerDecoderOptions; modelUrl: string; poster?: string; loading?: "lazy" | "eager"; // default "lazy" reveal?: "auto" | "interaction"; // default "auto" labels?: ModelViewerLabels; tilesetUrl?: string | null; tilesErrorTarget?: number; tilesCacheSize?: number; onObjectCatalog?: (objects: ViewerObjectInfo[]) => void; modelFormat?: "glb" | "gltf" | "obj" | "fbx" | "usdz" | "stl" | "ply"; licenseKey: string; objectBindings?: Record | "auto"; // default "auto" selectedObject?: ObjectBinding | null; panelHoveredObject?: ObjectBinding | null; onObjectBindingsChange?: (next: Record) => void; onObjectSelect?: (binding: ObjectBinding | null) => void; onObjectHover?: (binding: ObjectBinding | null) => void; onObjectDoubleClick?: (event: ObjectPointerEvent) => boolean | void; onObjectContextMenu?: (event: ObjectPointerEvent) => void; onModelLoaded?: (model: ViewerModelInfo) => void; onLoadError?: (error: unknown) => void; onProgress?: (event: ProgressEvent) => void; cameraControllerType?: "orbit" | "pointerLock"; onCameraControllerChange?: ( controller: "orbit" | "pointerLock", ) => void; onAction?: (event: ObjectActionEvent) => void; onEngagement?: (event: ViewerEngagementEvent) => void | Promise; debugEngagement?: boolean; onHiddenObjectsChange?: (next: Record) => void; onCameraChange?: (state: ViewerCameraState) => void; onViewerReady?: (viewer: ViewerReadyState) => void; onTextureUpload?: (file: File, objectId: string) => Promise; texturePositioning?: boolean | TexturePositioningControls; onAnimationsReady?: (controls: AnimationControls) => void; onAnnotationsChange?: (annotations: AnnotationMarker[]) => void; activeAnnotation?: AnnotationMarker | null; onActiveAnnotationChange?: (annotation: AnnotationMarker | null) => void; annotationCategories?: string[] | null; onAnnotationCategoriesChange?: (categories: string[] | null) => void; showAnnotationFilters?: boolean; camera?: ViewerCameraConfig; cameraLimits?: CameraLimits; backgroundColor?: string; shadows?: boolean; showObjectBindingDataPanel?: boolean; customObjectBindingDataPanel?: ( props: CustomObjectBindingDataPanelProps, ) => React.ReactNode; customSceneObjectsPanel?: ( props: CustomSceneObjectsPanelProps, ) => React.ReactNode; showSceneObjectsPanel?: boolean; showDownloadButton?: boolean; downloadFilename?: string; showResetButton?: boolean; showFullscreenButton?: boolean; showLoadingOverlay?: boolean; showMouseController?: boolean; mouseControllerPosition?: | "bottom-left" | "bottom-right" | "top-left" | "top-right" | "center" | "center-bottom" | "center-top"; mouseControllerOpacity?: number; moveSensitivity?: number; zoomSensitivity?: number; sceneConfig?: SceneConfig; disableZoom?: boolean; zoomOnSelected?: boolean; highlightOnHover?: boolean; enableCameraControls?: boolean; moveModeEnabled?: boolean; objectTransformSpace?: "local" | "world"; enableKeyboardNavigation?: boolean; showObjectNavigator?: boolean; reducedMotion?: "user" | "always" | "never"; onAutoFit?: () => Promise; refitOnResize?: boolean; renderMode?: "always" | "demand"; maxDpr?: number; preserveDrawingBuffer?: boolean; webgpu?: boolean; performanceProfile?: "auto" | "high" | "low"; dracoDecoderPath?: string | false; ktx2TranscoderPath?: string | false; meshopt?: boolean; showMeasureTools?: boolean; showUvCheckerButton?: boolean; showExplodeControls?: boolean; showSectionTools?: boolean; isolation?: ViewerIsolation | null; onIsolationChange?: (isolation: ViewerIsolation | null) => void; showIsolateControls?: boolean; showMaterialVariants?: boolean; cinematic?: boolean | CinematicConfig; measurementUnit?: string; enableXR?: boolean; usdzUrl?: string; arScaleMode?: "normalized" | "real-world"; mobileHandoffUrl?: string; showAnnotationNavigation?: boolean; showAnnotationOnHover?: boolean; onSceneConfigChange?: (config: SceneConfig) => void; showViewGizmo?: boolean; overlay?: React.ReactNode; unsafe_sceneChildren?: React.ReactNode; autoRotate?: boolean; autoRotateSpeed?: number; }; ``` --- ### `labels` Optional plain-text overrides for built-in viewer text, including button titles, accessible labels, loading messages, tool hints, and AR placement controls. Missing keys retain their English defaults. `ModelViewer` and `SimpleModelViewer` share the same dictionary. Each viewer has its own labels, and changing the prop updates text without reloading its model. See the [full label catalog](https://react-immersive.liveroom.dev/docs/reference/labels) for every key, default, and named placeholder. `labels` does not translate annotation content, object metadata, custom panels, or raw server/loader diagnostics. Supply translations for those in your application. ```tsx ``` --- ### `modelUrl` **Required.** URL or public path to a `.glb`, `.gltf`, `.obj`, `.fbx`, `.usdz`, `.stl`, or `.ply` model. Hosted assets must serve external buffers, MTL files, and textures at their declared relative paths; USDZ dependencies are packaged internally. ```tsx ``` --- ### `modelFormat` Optional explicit format for extensionless or signed URLs. Supported file extensions are detected automatically in normal URLs. ```tsx ``` This is separate from `usdzUrl`: `modelUrl` renders USDZ inside the viewer, while `usdzUrl` launches the native Apple Quick Look experience when XR is enabled. --- ### STL and PLY STL and PLY files hold geometry only: no object names, materials or animations. - **One object per file**, named after the file: `bracket.stl` loads as one object keyed `bracket`, which its bindings use. A text STL with several named solids loads one object per solid. A `.zip` holding one STL or PLY file also works. - **Colour:** objects start a matte grey. A PLY file's vertex colours show as they are, and picking a colour (or texture) replaces them. - **No texture coordinates:** STL files have none and PLY files rarely do, so an image can't be mapped onto them. The viewer's **Change Material** says so instead of offering an upload, BindingBuilder turns off its image map fields for that object, and new bindings leave Change Material out. [Decals](https://react-immersive.liveroom.dev/docs/reference/model-viewer#decals) work without texture coordinates. This applies to any object without them, in any format. - **Units and orientation:** STL files are usually in millimetres with Z up, so a part can load lying on its back. Turn it upright with [`sceneConfig.model.rotation`](https://react-immersive.liveroom.dev/docs/reference/model-viewer#sceneconfig) (BindingBuilder: **Scene** → model rotation, −90° on X), and set [`measurementUnit`](https://react-immersive.liveroom.dev/docs/reference/model-viewer#measurementunit) to `"mm"`. In AR, the default `"normalized"` [`arScaleMode`](https://react-immersive.liveroom.dev/docs/reference/model-viewer#arscalemode) fits the model to a metre; `"real-world"` would read millimetres as metres. - **PLY point clouds** (points with no faces) can't be shown; the viewer explains why instead of loading them. --- ### 3D Tiles streaming Use `tilesetUrl` to stream a processed 3D Tiles model instead of downloading `modelUrl` as one monolithic asset. `modelUrl` remains required by the component API, but `tilesetUrl` is the render source while streaming is enabled. ```tsx console.log(objects)} {...props} /> ``` - `tilesErrorTarget` controls the screen-space error target. Lower values load sharper tiles and use more bandwidth/GPU memory. Default: `8`. - `tilesCacheSize` is the maximum number of parsed tiles kept in the client cache. Default: `800`. - `onObjectCatalog` receives the bindable-object catalog from `objects.json` before all referenced tiles have loaded. See [`onObjectCatalog`](https://react-immersive.liveroom.dev/docs/reference/model-viewer#onobjectcatalog). Bindings, selection, hover, material overrides, transforms, exploded view, and scene renderer modes use the same public API in streamed and monolithic modes. The tileset and its tile payloads must be browser-accessible, and the optional object catalog must sit beside `tileset.json` as `objects.json`. --- ### `licenseKey` **Required.** License key for the library. ```tsx ``` To get a license key, sign in at the [React Immersive Console](https://react-immersive.liveroom.dev/console), choose a plan, and generate a key from your dashboard. The browser validates the key against `https://react-immersive.liveroom.dev/api/validate-license`. A successful result is cached in memory for five minutes and may include a signed, domain-and-key-scoped offline grant valid for up to 12 hours. If validation is temporarily indeterminate, an unexpired signed grant is used immediately when available. Without one, the SDK retries once after a 400 ms backoff; each attempt has a five-second timeout. Timeouts, transport failures, `429`, `5xx`, and malformed responses are retryable, while explicit licence denials are not. A first-time visitor without a valid grant fails closed if both attempts fail. --- ### `objectBindings` A map keyed by the model node identifier, or `"auto"`, the default (see [Generating bindings automatically](https://react-immersive.liveroom.dev/docs/reference/model-viewer#generating-bindings-automatically)). A uniquely named mesh keeps its authored name. When a model contains duplicate node names, each duplicate receives a stable traversal-order identifier such as `Cube [1]` and `Cube [2]`; the original `Object3D.name` is not mutated, so animation tracks continue to work. `BindingBuilder` generates these identifiers automatically and the viewer logs the duplicate-name mapping to the console. `objectBindings` is the single source of truth for all visual state: | Field | Effect | | --------------------------------------------- | ----------------------------------------------------------------------------- | | `visible` | Controls mesh visibility | | `style.material.baseColor` | Sets mesh color (committed by the built-in color picker on close) | | `style.material.texture.path` | Sets the base color (albedo) texture | | `style.material.*` | Per-object `MeshPhysicalMaterial` overrides, see below | | `cameraState.position` / `cameraState.target` | Camera view to use when this object is focused or selected from the viewer UI | All visual overrides live under `style.material` (an `ObjectBindingMaterial`). It covers base color/PBR, emission, opacity & blending, normal/bump, displacement, ambient occlusion, clearcoat, sheen, anisotropy, transmission/volume, and face sidedness, plus a texture-map slot for each. See [`ObjectBindingMaterial`](https://react-immersive.liveroom.dev/docs/reference/types#objectbindingmaterial) for the full field list and the per-slot color-space handling (color maps are decoded as sRGB, data maps stay linear). #### Generating bindings automatically `objectBindings` defaults to `"auto"`: leave it out and the viewer generates a binding for every mesh when the model loads: ```tsx ``` Each generated binding is keyed by the node identifier, with a readable `label`, an inferred `type`, a unique `id`, and built-in actions (`change-color`, `change-material`, `toggle-visibility`, or the three light actions for meshes whose names contain "light"). For a streamed model (`tilesetUrl`), bindings come from the object catalog. The generated bindings are reported through `onObjectBindingsChange`. To keep and refine them, store them and pass them back. After the first change the viewer is controlled by your state; set it back to `"auto"` when you switch models: ```tsx const [objectBindings, setObjectBindings] = useState< Record | "auto" >("auto"); ; ``` While the prop is `"auto"`, a reload of the same model keeps the bindings it already had, so runtime edits survive; a different `modelUrl` starts fresh. `buildBindingsFromNodes`, `createDefaultBinding`, and `inferType` are exported from `@liveroom-tech/react-immersive/utils` for building the same defaults yourself. They're the same defaults `BindingBuilder` generates. ```ts const objectBindings = { Object_2: { id: "obj-2", modelObjectId: "Object_2", type: "body", visible: true, cameraState: { position: [2.8, 1.6, 4.2], target: [0, 0.8, 0], }, style: { material: { baseColor: "#ff0000", texture: { path: "/materials/body-finish.jpg" }, roughness: 0.4, clearcoat: 1, }, }, actions: [ { id: "change-color", label: "Change Color", type: "command" }, { id: "toggle-visibility", label: "Toggle Visibility", type: "command" }, ], metrics: {}, metadata: {}, }, }; ``` --- ### `selectedObject` Optional currently selected object binding, or `null` when nothing is selected. When provided, `ModelViewer` behaves as a **controlled component**. When omitted, it manages its own selection state internally. When the selected object has a `cameraState` with both `position` and `target`, the viewer uses that saved camera view for focus/select behavior instead of falling back to `fitToBox`. --- ### `panelHoveredObject` Optional externally controlled binding to highlight as hovered by a custom scene-objects panel. It takes precedence over the built-in panel and canvas hover while `highlightOnHover` is enabled. Pass `null` when no panel row is hovered. --- ### `onObjectSelect` Called when the user clicks a mesh or closes the side panel. ```ts onObjectSelect?: (binding: ObjectBinding | null) => void ``` - Clicking a mesh → `onObjectSelect(binding)` - Closing the panel → `onObjectSelect(null)` - Clicking the same mesh again still fires; selection is not toggled off automatically - If the binding includes `cameraState.position` and `cameraState.target`, the viewer moves the camera to that saved view when focusing that object - Clicks resolve the front-most surface under the pointer; a mesh whose binding has `selectable: false` does not pass the click through to meshes behind it - On very large models, clicking meshes in the viewport is disabled entirely, see [Large models](https://react-immersive.liveroom.dev/docs/reference/model-viewer#large-models). Selection through the scene objects panel (and this callback) still works --- ### `onObjectHover` Called when the user hovers a mesh. ```ts onObjectHover?: (binding: ObjectBinding | null) => void ``` - Pointer over a mesh → `onObjectHover(binding)` - Pointer out → `onObjectHover(null)` - On very large models, viewport hover is disabled entirely, see [Large models](https://react-immersive.liveroom.dev/docs/reference/model-viewer#large-models) --- ### `onObjectDoubleClick` Optional callback called when the user double-clicks an object. ```ts onObjectDoubleClick?: (event: ObjectPointerEvent) => boolean | void type ObjectPointerEvent = { binding: ObjectBinding; clientX: number; // viewport coordinates clientY: number; pointerType: string; // "mouse" | "pen" | "touch" surface?: { position: Vec3; normal: Vec3 }; // where on the object, in its own space }; ``` Return `true` to say you handled it: the viewer then skips its own double-click behavior, which moves the camera's orbit point to that spot (or starts texture positioning, when `texturePositioning` is on). Return nothing to keep it. --- ### `onObjectContextMenu` Optional callback called when the user right-clicks an object, or long-presses it on a touch screen. Use it to open your own menu: ```tsx openMenu({ for: binding.id, x: clientX, y: clientY }) } /> ``` It receives the same `ObjectPointerEvent` as `onObjectDoubleClick`, with the position where the gesture started, ready for a `position: fixed` menu. A right-click fires when the button is released without the pointer moving, so a right-drag still pans the camera. A long-press fires after half a second, and not if the finger moves or a second finger touches down (a pinch); lifting the finger afterwards doesn't select the object. Objects with `selectable: false` don't fire it. --- ### `onObjectBindingsChange` Fired when the viewer's built-in UI updates binding data. Wire this back into your state to keep the viewer and your app in sync. ```ts onObjectBindingsChange?: (next: Record) => void ``` Fires for: - Visibility toggles (`binding.visible`) - Color picks (`binding.style.material.baseColor`), committed when the color picker closes - Texture uploads (`binding.style.material.texture.path`) - Texture removal - Object movement and rotation (`binding.transform.position` and `binding.transform.rotation`) Passing this callback makes binding edits controlled: update the `objectBindings` prop with the returned record. If it is omitted, the viewer keeps built-in edits in internal state for the lifetime of the mounted viewer. --- ### `onHiddenObjectsChange` Fired with the derived hidden-object map whenever binding visibility state changes. ```ts onHiddenObjectsChange?: (next: Record) => void ``` --- ### `onObjectCatalog` Lists every bindable object in the model: each named mesh, under the identifier `objectBindings` is keyed by. It's the quickest way to find out what your meshes are called: ```tsx console.table(objects)} /> ``` It fires each time a model loads. For a streamed model (`tilesetUrl`), the list comes from `objects.json`, before the tiles containing those objects have necessarily loaded. Each entry is a [`ViewerObjectInfo`](https://react-immersive.liveroom.dev/docs/reference/types#viewerobjectinfo). To keep the list in state, pass [`useViewerModel`](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-model)'s `handleObjectCatalog`. ### `onModelLoaded` Called after the model has loaded, with its bounds and mesh count. ```ts onModelLoaded?: (model: ViewerModelInfo) => void type ViewerModelInfo = { bounds: { min: Vec3; max: Vec3; center: Vec3; size: Vec3 }; // world space objectCount: number; // meshes in the loaded scene materialVariants: string[]; // glTF material variant names, if any materialVariantGroups: MaterialVariantGroup[]; // the same, as option groups }; ``` For the model's object names, use [`onObjectCatalog`](https://react-immersive.liveroom.dev/docs/reference/model-viewer#onobjectcatalog). --- ### `onLoadError` Called if the model fails to load or render. ```ts onLoadError?: (error: unknown) => void ``` Whether or not you pass it, `ModelViewer` shows a message in place of the model that explains the likely cause: a missing file (404), refused access (401/403), a blocked cross-origin request (CORS), a web page served instead of a model, or a file that isn't the expected format. It includes the URL and the underlying error. Set `showLoadingOverlay={false}` to show your own instead. --- ### `onProgress` Called with the loader's `ProgressEvent` while a monolithic model is being downloaded. When `event.lengthComputable` is true, progress is `event.loaded / event.total`; the built-in loading overlay uses the same value. ```ts onProgress?: (event: ProgressEvent) => void ``` --- ### `onAction` Called when a built-in action button is clicked from the side panel. ```ts type ObjectActionEvent = { objectId: string; action: ObjectBindingAction; binding?: ObjectBinding; screenX?: number; screenY?: number; }; onAction?: (event: ObjectActionEvent) => void ``` `ModelViewer` performs `change-color`, `change-material`, `add-text`, `add-image`, `toggle-visibility`, `toggle-light`, `change-light-color`, and `set-brightness` itself. `add-text` and `add-image` let the viewer's user put their own text or image on the object (see [Decals](https://react-immersive.liveroom.dev/docs/reference/model-viewer#decals)). The light actions turn the object's light on and off, open a color picker, and open a 0–200% brightness slider. If scene lights are attached to the object they control those, updating `sceneConfig.lighting` (through `onSceneConfigChange` if you pass it, otherwise inside the viewer); otherwise they control the object's own glow, saved on its binding as `style.material.emissive` and `emissiveIntensity`. Any other action id does something only when you handle it here (pass `useViewerActions().handleAction` to run an action's declared `effects`). Without `onAction`, those buttons do nothing, and the viewer logs a console warning naming each one. `ACTION_CATALOG` and `findCatalogAction`, from `@liveroom-tech/react-immersive/utils`, list the ids `BindingBuilder` offers and whether the viewer or your app handles each one. --- ### `onEngagement` / `debugEngagement` Optional interaction analytics. Set `onEngagement` to receive typed `ViewerEngagementEvent` objects, or set `debugEngagement` to log them in the browser's developer console with the `[react-immersive engagement]` prefix. Both are disabled by default. The viewer does not send telemetry or persist analytics identifiers; your callback decides where events go. ```tsx import { ModelViewer, type ViewerEngagementEvent } from "@liveroom-tech/react-immersive"; function handleEngagement(event: ViewerEngagementEvent) { if (event.type === "part-viewed") { console.info("Part viewed:", event.objectKey, event.source); } if (event.type === "ar-ended") { console.info("Time in AR:", event.durationMs); } // Connect this callback to your application's analytics integration. } ``` Every event includes `sessionId` (an ephemeral ID for this viewer/model), `timestamp` (ISO format), `elapsedMs`, `modelUrl`, and `tilesetUrl` (or `null`). Durations use a monotonic clock and are expressed in milliseconds. Session elapsed time includes loading and background time; it is not an attention metric. | Event `type` | Details and trigger | | --- | --- | | `part-viewed` | `objectId`, `objectKey`, optional `label`, and `source` (`"canvas"`, `"panel"`, or `"keyboard"`). Selecting/focusing a different part; reselecting the current part is ignored. | | `variant-chosen` | `variant`, `previousVariant` (names, or `null` for the default), and `group` (the option group's name, or `null`). Choosing or clearing a built-in material variant; `previousVariant` is the earlier choice in the same group. | | `ar-started` | A confirmed immersive WebXR AR session begins. | | `ar-ended` | `durationMs` and `reason` (`"session-ended"` or `"interrupted"`). A confirmed AR session ends, or tracking stops on model change, unmount, or context loss. | | `ar-launch-requested` | `channel` (`"quick-look"` or `"mobile-handoff"`). Opening external AR or its QR handoff; this does not claim an AR session started. | | `tour-started` | `annotationCount`. Starting the guided tour. | | `tour-completed` | `annotationCount` and `durationMs`. Advancing past its final annotation; emitted once per completed tour. | | `tour-stopped` | `annotationCount`, `durationMs`, and `reason` (`"stopped"`, `"restarted"`, `"annotations-changed"`, or `"interrupted"`). Leaving a tour before completion. | Hover, camera motion, renders, and externally supplied selection/variant updates do not create engagement events. WebXR duration measures the session interval; Quick Look runs outside the page and has no reliable duration callback. Turning tracking on during an existing AR session measures from attachment. Callback changes do not restart timing. Synchronous or asynchronous callback failures are logged without interrupting the viewer. A new model or re-enabling tracking creates a new analytics session. WebGL recovery keeps the session ID; context loss ends active intervals as interrupted. See [`ViewerEngagementEvent`](https://react-immersive.liveroom.dev/docs/reference/types#viewerengagementevent) for the complete event union. --- ### `onViewerReady` Called once camera controls are available and again when the viewer publishes updated ready-state data (for example after model load, camera changes, or binding changes). ```ts onViewerReady?: (viewer: ViewerReadyState) => void type ViewerReadyState = { objectBindings: Record; homeCameraState: { position: Vec3; target: Vec3 } | null; captureImage: (options?: CaptureImageOptions) => Promise; captureVideo: (options?: CaptureVideoOptions) => Promise; invalidate: () => void; rendererBackend: "webgl" | "webgpu"; // which renderer draws; see `webgpu` raw: { controls: CameraControls; // camera-controls scene: Object3D | null; // three.js nodes: Record; // model nodes by binding key and name }; }; type CaptureImageOptions = { width?: number; height?: number; transparent?: boolean; }; ``` `raw` holds the live three.js scene, the camera-controls instance, and the model's nodes, for integrations the rest of the API doesn't cover. You don't need it for anything the hooks and props already do, and its shapes are those libraries' own, so they can change when those libraries do. `captureImage` requires the opt-in `preserveDrawingBuffer` prop (not needed with [`webgpu`](https://react-immersive.liveroom.dev/docs/reference/model-viewer#webgpu)), forces a render, and resolves with a `data:image/png` URL of the canvas. Pass `width`/`height` to render at a resolution other than the canvas's current size, and `transparent` to hide the configured background so the PNG has an alpha channel. It rejects if called before the viewer is ready or when framebuffer preservation is disabled. ```tsx { viewer .captureImage({ width: 1920, height: 1080, transparent: true }) .then((dataUrl) => { // upload, preview, etc. }); }} />; ``` For turning the current camera/visibility/selection into a shareable link, see [`useShareableViewerState`](https://react-immersive.liveroom.dev/docs/hooks/use-shareable-viewer-state). --- ### Video export `captureVideo` records one complete cinematic pass or turntable revolution and resolves with a video `Blob`. It is also available on `useViewerConnection` and `useViewer().connection`. Call it from a button handler after the model has finished loading and framing; ready-state callbacks run repeatedly as the camera moves and should not start recordings automatically. ```tsx import { ModelViewer, useViewerConnection } from "@liveroom-tech/react-immersive"; const connection = useViewerConnection(); const abort = new AbortController(); ; // Run in a button handler after loading and framing have finished. const clip = await connection.captureVideo({ mode: "turntable", // or "cinematic" to record the enabled camera path format: "auto", // prefers WebM; falls back to MP4. "gif" for an animated GIF duration: 12, // seconds; defaults to path timing or a 24-second orbit fps: 30, videoBitsPerSecond: 8_000_000, signal: abort.signal, }); // Preview/upload the Blob; abort.abort() cancels and discards an active export. ``` The download menu offers **Record turntable video** and, when cinematic is enabled, **Record cinematic video**, plus Auto/WebM/MP4/GIF format selection. A recording shows progress and a Cancel control. The filename and Blob MIME type match the encoded format; an explicitly requested unsupported format rejects instead of silently changing formats. With `loop: true`, a cinematic export includes the return segment and stops after one cycle. Recording runs in real time at the canvas's current pixel resolution, includes post-processing, and hides editor gizmos. It pauses cinematic playback, blocks viewer gestures, then restores the previous camera pose and controls on success, cancellation, or failure. Keep the tab visible until recording completes. `preserveDrawingBuffer` is unnecessary for video. No audio, HTML overlays, or format transcoding is included. The browser must support `canvas.captureStream`, and `MediaRecorder` for WebM/MP4; exports reject before framing, during XR, during another recording, or for durations outside (0, 300] seconds. See [`CaptureVideoOptions`](https://react-immersive.liveroom.dev/docs/reference/types#capturevideooptions) for the complete options and defaults. **Animated GIF.** Pass `format: "gif"` (or pick **GIF** in the menu's format select) for a GIF that loops forever, for places that don't play video, such as email, chat, and some marketplaces: ```ts const gif = await connection.captureVideo({ mode: "turntable", format: "gif", gifWidth: 480, // pixels; the height follows the canvas. Default 480 // fps defaults to 15, and a turntable to an 8-second turn }); ``` A GIF holds 256 colours per frame and stores every frame whole, so it is much larger than a video of the same length and shows some banding on smooth gradients. Frames are taken from the same stream as a video and encoded as they're captured, so a long recording doesn't build up memory. Each frame lasts as long as it really showed, so a busy device drops frames rather than playing the GIF fast. `videoBitsPerSecond` doesn't apply to GIFs. The encoder ([gifenc](https://github.com/mattdesl/gifenc)) loads only when a GIF is made. --- ### `onCameraChange` Called whenever the camera moves, with its plain state. It fires often while the user orbits, pans, or zooms. ```ts onCameraChange?: (state: ViewerCameraState) => void type ViewerCameraState = { position: [number, number, number]; target: [number, number, number]; fov: number | null; zoom: number; azimuthAngle?: number; // radians, for setCameraState to restore polarAngle?: number; // radians }; ``` It's the same state [`useViewerCamera`](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-camera) exposes as `cameraState`, and `setCameraState` restores it. --- ### `onTextureUpload` Optional async callback for handling texture file uploads. Use it to upload to your own storage (S3, Cloudinary, CDN, etc.) and return a durable URL. ```ts onTextureUpload?: (file: File, objectId: string) => Promise ``` When omitted, the viewer falls back to `URL.createObjectURL(file)`, a session-scoped blob URL that is lost on reload. ```tsx { const formData = new FormData(); formData.append("file", file); formData.append("objectId", objectId); const res = await fetch("/api/upload-texture", { method: "POST", body: formData, }); const { url } = await res.json(); return url; }} /> ``` --- ### `texturePositioning` Turns on texture positioning for objects with an uploaded texture. Off by default. When on, the **Change Material** panel gains Offset X/Y and Rotation controls, and double-clicking the object puts the viewer in Move/Zoom mode: drag to move the texture, scroll to scale it, and click the canvas or **Done positioning** to finish. ```tsx ``` Changes are written to the binding's `style.material.texture` (`offset`, `rotation` in radians, and `scale`), so they reach `onObjectBindingsChange` and your saved bindings like any other edit. To handle some of it yourself, pass an object instead of `true`. Any field you leave out keeps the viewer's own behavior: ```ts type TexturePositioningControls = { offset?: [number, number]; // shown in the Offset fields instead of the binding's rotationDegrees?: number; // shown in the Rotation field, 0–360 onOffsetChange?: (objectId: string, axisIndex: 0 | 1, value: number) => void; onRotationChange?: (objectId: string, degrees: number) => void; isPositioning?: boolean; // shows "Done positioning" instead of the hint onEndPositioning?: () => void; // after Done positioning ends the mode }; ``` `onOffsetChange` and `onRotationChange` replace the viewer's writes from the panel fields; dragging and scrolling on the canvas still write to the binding. --- ### `onAnimationsReady` Called after the model is loaded, providing animation controls for supported embedded GLB/GLTF, FBX, or USDZ animations. Called even if the model has no animations (empty `clips` array, no-op functions). ```ts type AnimationControls = { clips: string[]; clipDetails?: { sourceName: string; duration: number }[]; play: (clipName: string, options?: AnimationPlayOptions) => void; pause: (clipName?: string) => void; stop: (clipName?: string) => void; setSpeed: (speed: number, clipName?: string) => void; seek?: (time: number, clipName?: string) => void; // absolute time, in seconds getState?: () => AnimationPlaybackState; // includes every active clip subscribe?: (listener: (state: AnimationPlaybackState) => void) => () => void; }; onAnimationsReady?: (controls: AnimationControls) => void ``` See [`AnimationControls`](https://react-immersive.liveroom.dev/docs/reference/types#animationcontrols) for `AnimationPlayOptions` and the full `AnimationPlaybackState` shape (`currentClip`, `isPlaying`, `speed`, `time`, `duration`, and `active`, one entry per active clip). Leave out `clipName` to act on every active clip. Wire to `useViewerAnimations().handleAnimationsReady` for the simplest integration. --- ### Lighting Lighting lives in `sceneConfig.lighting`: an ambient light plus a list of directional, point, spot, and hemisphere lights. Without a `sceneConfig`, `ModelViewer` uses its default rig (ambient, a hemisphere fill, and a shadow-casting key light). To use your own lights, patch the default scene config. `sceneLight` fills in every setting you leave out: ```tsx import { DEFAULT_SCENE_CONFIG, patchSceneConfig, sceneLight, } from "@liveroom-tech/react-immersive/utils"; const sceneConfig = patchSceneConfig(DEFAULT_SCENE_CONFIG, { lighting: { ambient: { intensity: 0.5 }, lights: [ sceneLight({ id: "key", type: "directional", position: [4, 8, 4], intensity: 1.6, castShadow: true }), sceneLight({ id: "fill", type: "point", position: [-3, 3, 2], intensity: 0.8 }), ], }, }); ; ``` To change lights while the app runs, use the lights controller from `useViewer` (or `useSceneConfig`): ```tsx const viewer = useViewer({ sceneConfig }); viewer.scene.lights.setIntensity("key", 2.2); viewer.scene.lights.toggle("fill"); viewer.scene.lights.add({ id: "bulb", type: "point", color: "#ffd27d", intensity: 4 }); viewer.scene.lights.attach("bulb", { objectId: "Lamp", offset: [0, 1.4, 0] }); ``` An attached light follows the object, offset from the object's origin in its local space (the origin is often at its base, not its center). Lighting set up in `BindingBuilder`'s Scene tab exports as the same `sceneConfig`. --- ### Style rules `sceneConfig.styleRules` restyles objects from their own data while the app runs: a colour for a temperature, grey for a disabled part, a pulsing glow for an alarm. Each rule picks objects (`target`), checks their data (`when`), and sets a look (`material`, `colorScale`, `pulse`): ```tsx const viewer = useViewer({ bindings, sceneConfig: { ...DEFAULT_SCENE_CONFIG, styleRules: [ { id: "temperature", target: { group: "pumps" }, colorScale: { metric: "temperature", stops: [[18, "#3b82f6"], [24, "#22c55e"], [32, "#ef4444"]], }, }, { id: "offline", when: { status: "disabled" }, material: { baseColor: "#9ca3af", opacity: 0.4 }, }, { id: "alarm", when: [{ metric: "temperature", above: 32 }, { metadata: "alarm.acknowledged", equals: false }], pulse: { color: "#ff3b30", speed: 1.5 }, }, ], }, }); ; // Live data: update the bindings and the looks follow. viewer.bindings.updateObjectBindings("pump-3", { metrics: { temperature: 34 } }); viewer.bindings.updateObjectBindings("valve-1", { status: "disabled" }); ``` - `target` takes the same targets as the binding helpers: an id, a list of ids, `{ group }`, `{ tag }`, or `{ type }`. Left out, the rule considers every bound object. - `when` is a condition, or a list that must all match; left out, the rule always applies. Conditions: `{ metric, above?, below? }` (the value must be greater than `above` and less than `below`; an object without the metric doesn't match), `{ status }` with one status or a list (a binding without one counts as `"normal"`), and `{ metadata, equals }`, where dots in the key reach into nested metadata. - `material` accepts any `style.material` field. `colorScale` sets `baseColor` (or `emissive`, with `channel: "emissive"`) from a metric, blending between hex colour stops and taking the nearest stop outside them. `pulse` makes the glow fade in and out (`true`, or `{ color, speed }` in pulses per second). - Rules apply in order over each object's own `style.material`, so a later rule wins where two set the same field. Hover and selection highlighting still show on top. - Rules only change how objects look. `objectBindings`, `onObjectBindingsChange`, and every callback keep the object's own values, so nothing a rule sets is saved with the bindings. - The pulse isn't drawn on streamed 3D Tiles models (`tilesetUrl`); the other looks are. BindingBuilder's Scene tab has a **Rules** sub-tab for building these rules visually. It suggests your groups, tags, types, metric names, and metadata keys, shows how many objects each rule matches right now, and previews the result live. Rules export and import with the rest of `sceneConfig`. `applyStyleRules(bindings, rules)` (from the main entry or `/utils`) returns the styled bindings, for example to colour your own list the way the viewer does, and `colorScaleValue(stops, value)` the colour a scale gives a value, for a legend. ### `camera` Optional starting camera: where it is and its lens. ```ts type ViewerCameraConfig = { position?: [number, number, number]; fov?: number; // vertical field of view in degrees; default 50 near?: number; far?: number; zoom?: number; }; ``` Without a `position`, the camera starts at `[0, 0, 5]`. Pass one for predictable framing. ```tsx ``` Passing an explicit `camera` counts as choosing the initial view yourself, so the mount-time auto-fit (see [`onAutoFit`](https://react-immersive.liveroom.dev/docs/reference/model-viewer#onautofit)) is skipped automatically and your `position`/`fov` stick. Provide `onAutoFit` as well only if you want to run your _own_ fit-to-scene logic instead. --- ### `backgroundColor` Optional background color for the viewer canvas. ```tsx ``` --- ### `shadows` Controls whether the viewer renders shadows. Default: `true`. When `true`, the canvas renders soft shadows: the default light rig casts a directional key-light shadow, furniture/decor meshes cast and receive shadows, and ambient occlusion is applied via post-processing. When `false`, shadow-map rendering is disabled on the canvas, no shadow-pass cost, and per-mesh/light `castShadow` flags are ignored. ```tsx ``` **Interior models (rooms, dollhouses):** the large mesh that forms the walls/ceiling enclosure is detected by its bounding-box size and automatically excluded from **casting** shadows (it still **receives** them). Without this, a closed shell would block a top-down light and seal the interior in darkness. Furniture and decor then cast realistic contact shadows onto the floor. Pass a your own lights in `sceneConfig.lighting` (see [Lighting](https://react-immersive.liveroom.dev/docs/reference/model-viewer#lighting)) to replace the default rig. --- ### `showObjectBindingDataPanel` Controls whether the built-in left object details panel is rendered. Default: `true`. --- ### `customObjectBindingDataPanel` Render prop for replacing the built-in left object details panel. ```ts type CustomObjectBindingDataPanelProps = { isOpen: boolean; selectedObject: ObjectBinding | null; currentAction: ObjectActionEvent | null; onClose: () => void; onAction: (event: ObjectActionEvent) => void; }; ``` --- ### `showSceneObjectsPanel` Controls whether the built-in right scene objects panel is rendered. Default: `true`. --- ### `overlay` Your own UI over the 3D view: badges, HUDs, buttons, custom panels. It sits above the model and below the viewer's toolbars and banners. ```tsx In stock } /> ``` The layer covers the view but lets pointer events through, so the model stays draggable around your elements. Its direct children receive pointer events, so position them yourself (for example with `position: "absolute"`) rather than wrapping everything in one full-size container. --- ### `unsafe_sceneChildren` react-three-fiber elements rendered inside the scene, for anything the viewer can't do itself, such as custom geometry, a helper, or an effect driven by `useFrame`: ```tsx import { useFrame } from "@react-three/fiber"; function Marker() { const ref = useRef(null); useFrame((_, delta) => (ref.current!.rotation.y += delta)); return ( ); } } />; ``` They're rendered inside their own `Suspense` boundary, so a child that loads assets doesn't hold up the model. Because they work with three.js directly, they're outside the viewer's own API: the name says so, and they can break when three.js or react-three-fiber change. Prefer the props and hooks for anything they cover. --- ### `customSceneObjectsPanel` Render prop for replacing the built-in right scene objects panel. ```ts type CustomSceneObjectsPanelProps = { objectBindings: Record; onAction?: (event: ObjectActionEvent) => void; onFocus?: (binding: ObjectBinding) => void; onHover?: (binding: ObjectBinding | null) => void; }; ``` --- ### `showDownloadButton` Enables the download split-button (export GLB, export the model's UV layout, and—when `preserveDrawingBuffer` is enabled—export a PNG screenshot). The menu also offers **Record turntable video** and, when cinematic is enabled, **Record cinematic video**, with Auto/WebM/MP4/GIF format selection, progress, and cancellation. See [Video export](https://react-immersive.liveroom.dev/docs/reference/model-viewer#video-export). Default: `true`. UV export downloads an editable 2048×2048 SVG UV-island outline when the model uses one material. For multi-material models it downloads a ZIP containing one SVG texture template per material. Meshes without a `uv` attribute are omitted; when the model has no UV coordinates, the viewer reports that no layout is available. The bottom-right action bar renders when at least one of `showResetButton`, `showDownloadButton`, `showFullscreenButton` (when supported), or (`showAnnotationNavigation` with annotations present) is `true`. --- ### `downloadFilename` Filename stem for the built-in model export button. Default: `"model"`. --- ### `showResetButton` Shows the "reset camera view" button in the bottom-right action bar. Default: `true`. --- ### `showFullscreenButton` Optional boolean that adds an enter/exit fullscreen toggle to the bottom-right action bar. Its grouped form is `ui.fullscreen`. Default: `false`. ```tsx ``` Fullscreen expands the whole viewer, including its panels and overlays. The button reflects browser exits such as Escape, and is hidden when fullscreen is unavailable. It is temporarily disabled during video recording. Embedded viewers need an iframe with `allow="fullscreen"` (or `allowFullScreen` in React). A rejected request displays an error beside the button. --- ### `showLoadingOverlay` Controls whether the built-in loading overlay is shown while the model is loading and the initial camera fit is settling, and the load-error message if the model fails to load (see [`onLoadError`](https://react-immersive.liveroom.dev/docs/reference/model-viewer#onloaderror)). Default: `true`. --- ### `showMouseController` Renders an on-screen joystick/zoom controller for moving the camera with a mouse or touch, useful on touch devices or kiosk layouts. Default: `false`. ```tsx ``` --- ### `cameraLimits` Constrains how far the orbit camera can zoom, tilt, turn, and pan. Angles are in degrees. ```tsx ``` ```ts type CameraLimits = { minDistance?: number; // default 0 maxDistance?: number; // default Infinity minPolarAngle?: number; // degrees, default 0 maxPolarAngle?: number; // degrees, default 180 minAzimuthAngle?: number; // degrees, default -Infinity maxAzimuthAngle?: number; // degrees, default Infinity panBoundary?: [ min: [number, number, number], max: [number, number, number], ]; boundaryEnclosesCamera?: boolean; // default false boundaryFriction?: number; // default 0 }; ``` - **Polar** is the vertical angle: `0` looks straight down from above, `90` is level with the target, and `180` looks straight up from below. - **Azimuth** is the horizontal angle around the vertical axis: `0` views the target from the front (+Z), `90` from its right (+X), and `-90` from its left. `panBoundary` is an axis-aligned world-space box. These limits apply to the orbit controller; pointer-lock mode is not orbiting around the same target. --- ### `mouseControllerPosition` Placement of the on-screen controller. Default: `"center-bottom"`. ```ts type MouseControllerPosition = | "bottom-left" | "bottom-right" | "top-left" | "top-right" | "center" | "center-bottom" | "center-top"; ``` --- ### `mouseControllerOpacity` Opacity of the on-screen controller. Default: `1`. --- ### `moveSensitivity` Movement sensitivity for the on-screen controller. Default: `0.08`. --- ### `zoomSensitivity` Zoom sensitivity for the on-screen controller. Default: `1.0`. --- ### `sceneConfig` Optional scene-wide configuration object, model, camera, lighting, wireframe, shadows, environment, background, ground shadows, post-processing, animations, annotations, and a cinematic camera path. This is the same `SceneConfig` shape authored by `BindingBuilder`'s **Scene** tab. If omitted, the viewer uses its built-in default scene config. Use it to define the experience around the model, rather than individual model objects. For example, a guided tour can use `annotations` and saved camera views to direct the camera through a scene without requiring `objectBindings` for every tour stop. See the [`Guided Tour`](https://react-immersive.liveroom.dev/examples/guided-tour) example. `sceneConfig.animations` drives autoplay and per-clip playback (see [`onAnimationsReady`](https://react-immersive.liveroom.dev/docs/reference/model-viewer#onanimationsready)), and `sceneConfig.annotations` seeds the annotation markers rendered on the model. `sceneConfig.model.renderer` accepts `"pbr"`, `"matcap"`, or `"uv-checker"`. UV Checker temporarily replaces every mesh material with an unlit numbered test chart without changing `objectBindings` or uploaded textures. Switching back restores the original material references. Meshes without UV0 coordinates appear magenta and trigger an explanatory notice. ```tsx const { sceneConfig, updateSceneConfig } = useSceneConfig(initialSceneConfig); const enableUvChecker = () => updateSceneConfig({ model: { renderer: "uv-checker" } }); ``` See [`useSceneConfig`](https://react-immersive.liveroom.dev/docs/hooks/use-scene-config) for nested scene updates and the pure `patchSceneConfig` alternative. --- ### `onSceneConfigChange` Called with an updated `SceneConfig` when viewer-owned scene editing changes the configuration. Currently this is used when a light-position or rotation gizmo moves a scene-config light. Update controlled `sceneConfig` state with the value returned by this callback. ```ts onSceneConfigChange?: (config: SceneConfig) => void ``` --- ### `disableZoom` Optional boolean. When `true`, the viewer never zooms the camera to an object on selection (selection still highlights and fires callbacks). Default: `false`. --- ### `zoomOnSelected` Optional boolean controlling whether selecting an object zooms/fits the camera to it. Set to `false` to keep the current camera framing on selection. Default: `true`. (Zooming is also skipped when `disableZoom` is `true`.) --- ### `highlightOnHover` Controls hover highlighting for the canvas, built-in object list, and `panelHoveredObject`. Default: `true`. On very large models, viewport hover is disabled for performance, while panel-row highlighting remains available. --- ### Moving and rotating objects Set `moveModeEnabled` to show a centered `PivotControls` gizmo for the selected bound object. Its translation arrows, plane handles, and rotation rings are available at the same time; scaling is disabled. ```tsx import { useState } from "react"; import { ModelViewer, type ObjectBinding, } from "@liveroom-tech/react-immersive"; export function EditableViewer({ initialBindings, }: { initialBindings: Record; }) { const [bindings, setBindings] = useState(initialBindings); return ( ); } ``` Click a bound mesh to select it. Drag an arrow to move on one axis, a plane handle to move on two axes, or a ring to rotate. The pivot is calculated from the center of the object's renderable geometry, so the object rotates around itself even when its authored node origin is elsewhere. `objectTransformSpace` accepts: - `"world"` (default): handles stay aligned with the scene axes - `"local"`: handles follow the selected object's current orientation Changing axis space only changes handle alignment; it does not move the object. Changes are persisted to the binding as local-space `transform.position` and `transform.rotation`. Positions use model units and rotations use radians. If you pass `selectedObject`, selection is controlled too. Update it from `onObjectSelect`. If you omit both props, `ModelViewer` manages selection internally. --- ### `enableCameraControls` Optional boolean controlling orbit, pan, and zoom input. Default: `true`. Camera controls are paused whenever `moveModeEnabled` is on so pointer drags manipulate the object. --- ### `cameraControllerType` / `onCameraControllerChange` Selects the active camera interaction mode: - `"orbit"` (default) uses orbit, pan, and zoom controls. - `"pointerLock"` captures the pointer for first-person look-around. Leaving pointer lock returns the viewer to orbit mode. The viewer's camera-controller menu can manage this internally. Pass `cameraControllerType` to control it from application state and update that state from `onCameraControllerChange`. ```tsx const [controller, setController] = useState<"orbit" | "pointerLock">("orbit"); ``` Auto-rotation pauses while pointer-lock mode is active. --- ### `autoRotate` / `autoRotateSpeed` Enables automatic orbiting and controls its speed. Defaults are `false` and `1`. When these props aren't passed, `sceneConfig.model.autoRotate` and `sceneConfig.model.autoRotateSpeed` apply, so a scene exported from `BindingBuilder` can carry the same behavior. When they are passed, they win. ```tsx ``` Auto-rotate doesn't start for visitors who ask their device to reduce motion. See [`reducedMotion`](https://react-immersive.liveroom.dev/docs/reference/model-viewer#reducedmotion). --- ### `enableKeyboardNavigation` Optional boolean enabling canvas-focused camera shortcuts. Default: `false`. Click or focus the canvas first, then use `W`/`S` to move forward/back, `A`/`D` to truck left/right, Space/`C` to move up/down, and the arrow keys to orbit. `Shift` + Up/Down moves forward/back. The shortcuts are inactive while object editing or camera controls are disabled. While the canvas has focus, a screen reader reads out what its keys do (the `cameraKeyboardHelp` [label](https://react-immersive.liveroom.dev/docs/reference/labels)). --- ### Accessibility The viewer can be used without a mouse, and reads out what is happening: - **Moving through objects with the keyboard.** Tabbing into the viewer reaches a small object list at the top of the canvas, shown only while it has focus. The arrow keys (or `Home` and `End`) move through the objects and highlight each one in the scene. Typing a letter jumps to an object whose name starts with it. `Enter` or Space selects the object, exactly as clicking it would, and `Escape` clears the selection. Screen readers hear each object's name and its position ("Wheel, 3 of 12"). The list follows the scene objects panel's order. It skips hidden objects, and objects an [isolation](https://react-immersive.liveroom.dev/docs/reference/model-viewer#isolating-objects) has set aside. - **Announcing the selection.** Every change of selection is announced, whether it came from a click, the keyboard, the objects panel, or your app ("Wheel selected", "Selection cleared"). - **The scene objects panel.** Each row is a button, so the panel can be used from the keyboard too. Focusing a row highlights its object, and the visibility buttons name the object they hide or show. - **Reduced motion.** See [`reducedMotion`](https://react-immersive.liveroom.dev/docs/reference/model-viewer#reducedmotion) below. All of this text can be translated with [`labels`](https://react-immersive.liveroom.dev/docs/reference/model-viewer#labels). ### `showObjectNavigator` Optional boolean for the keyboard object list described above. Default: `true`. It takes no space until a keyboard user reaches it, so it's on by default. Set it to `false` when your app provides its own way to choose objects from the keyboard. It is hidden while measuring, since clicks then place points instead of selecting. ```tsx ``` Keyboard selections reach [`onEngagement`](https://react-immersive.liveroom.dev/docs/reference/model-viewer#onengagement--debugengagement) as `part-viewed` events with `source: "keyboard"`. ### `reducedMotion` Cuts motion the viewer starts on its own, for visitors who find it hard to watch. Default: `"user"`, which follows the visitor's device setting (`prefers-reduced-motion`). `"always"` and `"never"` override it. ```ts reducedMotion?: "user" | "always" | "never"; // also controls.reducedMotion ``` When motion is reduced: - [Auto-rotate](https://react-immersive.liveroom.dev/docs/reference/model-viewer#autorotate--autorotatespeed) doesn't start. - A [cinematic](https://react-immersive.liveroom.dev/docs/reference/model-viewer#cinematic) path with `autoPlay` waits for its play button. - Camera moves jump straight to where they're going instead of gliding: the first framing, focusing a selected object, resetting the view, saved views, guided-tour stops, and moves made through [`useViewerCamera`](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-camera). - Panels, menus and popups appear in place instead of sliding or fading in. Loading spinners keep turning, since they show that something is still happening. Motion the visitor asks for still plays: the cinematic play button, video export, and dragging the camera. The viewer doesn't change animations your app starts, such as model clips, [effects](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-effects), or [style rule](https://react-immersive.liveroom.dev/docs/reference/model-viewer#style-rules) pulses. To respect the setting there too, check `matchMedia("(prefers-reduced-motion: reduce)")` in your app. `SimpleModelViewer` takes the same prop. --- ### `showViewGizmo` Optional boolean showing a clickable camera-orientation gizmo in the top-right of the viewer. Default: `false`. It displays the scene axes and can snap the camera to an axis. This is separate from the selected object's transform gizmo and is hidden while `moveModeEnabled` is on. --- ### `onAutoFit` Optional async callback invoked once the model has loaded and the scene is ready. When provided, it is called so consumers can run their own fit-to-scene logic (e.g. an animated fit); if omitted, the viewer falls back to its internal `fitScene`, unless an explicit [`camera`](https://react-immersive.liveroom.dev/docs/reference/model-viewer#camera) prop was given, in which case that position is treated as the chosen initial view and `fitScene` is skipped. ```ts onAutoFit?: () => Promise ``` --- ### `refitOnResize` Optional boolean controlling whether the camera re-frames the model to fit whenever the canvas is resized, a browser window resize, a device rotation, or a side panel opening/closing (which changes how much width the canvas has). Set to `false` to preserve the user's current orbit/zoom across resizes; the camera aspect stays correct either way, so the model never distorts, it just isn't re-centered. Only affects re-fits _after_ the initial mount-time auto-fit. Default: `true`. While a selected object is framed (see [`zoomOnSelected`](https://react-immersive.liveroom.dev/docs/reference/model-viewer#zoomonselected)), a resize frames that object again rather than the whole model, so the object panel opening doesn't undo the zoom. An object with an authored `cameraState` keeps that view. Once the selection is cleared, or something else moves the camera away from the object, resizes frame the whole model again. --- ### `onAnnotationsChange` Optional callback fired when the annotation markers on the model change (added, edited, or removed through the viewer UI). ```ts (annotations: AnnotationMarker[]) => void ``` `AnnotationMarker` is the `SceneAnnotationMarker` shape: ```ts type SceneAnnotationCameraView = { position: [number, number, number]; target: [number, number, number]; azimuthAngle?: number; // radians polarAngle?: number; // radians fov?: number | null; zoom?: number; }; type SceneAnnotationImage = { src: string; alt?: string }; type SceneAnnotationLink = { href: string; label: string }; type SceneAnnotationMarker = { id: number; worldPosition: [number, number, number]; localPosition: [number, number, number]; worldNormal?: [number, number, number]; anchorNodeName?: string; anchorLocalPosition?: [number, number, number]; title: string; description: string; cameraView?: SceneAnnotationCameraView; images?: SceneAnnotationImage[]; links?: SceneAnnotationLink[]; category?: string; minDistance?: number; maxDistance?: number; }; type AnnotationMarker = SceneAnnotationMarker; ``` --- ### Rich annotations Annotations can include a saved camera view, images, links, a category, and camera-distance limits. New annotations use automatic close-up tour framing toward the marker, including following animated anchors. A custom `cameraView` is optional: explicitly capture one in BindingBuilder or provide it in code. Selecting a marker with a saved view or visiting it in the guided tour restores its world-space position, target, orbit angles, field of view, and zoom. Saved views stay fixed while the model animates. Clearing the saved view in BindingBuilder restores automatic framing. ```tsx import { ModelViewer, DEFAULT_SCENE_CONFIG, type SceneConfig } from "@liveroom-tech/react-immersive"; const sceneConfig: SceneConfig = { ...DEFAULT_SCENE_CONFIG, annotations: [{ id: 1, title: "Replaceable filter", description: "Open this panel to access the filter.", worldPosition: [0, 1, 0], localPosition: [0, 1, 0], cameraView: { position: [2, 2, 4], target: [0, 1, 0], fov: 45, zoom: 1 }, images: [{ src: "/images/filter.jpg", alt: "Filter compartment" }], links: [{ href: "https://example.com/manual", label: "Read the manual" }], category: "Maintenance", minDistance: 0.5, maxDistance: 12, }], }; ``` Images and links accept HTTP(S) URLs and relative paths. Executable URL schemes are ignored. Descriptions and labels render as plain text. Supply image `alt` text for accessible descriptions. Image files stay at their URLs; they are not uploaded or bundled into scene exports automatically. `minDistance` and `maxDistance` are inclusive distances from the camera to the marker's live world position, in scene units. Omit either bound for no limit in that direction. These settings hide markers, including hover interaction, but leave explicit popups and tour steps available. Distance is unrelated to the measurement unit label. Occlusion behavior still applies. ### `annotationCategories` / `onAnnotationCategoriesChange` / `showAnnotationFilters` Category chips appear when any annotation has a category. Toggle chips to select one or more categories, or choose **All**. Filtering applies to markers, open popups, and tour steps. Changing the filter ends the current tour. Annotations keep their IDs and are visited in ID order. ```tsx const [categories, setCategories] = useState(null); ``` - `null` shows all categories. An empty array hides all annotations. - `""` selects annotations without a category. Category names are trimmed and matched case-sensitively. - Omitting `annotationCategories` lets the viewer manage the filter internally. - `showAnnotationFilters` (or `ui.annotationFilters`) defaults to `true`. Set it to `false` to supply your own filter UI; the filter props still apply. --- ### `activeAnnotation` / `onActiveAnnotationChange` Optional controlled state for which annotation is currently open. Pass `activeAnnotation` to control it from your app, and `onActiveAnnotationChange` to be notified when the viewer wants to open (`AnnotationMarker`) or close (`null`) one. If `activeAnnotation` is left `undefined`, the viewer manages this state internally (uncontrolled). ```ts activeAnnotation?: AnnotationMarker | null; onActiveAnnotationChange?: (annotation: AnnotationMarker | null) => void; ``` ### `renderMode` `"demand"` (default) only re-renders the canvas when something changes (camera move, state update, animation frame); `"always"` runs a continuous render loop. Leave at `"demand"` for lower GPU/battery cost unless you have a custom scene that mutates outside React/animation state. --- ### `maxDpr` Upper bound for the device pixel ratio used when rendering, so retina/4K displays don't render at full 2–3x cost. Default: `2`. --- ### `preserveDrawingBuffer` Retains the WebGL framebuffer so `captureImage` and the built-in Download PNG action can read the rendered frame. Default: `false`. Enable it only when PNG capture is needed because framebuffer retention adds GPU memory and bandwidth cost to every rendered frame. ```tsx ``` --- ### `webgpu` Optional boolean. Draws with three.js's WebGPU renderer when the browser has WebGPU, and with WebGL otherwise. Default: `false`. Also `perf.webgpu`. ```tsx ``` The viewer asks the browser for a WebGPU adapter once per page before creating its canvas. Without one, it uses WebGL and says so once in the console. `onViewerReady` reports which renderer is drawing as `rendererBackend` (`"webgl"` or `"webgpu"`). The WebGPU renderer is loaded only by viewers that use it. With WebGPU, the model, materials, bindings, hover highlight, picking, animations, decals, annotations, measurement, video and GIF export, and tone mapping and exposure work as they do with WebGL. Some features are built on WebGL and are left out: - The post-processing effects (outline, bloom, depth of field, and the rest) don't draw. Tone mapping and exposure from `sceneConfig.postProcessing` still apply, through the renderer. - Contact shadows (`sceneConfig.groundShadows` in shadow-catcher mode) don't draw. - The section cut and the move gizmo (`moveModeEnabled`) aren't available, and the console says so if they're asked for. - AR and VR sessions in the browser (WebXR) aren't offered. Quick Look on iOS and the phone hand-off still work. - Measurement lines are one pixel wide. `captureImage` and the Download PNG action work with WebGPU without `preserveDrawingBuffer`. A lost WebGPU device shows the same **Reload viewer** message as a lost WebGL context. --- ### Lazy loading and posters `loading`, `poster` and `reveal` decide when a viewer creates its 3D view, and what shows in its place until then. They matter most on pages with many viewers, such as a product grid: browsers allow a page about 16 WebGL contexts and take the oldest away past that. ```tsx ``` ```ts poster?: string; loading?: "lazy" | "eager"; // default "lazy" reveal?: "auto" | "interaction"; // default "auto" ``` `loading` is `"lazy"` by default. A lazy viewer: - starts its 3D view when it comes within about 200px of the screen. One that's on the screen when the page loads starts at once. - shares the page's live 3D views with the other lazy viewers: at most 12, or 6 on phones and tablets. Viewers on the screen come first. One scrolled away from keeps its view while there's room, so coming back is instant. When the room is needed, it's put away, leaving a picture of its last frame, and is built again when it comes back near the screen. - comes back as it was: the camera, picked variants, light and section-cut changes, isolation, the exploded view, the camera controller, the UV checker, the annotation filter, and, when the viewer keeps `objectBindings` itself, the changes made to them. The selection and open panels don't come back. `onModelLoaded` and `onViewerReady` run again, with a new viewer. - stops drawing while it's off the screen, and starts again when it's back. Browsers already stop drawing in hidden tabs. `loading="eager"` starts the 3D view at once and keeps it. Eager viewers don't count towards the 12. [`BindingBuilder`](https://react-immersive.liveroom.dev/docs/reference/binding-builder)'s preview is eager. `poster` is an image shown in the viewer's place until its 3D view is drawn, then fades out; the loading message shows over it. It's fitted inside the viewer. To fill the viewer instead, set the CSS variable `--ri-poster-fit: cover` on a parent. A render of the model framed as the viewer frames it works best: [`captureImage`](https://react-immersive.liveroom.dev/docs/reference/model-viewer#onviewerready) can make one. `reveal="interaction"` keeps the poster, with a **View in 3D** button, until the visitor presses it, so only the products someone picks use a 3D view. The button's text is the [`labels`](https://react-immersive.liveroom.dev/docs/reference/labels) key `viewIn3D`. `reveal` is `"auto"` by default: the view starts on its own. The same three props work on [`SimpleModelViewer`](https://react-immersive.liveroom.dev/docs/reference/simple-model-viewer#poster--loading--reveal) and [`CompareViewer`](https://react-immersive.liveroom.dev/docs/reference/compare-viewer#props). --- ### `performanceProfile` Optional control over how aggressively the viewer trades visual fidelity for a stable WebGL context on constrained GPUs. ```ts performanceProfile?: "auto" | "high" | "low"; ``` - `"auto"` (default) applies a reduced profile on handheld/mobile browsers and other low-power touch devices: the postprocessing pipeline is skipped, soft shadows are disabled, and the device pixel ratio is capped. Desktop-class touch devices may still keep the higher-quality path when they advertise plenty of memory. - `"high"` always renders at full quality. - `"low"` always applies the reduced profile. Default: `"auto"`. --- ### WebGL context recovery If the WebGL context is lost, rendering pauses and the viewer displays a **Reload viewer** button. This message appears even with `ui={{ loadingOverlay: false }}`. When the browser fires `webglcontextrestored`, the viewer automatically rebuilds its canvas and scene; the reload button starts the same rebuild without waiting for browser restoration. Recovery uses the current props and cached model assets, and cancels video recording. The rebuilt viewer keeps its camera and the visitor's changes, as a lazy viewer does when it comes back (see [Lazy loading and posters](https://react-immersive.liveroom.dev/docs/reference/model-viewer#lazy-loading-and-posters)); the selection, measurements and playback start over. `onModelLoaded`, `onAnimationsReady`, and `onViewerReady` run again for the rebuilt scene, so integrations receive fresh controls and object refs. --- ### `dracoDecoderPath` / `ktx2TranscoderPath` / `meshopt` Control the compressed-asset decoders used when loading the GLB/GLTF asset. ```ts dracoDecoderPath?: string | false; ktx2TranscoderPath?: string | false; meshopt?: boolean; ``` Meshopt is bundled and enabled by default. DRACO and KTX2 are also enabled by default through immutable, versioned builds hosted on the first-party `assets.react-immersive.liveroom.dev` domain. They are fetched lazily only when a model uses the corresponding compression format. ```ts dracoDecoderPath = "https://assets.react-immersive.liveroom.dev/three-r184/draco/"; ktx2TranscoderPath = "https://assets.react-immersive.liveroom.dev/three-r184/basis/"; ``` Pass `false` to disable either decoder. For a fully self-hosted or offline deployment, version-matched decoder files ship in `dist/decoders`. Copy them into your app's public/static directory: ```sh cp -R node_modules/@liveroom-tech/react-immersive/dist/decoders public/react-immersive-decoders ``` Then pass the public URL directories, including the trailing slash: ```tsx ``` The same props are available on `SimpleModelViewer` and `BindingBuilder`. When using the built-in defaults with a strict CSP, allow both the licence-validation and asset origins, plus blob workers. A typical baseline is `connect-src 'self' https://react-immersive.liveroom.dev https://assets.react-immersive.liveroom.dev; worker-src 'self' blob:; script-src 'self' 'wasm-unsafe-eval'`. Self-hosting the decoder assets removes the asset-domain requirement, but `ModelViewer` and `BindingBuilder` still require the licence-validation origin. --- ### `showUvCheckerButton` Shows a UV Checker toggle button over the canvas. Turning it on temporarily replaces the model's materials with the numbered diagnostic texture; turning it off restores the renderer configured by `sceneConfig`. Meshes without UV0 coordinates appear magenta. Default: `false`. ```tsx ``` --- ### `showMeasureTools` Optional boolean that shows measurement tools and a toggleable bounding-box dimensions overlay. Start measuring, then choose a tool: - **Distance:** pick points for distances between consecutive picks. - **Angle:** pick an arm, the angle's vertex, and the other arm. The readout is in degrees, with an arc around the middle pick. - **Area:** pick polygon corners in boundary order, then **Finish area**. The closed polygon is shaded and labeled in square units. Concave polygons are supported; nonplanar, crossing, touching, duplicate, or collinear boundaries show an error. Use Undo to correct a pick. - **Point to plane:** pick a reference face, then a point. The tool highlights the picked triangle and shows the shortest distance to its infinite plane, with a perpendicular segment to the projected point. **No snapping** keeps picks on the surface. **Snap: vertex**, **Snap: edge**, and **Snap: auto** snap within 12 CSS pixels to vertices or triangle edges on the picked face. Auto prioritizes nearby vertices. Hover markers identify the snap target; snapped picks are yellow. Snapping respects world transforms, instances, and the current pose of skinned/morph geometry. Mesh triangulation edges can also be snap targets. Undo removes the last pick; Clear removes the current measurement. Switching tools clears picks. A new pick after a completed angle, area, or point-to-plane measurement starts another measurement. Picks are snapshots in world space; model changes, display rotation, and binding transforms clear them. Pause animated models before measuring a pose. Hidden objects, decals, and editor helpers are excluded from picking. Default: `false`. ```tsx ``` --- ### `measurementUnit` Unit suffix for distance and dimension readouts; area uses its squared form (for example `m²`). Angles always use degrees. This only changes the label: values are not converted. glTF models use meters by specification. Default: `"m"`. --- ### `showExplodeControls` Shows an exploded-view control over the canvas: a slider that slides each bound part outward from the model center to reveal interior/assembly structure, then back to reassemble it. Best suited to static product/CAD models, the offset writes node positions each frame, so it conflicts with a playing skeletal animation. Default: `false`. ```tsx ``` See the [`Exploded View`](https://react-immersive.liveroom.dev/examples/exploded-view) example. --- ### `showMaterialVariants` Shows swatch buttons, centered at the bottom of the viewer, for switching between the model's glTF material variants (the `KHR_materials_variants` extension): the colorways, finishes, or trims a product file ships with. Each button shows the variant's colour when its material is a plain colour. It appears for a glTF/GLB with any variants. Default `true`. Variants come in **option groups**, one row each. Choosing a variant replaces the other choice in its row and keeps the other rows, so a paint colour and a wheel style combine. Clicking the chosen variant again returns that row to the file's default materials. The viewer works out the groups from the file: - A name like `"Paint: Red"` or `"Paint / Red"` puts the variant in a row labelled **Paint**, with a button labelled **Red**. - Otherwise, variants that restyle the same parts share a row, and variants that restyle different parts get separate rows. A shoe's `"midnight"`, `"beach"` and `"street"` colorways, which all restyle the same parts, are one row. The variants shown are `sceneConfig.model.activeVariants`, at most one from each group, which is also how you choose them yourself: ```tsx import { chooseMaterialVariant, useViewer } from "@liveroom-tech/react-immersive"; const viewer = useViewer(); ; // Once the model has loaded viewer.model.materialVariants; // ["Paint: Red", "Paint: Blue", "Wheels: Sport", "Wheels: Classic"] viewer.model.materialVariantGroups; // [ // { name: "Paint", variants: [{ name: "Paint: Red", label: "Red", swatch: "#ff0000" }, ...] }, // { name: "Wheels", variants: [{ name: "Wheels: Sport", label: "Sport" }, ...] }, // ] viewer.scene.updateSceneConfig({ model: { activeVariants: ["Paint: Red", "Wheels: Sport"] } }); viewer.scene.updateSceneConfig({ model: { activeVariants: [] } }); // the defaults // Your own swatches: swap a choice within its group, keeping the others. const active = viewer.scene.sceneConfig.model.activeVariants ?? []; viewer.scene.updateSceneConfig({ model: { activeVariants: chooseMaterialVariant(active, "Paint: Blue", viewer.model.materialVariantGroups), }, }); ``` - A variant sets each object's starting material. `objectBindings` material overrides (`style.material`) still apply on top of it, and clearing an override returns to the variant's material rather than the file's default. - Objects no active variant maps keep their default material, bound or not. If two active variants map the same object, the later one in the list wins. - A variant's materials and textures load the first time it's chosen; the current look stays until they're ready. - With `onSceneConfigChange`, a pick goes to your scene config (`useViewer` wires this up). Without it, `ModelViewer` keeps the picks itself until `sceneConfig.model.activeVariants` or `modelUrl` changes. - A name the model doesn't have is skipped with a console warning listing the model's variants. - An action can switch variants with a scene-config effect: `{ sceneConfig: { model: { activeVariants: ["Paint: Red"] } } }`. - Swatches use the colour of the material a variant puts on the most vertices. A textured material gets no swatch, just its label, so showing the buttons never loads textures. - Each pick reaches [`onEngagement`](https://react-immersive.liveroom.dev/docs/reference/model-viewer#onengagement--debugengagement) as a `variant-chosen` event with its `group`. - `onModelLoaded` reports `materialVariants` and `materialVariantGroups`, and BindingBuilder's Scene tab (**General**, one select per group) picks the variants the exported scene config starts with. OBJ, FBX, USDZ, STL, PLY, and streamed 3D Tiles models have no variants. - Scene configs saved before option groups, with a single `model.materialVariant`, still show that variant. ### Decals `binding.decals` prints images and text onto an object's surface: a logo on a shirt, a name on a mug, a label on a machine. Each decal is projected onto the object and follows it as it moves, and it's saved with the binding like any other edit. ```tsx const viewer = useViewer(); { if (!surface) return; viewer.bindings.addDecal(binding.id, { text: customerName, color: "#1e293b", ...surface, // position and normal, in the object's own space size: 0.06, // width in model units }); return true; // skip the viewer's own double-click behavior }} />; const id = viewer.bindings.addDecal("mug", { image: "/logos/acme.png", position, normal, size: 0.05 }); viewer.bindings.updateDecal("mug", id, { size: 0.08, rotation: Math.PI / 12 }); viewer.bindings.removeDecal("mug", id); ``` ```ts type ObjectBindingDecal = { id: string; image?: string; // a URL or public path text?: string; // shown when there's no image color?: string; // text colour, default "#ffffff" fontFamily?: string; // CSS font family, default "sans-serif" fontWeight?: "normal" | "bold"; // default "bold" position: [number, number, number]; // on the object, in its own space normal: [number, number, number]; // the way the surface faces there size: number; // width in model units; the height follows the image or text rotation?: number; // radians around the normal, default 0 opacity?: number; // 0–1, default 1 }; ``` - `onObjectDoubleClick` and `onObjectContextMenu` report `surface`: the point under the pointer and the way the surface faces, in the object's own space, which is exactly what a decal's `position` and `normal` take. - `addDecal` returns the decal's id (pass your own `id` to choose it), and takes the same targets as the other binding helpers, so `addDecal({ group: "panels" }, …)` prints on each. Decals are plain binding data: you can also set `decals` directly. - A decal wraps onto the surface around its position, reaching about half its size in depth, so it follows gentle curves without printing on the far side of a thin part. - A decal fades with its object when that object is ghosted by isolation or made see-through by a style rule, and is cut by a section cut. - To let the people using your viewer add their own, give a binding the built-in `add-text` and `add-image` actions: ```ts actions: [ { id: "add-text", label: "Add Text", type: "command" }, { id: "add-image", label: "Add Image", type: "command" }, ] ``` Each opens a control under its button in the object panel. **Add Text** takes the text and a colour; **Add Image** takes an image file (sent through `onTextureUpload` when you pass it, so the saved URL lasts; otherwise a blob URL for this session). The decal appears automatically at the centre of the camera-facing surface, ready to drag, resize, turn, or remove. **Move** enables dragging across the object's surface; release to save, or press Escape to cancel. The **Position** arrows move a decal in small steps relative to the current view, without needing to grab it. Click for a fine adjustment or hold an arrow for continuous movement. Each step stays on the nearby visible surface. Every change is saved to the binding's `decals` and reported through `onObjectBindingsChange`. While a decal is in Move mode, a hint shows over the viewer and Escape cancels the current drag without removing the centred decal. - On a rigged (skinned) mesh a decal bends with the animation: it takes on the bone weights of the surface under it and is driven by the same skeleton. A click on a posed mesh is stored at the matching point of its rest shape, so a decal lands where you clicked whatever pose the model is in. Over a joint that bends sharply across few polygons, the decal's outer edge can sit a hair off the surface. Decals don't follow morph targets (blend shapes). - Decals need the binding's object to be a mesh, and aren't drawn on streamed 3D Tiles models. ### Isolating objects `isolation` focuses the view on some objects: everything else is ghosted (drawn faintly) or hidden, and clicks and hovers pass through to what's isolated. Nothing about the bindings changes, and clearing the isolation puts everything back. It's view state, like the selection, so it isn't part of `sceneConfig`. ```tsx const viewer = useViewer(); ; viewer.isolation.isolate({ group: "gearbox" }); // ghost everything else viewer.isolation.isolate(["piston-1", "piston-2"], { mode: "hide" }); viewer.isolation.clearIsolation(); ``` ```ts type ViewerIsolation = { target: ObjectBindingTarget; // an id, a list of ids, { group }, { tag }, or { type } mode?: "ghost" | "hide"; // default "ghost" ghostOpacity?: number; // 0–1, default 0.12 }; isolation?: ViewerIsolation | null; onIsolationChange?: (isolation: ViewerIsolation | null) => void; showIsolateControls?: boolean; // tools.isolate ``` - Pass `isolation` (including `null`) to control it, and update it from `onIsolationChange`; `useViewer` wires both, as `viewer.isolation`. Leave `isolation` out and `ModelViewer` keeps it itself, clearing it when `modelUrl` changes. `useViewerIsolation` is the same state on its own. - `showIsolateControls` (`tools.isolate`, default `false`) adds an isolate button to each row of the scene objects panel (click again to show all) and a "Show all" pill at the bottom of the viewer while anything is isolated. - An action can isolate with an `isolation` effect: `{ isolation: { target: { group: "engine" } } }`, or `{ isolation: null }` to show everything again. Pass `isolation` to `useViewerActions` for it (`useViewer` does). - A target that matches no object isolates nothing, rather than fading out the whole model. Ghosting never raises an object's own lower opacity, and style rules still apply underneath. - `applyIsolation` (main entry and `/utils`) applies an isolation to bindings the same way, for rendering your own lists. ### `showSectionTools` Shows a section-cut button over the canvas (`tools.section`). It cuts the model open along an axis, hiding everything on one side of a plane, to look inside assemblies, equipment, and buildings. Default `false`. Not available with [`webgpu`](https://react-immersive.liveroom.dev/docs/reference/model-viewer#webgpu). With the cut on, the toolbar picks the axis (X, Y, or Z), the position along it, and which side stays, and a plane appears across the model that you can drag. The cut is stored in `sceneConfig.section`, so you can also set it yourself, author it in BindingBuilder, or switch it from an action: ```tsx viewer.scene.updateSceneConfig({ section: { enabled: true, axis: "y", position: 0.4, flip: false }, // a floor-plan cut }); ``` ```ts type SceneSectionConfig = { enabled: boolean; axis: "x" | "y" | "z"; // world axis the cut runs across position: number; // 0 at the model's minimum along the axis, 1 at its maximum flip: boolean; // keep the higher side instead of the lower one }; ``` - The cut applies to the model only; lights, helpers, and gizmos stay whole. It also shows without the toolbar whenever `sceneConfig.section.enabled` is set; the draggable plane appears only with `showSectionTools`. - While the cut is on, the model's materials render both sides of each surface, so a cut part shows its inside surfaces instead of disappearing. There are no solid caps over the cut. - Clicking and hovering ignore whatever is cut away, so the parts you can see behind the cut are the ones you select. - Toolbar changes go to `onSceneConfigChange` when you pass it (`useViewer` does); otherwise `ModelViewer` keeps them until `sceneConfig.section` or `modelUrl` changes. - The measuring tool can still pick points on the hidden side of a cut. Default: ```ts false; ``` ### `cinematic` Enables the cinematic auto-camera: the camera glides along a path on its own, like a film, ideal for showing off a gallery, apartment, or product with no user interaction. Renders a play/pause button over the canvas; grabbing the camera (or selecting a part) pauses it and hands control back. ```ts cinematic?: boolean | CinematicConfig; type CinematicConfig = { waypoints?: CinematicWaypoint[]; // camera keyframes to glide through duration?: number; // seconds for one full pass (auto-scales when omitted) loop?: boolean; // default true autoPlay?: boolean; // start once the model has loaded and framed (default false); waits for the play button under reduced motion }; type CinematicWaypoint = { position: [number, number, number]; target: [number, number, number]; // world-space look-at point azimuthAngle?: number; // radians, captured by BindingBuilder; omit by hand polarAngle?: number; // radians }; ``` - Pass `cinematic` as boolean `true` (or an object with fewer than two waypoints) to get a zero-config **showcase orbit** around the model's bounding sphere. - Pass two or more `waypoints` to author a **walkthrough**: the camera follows a smooth Catmull-Rom spline through them at a constant on-screen speed, looping seamlessly. - Waypoints are easiest to capture visually in `BindingBuilder`'s **Cinematic** tab (frame a view, click _Add waypoint_), which stores them on `sceneConfig.cinematic`. **Precedence.** A path can also come from [`sceneConfig.cinematic`](https://react-immersive.liveroom.dev/docs/reference/model-viewer#sceneconfig) (authored in BindingBuilder). The `cinematic` prop _overwrites_ it: a boolean toggles the feature (`true` keeps any authored waypoints, `false` forces it off), and an object overrides field-by-field, so you can keep the authored waypoints while overriding, say, `autoPlay` or `loop`. ```tsx // Zero-config showcase orbit // Authored walkthrough // Keep the BindingBuilder-authored path, just autoplay it ``` Default: disabled. --- ### `enableXR` Optional boolean that enables XR entry points. On WebXR-capable browsers, the viewer shows an AR and/or VR button once the relevant immersive session support is confirmed. On iPhone/iPad, WebXR AR is not available, but if you also pass [`usdzUrl`](https://react-immersive.liveroom.dev/docs/reference/model-viewer#usdzurl), the viewer shows a **"View in AR"** button that launches Apple Quick Look instead. Default: `false`. --- ### `usdzUrl` Optional `USDZ` URL used as an Apple-device AR fallback when `enableXR` is `true`. ```ts usdzUrl?: string; ``` - Point this to a `.usdz` file Safari can fetch directly. - iPhone/iPad will show a **"View in AR"** button that launches Apple Quick Look when WebXR AR is unavailable. - Android / WebXR-capable devices ignore this prop and continue using the normal WebXR AR flow. --- ### `arScaleMode` Controls the initial model size in WebXR AR and VR sessions. ```ts arScaleMode?: "normalized" | "real-world"; ``` - `"normalized"` (default) fits the longest model dimension to approximately 1 metre. Use this for arbitrary uploads and room-scale scenes. - `"real-world"` preserves the GLB's authored scale. glTF units are metres, so use this for product models exported at their real dimensions. Users can still pinch to resize after placement in either mode. --- ### `mobileHandoffUrl` Optional public URL used for the desktop-to-phone AR handoff. When `enableXR` is `true`, desktop viewers show an **"Open on phone"** QR-code control. Scan it to open the experience on a phone, then use its AR control (or iOS Quick Look). ```ts mobileHandoffUrl?: string; ``` - Omit it to encode the current page URL, including its hash. This works with [`useShareableViewerState`](https://react-immersive.liveroom.dev/docs/hooks/use-shareable-viewer-state) so the phone opens the same camera/visibility/selection state. - Pass a dedicated HTTPS mobile-viewer URL when desktop and mobile use different routes. - The QR handoff is hidden on phones and tablets because they can enter AR directly. - The target must be publicly reachable from the phone; `localhost` URLs will not work. --- ### `showAnnotationNavigation` Shows the "Guided Tour" control cluster (left side of the bottom action bar) when the selected categories contain at least one annotation: a single "Guided Tour" button that, once started, becomes Previous/Stop/Next controls for stepping through annotations in order. Default: `true`. --- ### `showAnnotationOnHover` Temporarily opens an annotation's detail popup when the pointer hovers over its marker. Moving the pointer away closes the hover popup after a short grace period. Clicking a marker still locks its popup open until it is closed. Default: `false`. ```tsx ``` --- ## Large models The viewer accelerates viewport picking by building a BVH (bounding volume hierarchy) index for the loaded model in a background worker. Models above roughly **12 million triangles** are too large to index without a significant memory cost, so the viewer degrades gracefully instead: - Viewport pointer interaction is disabled: clicking or hovering meshes in the canvas does nothing, and `onObjectSelect` / `onObjectHover` do not fire from the viewport. Selection through the scene objects panel still works and still fires `onObjectSelect`. - A dismissible banner appears over the canvas telling the user the model is very large, that it may render slowly on their device, and that selection is available through the panel. - The loading overlay narrates the extra work ("Preparing large model…"), uploading that much geometry to the GPU is a single synchronous operation that can block the page for tens of seconds, on any WebGL viewer. Orbit, zoom, pan, animations, annotations, and the panels all keep working. There is no way to make a model of this size feel fast in a browser, the per-frame cost of tens of millions of triangles is inherent, and on mobile devices the decoded geometry alone can exceed the tab's memory budget. For assets like this, decimate at ingestion (for example to ~2–3M triangles) rather than shipping the raw export; a decimated model loads in seconds and keeps full interactivity. Note that Blender or native tooling is required for very large Draco-compressed files, the WASM builds of `gltf-transform` and `gltfpack` cannot decode them within their 4GB address space. --- ## Theming The scene objects panel and object binding data panel read their colors, borders, shadows, and fonts from CSS custom properties instead of hardcoded values. Set them on a `.ri-viewer` ancestor (or any element wrapping the viewer) to re-skin those panels without overriding individual classes, every variable already has the current default baked in as a fallback, so you only need to set the ones you actually want to change. ```css .my-app .ri-viewer { --ri-sidepanel-bg: #1a0033; --ri-sidepanel-accent: #ff2d75; --ri-sidepanel-accent-soft: rgba(255, 45, 117, 0.25); --ri-sidepanel-text: #ffe8f5; } ``` ```tsx
``` ### Scene objects panel, `--ri-sidepanel-*` | Variable | Default | | ---------------------------------- | ------------------------------------------------- | | `--ri-sidepanel-font-family` | `Inter, "Segoe UI", Helvetica, Arial, sans-serif` | | `--ri-sidepanel-bg` | `#141824` | | `--ri-sidepanel-open-btn-bg` | `rgba(20, 20, 20, 0.82)` | | `--ri-sidepanel-open-btn-bg-hover` | `#27282b7e` | | `--ri-sidepanel-border` | `rgba(255, 255, 255, 0.06)` | | `--ri-sidepanel-divider` | `rgba(255, 255, 255, 0.04)` | | `--ri-sidepanel-border-strong` | `rgba(255, 255, 255, 0.08)` | | `--ri-sidepanel-border-active` | `rgba(255, 255, 255, 0.18)` | | `--ri-sidepanel-shadow-left` | `4px 0 24px rgba(0, 0, 0, 0.35)` | | `--ri-sidepanel-shadow-right` | `-4px 0 24px rgba(0, 0, 0, 0.35)` | | `--ri-sidepanel-text` | `#f1f5f9` | | `--ri-sidepanel-text-emphasis` | `#ffffff` | | `--ri-sidepanel-text-muted` | `#94a3b8` | | `--ri-sidepanel-text-subtle` | `#64748b` | | `--ri-sidepanel-placeholder` | `#475569` | | `--ri-sidepanel-surface` | `rgba(255, 255, 255, 0.05)` | | `--ri-sidepanel-surface-hover` | `rgba(255, 255, 255, 0.1)` | | `--ri-sidepanel-accent` | `#3b82f6` | | `--ri-sidepanel-accent-soft` | `rgba(59, 130, 246, 0.12)` | ### Object binding data panel, `--ri-databinding-*` | Variable | Default | | ----------------------------------------- | ---------------------------------------------------------------- | | `--ri-databinding-bg` | `#111318` | | `--ri-databinding-border` | `rgba(255, 255, 255, 0.06)` | | `--ri-databinding-border-strong` | `rgba(255, 255, 255, 0.08)` | | `--ri-databinding-border-hover` | `rgba(255, 255, 255, 0.15)` | | `--ri-databinding-shadow` | `4px 0 24px rgba(0, 0, 0, 0.35)` | | `--ri-databinding-font-family` | `"Inter", "Segoe UI", sans-serif` | | `--ri-databinding-mono-font-family` | `"JetBrains Mono", "Fira Code", "Menlo", monospace` | | `--ri-databinding-text` | `#f1f5f9` | | `--ri-databinding-text-muted` | `#94a3b8` | | `--ri-databinding-text-subtle` | `#64748b` | | `--ri-databinding-text-faint` | `#475569` | | `--ri-databinding-text-hover` | `#cbd5e1` | | `--ri-databinding-surface` | `rgba(255, 255, 255, 0.03)` | | `--ri-databinding-surface-hover` | `rgba(255, 255, 255, 0.06)` | | `--ri-databinding-accent` | `#93c5fd` | | `--ri-databinding-accent-glow` | `rgba(147, 197, 253, 0.8)` | | `--ri-databinding-accent-border` | `rgba(96, 165, 250, 0.55)` | | `--ri-databinding-accent-gradient` | `linear-gradient(135deg, #1e3a8a 0%, #1d4ed8 60%, #2563eb 100%)` | | `--ri-databinding-accent-gradient-active` | `linear-gradient(135deg, #1e3a8a 0%, #1e40af 100%)` | `ModelViewer.css` also declares the original `--ri-panel-*` and `--ri-license-overlay-*` variables for its other surfaces, the two sets above are specifically for the scene objects panel and the object binding data panel. --- ## Built-In Actions | Action ID | Behavior | | ------------------- | ------------------------------------------------------------------------------------------------------------ | | `toggle-visibility` | Hides or shows the selected mesh via `binding.visible` + `onObjectBindingsChange` | | `change-color` | Opens the color picker inline under the action; hex committed to `binding.style.material.baseColor` on close | | `change-material` | Opens the texture upload panel inline under the action; URL stored in `binding.style.material.texture.path` | --- Canonical: https://react-immersive.liveroom.dev/docs/reference/simple-model-viewer Package: @liveroom-tech/react-immersive@5.2.0 # `SimpleModelViewer` A lightweight viewer for cases where you want to load a GLB, GLTF, OBJ, FBX, USDZ, STL, or PLY asset, inspect meshes, and toggle visibility **without** building `objectBindings`. It can either load a fixed `modelUrl` or, when `enableModelUpload` is enabled, let the user drag-and-drop or choose a local model at runtime. ## Usage ```tsx import "@liveroom-tech/react-immersive/simple-model-viewer.css"; import { SimpleModelViewer } from "@liveroom-tech/react-immersive/simple-model-viewer"; export default function App() { return (
); } ``` ## Behavior - Discovers renderable meshes directly from the loaded model scene - Renders a right-side scene objects panel with search and visibility toggles - Renders a left-side environment/scene settings panel (background color, environment preset, auto-rotate, exposure, ambient + directional light color/intensity), each seedable via props - Can swap `modelUrl` for a built-in drag-and-drop / file-picker upload flow - Lets users click a mesh or panel row to select and focus it. Panel rows are buttons, so the keyboard can select from the panel too - Fits the full scene on initial load, and re-fits when the canvas is resized (window resize, device rotation, or a panel opening/closing), set `refitOnResize={false}` to instead preserve the user's orbit/zoom - Uses built-in ambient, directional, and environment lighting - Renders on demand: frames are only drawn when something changes (camera movement, hover, auto-rotate, panel edits), so an idle viewer costs no GPU time or battery - Degrades gracefully on very large models (above roughly 12 million triangles): viewport click/hover picking is disabled for performance, a dismissible banner explains this to the user, and selection stays available through the scene objects panel Use `SimpleModelViewer` when you want a quick inspection viewer. Use [`ModelViewer`](https://react-immersive.liveroom.dev/docs/reference/model-viewer) when you need binding-driven styling, actions, metadata, custom panels, exports, or animation integrations. ## Props ```ts type SimpleModelViewerProps = { modelUrl: string; poster?: string; loading?: "lazy" | "eager"; // default "lazy" reveal?: "auto" | "interaction"; // default "auto" labels?: ModelViewerLabels; modelFormat?: "glb" | "gltf" | "obj" | "fbx" | "usdz" | "stl" | "ply"; dracoDecoderPath?: string | false; ktx2TranscoderPath?: string | false; meshopt?: boolean; backgroundColor?: string; showSceneObjectsPanel?: boolean; showSceneSettingsPanel?: boolean; enableModelUpload?: boolean; zoomOnSelected?: boolean; highlightOnHover?: boolean; showLoadingOverlay?: boolean; refitOnResize?: boolean; reducedMotion?: "user" | "always" | "never"; // Initial values for the scene settings (environment) panel backgroundEnabled?: boolean; autoRotate?: boolean; envPreset?: PresetsType; // @react-three/drei environment preset exposure?: number; ambientIntensity?: number; ambientColor?: string; directionalIntensity?: number; directionalColor?: string; onModelLoaded?: (model: ViewerModelInfo) => void; onLoadError?: (error: unknown) => void; }; ``` `SimpleModelViewer` does not take a `licenseKey` or perform runtime key validation. It needs no key or paid plan, including for production use. See [Licensing](https://react-immersive.liveroom.dev/docs/licensing) for the component/environment matrix. --- ### `labels` Optional plain-text overrides for built-in viewer text, including button titles, accessible labels, loading messages, tool hints, and AR placement controls. Missing keys retain their English defaults. `ModelViewer` and `SimpleModelViewer` share the same dictionary. Each viewer has its own labels, and changing the prop updates text without reloading its model. See the [full label catalog](https://react-immersive.liveroom.dev/docs/reference/labels) for every key, default, and named placeholder. `labels` does not translate annotation content, object metadata, custom panels, or raw server/loader diagnostics. Supply translations for those in your application. ```tsx ``` --- ### `modelUrl` **Required.** URL or public path to a `.glb`, `.gltf`, `.obj`, `.fbx`, `.usdz`, `.stl`, or `.ply` model. Hosted assets must serve external dependencies at their declared relative paths; USDZ dependencies are packaged internally. --- ### `modelFormat` Optional explicit format for extensionless or signed URLs. Supported extensions are detected automatically in normal URLs. --- ### `poster` / `loading` / `reveal` Work as on [`ModelViewer`](https://react-immersive.liveroom.dev/docs/reference/model-viewer#lazy-loading-and-posters), sharing the same page-wide count of live 3D views. When a `SimpleModelViewer` is put away, only its canvas goes: the panels and their settings stay, and the canvas comes back with its camera, hidden objects and selection. `onModelLoaded` runs again when it does. --- ### `dracoDecoderPath` / `ktx2TranscoderPath` / `meshopt` Compressed-asset decoder configuration. Meshopt is bundled; DRACO and KTX2 default to immutable, versioned files on the first-party React Immersive domain. Override with self-hosted directories or pass `false` to disable either format. See the [`ModelViewer` decoder setup](https://react-immersive.liveroom.dev/docs/reference/model-viewer#dracodecoderpath--ktx2transcoderpath--meshopt). --- ### `backgroundColor` Optional canvas background color. Default: ```ts "#1a1a1a"; ``` --- ### `showSceneObjectsPanel` Optional boolean that controls whether the right-side scene objects panel is rendered. Default: ```ts true; ``` --- ### `showSceneSettingsPanel` Optional boolean that controls whether the left-side environment/scene settings panel is rendered. It exposes background color/visibility, the `@react-three/drei` environment preset, auto-rotate, exposure, and ambient/directional light color and intensity. Default: ```ts true; ``` --- ### Scene settings defaults The following optional props seed the initial values of the scene settings (environment) panel. Each maps to one control; the panel stays interactive, so the user can still adjust them at runtime. Changing one of these props after mount does **not** override a value the user has since changed in the panel (they are initial values, not controlled props), the exception is `backgroundColor`, which is kept in sync live. | Prop | Type | Panel control | Default | | ---------------------- | ------------- | ----------------------- | ----------- | | `backgroundEnabled` | `boolean` | Background on/off | `false` | | `autoRotate` | `boolean` | Auto Rotate | `false` | | `envPreset` | `PresetsType` | Environment preset | `"city"` | | `exposure` | `number` | Exposure | `1` | | `ambientIntensity` | `number` | Ambient Light Intensity | `0.6` | | `ambientColor` | `string` | Ambient Light Color | `"#ffffff"` | | `directionalIntensity` | `number` | Direct Light Intensity | `1.2` | | `directionalColor` | `string` | Direct Light Color | `"#ffffff"` | `envPreset` accepts any `@react-three/drei` `PresetsType` (for example `"city"`, `"sunset"`, `"dawn"`, `"night"`, `"warehouse"`, `"forest"`, `"apartment"`, `"studio"`, `"park"`, `"lobby"`). ```tsx ``` --- ### `zoomOnSelected` Optional boolean. When `true`, selecting a mesh (via click or the objects panel) zooms/frames the camera on it. Default: ```ts false; ``` --- ### `highlightOnHover` Optional boolean. When `true`, hovering a mesh (in the viewport or the objects panel) highlights it. On very large models (above roughly 12 million triangles) viewport hover is disabled for performance regardless of this prop, panel-row hover highlighting still works. Default: ```ts false; ``` --- ### `showLoadingOverlay` Optional boolean controlling whether the built-in loading overlay is shown while the model is loading, uploaded files are being prepared, and the initial camera fit is settling. Default: ```ts true; ``` --- ### `refitOnResize` Optional boolean controlling whether the camera re-frames the model to fit whenever the canvas is resized, a browser window resize, a device rotation, or a side panel (scene settings / scene objects) opening or closing. Set to `false` to preserve the user's current orbit/zoom across resizes; the camera aspect stays correct either way, so the model never distorts, it just isn't re-centered. Default: ```ts true; ``` --- ### `reducedMotion` Cuts motion the viewer starts on its own. `"user"` follows the visitor's device setting (`prefers-reduced-motion`); `"always"` and `"never"` override it. When motion is reduced, the `autoRotate` prop doesn't start auto-rotate (the visitor can still turn it on in the scene settings panel), camera moves jump instead of gliding, and panels appear in place. See [`ModelViewer`'s `reducedMotion`](https://react-immersive.liveroom.dev/docs/reference/model-viewer#reducedmotion). Default: ```ts "user"; ``` --- ### `enableModelUpload` Optional boolean that swaps the fixed `modelUrl` workflow for a built-in upload UI. It accepts `.glb`, standalone/data-URI `.gltf`, `.obj`, `.fbx`, `.usdz`, `.stl`, `.ply`, or `.zip`. A ZIP may bundle GLTF dependencies, OBJ + MTL/textures, or FBX external textures; USDZ is already self-contained. ZIP uploads are limited to 128 MiB compressed, 1,024 entries, 128 MiB per expanded entry, 256 MiB expanded in total, and a 200:1 per-entry compression ratio. Zipped GLTF assets may use `data:` URIs or paths included in the archive; external asset URLs are rejected. Default: ```ts false; ``` --- ### `onModelLoaded` Optional callback fired after the model scene has loaded. ```ts onModelLoaded?: (model: ViewerModelInfo) => void // { bounds, objectCount, materialVariants, materialVariantGroups } ``` --- ### `onLoadError` Optional callback fired if the model fails to load or render. ```ts onLoadError?: (error: unknown) => void ``` --- Canonical: https://react-immersive.liveroom.dev/docs/reference/types Package: @liveroom-tech/react-immersive@5.2.0 # Types Reference ## `ObjectBinding` The core data record for each interactive mesh in the viewer. ```ts type ObjectBinding = { id: string; type?: ObjectBindingType; // defaults to "other" modelObjectId: string; label?: string; group?: string; tags?: string[]; status?: "normal" | "hidden" | "selected" | "disabled"; visible?: boolean; selectable?: boolean; hoverable?: boolean; transform?: ObjectBindingTransform; style?: ObjectBindingStyle; decals?: ObjectBindingDecal[]; // images and text on the surface; see ModelViewer's "Decals" actions?: ObjectBindingAction[]; metrics?: Record; metadata?: Record; cameraState?: ObjectBindingCameraState; }; ``` ### Key Notes - The `objectBindings` record is **keyed by model node identifier**, not by `id`; unique names remain unchanged and duplicates use suffixed IDs such as `Cube [1]` - `group` assigns a binding to one named set, while `tags` can assign it to several queryable sets - `status` stores the authoring/domain status assigned in `BindingBuilder` - `transform` stores persistent local-space position, rotation, and scale overrides; rotation values use radians - `visible` is live render state, not just an initial default - `selectable: false` and `hoverable: false` make the mesh inert to viewport picking; clicks resolve the front-most surface under the pointer, so a non-selectable mesh does **not** pass the click through to meshes behind it - `style.material.baseColor` is the committed source of truth for color picks - `style.material.texture.path` is the committed source of truth for texture uploads - If no `texture.path` and no `style.material.baseColor` are set, the viewer falls back to the original model material - See [`ObjectBindingMaterial`](https://react-immersive.liveroom.dev/docs/reference/types#objectbindingmaterial) for the full set of per-object material overrides - `metrics` and `metadata` are displayed in the side panel as nested key/value fields (objects expand into indented child labels) - `cameraState` stores a saved camera view for the object; when both `position` and `target` are set, the viewer uses that view when the object is focused or selected instead of falling back to `fitToBox` ## `ObjectBindingStatus` ```ts type ObjectBindingStatus = "normal" | "hidden" | "selected" | "disabled"; ``` This is the value stored in `ObjectBinding.status` and edited by `BindingBuilder`'s Basics panel. Use `visible`, `selectable`, and `hoverable` for the corresponding live viewer behavior. ## `defineObjectBindings` A runtime identity helper that validates binding literals while preserving their exact inferred TypeScript types. ```ts import { defineObjectBindings, type ObjectBindingActionId, type ObjectBindingKey, } from "@liveroom-tech/react-immersive/utils"; const bindings = defineObjectBindings({ body: { id: "vehicle-body", modelObjectId: "body", type: "body", actions: [{ id: "repaint", label: "Repaint", type: "command" }], }, }); type BindingKeys = ObjectBindingKey; // "body" type BodyActions = ObjectBindingActionId; // "repaint" ``` `ObjectBindingKey` extracts the binding-map key union. `ObjectBindingActionId` extracts the declared action-id union for binding `K`. ## `ObjectBindingTransform` ```ts type ObjectBindingTransform = { position?: [number, number, number]; // local model units rotation?: [number, number, number]; // local Euler angles in radians scale?: [number, number, number]; }; ``` Missing fields retain their model-authored local values. Removing the complete transform restores the model-authored position, rotation, and scale. `ModelViewer` and `BindingBuilder` convert World/Local gizmo interactions back into this local-space data before saving. BindingBuilder displays rotation in degrees for authoring, but exports radians. Its transform gizmo is positioned at the center of the object's renderable geometry so an object can rotate around itself even when its source node origin is offset; that temporary authoring pivot does not modify the model file's origin. --- ## `ObjectBindingType` What kind of object a binding is, used to group and filter bindings (for example `hideObject({ type: "wheel" })`). Any string works. The listed values cover vehicles, rooms, furniture, characters, weapons, environment, and more, and your editor suggests them; a domain they don't cover can use its own, such as `"bone"` or `"turbine-blade"`. A binding without a `type` counts as `"other"`. ```ts type ObjectBindingType = | "body" | "light" | "wheel" | "glass" | "interior" | "floor" | "wall" | "door" | "furniture" | "decor" | "electronics" | "other" // ... and more suggestions | (string & {}); // any other string ``` --- ## `ObjectBindingStyle` Controls the visual appearance of a mesh. All visual overrides live under `material` (an `ObjectBindingMaterial`); there are no style-level `color` / `metalness` / `roughness` fields. ```ts type ObjectBindingStyle = { material?: ObjectBindingMaterial; }; ``` --- ## `ObjectBindingMaterial` Per-object overrides applied on top of the mesh's original model material as a `MeshPhysicalMaterial`. Every field is optional, only the fields you set are overridden; everything else falls back to the model-authored value. Texture slots are `{ path: string }` objects. ```ts type TextureSlot = { path: string }; type ObjectBindingMaterial = { // ── Base / PBR ────────────────────────────────────────────── texture?: TextureSlot; // base color (albedo) map baseColor?: string; // hex, e.g. "#ff0000" metalness?: number; // 0–1 metalnessMap?: TextureSlot; roughness?: number; // 0–1 roughnessMap?: TextureSlot; reflectivity?: number; // 0–1 (specular F0) specularIntensity?: number; // 0–1 specularIntensityMap?: TextureSlot; specularColor?: string; // hex specularColorMap?: TextureSlot; // ── Emission ──────────────────────────────────────────────── emissive?: string; // hex emissiveIntensity?: number; // 0–10 emissiveMap?: TextureSlot; // ── Opacity / blending ────────────────────────────────────── opacity?: number; // 0–1 alphaMap?: TextureSlot; alphaTest?: number; // 0–1 alphaHash?: boolean; dithering?: boolean; // banding reduction on gradients blendingMode?: "normal" | "additive"; // ── Normal / bump ─────────────────────────────────────────── normalMap?: TextureSlot; normalScale?: number; flipNormalY?: boolean; bumpMap?: TextureSlot; bumpScale?: number; // ── Displacement ──────────────────────────────────────────── displacementMap?: TextureSlot; displacementScale?: number; displacementBias?: number; // ── Ambient occlusion ─────────────────────────────────────── aoMap?: TextureSlot; aoMapIntensity?: number; // ── Clearcoat ─────────────────────────────────────────────── clearcoat?: number; // 0–1 clearcoatMap?: TextureSlot; clearcoatRoughness?: number; // 0–1 clearcoatRoughnessMap?: TextureSlot; clearcoatNormalMap?: TextureSlot; clearcoatNormalScale?: number; flipClearcoatNormalY?: boolean; // ── Sheen ─────────────────────────────────────────────────── sheen?: number; // 0–1 sheenColor?: string; // hex sheenColorMap?: TextureSlot; sheenRoughness?: number; // 0–1 sheenRoughnessMap?: TextureSlot; // ── Anisotropy ────────────────────────────────────────────── anisotropy?: number; // 0–1 anisotropyRotation?: number; // radians anisotropyMap?: TextureSlot; // ── Transmission / volume ─────────────────────────────────── transmission?: number; // 0–1 transmissionMap?: TextureSlot; thickness?: number; thicknessMap?: TextureSlot; attenuationColor?: string; // hex (volume tint) attenuationDistance?: number; // ── Faces ─────────────────────────────────────────────────── side?: "front" | "back" | "double"; }; ``` ### Texture color space Texture slots are decoded with the correct color space automatically, so maps render at the right brightness: - **sRGB (color) maps:** `texture` (base color/albedo), `emissiveMap`, `sheenColorMap`, `specularColorMap` - **Linear (data) maps:** everything else, `normalMap`, `roughnessMap`, `metalnessMap`, `aoMap`, `displacementMap`, `bumpMap`, `alphaMap`, `clearcoat*Map`, `anisotropyMap`, `transmissionMap`, `thicknessMap`, `sheenRoughnessMap`, `specularIntensityMap` The shared texture cache is keyed by URL **and** color space, so the same image URL can be used safely as both a color map and a data map. The package exports `MATERIAL_SIDES` (`["front", "back", "double"]`) and `MATERIAL_BLENDING_MODES` (`["normal", "additive"]`) for building typed UI controls without duplicating these option lists. --- ## `ObjectBindingAction` Defines an action button shown in the side panel. ```ts type ObjectBindingAction = { id: string; label: string; type: "command"; effects?: ObjectActionEffect[]; }; ``` `effects` are executed in declaration order by `useViewerActions`. Each effect updates one concern through the corresponding library controller: ```ts type ObjectActionEffect = | { target: ObjectBindingTarget; objectBindings: ObjectBindingPatch; } | { sceneConfig: SceneConfigPatch } | { target: ObjectBindingTarget; visibility: "show" | "hide" | "toggle"; } | { camera: CameraAction } | { animation: AnimationAction }; type CameraAction = | { type: "focus-object"; objectId?: string } | { type: "fit-scene" } | { type: "reset" } | { type: "set-target"; target: [number, number, number]; transition?: boolean } | { type: "set-state"; state: ViewerCameraState; transition?: boolean }; // play replaces the clips already playing unless alongside is set; the others // act on clip, or on every active clip when it's left out. type AnimationAction = | ({ type: "play"; clip: string } & AnimationPlayOptions) | { type: "pause"; clip?: string } | { type: "stop"; clip?: string } | { type: "set-speed"; speed: number; clip?: string } | { type: "seek"; time: number; clip?: string }; ``` `ObjectBindingTarget` supports an object id, an array of ids, `{ group }`, `{ tag }`, or `{ type }`. Built-in action IDs: | ID | Behavior | | ------------------- | ---------------------- | | `toggle-visibility` | Toggle mesh visibility | | `change-color` | Open color picker | | `change-material` | Open texture upload | --- ## `ObjectBindingCameraState` Saved camera state for zooming to an object. ```ts type ObjectBindingCameraState = { position?: [number, number, number]; target?: [number, number, number]; fov?: number; zoom?: number; }; ``` When both `position` and `target` are set on a binding's `cameraState`, the viewer uses that saved view when the object is focused or selected instead of falling back to `fitToBox`. --- ## `ObjectActionEvent` The event shape emitted by `onAction` and `useViewerActions.runAction`. ```ts type ObjectActionEvent = { objectId: string; action: ObjectBindingAction; binding?: ObjectBinding; screenX?: number; screenY?: number; }; ``` --- ## `ObjectPointerEvent` The event shape passed to `onObjectDoubleClick` and `onObjectContextMenu`. ```ts type ObjectPointerEvent = { binding: ObjectBinding; clientX: number; // viewport coordinates, e.g. for a position: fixed menu clientY: number; pointerType: string; // "mouse" | "pen" | "touch" surface?: { position: Vec3; normal: Vec3 }; // where on the object, in its own space }; ``` --- ## `AnimationControls` Provided via `onAnimationsReady`. ```ts type AnimationPlayOptions = { alongside?: boolean; // keep the clips already playing (default false: replace them) fade?: number; // seconds to fade in, and to fade out the clips it replaces reverse?: boolean; // play backwards, from where the clip is or from its end }; type AnimationClipState = { clip: string; status: "playing" | "paused" | "finished"; // finished: a play-once clip holding its last pose time: number; // seconds duration: number; // seconds speed: number; reverse: boolean; }; type AnimationPlaybackState = { currentClip: string | null; // the clip played most recently, while it's active isPlaying: boolean; // whether any clip is playing speed: number; // speed, position and length of currentClip time: number; duration: number; active: AnimationClipState[]; // every active clip, most recently played last }; // Leave out clipName to act on every active clip. type AnimationControls = { clips: string[]; clipDetails?: { sourceName: string; duration: number }[]; play: (clipName: string, options?: AnimationPlayOptions) => void; pause: (clipName?: string) => void; stop: (clipName?: string) => void; // stops and resets the pose setSpeed: (speed: number, clipName?: string) => void; seek?: (time: number, clipName?: string) => void; // absolute time, in seconds getState?: () => AnimationPlaybackState; subscribe?: (listener: (state: AnimationPlaybackState) => void) => () => void; }; ``` --- ## `ViewerIsolation` `ModelViewer`'s `isolation` prop. See [Isolating objects](https://react-immersive.liveroom.dev/docs/reference/model-viewer#isolating-objects). ```ts type ViewerIsolation = { target: ObjectBindingTarget; // an id, a list of ids, { group }, { tag }, or { type } mode?: "ghost" | "hide"; // default "ghost" ghostOpacity?: number; // 0–1, default 0.12 }; ``` --- ## `SceneSectionConfig` `sceneConfig.section`. See [`showSectionTools`](https://react-immersive.liveroom.dev/docs/reference/model-viewer#showsectiontools). ```ts type SceneSectionConfig = { enabled: boolean; axis: "x" | "y" | "z"; // world axis the cut runs across position: number; // 0 at the model's minimum along the axis, 1 at its maximum flip: boolean; // keep the higher side instead of the lower one }; ``` `DEFAULT_SECTION_CONFIG` is `{ enabled: false, axis: "y", position: 0.5, flip: false }`. --- ## `SceneStyleRule` The rules in `sceneConfig.styleRules`, applied in order. See [Style rules](https://react-immersive.liveroom.dev/docs/reference/model-viewer#style-rules). ```ts type SceneStyleRule = { id: string; label?: string; target?: ObjectBindingTarget; // every bound object when left out when?: SceneStyleCondition | SceneStyleCondition[]; // all must match material?: ObjectBindingMaterial; colorScale?: SceneStyleColorScale; pulse?: boolean | SceneStylePulse; }; type SceneStyleCondition = | { metric: string; above?: number; below?: number } | { status: ObjectBindingStatus | ObjectBindingStatus[] } | { metadata: string; equals: string | number | boolean | null }; // "a.b" reaches nested keys type SceneStyleColorScale = { metric: string; stops: [number, string][]; // [value, "#rrggbb"] channel?: "baseColor" | "emissive"; // default "baseColor" }; type SceneStylePulse = { color?: string; // default "#ff3b30" speed?: number; // pulses per second, default 1 }; ``` --- ## `SceneLight` ```ts type SceneLightAttachment = { objectId: string; offset: [number, number, number]; }; type SceneLight = { id: string; type: "none" | "directional" | "point" | "spot" | "hemisphere"; color: string; intensity: number; position: [number, number, number]; rotation?: [number, number, number]; distance?: number; decay?: number; angle?: number; penumbra?: number; attachedToCamera: boolean; attachment?: SceneLightAttachment; castShadow: boolean; shadowBias: number; visible: boolean; }; ``` `position` and `rotation` are world-space unless the light is attached. `rotation` uses Euler radians and controls directional/spot-light orientation. An attachment resolves binding keys, binding ids, `modelObjectId` values, and raw model node names. Its offset uses the target object's local space. `distance` and `decay` apply to point and spot lights; spot lights also use `angle` (radians) and `penumbra` (`0`–`1`). --- ## `SceneModelRenderer` Selects the whole-model material renderer used by `SceneConfig.model.renderer`. ```ts type SceneModelRenderer = "pbr" | "matcap" | "uv-checker"; ``` - `"pbr"` renders the model-authored and binding-overridden physical materials. - `"matcap"` renders a lighting-independent studio material. - `"uv-checker"` renders a numbered directional UV0 test chart. Meshes without UV0 coordinates appear magenta. --- ## `SceneConfig` The complete serializable scene state consumed by `ModelViewer.sceneConfig`, authored by `BindingBuilder`, and managed by `useSceneConfig`. ```ts type SceneConfig = { model: { rotation: [number, number, number]; showAdvancedRotation: boolean; renderer: "pbr" | "matcap" | "uv-checker"; shading: "lit" | "unlit" | "normals"; autoRotate?: boolean; autoRotateSpeed?: number; activeVariants?: string[]; // glTF material variants shown, one per option group }; camera: SceneCameraConfig; lighting: SceneLightingConfig; wireframe: SceneWireframeConfig; shadows: SceneShadowConfig; environment: SceneEnvironmentConfig; background: SceneBackgroundConfig; groundShadows: SceneGroundShadowConfig; postProcessing: ScenePostProcessingConfig; animations: SceneAnimationConfig; annotations: SceneAnnotationMarker[]; cinematic?: SceneCinematicConfig; styleRules?: SceneStyleRule[]; // data-driven looks; see ModelViewer's "Style rules" section?: SceneSectionConfig; // cut-away view; see ModelViewer's showSectionTools }; ``` `model.autoRotate` and `model.autoRotateSpeed` apply when the equivalent `ModelViewer` props aren't passed; passed props win. `model.activeVariants` selects the model's glTF material variants by name, at most one from each option group (see [`showMaterialVariants`](https://react-immersive.liveroom.dev/docs/reference/model-viewer#showmaterialvariants)). Import `DEFAULT_SCENE_CONFIG` when creating a scene from code so new sections and defaults remain populated, then update it with `useSceneConfig` or `patchSceneConfig`. For `lighting.lights`, `sceneLight({ id, ...settings })` builds a complete light, filling in the settings you leave out. --- ## 3D Tiles types ### `ViewerObjectInfo` `onObjectCatalog` receives one entry for every bindable object in the model, whether it's loaded whole or streamed: ```ts type ViewerObjectInfo = { name: string; // the key to use in objectBindings triangles: number; minimum: [number, number, number]; maximum: [number, number, number]; }; ``` `minimum` and `maximum` are the object's bounds in the model's own units, in Z-up coordinates as in 3D Tiles: a point at glTF `(x, y, z)` is `(x, -z, y)` here. They don't change when the viewer moves or scales the model. --- ## `CinematicConfig` Configures the [`cinematic`](https://react-immersive.liveroom.dev/docs/reference/model-viewer#cinematic) auto-camera on `ModelViewer`. ```ts type CinematicConfig = { waypoints?: CinematicWaypoint[]; // camera keyframes to glide through duration?: number; // seconds for one full pass (auto-scales when omitted) loop?: boolean; // default true autoPlay?: boolean; // start once the model has loaded and framed (default false) }; type CinematicWaypoint = { position: [number, number, number]; target: [number, number, number]; // world-space look-at point azimuthAngle?: number; // radians, captured by BindingBuilder; omit by hand polarAngle?: number; // radians segmentDuration?: number; // seconds spent reaching the next waypoint }; ``` With fewer than two waypoints the camera falls back to an auto-generated orbit around the model's bounding sphere. --- ## `SceneCinematicConfig` The persisted form of the cinematic path carried on `SceneConfig.cinematic`, authored visually in `BindingBuilder`'s Cinematic tab. ```ts type SceneCinematicConfig = { enabled: boolean; // turns the auto-camera on at runtime waypoints: SceneCinematicWaypoint[]; duration?: number; loop: boolean; autoPlay: boolean; }; type SceneCinematicWaypoint = { position: [number, number, number]; target: [number, number, number]; azimuthAngle?: number; // radians, captured by BindingBuilder; omit by hand polarAngle?: number; // radians segmentDuration?: number; }; ``` `ModelViewer` activates a `sceneConfig`-authored path when `enabled` is `true` and there are at least two waypoints. The [`cinematic`](https://react-immersive.liveroom.dev/docs/reference/model-viewer#cinematic) prop overrides it field-by-field. --- ## `CustomObjectBindingDataPanelProps` Props passed to a `customObjectBindingDataPanel` render prop. ```ts type CustomObjectBindingDataPanelProps = { isOpen: boolean; selectedObject: ObjectBinding | null; currentAction: ObjectActionEvent | null; onClose: () => void; onAction: (event: ObjectActionEvent) => void; }; ``` --- ## `CustomSceneObjectsPanelProps` Props passed to a `customSceneObjectsPanel` render prop. ```ts type CustomSceneObjectsPanelProps = { objectBindings: Record; onAction?: (event: ObjectActionEvent) => void; onFocus?: (binding: ObjectBinding) => void; onHover?: (binding: ObjectBinding | null) => void; }; ``` ## `CaptureVideoOptions` Options for `ViewerReadyState.captureVideo`, `useViewerConnection().captureVideo`, and `useViewer().connection.captureVideo`. The result is a video `Blob` in the browser-supported format. See [Video export](https://react-immersive.liveroom.dev/docs/reference/model-viewer#video-export) for readiness requirements and recording behavior. ```ts type CaptureVideoOptions = { /** Defaults to cinematic when a path is enabled, otherwise turntable. */ mode?: "cinematic" | "turntable"; /** Auto prefers WebM, then MP4. "gif" makes a looping GIF. Explicit formats reject if unsupported. */ format?: "auto" | "webm" | "mp4" | "gif"; /** Seconds per pass, in (0, 300]. Defaults to path timing, or a 24s orbit (8s for a GIF). */ duration?: number; /** Capture frame rate, from 1 to 60. Default 30 (15 for a GIF). */ fps?: number; /** Encoder target bitrate in bits per second. Default 8,000,000. Not used for GIFs. */ videoBitsPerSecond?: number; /** GIF width in pixels, 16 to 4096, never wider than the canvas; the height follows. Default 480. */ gifWidth?: number; /** Aborting discards the recording and restores the viewer. */ signal?: AbortSignal; }; ``` ## `ViewerEngagementEvent` The discriminated event union received by `ModelViewer.onEngagement`. Narrow on `event.type` to access event-specific fields. See [Engagement analytics](https://react-immersive.liveroom.dev/docs/reference/model-viewer#onengagement--debugengagement) for event triggers, opt-in logging, and session semantics. ```ts /** Engagement from deliberate viewer interactions, with model/session context. */ type ViewerEngagementEvent = { /** Ephemeral identifier for this viewer/model session; no persistent storage. */ sessionId: string; /** ISO timestamp of the event. */ timestamp: string; /** Milliseconds since tracking started for this model. */ elapsedMs: number; modelUrl: string; tilesetUrl: string | null; } & ( | { type: "part-viewed"; objectId: string; objectKey: string; label?: string; source: "canvas" | "panel" | "keyboard"; } | { type: "variant-chosen"; variant: string | null; // null when a group's choice was cleared previousVariant: string | null; // the group's previous choice group: string | null; // the option group's name; null for an unnamed group } | { type: "ar-started" } | { type: "ar-ended"; durationMs: number; reason: "session-ended" | "interrupted"; } | { type: "ar-launch-requested"; channel: "quick-look" | "mobile-handoff"; } | { type: "tour-started"; annotationCount: number } | { type: "tour-completed"; annotationCount: number; durationMs: number } | { type: "tour-stopped"; annotationCount: number; durationMs: number; reason: "stopped" | "restarted" | "annotations-changed" | "interrupted"; } ); ``` ## SceneAnnotationMarker ```ts type SceneAnnotationCameraView = { position: [number, number, number]; target: [number, number, number]; azimuthAngle?: number; // radians polarAngle?: number; // radians fov?: number | null; zoom?: number; }; type SceneAnnotationImage = { src: string; alt?: string }; type SceneAnnotationLink = { href: string; label: string }; type SceneAnnotationMarker = { id: number; worldPosition: [number, number, number]; localPosition: [number, number, number]; worldNormal?: [number, number, number]; anchorNodeName?: string; anchorLocalPosition?: [number, number, number]; title: string; description: string; cameraView?: SceneAnnotationCameraView; images?: SceneAnnotationImage[]; links?: SceneAnnotationLink[]; category?: string; minDistance?: number; maxDistance?: number; }; ``` All rich fields are optional. See [Rich annotations](https://react-immersive.liveroom.dev/docs/reference/model-viewer#rich-annotations) for saved-view playback, category filtering, and marker-distance behavior. These annotation types are exported from the main entry and the server-safe `/utils` entry. ## Other exported types Every type below is exported from `@liveroom-tech/react-immersive` and is described with the API it belongs to. | Types | Described with | | --- | --- | | `ModelViewerProps`, `ModelViewerPreset`, `GroupedModelViewerProps`, `ModelViewerUiOptions`, `ModelViewerToolsOptions`, `ModelViewerControlsOptions`, `ModelViewerPerfOptions`, `ModelViewerTilesOptions`, `ModelViewerXrOptions`, `ModelViewerDecoderOptions`, `ObjectTransformSpace`, `TexturePositioningControls`, `XRScaleMode`, `ModelFormat`, `AnnotationMarker`, `CameraLimits`, `CameraControllerType`, `MouseControllerPosition`, `PerformanceProfile`, `ViewerObjectInfo` | [`ModelViewer`](https://react-immersive.liveroom.dev/docs/reference/model-viewer) | | `SimpleModelViewerProps` (from `/simple-model-viewer`) | [`SimpleModelViewer`](https://react-immersive.liveroom.dev/docs/reference/simple-model-viewer) | | `BindingBuilderProps` (from `/binding-builder`) | [`BindingBuilder`](https://react-immersive.liveroom.dev/docs/reference/binding-builder) | | `ObjectBindingMaterialBlendingMode`, `ObjectBindingMaterialSide`, `ObjectBindingTextureSlot`, `ObjectBindingTextureChannel` | [`ObjectBindingMaterial`](https://react-immersive.liveroom.dev/docs/reference/types#objectbindingmaterial) | | `AnimationActionEffect`, `CameraActionEffect`, `ObjectBindingsActionEffect`, `SceneConfigActionEffect`, `VisibilityActionEffect` | [`ObjectBindingAction`](https://react-immersive.liveroom.dev/docs/reference/types#objectbindingaction) | | `AnimationLoopMode`, `SceneAnimationClipConfig`, `SceneLightType`, `SceneModelConfig`, `SceneCameraConfig`, `SceneLightingConfig`, `SceneShadowConfig`, `SceneEnvironmentConfig`, `SceneWireframeConfig`, `SceneBackgroundConfig`, `SceneGroundShadowConfig`, `ScenePostProcessingConfig`, `EnvironmentPreset`, `GlitchModeName`, `KernelSizeName`, `RampTypeName` | [`SceneConfig`](https://react-immersive.liveroom.dev/docs/reference/types#sceneconfig) and [`SceneLight`](https://react-immersive.liveroom.dev/docs/reference/types#scenelight) | | `SceneAnnotationMarker`, `SceneAnnotationCameraView`, `SceneAnnotationImage`, `SceneAnnotationLink` | [`SceneAnnotationMarker`](https://react-immersive.liveroom.dev/docs/reference/types#sceneannotationmarker) | | `ObjectBindingDecal`, `ObjectSurfacePoint` | [Decals](https://react-immersive.liveroom.dev/docs/reference/model-viewer#decals) | | `MaterialVariantGroup`, `MaterialVariantOption` (also from `/simple-model-viewer` and `/utils`) | [`showMaterialVariants`](https://react-immersive.liveroom.dev/docs/reference/model-viewer#showmaterialvariants) | | `ReducedMotionSetting` (also from `/simple-model-viewer` and `/utils`) | [`reducedMotion`](https://react-immersive.liveroom.dev/docs/reference/model-viewer#reducedmotion) | | `RendererBackend` (also from `/utils`) | [`webgpu`](https://react-immersive.liveroom.dev/docs/reference/model-viewer#webgpu) | | `ViewerLoading`, `ViewerReveal` (also from `/simple-model-viewer`) | [Lazy loading and posters](https://react-immersive.liveroom.dev/docs/reference/model-viewer#lazy-loading-and-posters) | | `CompareViewerProps`, `CompareViewerSide` | [`CompareViewer`](https://react-immersive.liveroom.dev/docs/reference/compare-viewer) | | `ViewerIsolation`, `IsolatedObjectBindings` | [`ViewerIsolation`](https://react-immersive.liveroom.dev/docs/reference/types#viewerisolation) | | `ViewerIsolationProps` | [`useViewerIsolation`](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-isolation) | | `SceneSectionConfig`, `SceneSectionAxis` | [`SceneSectionConfig`](https://react-immersive.liveroom.dev/docs/reference/types#scenesectionconfig) | | `SceneStyleRule`, `SceneStyleCondition`, `SceneStyleColorScale`, `SceneStylePulse` | [`SceneStyleRule`](https://react-immersive.liveroom.dev/docs/reference/types#scenestylerule) | | `StyledObjectBindings`, `StyledObjectPulse` | `applyStyleRules`, see [Style rules](https://react-immersive.liveroom.dev/docs/reference/model-viewer#style-rules) | | `HiddenObjectsState`, `ObjectBindingsController`, `BindingEdit`, `ObjectBindingSelector`, `DeepPartial` | [`useObjectBindings`](https://react-immersive.liveroom.dev/docs/hooks/use-object-bindings) | | `ViewerSelectionProps` | [`useViewerSelection`](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-selection) | | `ViewerHoverProps` | [`useViewerHover`](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-hover) | | `ShareableViewerStateProps`, `UseShareableViewerStateOptions` | [`useShareableViewerState`](https://react-immersive.liveroom.dev/docs/hooks/use-shareable-viewer-state) | | `ViewerModelBounds`, `ViewerModelInfo`, `ViewerModelProps` | [`useViewerModel`](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-model) | | `ViewerActionAnimationController`, `ViewerActionBindingsController`, `ViewerActionCameraController`, `ViewerActionSceneController`, `ViewerActionsOptions`, `ViewerActionsProps` | [`useViewerActions`](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-actions) | | `HomeCameraState`, `ViewerCameraConfig`, `ViewerCameraProps`, `UseViewerCameraOptions` | [`useViewerCamera`](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-camera) | | `ViewerConnectionController`, `ViewerReadyConsumer`, `ViewerReadyHandler` | [`useViewerConnection`](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-connection) | | `AnimationClipInfo`, `AnimationClipState`, `AnimationClipStatus`, `AnimationPlayOptions`, `ViewerAnimationsProps` | [`useViewerAnimations`](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-animations) | | `RuntimeMaterialPatch`, `RuntimeMaterialRanges`, `RuntimePointLightOptions`, `RuntimePointLightPatch`, `RuntimeTransformPatch`, `RuntimeTransformRanges`, `UseViewerEffectsOptions`, `ViewerEffectEasing`, `ViewerEffectsController`, `ViewerEffectRange`, `ViewerEffectTarget`, `ViewerFlickerOptions`, `ViewerLightEffectChannel`, `ViewerMaterialEffectChannel`, `ViewerTimelineOptions`, `ViewerTimelineStep`, `ViewerPulseOptions`, `ViewerTransformEffectChannel`, `ViewerTransitionOptions`, `ViewerVector3` | [`useViewerEffects`](https://react-immersive.liveroom.dev/docs/hooks/use-viewer-effects) | ## `ModelViewerLabels` / `ModelViewerLabelKey` `ModelViewerLabelKey` is the union of keys in `DEFAULT_MODEL_VIEWER_LABELS`. `ModelViewerLabels` is `Partial>`, accepted by both viewers' `labels` prop. The default dictionary and both types are exported from the main package, `/simple-model-viewer`, and `/utils` (which can be imported without React). See the [labels reference](https://react-immersive.liveroom.dev/docs/reference/labels) for examples and the complete catalog. --- Canonical: https://react-immersive.liveroom.dev/docs/releases Package: @liveroom-tech/react-immersive@5.2.0 # Versions and migration ## Supported documentation baseline: 5.2.0 The current documentation and public starter applications target `@liveroom-tech/react-immersive@5.2.0`, published to npm on 5 October 2026. The [machine-readable manifest](https://react-immersive.liveroom.dev/docs/manifest.json) records the package version, peer dependencies, and documentation URLs. | Peer | Supported range | | -------------------- | --------------- | | `react` | `^19.0.0` | | `react-dom` | `^19.0.0` | | `three` | `^0.184.0` | | `@react-three/fiber` | `^9.6.1` | The Vite examples require Node `^20.19.0 || >=22.12.0`; the Next.js starter uses Node 22.12+. These are starter tooling requirements, separate from the React package's runtime peers. ## Upgrade a public starter previously pinned to 5.0.2 From that starter's directory: ```bash npm install --save-exact @liveroom-tech/react-immersive@5.2.0 npm run typecheck npm run build ``` Keep its lockfile with the updated manifest. Use React 19 and the peer ranges above. Import SimpleModelViewer from `@liveroom-tech/react-immersive/simple-model-viewer`, BindingBuilder from `@liveroom-tech/react-immersive/binding-builder`, and the corresponding CSS entry for each component. Pure data helpers can be imported from `/utils`, including in server code. If `defineObjectBindings` preserves literal keys in your initial data, widen editable React state to `Record`. Follow [Getting Started](https://react-immersive.liveroom.dev/docs/getting-started) for the complete pattern. Then verify a browser render and your intended interactions. Back up authored bindings/scene data before changing an existing integration. --- Canonical: https://react-immersive.liveroom.dev/docs/troubleshooting Package: @liveroom-tech/react-immersive@5.2.0 # Troubleshooting These checks apply to React Immersive **5.2.0**. | Symptom | Check | Fix | | --- | --- | --- | | Peer dependency conflict | React/React DOM version and duplicate installations | Use React 19, Three.js `^0.184.0`, and React Three Fiber `^9.6.1`; inspect `npm ls react react-dom three @react-three/fiber` | | Component import is missing | Import entry point | Import SimpleModelViewer from `/simple-model-viewer`, BindingBuilder from `/binding-builder`, and ModelViewer from the main package | | Unstyled or cramped viewer | CSS and parent dimensions | Import the component's own stylesheet and give its container an explicit height | | Empty model or loader failure | Browser Network panel and `onLoadError` | Open `modelUrl` directly; it must return the asset rather than an HTML 404/sign-in page | | Model works locally but fails remotely | CORS and external files | Allow requests from the app origin and host GLTF/OBJ/FBX dependencies at their declared relative paths | | Signed or extensionless URL fails | Format detection | Set the supported `modelFormat` explicitly | | ModelViewer/BindingBuilder shows a license error | Key, plan, origin, registered domain | Follow the licensing guide; hosted previews are not automatically local domains | | `process is not defined` in Vite | Environment variable syntax | Use `import.meta.env.VITE_LICENSE_KEY`; Next.js uses `process.env.NEXT_PUBLIC_LICENSE_KEY` | | Next.js rejects hooks or event handlers | Client boundary | Put hooks/callbacks in a `"use client"` file; see the complete Next.js guide | | Binding changes do not affect the intended mesh | Actual node IDs | Read `onObjectCatalog`, generate automatic bindings, or export from BindingBuilder; do not guess IDs | | Binding import or state setter fails TypeScript | Export and inferred state type | Export the bindings; use `useState>(initialBindings)` for mutable state | | Compressed model fails | Decoder requests and CSP | Follow the ModelViewer decoder setup; self-host version-matched files when necessary | ## Observe load success and failure Use `onModelLoaded` to confirm the asset loaded, and `onLoadError` to present a failure state. For ModelViewer, `onObjectCatalog` reveals bindable identifiers; `onViewerReady` connects runtime controls. Do not treat a successful build as proof that the asset and browser rendering succeeded. The public [sample GLB](https://react-immersive.liveroom.dev/red_room.glb) is useful for separating integration problems from problems in your own asset. It must retain its [CC BY 4.0 attribution](https://skfb.ly/6VBJR). ## Large or blank scenes Use a browser/device with WebGL support. Inspect the console for renderer or context-loss errors. Very large assets can exceed device memory; optimize the asset and consult the performance and large-model sections in the [ModelViewer reference](https://react-immersive.liveroom.dev/docs/reference/model-viewer). ## Report an issue Include the package version, framework, browser/device, sanitized error text, reproduction steps, and a small redistributable model or public sample. Use the [public issue tracker](https://github.com/Liveroom-Technologies/react-immersive-docs/issues). Send license keys, private models, account information and security reports through private support rather than a public issue.