@liveroom-tech/react-immersive 5.0.2 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +1030 -59
  2. package/dist/binding-builder.css +1 -1
  3. package/dist/binding-builder.js +17 -12
  4. package/dist/binding-builder.mjs +1 -1
  5. package/dist/chunk-4NLJSD3S.mjs +1 -0
  6. package/dist/chunk-B7REIGDG.mjs +1 -0
  7. package/dist/chunk-HYFJ5DYJ.mjs +1 -0
  8. package/dist/chunk-QC3NT76G.mjs +3 -0
  9. package/dist/chunk-T3M3BQ7H.mjs +32 -0
  10. package/dist/chunk-ZPK67UG7.mjs +1 -0
  11. package/dist/hosted.css +1 -1
  12. package/dist/hosted.d.mts +4 -3
  13. package/dist/hosted.d.ts +4 -3
  14. package/dist/hosted.js +16 -11
  15. package/dist/hosted.mjs +1 -1
  16. package/dist/index.css +1 -1
  17. package/dist/index.d.mts +85 -10
  18. package/dist/index.d.ts +85 -10
  19. package/dist/index.js +15 -10
  20. package/dist/index.mjs +1 -1
  21. package/dist/materialVariants-DnZe9eMC.d.mts +309 -0
  22. package/dist/materialVariants-DnZe9eMC.d.ts +309 -0
  23. package/dist/modelInfo-BYebMPdZ.d.mts +43 -0
  24. package/dist/modelInfo-XfUQO7C_.d.ts +43 -0
  25. package/dist/{ModelViewer-CbbJT85n.d.mts → modelViewerProps-CIZ2R_Fv.d.ts} +164 -43
  26. package/dist/{ModelViewer-DOexHkJC.d.ts → modelViewerProps-DoRiTZMn.d.mts} +164 -43
  27. package/dist/{objectCatalog-5W8n5xNQ.d.ts → rendererBackend-QwMzISTn.d.mts} +304 -5
  28. package/dist/{objectCatalog-5W8n5xNQ.d.mts → rendererBackend-QwMzISTn.d.ts} +304 -5
  29. package/dist/simple-model-viewer.css +1 -1
  30. package/dist/simple-model-viewer.d.mts +32 -4
  31. package/dist/simple-model-viewer.d.ts +32 -4
  32. package/dist/simple-model-viewer.js +3 -3
  33. package/dist/simple-model-viewer.mjs +1 -1
  34. package/dist/utils.d.mts +5 -4
  35. package/dist/utils.d.ts +5 -4
  36. package/dist/utils.js +1 -1
  37. package/dist/utils.mjs +1 -1
  38. package/package.json +2 -1
  39. package/dist/chunk-3GX7DOGS.mjs +0 -3
  40. package/dist/chunk-AH5NAZWX.mjs +0 -27
  41. package/dist/chunk-BGYK4Y2Y.mjs +0 -1
  42. package/dist/chunk-WFEYRUZC.mjs +0 -1
  43. package/dist/chunk-XTLSAYAQ.mjs +0 -1
  44. package/dist/modelInfo-CEmmvVCH.d.mts +0 -17
  45. package/dist/modelInfo-CEmmvVCH.d.ts +0 -17
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # React Immersive — 3D model viewer for React
2
2
 
3
- `@liveroom-tech/react-immersive` is a React-based 3D model viewer for interactive GLB, GLTF, OBJ, FBX, and USDZ 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.
3
+ `@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.
4
4
 
5
5
  [Website](https://react-immersive.liveroom.dev) · [Documentation](https://react-immersive.liveroom.dev/docs) · [Live editor](https://react-immersive.liveroom.dev/editor) · [npm](https://www.npmjs.com/package/@liveroom-tech/react-immersive) · [GitHub](https://github.com/Liveroom-Technologies/react-immersive-docs)
6
6
 
@@ -18,6 +18,7 @@ import {
18
18
  useObjectBindingIds,
19
19
  useObjectBindings,
20
20
  useSceneConfig,
21
+ useShareableViewerState,
21
22
  useViewer,
22
23
  useViewerActions,
23
24
  useViewerAnimations,
@@ -25,6 +26,7 @@ import {
25
26
  useViewerConnection,
26
27
  useViewerEffects,
27
28
  useViewerHover,
29
+ useViewerIsolation,
28
30
  useViewerModel,
29
31
  useViewerSelection,
30
32
  MATERIAL_BLENDING_MODES,
@@ -41,7 +43,7 @@ It also exports types for `ObjectBinding`, `ObjectBindingMaterial`, `ObjectBindi
41
43
 
42
44
  `ModelViewer` provides:
43
45
 
44
- - GLB/GLTF, OBJ/MTL, FBX, and USDZ model rendering through `@react-three/fiber`
46
+ - GLB/GLTF, OBJ/MTL, FBX, USDZ, STL, and PLY model rendering through `@react-three/fiber`
45
47
  - camera-driven 3D Tiles streaming through `tilesetUrl`, with configurable screen-space error and bounded tile cache
46
48
  - orbit/pan/zoom camera controls via `CameraControls`
47
49
  - auto-fits the model on load, and re-fits when the canvas is resized (window resize, device rotation, or a panel opening/closing), toggleable through `refitOnResize` to instead preserve the user's orbit/zoom
@@ -51,6 +53,7 @@ It also exports types for `ObjectBinding`, `ObjectBindingMaterial`, `ObjectBindi
51
53
  - optional shadow rendering toggle through a `shadows` prop (on by default)
52
54
  - optional on-screen movement controller through `showMouseController` and its tuning props
53
55
  - optional canvas-focused keyboard camera navigation through `enableKeyboardNavigation`
56
+ - keyboard object navigation, screen-reader selection announcements, and reduced motion for visitors who ask for it (see [Accessibility](#accessibility))
54
57
  - optional top-right camera orientation control through `showViewGizmo`
55
58
  - optional per-object move and rotate gizmo through `moveModeEnabled`, with World/Local axis alignment through `objectTransformSpace`
56
59
  - optional custom left object data panel through `customObjectBindingDataPanel`
@@ -59,13 +62,21 @@ It also exports types for `ObjectBinding`, `ObjectBindingMaterial`, `ObjectBindi
59
62
  - optional hiding of either built-in panel through `showObjectBindingDataPanel` and `showSceneObjectsPanel`
60
63
  - optional model export button through `showDownloadButton` and `downloadFilename`, opt-in PNG capture through `preserveDrawingBuffer`, plus an independent `showResetButton` toggle for the rest of that action bar
61
64
  - an optional UV Checker toolbar button through `showUvCheckerButton`, for inspecting UV scale, stretching, seams, orientation, and missing UV0 coordinates directly in `ModelViewer`
62
- - click-to-measure distance tool and a bounding-box dimensions overlay through `showMeasureTools` and `measurementUnit`
65
+ - glTF material variants (`KHR_materials_variants`) as swatch rows, one per option group, through `showMaterialVariants`, selected by `sceneConfig.model.activeVariants`
66
+ - distance, angle, polygon area, and point-to-plane measurements with vertex/edge snapping and bounding dimensions through `showMeasureTools` and `measurementUnit`
63
67
  - an exploded-view slider that slides each bound part outward from the model center to reveal interior/assembly structure through `showExplodeControls`
68
+ - isolation that ghosts or hides everything but the objects you focus on, through `isolation` / `onIsolationChange` and `showIsolateControls`
69
+ - a section cut that opens the model along an axis, with a draggable plane, through `showSectionTools` and `sceneConfig.section`
64
70
  - a cinematic auto-camera that glides the camera along authored waypoints (or a zero-config showcase orbit), like a film, through `cinematic`, with a play/pause control that yields the moment you touch the camera
71
+ - video export of a cinematic pass or turntable revolution, through the download menu or `captureVideo`, as browser-supported WebM/MP4 or an animated GIF
72
+ - `CompareViewer`, which shows two configurations (or two models) with one camera, as a before/after slider or side by side
73
+ - opt-in WebGPU rendering through `perf={{ webgpu: true }}`, falling back to WebGL where the browser lacks it
74
+ - lazy loading: a viewer starts its 3D view when it's near the screen, and a page of viewers stays within the browser's limit on 3D views, with a `poster` image and an optional "View in 3D" button (`reveal`) until the view is there (see [Lazy loading and posters](#lazy-loading-and-posters))
65
75
  - a guided-tour control cluster (Previous/Stop/Next) for stepping through annotations, toggleable via `showAnnotationNavigation`
66
76
  - optional annotation detail popups on marker hover via `showAnnotationOnHover`
67
77
  - PBR, Matcap, and UV Checker renderer modes, with the checker exposing UV stretching, seams, rotation, and missing UV0 data directly on the model
68
78
  - a `sceneConfig` prop accepting the same scene-wide config (lighting, environment, background, post-processing, animations, annotations) authored by `BindingBuilder`'s Scene tab
79
+ - WebGL context-loss recovery with a reload button and automatic rebuild when the context returns, keeping the camera and the visitor's changes
69
80
  - render-loop and perf tuning through `renderMode`, `maxDpr`, `performanceProfile`, and compressed-asset decoder options (`dracoDecoderPath`, `ktx2TranscoderPath`, `meshopt`)
70
81
  - WebXR "View in your space" (AR) and "Enter VR" through `enableXR`, with tap-to-place, pinch-to-resize, and twist-to-rotate AR placement gestures
71
82
  - mesh selection by object name
@@ -75,6 +86,8 @@ It also exports types for `ObjectBinding`, `ObjectBindingMaterial`, `ObjectBindi
75
86
  - built-in texture upload that commits a URL into `objectBindings.style.material.texture.path` (blob URL by default, or a durable URL when `onTextureUpload` is provided)
76
87
  - per-object `MeshPhysicalMaterial` overrides for metalness, roughness, emissive, normal/bump, AO, displacement, clearcoat, sheen, anisotropy, specular, transmission, thickness, reflectivity, sidedness, and limited blending modes
77
88
  - per-object visibility toggling through `objectBindings.visible`
89
+ - decals: images and text printed on an object's surface through `objectBindings.decals`, placed from the surface point pointer events report
90
+ - data-driven looks through `sceneConfig.styleRules`: colour scales from metrics, material overrides by status or metadata, and pulsing alarms
78
91
  - hover and selected-state highlighting
79
92
  - external selection state control through `selectedObject` and `onObjectSelect`
80
93
  - hover callbacks through `onObjectHover`
@@ -84,13 +97,14 @@ It also exports types for `ObjectBinding`, `ObjectBindingMaterial`, `ObjectBindi
84
97
  - camera change callbacks through `onCameraChange`
85
98
  - viewer-ready callbacks through `onViewerReady`
86
99
  - action event callbacks through `onAction`
100
+ - opt-in engagement analytics through `onEngagement`, with `debugEngagement` console logging
87
101
  - animation playback for GLB/GLTF, FBX, and USDZ models with supported embedded animations through `onAnimationsReady`
88
102
  - annotation marker callbacks through `onAnnotationsChange` and controlled/uncontrolled `activeAnnotation` / `onActiveAnnotationChange`
89
103
 
90
104
  `BindingBuilder` provides:
91
105
 
92
- - a design-time UI for generating starter bindings from GLB, GLTF, OBJ, FBX, and USDZ assets, gated by `licenseKey` (see "`BindingBuilder` licensing & plan tiers" below)
93
- - demo model loading or custom `.glb`, `.gltf`, `.obj`, `.fbx`, `.usdz`, or model ZIP bundle upload
106
+ - a design-time UI for generating starter bindings from GLB, GLTF, OBJ, FBX, USDZ, STL, and PLY assets, gated by `licenseKey` (see "`BindingBuilder` licensing & plan tiers" below)
107
+ - demo model loading or custom `.glb`, `.gltf`, `.obj`, `.fbx`, `.usdz`, `.stl`, `.ply`, or model ZIP bundle upload
94
108
  - editable binding fields for identity, basics, style, actions, metrics, metadata, and saved camera state
95
109
  - per-object move and rotate authoring with a geometry-centered pivot, World/Local axes, numeric fields, and restore-to-authored-transform support
96
110
  - a one-click material preset gallery (wood, metal, chrome, glass, plastic, fabric, ceramic, concrete, etc.)
@@ -100,12 +114,12 @@ It also exports types for `ObjectBinding`, `ObjectBindingMaterial`, `ObjectBindi
100
114
  - import a previously exported `objectBindings.json` or `sceneConfig.json` and merge it back onto the current model/config
101
115
  - live preview using `ModelViewer`
102
116
  - export as JSON or TypeScript, bundled into a `.zip` with a `materials/` folder automatically when any texture field was filled by file upload, otherwise a plain file
103
- - license-tier gating for some editor features (texture maps, environment lighting/backgrounds, wireframe preview, animation configuration, annotations, post-processing)
117
+ - license-tier gating for some editor features (texture maps, environment lighting/backgrounds, wireframe preview, animation configuration, annotations, style rules, post-processing)
104
118
 
105
119
  `SimpleModelViewer` provides:
106
120
 
107
- - a lightweight GLB/GLTF/OBJ/FBX/USDZ viewer with no bindings or actions that does not require a `licenseKey`
108
- - optional local `.glb`, standalone/data-URI `.gltf`, `.obj`, `.fbx`, `.usdz`, or model ZIP bundle upload mode for ad-hoc inspection without relying on the `modelUrl` asset
121
+ - a lightweight GLB/GLTF/OBJ/FBX/USDZ/STL/PLY viewer with no bindings or actions that does not require a `licenseKey`
122
+ - optional local `.glb`, standalone/data-URI `.gltf`, `.obj`, `.fbx`, `.usdz`, `.stl`, `.ply`, or model ZIP bundle upload mode for ad-hoc inspection without relying on the `modelUrl` asset
109
123
  - built-in scene objects panel with search and visibility toggles
110
124
  - a built-in environment/scene settings panel (background color, environment preset, auto-rotate, exposure, ambient + directional light) toggleable via `showSceneSettingsPanel`, with each control's initial value seedable via props
111
125
  - click-to-select and fit-to-object focus behavior
@@ -267,7 +281,7 @@ export default function Configurator() {
267
281
 
268
282
  `viewer.bindings`, `viewer.selection`, `viewer.hover`, `viewer.camera`,
269
283
  `viewer.model`, `viewer.animations`, `viewer.actions`, `viewer.scene`,
270
- `viewer.effects`, and `viewer.connection` are the results of the hooks they're
284
+ `viewer.isolation`, `viewer.effects`, and `viewer.connection` are the results of the hooks they're
271
285
  named after (`useObjectBindings`, `useViewerSelection`, and so on), so
272
286
  everything below applies to them. Without a `bindings` option, `useViewer`
273
287
  generates bindings from the model like `objectBindings="auto"`; pass
@@ -462,6 +476,9 @@ const {
462
476
  setTransform,
463
477
  resetTransform,
464
478
  updateMetadata,
479
+ addDecal,
480
+ updateDecal,
481
+ removeDecal,
465
482
  hiddenObjects,
466
483
  hiddenObjectIds,
467
484
  hideObject,
@@ -524,6 +541,7 @@ The hook returns:
524
541
  - `setMaterial`, `setBaseColor`, `setTexture`, `clearTexture`, `copyMaterial`, `resetMaterial`, and `getMaterial`
525
542
  - `setTransform` and `resetTransform` for persistent local-space model transforms
526
543
  - `updateMetadata` for recursively merged application data
544
+ - `addDecal`, `updateDecal`, and `removeDecal` for images and text printed on objects (see [Decals](#decals))
527
545
  - `hiddenObjects`: a derived map of hidden binding keys
528
546
  - `hiddenObjectIds`: the currently hidden binding IDs
529
547
  - `hideObject`, `showObject`, `toggleObjectVisibility`
@@ -661,6 +679,7 @@ const {
661
679
  error,
662
680
  bounds,
663
681
  objectCount,
682
+ materialVariants,
664
683
  objects,
665
684
  handleModelLoaded,
666
685
  handleObjectCatalog,
@@ -684,6 +703,8 @@ const {
684
703
  - `error`: the most recent load/render error, or `null`
685
704
  - `bounds`: the loaded scene bounds as `min`, `max`, `center`, and `size`
686
705
  - `objectCount`: the number of mesh objects in the loaded scene
706
+ - `materialVariants`: the names of the model's glTF material variants, or an
707
+ empty array (see [`showMaterialVariants`](#showmaterialvariants))
687
708
  - `objects`: every bindable object in the model as a `ViewerObjectInfo`, once
688
709
  `handleObjectCatalog` is passed to `onObjectCatalog`
689
710
  - `handleModelLoaded`: a callback you can pass directly to `onModelLoaded`
@@ -748,9 +769,11 @@ type BindingBuilderProps = {
748
769
 
749
770
  Current behavior, Object tab:
750
771
 
751
- - upload a `.glb`, standalone/data-URI `.gltf`, `.obj`, `.fbx`, or `.usdz` file, or a `.zip` bundle (a `.gltf` with its `.bin` and textures, an `.obj` with its MTL and textures, or an `.fbx` with external textures)
772
+ - upload a `.glb`, standalone/data-URI `.gltf`, `.obj`, `.fbx`, `.usdz`, `.stl`, or `.ply` file, or a `.zip` bundle (a `.gltf` with its `.bin` and textures, an `.obj` with its MTL and textures, or an `.fbx` with external textures)
752
773
  - traverse renderable mesh nodes and generate starter bindings automatically
774
+ - 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](#stl-and-ply))
753
775
  - edit binding identity, label, type, status, booleans, style, actions, metrics, metadata, and saved camera state
776
+ - add decals to the selected object from the **Decals** panel: upload an image or add text to place it automatically at the centre of the camera-facing surface, then set its size, rotation, and opacity; **Move** lets you drag the decal across the object's surface. Release to save, or press Escape to cancel. Uploaded images are exported under `materials/` with the bindings
754
777
  - move and rotate the selected object from the **Move & Rotate** panel using a geometry-centered pivot, World/Local axis alignment, or numeric position and degree fields; restore its authored transform with one click
755
778
  - apply a one-click material preset (wood, metal, chrome, glass, plastic, fabric, ceramic, concrete, etc.) onto the selected object's material
756
779
  - undo/redo the current bindings (toolbar buttons or `Cmd/Ctrl+Z` / `Cmd/Ctrl+Shift+Z`); rapid edits coalesce into a single undo step
@@ -761,6 +784,12 @@ Current behavior, Scene tab:
761
784
 
762
785
  - configure the same `SceneConfig` shape `ModelViewer`'s `sceneConfig` prop accepts (lighting, environment, background, ground shadows, wireframe, post-processing, animations, annotations)
763
786
  - configure auto-rotation and its speed, exported as `sceneConfig.model.autoRotate` and `sceneConfig.model.autoRotateSpeed`
787
+ - preview animation clips, several at once and forwards or backwards, and set each clip's loop mode (loop, play once, or ping-pong), speed, and display name
788
+ - check changes with **Compare** above the preview: a before/after divider across the preview, with the project as it was opened on the left and as it is now on the right; orbiting either side moves both, and **Pin** on the "Before" label makes the current state the "before" for the next round of changes
789
+ - pick which of the model's glTF material variants the scene starts with, one per option group, exported as `sceneConfig.model.activeVariants`
790
+ - isolate a node from the Scene Nodes list to see it on its own in the preview while you edit it (not exported)
791
+ - set up a section cut (**General → Section Cut**: axis, position, and side to keep), exported as `sceneConfig.section`; the preview has the section toolbar and draggable plane too
792
+ - build style rules (**Rules** sub-tab) that restyle objects from their metrics, status, and metadata, with suggestions from the model's bindings, a live match count, and a live preview; exported as `sceneConfig.styleRules`
764
793
  - position and rotate directional, point, and spot lights with transform gizmos; spot lights also expose angle and penumbra controls
765
794
  - undo/redo the scene config independently of the Object tab's bindings history
766
795
  - import a previously exported `sceneConfig.json`, merged by top-level section
@@ -773,7 +802,7 @@ Preview:
773
802
  Autosave:
774
803
 
775
804
  - the session (the model, its object bindings, and the scene settings, with an uploaded model file included) is saved to this browser's IndexedDB, in a database named `react-immersive:binding-builder`, shortly after each edit and when the tab is hidden or closed
776
- - **Restore last session** in the preview toolbar reopens it; one session is kept per browser
805
+ - **Restore last session** in the preview's "⋯" menu reopens it; one session is kept per browser
777
806
  - while there are edits on screen, leaving or reloading the page asks for confirmation
778
807
  - if the browser refuses to store a session, usually for lack of space, the editor says so; export your work to keep it
779
808
  - enable **Transform gizmo**, select a mesh, then use the arrows and plane handles to move it or the rings to rotate it; all changes are saved to `binding.transform`
@@ -785,14 +814,14 @@ Some editor features are gated by the license's plan tier (the server-returned `
785
814
  | Tier | Unlocks |
786
815
  | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
787
816
  | `free` | All 3 components are available. `SimpleModelViewer` is fully available, while `ModelViewer` and `BindingBuilder` are limited to local development with base color, metalness/roughness sliders, and basic scene settings only |
788
- | `starter` | + Texture maps, the specular workflow, reflectivity, environment lighting, environment backgrounds, wireframe preview, animation configuration, annotations |
817
+ | `starter` | + Texture maps, the specular workflow, reflectivity, environment lighting, environment backgrounds, wireframe preview, animation configuration, annotations, style rules, decals |
789
818
  | `growth` | + Post-processing effects |
790
819
 
791
820
  A locked feature renders in place (not hidden) with a message naming the required plan, so it stays discoverable while editing.
792
821
 
793
822
  ## `SimpleModelViewer`
794
823
 
795
- `SimpleModelViewer` is an exported lightweight viewer for cases where you want to load a GLB, GLTF, OBJ, FBX, or USDZ asset, inspect meshes, and toggle visibility without building `objectBindings`. It can either load a fixed `modelUrl` or, when `enableModelUpload` is turned on, let the user drag-and-drop or choose a local model at runtime.
824
+ `SimpleModelViewer` is an exported 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 turned on, let the user drag-and-drop or choose a local model at runtime.
796
825
 
797
826
  Basic usage:
798
827
 
@@ -879,6 +908,60 @@ const {
879
908
 
880
909
  Pass `initialPosition`, `initialTarget`, and optional named `presets` to `useViewerCamera` to configure the first view without writing an `onViewerReady` wrapper.
881
910
 
911
+ If you want a link that opens the viewer the way it looks now, the library also exports `useShareableViewerState`. It keeps the camera, the hidden objects, and the selection in the page URL's hash (`#view=…`), and puts them back when the link is opened:
912
+
913
+ ```tsx
914
+ const viewer = useViewer();
915
+ const share = useShareableViewerState({
916
+ cameraState: viewer.camera.cameraState,
917
+ setCameraState: viewer.camera.setCameraState,
918
+ objectBindings: viewer.bindings.objectBindings,
919
+ onObjectBindingsChange: viewer.bindings.setObjectBindings,
920
+ selectedObjectBinding: viewer.selection.selectedObjectBinding,
921
+ setSelectedObjectBinding: viewer.selection.setSelectedObjectBinding,
922
+ });
923
+
924
+ <ModelViewer
925
+ modelUrl="/model.glb"
926
+ licenseKey="your-license-key"
927
+ {...viewer.props}
928
+ onViewerReady={(ready) => {
929
+ viewer.props.onViewerReady(ready);
930
+ void share.restoreFromUrl();
931
+ }}
932
+ refitOnResize={false}
933
+ />;
934
+
935
+ <button onClick={() => navigator.clipboard.writeText(share.getShareUrl())}>
936
+ Copy link
937
+ </button>;
938
+ ```
939
+
940
+ `useShareableViewerState` takes:
941
+
942
+ - `cameraState` and `setCameraState`: from `useViewerCamera` (`viewer.camera`)
943
+ - `objectBindings` and `onObjectBindingsChange`: the bindings and their setter; hidden objects are restored by setting their `visible` to `false`
944
+ - `selectedObjectBinding` and `setSelectedObjectBinding`: from `useViewerSelection` (`viewer.selection`). `ModelViewer` also needs `selectedObject` (`viewer.props` passes it), or a restored selection won't show
945
+ - `paramKey`: the hash key the view is stored under. Default `"view"`
946
+ - `debounceMs`: how long after a change the hash is updated, in milliseconds. Default `400`
947
+ - `autoSync`: keep the hash up to date as the view changes. Default `true`. With `false`, write it with `syncUrl` or build links with `getShareUrl`
948
+
949
+ `useShareableViewerState` returns:
950
+
951
+ - `getShareUrl`: the current page URL with the current view in its hash, without changing the address bar
952
+ - `syncUrl`: writes the current view into the hash now, with `history.replaceState`, so no history entry is added
953
+ - `restoreFromUrl`: applies the view in the hash, if there is one, and resolves `true` once it has applied all of it. Called again, it does nothing until the hash holds a different view, so it's safe to call from `onViewerReady`, which runs often
954
+
955
+ Things to know:
956
+
957
+ - The link holds the view, not the model. Keep whatever identifies the model, such as a route or query parameter, in the URL too. Objects are stored by `binding.id`, so links keep working while the ids stay the same.
958
+ - The hash is only updated after `restoreFromUrl` has been called once, so an incoming link isn't overwritten before it's read. Call it even if you don't restore links; with nothing in the hash it does nothing.
959
+ - 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.
960
+ - The camera jumps to the shared view without a transition. `refitOnResize={false}` keeps a later resize from re-framing the model away from it.
961
+ - The hook listens for `hashchange`, so pasting a newer link into an open tab applies it.
962
+
963
+ See the [Shareable View example](https://react-immersive.liveroom.dev/examples/shareable-view) and the [`useShareableViewerState` docs](https://react-immersive.liveroom.dev/docs/hooks/use-shareable-viewer-state).
964
+
882
965
  If your GLB/GLTF model contains animations, the library also exports:
883
966
 
884
967
  ```tsx
@@ -906,16 +989,17 @@ const {
906
989
 
907
990
  - `clips`: an array of animation clip names found in the GLB/GLTF file
908
991
  - `clipDetails`: clip metadata including `sourceName` and `duration` in seconds
909
- - `currentClip`: the name of the currently playing clip, or `null`
910
- - `isPlaying`: `true` while an animation is playing (not paused or stopped)
911
- - `speed`: the current playback speed (default `1`)
912
- - `time`: the live playback position of the current clip, in seconds (updates while playing)
913
- - `duration`: the length of the current clip, in seconds
914
- - `play`: starts a clip by name; if the same clip is paused, resumes it instead of restarting
915
- - `pause`: pauses the currently playing clip at its current time
916
- - `stop`: stops the current clip and resets it
917
- - `setSpeed`: changes the playback speed (e.g. `0.5` for half speed, `2` for double)
918
- - `seek`: scrubs the current clip to an absolute time in seconds (works while playing or paused), clamped to the clip length
992
+ - `currentClip`: the clip played most recently, while it's active, or `null`
993
+ - `isPlaying`: `true` while any clip is playing (not paused, finished, or stopped)
994
+ - `speed`: the playback speed of `currentClip` (default `1`)
995
+ - `time`: the live playback position of `currentClip`, in seconds (updates while playing)
996
+ - `duration`: the length of `currentClip`, in seconds
997
+ - `active`: every active clip (playing, paused, or finished) with its `status`, `time`, `duration`, `speed`, and `reverse`, most recently played last
998
+ - `play(clip, options?)`: starts a clip by name, replacing the clips already playing unless `options.alongside` is set; `options.fade` blends over that many seconds, and `options.reverse` plays it backwards. A paused clip resumes instead of restarting
999
+ - `pause(clip?)`: pauses one clip, or every active clip, at its current time
1000
+ - `stop(clip?)`: stops one clip, or every active clip, and resets the pose
1001
+ - `setSpeed(speed, clip?)`: changes the playback speed (e.g. `0.5` for half speed, `2` for double) of one clip, or of every active clip and those played later without their own speed
1002
+ - `seek(time, clip?)`: scrubs one clip, or every active clip, to an absolute time in seconds (works while playing or paused), clamped to the clip length
919
1003
  - `handleAnimationsReady`: a callback you can pass directly to `onAnimationsReady`
920
1004
 
921
1005
  Example with playback controls:
@@ -930,7 +1014,7 @@ const { clips, currentClip, isPlaying, play, pause, stop, setSpeed } =
930
1014
  </button>
931
1015
 
932
1016
  // Stop
933
- <button onClick={stop}>Stop</button>
1017
+ <button onClick={() => stop()}>Stop</button>
934
1018
 
935
1019
  // Speed slider
936
1020
  <input
@@ -950,16 +1034,128 @@ const { clips, currentClip, isPlaying, play, pause, stop, setSpeed } =
950
1034
  ))}
951
1035
  ```
952
1036
 
1037
+ Several clips can play at once. `play` replaces the clips already playing
1038
+ unless you pass `alongside: true`, and the other controls act on one clip when
1039
+ you name it, or on every active clip when you don't:
1040
+
1041
+ ```tsx
1042
+ const animations = useViewerAnimations();
1043
+
1044
+ // Open both doors together, then close the left one.
1045
+ animations.play("DoorLeftOpen", { alongside: true });
1046
+ animations.play("DoorRightOpen", { alongside: true });
1047
+ animations.play("DoorLeftOpen", { alongside: true, reverse: true });
1048
+
1049
+ // Blend from walking to running over half a second.
1050
+ animations.play("Run", { fade: 0.5 });
1051
+
1052
+ animations.pause("Wheels"); // one clip
1053
+ animations.stop(); // every clip
1054
+
1055
+ // What's active, and how far along each clip is
1056
+ animations.active.map(({ clip, status, time }) => `${clip}: ${status} at ${time}s`);
1057
+ ```
1058
+
1059
+ A clip whose `loopMode` is `"once"` holds its last pose when it finishes
1060
+ (`status: "finished"`) until it's stopped or played again, so an opened door
1061
+ stays open. `"ping-pong"` plays forwards then backwards, repeatedly. Playing a
1062
+ clip that's already playing in the same direction leaves it running; playing
1063
+ it the other way turns it around where it is.
1064
+
1065
+ ## `CompareViewer`
1066
+
1067
+ Shows two configurations next to each other with one camera: a before and
1068
+ after of a renovation, two paint options, the current part against its
1069
+ replacement. Each side is a full [`ModelViewer`](#public-api),
1070
+ so a side can differ in anything a viewer takes: `objectBindings`,
1071
+ `sceneConfig`, material variants, even a different `modelUrl`.
1072
+
1073
+ ```tsx
1074
+ import "@liveroom-tech/react-immersive/styles.css";
1075
+ import { CompareViewer } from "@liveroom-tech/react-immersive";
1076
+
1077
+ <div style={{ height: 600 }}>
1078
+ <CompareViewer
1079
+ modelUrl="/kitchen.glb"
1080
+ licenseKey="your-license-key"
1081
+ before={{ label: "Current", objectBindings: currentBindings }}
1082
+ after={{ label: "Proposed", objectBindings: proposedBindings }}
1083
+ />
1084
+ </div>;
1085
+ ```
1086
+
1087
+ ### Layouts
1088
+
1089
+ - **`layout="slider"`** (default) lays the two views over each other. A divider
1090
+ splits them: the left of it shows `before`, the right shows `after`. Drag the
1091
+ divider, or focus it and use the arrow keys, Page Up/Down, Home, and End. It
1092
+ is a slider to screen readers, with each side's label and share.
1093
+ - **`layout="side-by-side"`** shows the two views next to each other, each half
1094
+ the width.
1095
+
1096
+ ```tsx
1097
+ <CompareViewer
1098
+ modelUrl="/car.glb"
1099
+ licenseKey="your-license-key"
1100
+ layout="side-by-side"
1101
+ before={{ sceneConfig: { ...config, model: { ...config.model, activeVariants: ["Paint: Red"] } } }}
1102
+ after={{ sceneConfig: { ...config, model: { ...config.model, activeVariants: ["Paint: Blue"] } } }}
1103
+ />
1104
+ ```
1105
+
1106
+ ### One camera
1107
+
1108
+ Orbiting, panning, or zooming either side moves both. The side you last
1109
+ grabbed leads and the other copies it every frame, so a fly-in, a zoom to a
1110
+ selected object, or a refit on one side shows on both. With two different
1111
+ models, the views line up on the same point in space, so the models should
1112
+ share a coordinate system.
1113
+
1114
+ ### Props
1115
+
1116
+ ```ts
1117
+ type CompareViewerProps = Partial<ModelViewerProps> & {
1118
+ licenseKey: string;
1119
+ before: CompareViewerSide; // the left side
1120
+ after: CompareViewerSide; // the right side
1121
+ layout?: "slider" | "side-by-side"; // default "slider"
1122
+ split?: number; // the divider, 0 (left edge) to 1; default 0.5
1123
+ onSplitChange?: (split: number) => void;
1124
+ };
1125
+
1126
+ type CompareViewerSide = Partial<ModelViewerProps> & {
1127
+ label?: string; // shown over the side; default "Before" / "After"
1128
+ };
1129
+ ```
1130
+
1131
+ Every other prop is shared by both sides, and a side's own props win.
1132
+ Callbacks run per side: give `before.onObjectSelect` and
1133
+ `after.onObjectSelect` to tell the sides apart. Both sides start from the
1134
+ `minimal` [preset](#presets-and-grouped-props),
1135
+ with no panels or buttons, so their interfaces don't overlap; pass `preset`
1136
+ or `ui` to change that. Pass `split` to control the divider yourself;
1137
+ without it, `CompareViewer` keeps the position.
1138
+
1139
+ > Each side has its own WebGL (or [WebGPU](#webgpu)) canvas. The two share one download and parse of the model file, but the GPU holds two copies of its geometry and textures.
1140
+
1141
+ `poster`, `loading` and `reveal` work as on
1142
+ [`ModelViewer`](#lazy-loading-and-posters). With `reveal="interaction"`, one
1143
+ **View in 3D** button starts both sides.
1144
+
1145
+ The divider and the default side labels use the
1146
+ [`labels`](https://react-immersive.liveroom.dev/docs/reference/labels) keys `compareBefore`, `compareAfter`,
1147
+ `compareDivider`, and `compareDividerValue`.
1148
+
953
1149
  ## Presets and grouped props
954
1150
 
955
1151
  `preset` sets `ModelViewer` up for a common kind of page:
956
1152
 
957
1153
  | Preset | For | What it sets |
958
1154
  | --- | --- | --- |
959
- | `minimal` | Just the model | Hides both panels, the download and reset buttons, and tour controls |
1155
+ | `minimal` | Just the model | Hides both panels, the download and reset buttons, tour controls, and the material variant buttons |
960
1156
  | `showcase` | A product on display | Everything `minimal` does, plus auto-rotation |
961
- | `configurator` | Customers changing parts | Shows the selected object's panel and the reset button; hides the scene objects panel and download |
962
- | `inspector` | Checking a model | Every panel, the view gizmo, the measure, UV checker, and exploded-view tools, and keyboard navigation |
1157
+ | `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 |
1158
+ | `inspector` | Checking a model | Every panel, the view gizmo, the measure, UV checker, exploded-view, section-cut, and isolate tools, and keyboard navigation |
963
1159
 
964
1160
  `MODEL_VIEWER_PRESETS` lists exactly which props each preset sets.
965
1161
 
@@ -981,10 +1177,10 @@ readable:
981
1177
 
982
1178
  | Group | Fields, and the flat prop each one sets |
983
1179
  | --- | --- |
984
- | `ui` | `objectPanel` (`showObjectBindingDataPanel`), `sceneObjects` (`showSceneObjectsPanel`), `download` (`showDownloadButton`; a string also sets `downloadFilename`), `reset` (`showResetButton`), `loadingOverlay` (`showLoadingOverlay`), `viewGizmo` (`showViewGizmo`), `mouseController` (`showMouseController`; `{ position, opacity }` also sets `mouseControllerPosition` and `mouseControllerOpacity`), `annotationNavigation` (`showAnnotationNavigation`), `annotationOnHover` (`showAnnotationOnHover`), `highlightOnHover` |
985
- | `tools` | `measure` (`showMeasureTools`; `{ unit }` also sets `measurementUnit`), `uvChecker` (`showUvCheckerButton`), `explode` (`showExplodeControls`), `texturePositioning` (`texturePositioning`) |
986
- | `controls` | `enabled` (`enableCameraControls`), `keyboard` (`enableKeyboardNavigation`), `zoom` (the opposite of `disableZoom`), `zoomOnSelect` (`zoomOnSelected`), `autoRotate` (a number also sets `autoRotateSpeed`), `refitOnResize`, `limits` (`cameraLimits`), `moveSensitivity`, `zoomSensitivity` |
987
- | `perf` | `renderMode`, `maxDpr`, `profile` (`performanceProfile`), `preserveDrawingBuffer` |
1180
+ | `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`), `highlightOnHover`, `materialVariants` (`showMaterialVariants`), `objectNavigator` (`showObjectNavigator`) |
1181
+ | `tools` | `measure` (`showMeasureTools`; `{ unit }` also sets `measurementUnit`), `uvChecker` (`showUvCheckerButton`), `explode` (`showExplodeControls`), `section` (`showSectionTools`), `isolate` (`showIsolateControls`), `texturePositioning` (`texturePositioning`) |
1182
+ | `controls` | `enabled` (`enableCameraControls`), `keyboard` (`enableKeyboardNavigation`), `zoom` (the opposite of `disableZoom`), `zoomOnSelect` (`zoomOnSelected`), `autoRotate` (a number also sets `autoRotateSpeed`), `refitOnResize`, `limits` (`cameraLimits`), `moveSensitivity`, `zoomSensitivity`, `reducedMotion` |
1183
+ | `perf` | `renderMode`, `maxDpr`, `profile` (`performanceProfile`), `preserveDrawingBuffer`, `webgpu` |
988
1184
  | `tiles` | `url` (`tilesetUrl`), `errorTarget` (`tilesErrorTarget`), `cacheSize` (`tilesCacheSize`) |
989
1185
  | `xr` | `enabled` (`enableXR`), `usdzUrl`, `scaleMode` (`arScaleMode`), `mobileHandoffUrl` |
990
1186
  | `decoders` | `draco` (`dracoDecoderPath`), `ktx2` (`ktx2TranscoderPath`), `meshopt` |
@@ -1007,11 +1203,14 @@ type Props = {
1007
1203
  xr?: ModelViewerXrOptions;
1008
1204
  decoders?: ModelViewerDecoderOptions;
1009
1205
  modelUrl: string;
1206
+ poster?: string;
1207
+ loading?: "lazy" | "eager"; // default "lazy"
1208
+ reveal?: "auto" | "interaction"; // default "auto"
1010
1209
  tilesetUrl?: string | null;
1011
1210
  tilesErrorTarget?: number;
1012
1211
  tilesCacheSize?: number;
1013
1212
  onObjectCatalog?: (objects: ViewerObjectInfo[]) => void;
1014
- modelFormat?: "glb" | "gltf" | "obj" | "fbx" | "usdz";
1213
+ modelFormat?: "glb" | "gltf" | "obj" | "fbx" | "usdz" | "stl" | "ply";
1015
1214
  licenseKey: string;
1016
1215
  objectBindings?: Record<string, ObjectBinding> | "auto"; // default "auto"
1017
1216
  selectedObject?: ObjectBinding | null;
@@ -1029,6 +1228,8 @@ type Props = {
1029
1228
  controller: "orbit" | "pointerLock",
1030
1229
  ) => void;
1031
1230
  onAction?: (event: ObjectActionEvent) => void;
1231
+ onEngagement?: (event: ViewerEngagementEvent) => void | Promise<void>;
1232
+ debugEngagement?: boolean;
1032
1233
  onHiddenObjectsChange?: (next: Record<string, boolean>) => void;
1033
1234
  onCameraChange?: (state: ViewerCameraState) => void;
1034
1235
  onViewerReady?: (viewer: ViewerReadyState) => void;
@@ -1053,6 +1254,7 @@ type Props = {
1053
1254
  showDownloadButton?: boolean;
1054
1255
  downloadFilename?: string;
1055
1256
  showResetButton?: boolean;
1257
+ showFullscreenButton?: boolean;
1056
1258
  showLoadingOverlay?: boolean;
1057
1259
  showMouseController?: boolean;
1058
1260
  mouseControllerPosition?:
@@ -1074,11 +1276,14 @@ type Props = {
1074
1276
  moveModeEnabled?: boolean;
1075
1277
  objectTransformSpace?: "local" | "world";
1076
1278
  enableKeyboardNavigation?: boolean;
1279
+ showObjectNavigator?: boolean;
1280
+ reducedMotion?: "user" | "always" | "never";
1077
1281
  onAutoFit?: () => Promise<boolean>;
1078
1282
  refitOnResize?: boolean;
1079
1283
  renderMode?: "always" | "demand";
1080
1284
  maxDpr?: number;
1081
1285
  preserveDrawingBuffer?: boolean;
1286
+ webgpu?: boolean;
1082
1287
  performanceProfile?: "auto" | "high" | "low";
1083
1288
  dracoDecoderPath?: string | false;
1084
1289
  ktx2TranscoderPath?: string | false;
@@ -1086,6 +1291,11 @@ type Props = {
1086
1291
  showMeasureTools?: boolean;
1087
1292
  showUvCheckerButton?: boolean;
1088
1293
  showExplodeControls?: boolean;
1294
+ showSectionTools?: boolean;
1295
+ isolation?: ViewerIsolation | null;
1296
+ onIsolationChange?: (isolation: ViewerIsolation | null) => void;
1297
+ showIsolateControls?: boolean;
1298
+ showMaterialVariants?: boolean;
1089
1299
  cinematic?: boolean | CinematicConfig;
1090
1300
  measurementUnit?: string;
1091
1301
  enableXR?: boolean;
@@ -1105,7 +1315,7 @@ type Props = {
1105
1315
 
1106
1316
  ### `modelUrl`
1107
1317
 
1108
- URL or public path to a `.glb`, `.gltf`, `.obj`, `.fbx`, or `.usdz` model. Hosted GLTF, OBJ, and FBX assets must serve external buffers, MTL files, and textures at their declared relative paths. USDZ dependencies are contained in the USDZ package. Use `modelFormat` when a signed or extensionless URL cannot be auto-detected.
1318
+ URL or public path to a `.glb`, `.gltf`, `.obj`, `.fbx`, `.usdz`, `.stl`, or `.ply` model. Hosted GLTF, OBJ, and FBX assets must serve external buffers, MTL files, and textures at their declared relative paths. USDZ dependencies are contained in the USDZ package. Use `modelFormat` when a signed or extensionless URL cannot be auto-detected.
1109
1319
 
1110
1320
  Example:
1111
1321
 
@@ -1116,7 +1326,33 @@ modelUrl = "/model.glb";
1116
1326
  ### `modelFormat`
1117
1327
 
1118
1328
  Optional explicit format for signed or extensionless model URLs. Normal URLs
1119
- ending in `.glb`, `.gltf`, `.obj`, `.fbx`, or `.usdz` are detected automatically.
1329
+ ending in `.glb`, `.gltf`, `.obj`, `.fbx`, `.usdz`, `.stl`, or `.ply` are detected automatically.
1330
+
1331
+ ### STL and PLY
1332
+
1333
+ STL and PLY files hold geometry only: no object names, materials or
1334
+ animations.
1335
+
1336
+ - **One object per file**, named after the file: `bracket.stl` loads as one
1337
+ object keyed `bracket`, which its bindings use. A text STL with several named
1338
+ solids loads one object per solid. A `.zip` holding one STL or PLY file also
1339
+ works.
1340
+ - **Colour:** objects start a matte grey. A PLY file's vertex colours show as
1341
+ they are, and picking a colour (or texture) replaces them.
1342
+ - **No texture coordinates:** STL files have none and PLY files rarely do, so an
1343
+ image can't be mapped onto them. The viewer's **Change Material** says so
1344
+ instead of offering an upload, BindingBuilder turns off its image map fields
1345
+ for that object, and new bindings leave Change Material out. [Decals](#decals)
1346
+ work without texture coordinates. This applies to any object without them, in
1347
+ any format.
1348
+ - **Units and orientation:** STL files are usually in millimetres with Z up, so a
1349
+ part can load lying on its back. Turn it upright with
1350
+ [`sceneConfig.model.rotation`](#sceneconfig) (BindingBuilder: **Scene** →
1351
+ model rotation, −90° on X), and set [`measurementUnit`](#measurementunit) to
1352
+ `"mm"`. In AR, the default `"normalized"` [`arScaleMode`](#arscalemode) fits the
1353
+ model to a metre; `"real-world"` would read millimetres as metres.
1354
+ - **PLY point clouds** (points with no faces) can't be shown; the viewer
1355
+ explains why instead of loading them.
1120
1356
 
1121
1357
  ### 3D Tiles streaming
1122
1358
 
@@ -1312,6 +1548,7 @@ type ObjectPointerEvent = {
1312
1548
  clientX: number; // viewport coordinates
1313
1549
  clientY: number;
1314
1550
  pointerType: string; // "mouse" | "pen" | "touch"
1551
+ surface?: { position: Vec3; normal: Vec3 }; // where on the object, in its own space
1315
1552
  };
1316
1553
  ```
1317
1554
 
@@ -1403,6 +1640,8 @@ count:
1403
1640
  type ViewerModelInfo = {
1404
1641
  bounds: { min: Vec3; max: Vec3; center: Vec3; size: Vec3 }; // world space
1405
1642
  objectCount: number; // meshes in the loaded scene
1643
+ materialVariants: string[]; // glTF material variant names, if any
1644
+ materialVariantGroups: MaterialVariantGroup[]; // the same, as option groups
1406
1645
  };
1407
1646
  ```
1408
1647
 
@@ -1450,6 +1689,60 @@ type ObjectActionEvent = {
1450
1689
  };
1451
1690
  ```
1452
1691
 
1692
+ ### `onEngagement` / `debugEngagement`
1693
+
1694
+ Optional interaction analytics. Set `onEngagement` to receive typed
1695
+ `ViewerEngagementEvent` objects, or set `debugEngagement` to log them in the
1696
+ browser's developer console with the `[react-immersive engagement]` prefix.
1697
+ Both are disabled by default. The viewer does not send telemetry or persist
1698
+ analytics identifiers; your callback decides where events go.
1699
+
1700
+ ```tsx
1701
+ import { ModelViewer, type ViewerEngagementEvent } from "@liveroom-tech/react-immersive";
1702
+
1703
+ function handleEngagement(event: ViewerEngagementEvent) {
1704
+ if (event.type === "part-viewed") {
1705
+ console.info("Part viewed:", event.objectKey, event.source);
1706
+ }
1707
+ if (event.type === "ar-ended") {
1708
+ console.info("Time in AR:", event.durationMs);
1709
+ }
1710
+ // Connect this callback to your application's analytics integration.
1711
+ }
1712
+
1713
+ <ModelViewer
1714
+ modelUrl="/car.glb"
1715
+ licenseKey={licenseKey}
1716
+ onEngagement={handleEngagement}
1717
+ debugEngagement
1718
+ />
1719
+ ```
1720
+
1721
+ Every event includes `sessionId` (an ephemeral ID for this viewer/model),
1722
+ `timestamp` (ISO format), `elapsedMs`, `modelUrl`, and `tilesetUrl` (or `null`).
1723
+ Durations use a monotonic clock and are expressed in milliseconds. Session
1724
+ elapsed time includes loading and background time; it is not an attention metric.
1725
+
1726
+ | Event `type` | Details and trigger |
1727
+ | --- | --- |
1728
+ | `part-viewed` | `objectId`, `objectKey`, optional `label`, and `source` (`"canvas"`, `"panel"`, or `"keyboard"`). Selecting/focusing a different part; reselecting the current part is ignored. |
1729
+ | `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. |
1730
+ | `ar-started` | A confirmed immersive WebXR AR session begins. |
1731
+ | `ar-ended` | `durationMs` and `reason` (`"session-ended"` or `"interrupted"`). A confirmed AR session ends, or tracking stops on model change, unmount, or context loss. |
1732
+ | `ar-launch-requested` | `channel` (`"quick-look"` or `"mobile-handoff"`). Opening external AR or its QR handoff; this does not claim an AR session started. |
1733
+ | `tour-started` | `annotationCount`. Starting the guided tour. |
1734
+ | `tour-completed` | `annotationCount` and `durationMs`. Advancing past its final annotation; emitted once per completed tour. |
1735
+ | `tour-stopped` | `annotationCount`, `durationMs`, and `reason` (`"stopped"`, `"restarted"`, `"annotations-changed"`, or `"interrupted"`). Leaving a tour before completion. |
1736
+
1737
+ Hover, camera motion, renders, and externally supplied selection/variant updates
1738
+ do not create engagement events. WebXR duration measures the session interval;
1739
+ Quick Look runs outside the page and has no reliable duration callback. Turning
1740
+ tracking on during an existing AR session measures from attachment. Callback
1741
+ changes do not restart timing, and synchronous or asynchronous callback failures are logged without
1742
+ interrupting the viewer. A new model or re-enabling tracking creates a new
1743
+ analytics session; WebGL recovery keeps the session ID; context loss ends active intervals
1744
+ as interrupted.
1745
+
1453
1746
  ### `onViewerReady`
1454
1747
 
1455
1748
  Optional callback fired once camera controls are available and again when the
@@ -1469,7 +1762,9 @@ type ViewerReadyState = {
1469
1762
  objectBindings: Record<string, ObjectBinding>;
1470
1763
  homeCameraState: { position: Vec3; target: Vec3 } | null;
1471
1764
  captureImage: (options?: CaptureImageOptions) => Promise<string>;
1765
+ captureVideo: (options?: CaptureVideoOptions) => Promise<Blob>;
1472
1766
  invalidate: () => void;
1767
+ rendererBackend: "webgl" | "webgpu"; // which renderer draws; see `webgpu`
1473
1768
  raw: {
1474
1769
  controls: CameraControls; // camera-controls
1475
1770
  scene: Object3D | null; // three.js
@@ -1521,6 +1816,67 @@ const connection = useViewerConnection(camera, effects);
1521
1816
  const dataUrl = await connection.captureImage({ width: 1920, height: 1080 });
1522
1817
  ```
1523
1818
 
1819
+ `captureVideo` records one complete cinematic pass or turntable revolution and
1820
+ resolves with a video `Blob`. It is also available on `useViewerConnection` and
1821
+ `useViewer().connection`. Call it from a button handler after the model has
1822
+ finished loading and framing; ready-state callbacks run repeatedly as the camera
1823
+ moves and should not start recordings automatically.
1824
+
1825
+ ```tsx
1826
+ const viewer = useViewer();
1827
+ const abort = new AbortController();
1828
+
1829
+ <ModelViewer {...viewer.props} cinematic showDownloadButton />;
1830
+
1831
+ // Run in an event handler. Upload the Blob or preview it with an object URL.
1832
+ const clip = await viewer.connection.captureVideo({
1833
+ mode: "turntable", // or "cinematic" to use the enabled camera path
1834
+ format: "auto", // prefers WebM; falls back to MP4. "gif" for an animated GIF
1835
+ duration: 12, // seconds; defaults to path timing or a 24-second orbit
1836
+ fps: 30, // 1–60; actual throughput depends on rendering/encoding
1837
+ videoBitsPerSecond: 8_000_000,
1838
+ signal: abort.signal,
1839
+ });
1840
+ // abort.abort() cancels an in-progress export and discards its data.
1841
+ ```
1842
+
1843
+ The download menu offers **Record turntable video** and, when cinematic is
1844
+ enabled, **Record cinematic video**, plus Auto/WebM/MP4/GIF format selection. A
1845
+ recording shows progress and a Cancel control. The filename and Blob MIME type
1846
+ match the encoded format; an explicitly requested unsupported format rejects
1847
+ instead of silently changing formats. With `loop: true`, a cinematic export
1848
+ includes the return segment and stops after one cycle.
1849
+
1850
+ Recording runs in real time at the canvas's current pixel resolution, includes
1851
+ post-processing, and hides editor gizmos. It pauses cinematic playback, blocks
1852
+ viewer gestures, then restores the previous camera pose and controls on success,
1853
+ cancellation, or failure. Keep the tab visible until recording completes.
1854
+ `preserveDrawingBuffer` is unnecessary for video. No audio, HTML overlays, or
1855
+ format transcoding is included. The browser must support
1856
+ `canvas.captureStream`, and `MediaRecorder` for WebM/MP4; exports reject before framing,
1857
+ during XR, during another recording, or for durations outside (0, 300] seconds.
1858
+
1859
+ **Animated GIF.** Pass `format: "gif"` (or pick **GIF** in the menu's format
1860
+ select) for a GIF that loops forever, for places that don't play video, such as
1861
+ email, chat, and some marketplaces:
1862
+
1863
+ ```ts
1864
+ const gif = await connection.captureVideo({
1865
+ mode: "turntable",
1866
+ format: "gif",
1867
+ gifWidth: 480, // pixels; the height follows the canvas. Default 480
1868
+ // fps defaults to 15, and a turntable to an 8-second turn
1869
+ });
1870
+ ```
1871
+
1872
+ A GIF holds 256 colours per frame and stores every frame whole, so it is much
1873
+ larger than a video of the same length and shows some banding on smooth
1874
+ gradients. Frames are taken from the same stream as a video and encoded as
1875
+ they're captured, so a long recording doesn't build up memory. Each frame lasts
1876
+ as long as it really showed, so a busy device drops frames rather than playing
1877
+ the GIF fast. `videoBitsPerSecond` doesn't apply to GIFs. The encoder
1878
+ ([gifenc](https://github.com/mattdesl/gifenc)) loads only when a GIF is made.
1879
+
1524
1880
  ### `onTextureUpload`
1525
1881
 
1526
1882
  Optional async callback for handling texture file uploads. When provided, the viewer calls it instead of creating a blob URL, letting consumers upload to their own storage (S3, Cloudinary, a CDN, etc.) and return a durable URL that survives page reloads.
@@ -1605,22 +1961,39 @@ Current callback shape:
1605
1961
  Where `AnimationControls` is:
1606
1962
 
1607
1963
  ```ts
1608
- type AnimationPlaybackState = {
1609
- currentClip: string | null;
1610
- isPlaying: boolean;
1964
+ type AnimationPlayOptions = {
1965
+ alongside?: boolean; // keep the clips already playing (default false: replace them)
1966
+ fade?: number; // seconds to fade in, and to fade out the clips it replaces
1967
+ reverse?: boolean; // play backwards, from where the clip is or from its end
1968
+ };
1969
+
1970
+ type AnimationClipState = {
1971
+ clip: string;
1972
+ status: "playing" | "paused" | "finished"; // finished: a play-once clip holding its last pose
1973
+ time: number; // seconds
1974
+ duration: number; // seconds
1611
1975
  speed: number;
1612
- time: number; // live position of the current clip, in seconds
1613
- duration: number; // length of the current clip, in seconds
1976
+ reverse: boolean;
1614
1977
  };
1615
1978
 
1979
+ type AnimationPlaybackState = {
1980
+ currentClip: string | null; // the clip played most recently, while it's active
1981
+ isPlaying: boolean; // whether any clip is playing
1982
+ speed: number; // speed, position and length of currentClip
1983
+ time: number;
1984
+ duration: number;
1985
+ active: AnimationClipState[]; // every active clip, most recently played last
1986
+ };
1987
+
1988
+ // Leave out clipName to act on every active clip.
1616
1989
  type AnimationControls = {
1617
1990
  clips: string[];
1618
1991
  clipDetails?: { sourceName: string; duration: number }[];
1619
- play: (clipName: string) => void;
1620
- pause: () => void;
1621
- stop: () => void;
1622
- setSpeed: (speed: number) => void;
1623
- seek?: (time: number) => void; // scrub the current clip to an absolute time (seconds)
1992
+ play: (clipName: string, options?: AnimationPlayOptions) => void;
1993
+ pause: (clipName?: string) => void;
1994
+ stop: (clipName?: string) => void; // stops and resets the pose
1995
+ setSpeed: (speed: number, clipName?: string) => void;
1996
+ seek?: (time: number, clipName?: string) => void; // absolute time, in seconds
1624
1997
  getState?: () => AnimationPlaybackState;
1625
1998
  subscribe?: (listener: (state: AnimationPlaybackState) => void) => () => void;
1626
1999
  };
@@ -1628,8 +2001,8 @@ type AnimationControls = {
1628
2001
 
1629
2002
  Wire this to `useViewerAnimations().handleAnimationsReady` for the simplest integration.
1630
2003
  When `sceneConfig.animations.autoplayClip` is set, `ModelViewer` will auto-play
1631
- that clip on load and respect per-clip `loopMode`, `speed`, `displayName`, and
1632
- soft-delete (`hidden`) settings.
2004
+ that clip on load and respect per-clip `loopMode` (`"repeat"`, `"once"`, or
2005
+ `"ping-pong"`), `speed`, `displayName`, and soft-delete (`hidden`) settings.
1633
2006
 
1634
2007
  ### Lighting
1635
2008
 
@@ -1679,6 +2052,81 @@ An attached light follows the object, offset from the object's origin in its
1679
2052
  local space (the origin is often at its base, not its center). Lighting set up
1680
2053
  in `BindingBuilder`'s Scene tab exports as the same `sceneConfig`.
1681
2054
 
2055
+ ### Style rules
2056
+
2057
+ `sceneConfig.styleRules` restyles objects from their own data while the app
2058
+ runs: a colour for a temperature, grey for a disabled part, a pulsing glow for
2059
+ an alarm. Each rule picks objects (`target`), checks their data (`when`), and
2060
+ sets a look (`material`, `colorScale`, `pulse`):
2061
+
2062
+ ```tsx
2063
+ const viewer = useViewer({
2064
+ bindings,
2065
+ sceneConfig: {
2066
+ ...DEFAULT_SCENE_CONFIG,
2067
+ styleRules: [
2068
+ {
2069
+ id: "temperature",
2070
+ target: { group: "pumps" },
2071
+ colorScale: {
2072
+ metric: "temperature",
2073
+ stops: [[18, "#3b82f6"], [24, "#22c55e"], [32, "#ef4444"]],
2074
+ },
2075
+ },
2076
+ {
2077
+ id: "offline",
2078
+ when: { status: "disabled" },
2079
+ material: { baseColor: "#9ca3af", opacity: 0.4 },
2080
+ },
2081
+ {
2082
+ id: "alarm",
2083
+ when: [{ metric: "temperature", above: 32 }, { metadata: "alarm.acknowledged", equals: false }],
2084
+ pulse: { color: "#ff3b30", speed: 1.5 },
2085
+ },
2086
+ ],
2087
+ },
2088
+ });
2089
+
2090
+ <ModelViewer modelUrl="/plant.glb" licenseKey="your-license-key" {...viewer.props} />;
2091
+
2092
+ // Live data: update the bindings and the looks follow.
2093
+ viewer.bindings.updateObjectBindings("pump-3", { metrics: { temperature: 34 } });
2094
+ viewer.bindings.updateObjectBindings("valve-1", { status: "disabled" });
2095
+ ```
2096
+
2097
+ - `target` takes the same targets as the binding helpers: an id, a list of
2098
+ ids, `{ group }`, `{ tag }`, or `{ type }`. Left out, the rule considers every
2099
+ bound object.
2100
+ - `when` is a condition, or a list that must all match; left out, the rule
2101
+ always applies. Conditions: `{ metric, above?, below? }` (the value must be
2102
+ greater than `above` and less than `below`; an object without the metric
2103
+ doesn't match), `{ status }` with one status or a list (a binding without
2104
+ one counts as `"normal"`), and `{ metadata, equals }`, where dots in the key
2105
+ reach into nested metadata.
2106
+ - `material` accepts any `style.material` field. `colorScale` sets
2107
+ `baseColor` (or `emissive`, with `channel: "emissive"`) from a metric,
2108
+ blending between hex colour stops and taking the nearest stop outside them.
2109
+ `pulse` makes the glow fade in and out (`true`, or `{ color, speed }` in
2110
+ pulses per second).
2111
+ - Rules apply in order over each object's own `style.material`, so a later
2112
+ rule wins where two set the same field. Hover and selection highlighting
2113
+ still show on top.
2114
+ - Rules only change how objects look. `objectBindings`, `onObjectBindingsChange`,
2115
+ and every callback keep the object's own values, so nothing a rule sets is
2116
+ saved with the bindings.
2117
+ - The pulse isn't drawn on streamed 3D Tiles models (`tilesetUrl`); the other
2118
+ looks are.
2119
+
2120
+ BindingBuilder's Scene tab has a **Rules** sub-tab for building these rules
2121
+ visually. It suggests your groups, tags, types, metric names, and metadata
2122
+ keys, shows how many objects each rule matches right now, and previews the
2123
+ result live. Rules export and import with the rest of `sceneConfig`.
2124
+
2125
+ `applyStyleRules(bindings, rules)` (from the main entry or `/utils`) returns
2126
+ the styled bindings, for example to colour your own list the way the viewer
2127
+ does, and `colorScaleValue(stops, value)` the colour a scale gives a value, for
2128
+ a legend.
2129
+
1682
2130
  ### `camera`
1683
2131
 
1684
2132
  Optional starting camera: where it is and its lens.
@@ -1942,7 +2390,7 @@ SVG texture template per material. Meshes without a `uv` attribute are omitted;
1942
2390
  when the model has no UV coordinates, the viewer reports that no layout is
1943
2391
  available.
1944
2392
 
1945
- The bottom-right action bar renders when at least one of `showResetButton`, `showDownloadButton`, or (`showAnnotationNavigation` with annotations present) is `true`.
2393
+ The bottom-right action bar renders when at least one of `showResetButton`, `showDownloadButton`, `showFullscreenButton` (when supported), or (`showAnnotationNavigation` with annotations present) is `true`.
1946
2394
 
1947
2395
  ### `downloadFilename`
1948
2396
 
@@ -1964,6 +2412,26 @@ Default:
1964
2412
  true;
1965
2413
  ```
1966
2414
 
2415
+ ### `showFullscreenButton`
2416
+
2417
+ Optional boolean that adds an enter/exit fullscreen toggle to the bottom-right
2418
+ action bar. Its grouped form is `ui.fullscreen`. Default: `false`.
2419
+
2420
+ ```tsx
2421
+ <ModelViewer
2422
+ modelUrl="/model.glb"
2423
+ licenseKey={licenseKey}
2424
+ ui={{ fullscreen: true }}
2425
+ />
2426
+ ```
2427
+
2428
+ Fullscreen expands the whole viewer, including its panels and overlays. The
2429
+ button reflects browser exits such as Escape, and is hidden when fullscreen is
2430
+ unavailable. It is temporarily disabled during video recording. Embedded viewers
2431
+ need an iframe with `allow="fullscreen"` (or `allowFullScreen` in React). A rejected
2432
+ request displays an error beside the button; see the
2433
+ [Fullscreen API permissions](https://developer.mozilla.org/en-US/docs/Web/API/Element/requestFullscreen#security).
2434
+
1967
2435
  ### `showLoadingOverlay`
1968
2436
 
1969
2437
  Optional boolean controlling 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`](#onloaderror)).
@@ -2199,13 +2667,76 @@ Enables automatic orbiting and controls its speed. Defaults are `false` and
2199
2667
  <ModelViewer {...props} autoRotate autoRotateSpeed={0.75} />
2200
2668
  ```
2201
2669
 
2670
+ Auto-rotate doesn't start for visitors who ask their device to reduce motion.
2671
+ See [`reducedMotion`](#reducedmotion).
2672
+
2202
2673
  ### `enableKeyboardNavigation`
2203
2674
 
2204
2675
  Optional boolean enabling camera shortcuts after the viewer canvas is clicked
2205
2676
  or focused. Default: `false`. Use `W`/`S` to move forward/back, `A`/`D` to
2206
2677
  truck left/right, Space/`C` to move up/down, and the arrow keys to orbit.
2207
2678
  `Shift` + Up/Down moves forward/back. Keyboard navigation is inactive while
2208
- object editing or camera controls are disabled.
2679
+ object editing or camera controls are disabled. While the canvas has focus, a
2680
+ screen reader reads out what its keys do (the `cameraKeyboardHelp` label).
2681
+
2682
+ ### Accessibility
2683
+
2684
+ The viewer can be used without a mouse, and reads out what is happening:
2685
+
2686
+ - **Moving through objects with the keyboard.** Tabbing into the viewer reaches
2687
+ a small object list at the top of the canvas, shown only while it has focus.
2688
+ The arrow keys (or `Home` and `End`) move through the objects and highlight
2689
+ each one in the scene. Typing a letter jumps to an object whose name starts
2690
+ with it. `Enter` or Space selects the object, exactly as clicking it would,
2691
+ and `Escape` clears the selection. Screen readers hear each object's name and
2692
+ its position ("Wheel, 3 of 12"). The list follows the scene objects panel's
2693
+ order. It skips hidden objects, and objects an isolation has set aside.
2694
+ - **Announcing the selection.** Every change of selection is announced, whether
2695
+ it came from a click, the keyboard, the objects panel, or your app ("Wheel
2696
+ selected", "Selection cleared").
2697
+ - **The scene objects panel.** Each row is a button, so the panel can be used
2698
+ from the keyboard too. Focusing a row highlights its object, and the
2699
+ visibility buttons name the object they hide or show.
2700
+ - **Reduced motion.** See [`reducedMotion`](#reducedmotion) below.
2701
+
2702
+ All of this text can be translated with `labels`.
2703
+
2704
+ ### `showObjectNavigator`
2705
+
2706
+ Optional boolean for the keyboard object list described above. Default: `true`.
2707
+ It takes no space until a keyboard user reaches it, so it's on by default. Set it
2708
+ to `false` when your app provides its own way to choose objects from the
2709
+ keyboard. It is hidden while measuring, since clicks then place points instead
2710
+ of selecting. Keyboard selections reach `onEngagement` as `part-viewed` events
2711
+ with `source: "keyboard"`.
2712
+
2713
+ ```tsx
2714
+ <ModelViewer {...props} ui={{ objectNavigator: false }} />
2715
+ ```
2716
+
2717
+ ### `reducedMotion`
2718
+
2719
+ Cuts motion the viewer starts on its own, for visitors who find it hard to
2720
+ watch. Default: `"user"`, which follows the visitor's device setting
2721
+ (`prefers-reduced-motion`). `"always"` and `"never"` override it. Also
2722
+ `controls.reducedMotion`; the type is exported as `ReducedMotionSetting`.
2723
+
2724
+ When motion is reduced:
2725
+
2726
+ - Auto-rotate doesn't start.
2727
+ - A `cinematic` path with `autoPlay` waits for its play button.
2728
+ - Camera moves jump straight to where they're going instead of gliding: the
2729
+ first framing, focusing a selected object, resetting the view, saved views,
2730
+ guided-tour stops, and moves made through `useViewerCamera`.
2731
+ - Panels, menus and popups appear in place instead of sliding or fading in.
2732
+ Loading spinners keep turning, since they show that something is still
2733
+ happening.
2734
+
2735
+ Motion the visitor asks for still plays: the cinematic play button, video
2736
+ export, and dragging the camera. The viewer doesn't change animations your app
2737
+ starts, such as model clips, effects, or style rule pulses. To respect the
2738
+ setting there too, check `matchMedia("(prefers-reduced-motion: reduce)")` in
2739
+ your app. `SimpleModelViewer` takes the same prop.
2209
2740
 
2210
2741
  ### `showViewGizmo`
2211
2742
 
@@ -2228,6 +2759,8 @@ Current callback shape:
2228
2759
 
2229
2760
  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. This only affects re-fits _after_ the initial mount-time auto-fit.
2230
2761
 
2762
+ While a selected object is framed (see `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.
2763
+
2231
2764
  Default:
2232
2765
 
2233
2766
  ```ts
@@ -2293,6 +2826,89 @@ Default:
2293
2826
  false;
2294
2827
  ```
2295
2828
 
2829
+ ### `webgpu`
2830
+
2831
+ Optional boolean. Draws with three.js's WebGPU renderer when the browser has
2832
+ WebGPU, and with WebGL otherwise. Default: `false`. Also `perf.webgpu`.
2833
+
2834
+ ```tsx
2835
+ <ModelViewer {...props} perf={{ webgpu: true }} />
2836
+ ```
2837
+
2838
+ The viewer asks the browser for a WebGPU adapter once per page before
2839
+ creating its canvas. Without one, it uses WebGL and says so once in the
2840
+ console. `onViewerReady` reports which renderer is drawing as
2841
+ `rendererBackend` (`"webgl"` or `"webgpu"`). The WebGPU renderer is loaded only
2842
+ by viewers that use it.
2843
+
2844
+ With WebGPU, the model, materials, bindings, hover highlight, picking,
2845
+ animations, decals, annotations, measurement, video and GIF export, and tone
2846
+ mapping and exposure work as they do with WebGL. Some features are built on
2847
+ WebGL and are left out:
2848
+
2849
+ - The post-processing effects (outline, bloom, depth of field, and the rest)
2850
+ don't draw. Tone mapping and exposure from `sceneConfig.postProcessing` still
2851
+ apply, through the renderer.
2852
+ - Contact shadows (`sceneConfig.groundShadows` in shadow-catcher mode) don't
2853
+ draw.
2854
+ - The section cut and the move gizmo (`moveModeEnabled`) aren't available, and
2855
+ the console says so if they're asked for.
2856
+ - AR and VR sessions in the browser (WebXR) aren't offered. Quick Look on iOS
2857
+ and the phone hand-off still work.
2858
+ - Measurement lines are one pixel wide.
2859
+
2860
+ `captureImage` and the Download PNG action work with WebGPU without
2861
+ `preserveDrawingBuffer`. A lost WebGPU device shows the same **Reload viewer**
2862
+ message as a lost WebGL context.
2863
+
2864
+ ### Lazy loading and posters
2865
+
2866
+ `loading`, `poster` and `reveal` decide when a viewer creates its 3D view, and
2867
+ what shows in its place until then. They matter most on pages with many
2868
+ viewers, such as a product grid: browsers allow a page about 16 WebGL contexts
2869
+ and take the oldest away past that.
2870
+
2871
+ ```tsx
2872
+ <ModelViewer {...props} poster="/chair.webp" />
2873
+ <ModelViewer {...props} poster="/chair.webp" reveal="interaction" />
2874
+ ```
2875
+
2876
+ `loading` is `"lazy"` by default. A lazy viewer:
2877
+
2878
+ - starts its 3D view when it comes within about 200px of the screen. One that's
2879
+ on the screen when the page loads starts at once.
2880
+ - shares the page's live 3D views with the other lazy viewers: at most 12, or 6
2881
+ on phones and tablets. Viewers on the screen come first. One scrolled away
2882
+ from keeps its view while there's room, so coming back is instant. When the
2883
+ room is needed, it's put away, leaving a picture of its last frame, and is
2884
+ built again when it comes back near the screen.
2885
+ - comes back as it was: the camera, picked variants, light and section-cut
2886
+ changes, isolation, the exploded view, the camera controller, the UV checker,
2887
+ the annotation filter, and, when the viewer keeps `objectBindings` itself,
2888
+ the changes made to them. The selection and open panels don't come back. `onModelLoaded` and
2889
+ `onViewerReady` run again, with a new viewer.
2890
+ - stops drawing while it's off the screen, and starts again when it's back.
2891
+ Browsers already stop drawing in hidden tabs.
2892
+
2893
+ `loading="eager"` starts the 3D view at once and keeps it. Eager viewers don't
2894
+ count towards the 12. `BindingBuilder`'s preview is eager.
2895
+
2896
+ `poster` is an image shown in the viewer's place until its 3D view is drawn,
2897
+ then fades out; the loading message shows over it. It's fitted inside the
2898
+ viewer. To fill the viewer instead, set the CSS variable
2899
+ `--ri-poster-fit: cover` on a parent. A render of the model framed as the
2900
+ viewer frames it works best: [`captureImage`](#onviewerready) can make one.
2901
+
2902
+ `reveal="interaction"` keeps the poster, with a **View in 3D** button, until the
2903
+ visitor presses it, so only the products someone picks use a 3D view. The
2904
+ button's text is the [`labels`](https://react-immersive.liveroom.dev/docs/reference/labels)
2905
+ key `viewIn3D`. `reveal` is `"auto"` by default: the view starts on its own.
2906
+
2907
+ The same three props work on [`SimpleModelViewer`](#simplemodelviewer-public-api)
2908
+ and [`CompareViewer`](#compareviewer).
2909
+
2910
+ ---
2911
+
2296
2912
  ### `performanceProfile`
2297
2913
 
2298
2914
  Optional control over how aggressively the viewer trades visual fidelity for a stable WebGL context on constrained GPUs.
@@ -2311,6 +2927,20 @@ Default:
2311
2927
  "auto";
2312
2928
  ```
2313
2929
 
2930
+ If the WebGL context is lost, rendering pauses and the viewer displays a
2931
+ **Reload viewer** button. This message appears even with
2932
+ `ui={{ loadingOverlay: false }}`. When the browser fires `webglcontextrestored`,
2933
+ the viewer automatically rebuilds its canvas and scene; the reload button starts
2934
+ the same rebuild without waiting for browser restoration.
2935
+
2936
+ Recovery uses the current props and cached model assets, and cancels video
2937
+ recording. The rebuilt viewer keeps its camera and the visitor's changes, as a
2938
+ lazy viewer does when it comes back (see
2939
+ [Lazy loading and posters](#lazy-loading-and-posters)); the selection,
2940
+ measurements and playback start over.
2941
+ `onSceneLoaded`, `onModelLoaded`, `onAnimationsReady`, and `onViewerReady` run again
2942
+ for the rebuilt scene, so integrations receive fresh controls and object refs.
2943
+
2314
2944
  ### `dracoDecoderPath` / `ktx2TranscoderPath` / `meshopt`
2315
2945
 
2316
2946
  Optional controls for the compressed-asset decoders used when loading the GLB/GLTF asset.
@@ -2375,7 +3005,33 @@ false;
2375
3005
 
2376
3006
  ### `showMeasureTools`
2377
3007
 
2378
- Optional boolean that shows a click-to-measure toolbar over the canvas: click two points on the model for a distance readout, plus a toggleable bounding-box dimensions overlay.
3008
+ Optional boolean that shows measurement tools and a toggleable bounding-box
3009
+ dimensions overlay. Start measuring, then choose a tool:
3010
+
3011
+ - **Distance:** pick points for distances between consecutive picks.
3012
+ - **Angle:** pick an arm, the angle's vertex, and the other arm. The readout is
3013
+ in degrees, with an arc around the middle pick.
3014
+ - **Area:** pick polygon corners in boundary order, then **Finish area**. The
3015
+ closed polygon is shaded and labeled in square units. Concave polygons are
3016
+ supported; nonplanar, crossing, touching, duplicate, or collinear boundaries
3017
+ show an error. Use Undo to correct a pick.
3018
+ - **Point to plane:** pick a reference face, then a point. The tool highlights
3019
+ the picked triangle and shows the shortest distance to its infinite plane,
3020
+ with a perpendicular segment to the projected point.
3021
+
3022
+ **No snapping** keeps picks on the surface. **Snap: vertex**, **Snap: edge**,
3023
+ and **Snap: auto** snap within 12 CSS pixels to vertices or triangle edges on
3024
+ the picked face. Auto prioritizes nearby vertices. Hover markers identify the
3025
+ snap target; snapped picks are yellow. Snapping respects world transforms,
3026
+ instances, and the current pose of skinned/morph geometry. Mesh triangulation
3027
+ edges can also be snap targets.
3028
+
3029
+ Undo removes the last pick; Clear removes the current measurement. Switching
3030
+ tools clears picks. A new pick after a completed angle, area, or point-to-plane
3031
+ measurement starts another measurement. Picks are snapshots in world space;
3032
+ model changes, display rotation, and binding transforms clear them. Pause
3033
+ animated models before measuring a pose. Hidden objects, decals, and editor
3034
+ helpers are excluded from picking.
2379
3035
 
2380
3036
  Default:
2381
3037
 
@@ -2385,7 +3041,10 @@ false;
2385
3041
 
2386
3042
  ### `measurementUnit`
2387
3043
 
2388
- Optional unit suffix appended to measurement readouts. glTF models are authored in meters by spec, so values are not converted, this only changes the displayed label.
3044
+ Optional unit suffix appended to distance and dimension readouts; area uses its
3045
+ squared form (for example `m²`). Angles always use degrees. glTF models are
3046
+ authored in meters by spec, so values are not converted; this only changes the
3047
+ displayed label.
2389
3048
 
2390
3049
  Default:
2391
3050
 
@@ -2403,6 +3062,272 @@ Default:
2403
3062
  false;
2404
3063
  ```
2405
3064
 
3065
+ ### `showMaterialVariants`
3066
+
3067
+ Shows swatch buttons, centered at the bottom of the viewer, for switching
3068
+ between the model's glTF material variants (the `KHR_materials_variants`
3069
+ extension): the colorways, finishes, or trims a product file ships with. Each
3070
+ button shows the variant's colour when its material is a plain colour. It
3071
+ appears for a glTF/GLB with any variants. Default `true`.
3072
+
3073
+ Variants come in **option groups**, one row each. Choosing a variant replaces
3074
+ the other choice in its row and keeps the other rows, so a paint colour and a
3075
+ wheel style combine. Clicking the chosen variant again returns that row to the
3076
+ file's default materials. The viewer works out the groups from the file:
3077
+
3078
+ - A name like `"Paint: Red"` or `"Paint / Red"` puts the variant in a row
3079
+ labelled **Paint**, with a button labelled **Red**.
3080
+ - Otherwise, variants that restyle the same parts share a row, and variants
3081
+ that restyle different parts get separate rows. A shoe's `"midnight"`,
3082
+ `"beach"` and `"street"` colorways, which all restyle the same parts, are one
3083
+ row.
3084
+
3085
+ The variants shown are `sceneConfig.model.activeVariants`, at most one from
3086
+ each group, which is also how you choose them yourself:
3087
+
3088
+ ```tsx
3089
+ import { chooseMaterialVariant, useViewer } from "@liveroom-tech/react-immersive";
3090
+
3091
+ const viewer = useViewer();
3092
+
3093
+ <ModelViewer modelUrl="/car.glb" licenseKey="your-license-key" {...viewer.props} />;
3094
+
3095
+ // Once the model has loaded
3096
+ viewer.model.materialVariants; // ["Paint: Red", "Paint: Blue", "Wheels: Sport", "Wheels: Classic"]
3097
+ viewer.model.materialVariantGroups;
3098
+ // [
3099
+ // { name: "Paint", variants: [{ name: "Paint: Red", label: "Red", swatch: "#ff0000" }, ...] },
3100
+ // { name: "Wheels", variants: [{ name: "Wheels: Sport", label: "Sport" }, ...] },
3101
+ // ]
3102
+
3103
+ viewer.scene.updateSceneConfig({ model: { activeVariants: ["Paint: Red", "Wheels: Sport"] } });
3104
+ viewer.scene.updateSceneConfig({ model: { activeVariants: [] } }); // the defaults
3105
+
3106
+ // Your own swatches: swap a choice within its group, keeping the others.
3107
+ const active = viewer.scene.sceneConfig.model.activeVariants ?? [];
3108
+ viewer.scene.updateSceneConfig({
3109
+ model: {
3110
+ activeVariants: chooseMaterialVariant(active, "Paint: Blue", viewer.model.materialVariantGroups),
3111
+ },
3112
+ });
3113
+ ```
3114
+
3115
+ - A variant sets each object's starting material. `objectBindings` material
3116
+ overrides (`style.material`) still apply on top of it, and clearing an
3117
+ override returns to the variant's material rather than the file's default.
3118
+ - Objects no active variant maps keep their default material, bound or not.
3119
+ If two active variants map the same object, the later one in the list wins.
3120
+ - A variant's materials and textures load the first time it's chosen; the
3121
+ current look stays until they're ready.
3122
+ - With `onSceneConfigChange`, a pick goes to your scene config (`useViewer`
3123
+ wires this up). Without it, `ModelViewer` keeps the picks itself until
3124
+ `sceneConfig.model.activeVariants` or `modelUrl` changes.
3125
+ - A name the model doesn't have is skipped with a console warning listing the
3126
+ model's variants.
3127
+ - An action can switch variants with a scene-config effect:
3128
+ `{ sceneConfig: { model: { activeVariants: ["Paint: Red"] } } }`.
3129
+ - Swatches use the colour of the material a variant puts on the most vertices.
3130
+ A textured material gets no swatch, just its label, so showing the buttons
3131
+ never loads textures.
3132
+ - Each pick reaches [`onEngagement`](#onengagement--debugengagement) as a
3133
+ `variant-chosen` event with its `group`.
3134
+ - `onModelLoaded` reports `materialVariants` and `materialVariantGroups`, and
3135
+ BindingBuilder's Scene tab (**General**, one select per group) picks the
3136
+ variants the exported scene config starts with. OBJ, FBX, USDZ, STL, PLY, and
3137
+ streamed 3D Tiles models have no variants.
3138
+ - Scene configs saved before option groups, with a single
3139
+ `model.materialVariant`, still show that variant.
3140
+
3141
+ ### Decals
3142
+
3143
+ `binding.decals` prints images and text onto an object's surface: a logo on a
3144
+ shirt, a name on a mug, a label on a machine. Each decal is projected onto the
3145
+ object and follows it as it moves, and it's saved with the binding like any
3146
+ other edit.
3147
+
3148
+ ```tsx
3149
+ const viewer = useViewer();
3150
+
3151
+ <ModelViewer
3152
+ modelUrl="/mug.glb"
3153
+ licenseKey="your-license-key"
3154
+ {...viewer.props}
3155
+ // Put the customer's text where they double-click.
3156
+ onObjectDoubleClick={({ binding, surface }) => {
3157
+ if (!surface) return;
3158
+ viewer.bindings.addDecal(binding.id, {
3159
+ text: customerName,
3160
+ color: "#1e293b",
3161
+ ...surface, // position and normal, in the object's own space
3162
+ size: 0.06, // width in model units
3163
+ });
3164
+ return true; // skip the viewer's own double-click behavior
3165
+ }}
3166
+ />;
3167
+
3168
+ const id = viewer.bindings.addDecal("mug", { image: "/logos/acme.png", position, normal, size: 0.05 });
3169
+ viewer.bindings.updateDecal("mug", id, { size: 0.08, rotation: Math.PI / 12 });
3170
+ viewer.bindings.removeDecal("mug", id);
3171
+ ```
3172
+
3173
+ ```ts
3174
+ type ObjectBindingDecal = {
3175
+ id: string;
3176
+ image?: string; // a URL or public path
3177
+ text?: string; // shown when there's no image
3178
+ color?: string; // text colour, default "#ffffff"
3179
+ fontFamily?: string; // CSS font family, default "sans-serif"
3180
+ fontWeight?: "normal" | "bold"; // default "bold"
3181
+ position: [number, number, number]; // on the object, in its own space
3182
+ normal: [number, number, number]; // the way the surface faces there
3183
+ size: number; // width in model units; the height follows the image or text
3184
+ rotation?: number; // radians around the normal, default 0
3185
+ opacity?: number; // 0–1, default 1
3186
+ };
3187
+ ```
3188
+
3189
+ - `onObjectDoubleClick` and `onObjectContextMenu` report `surface`: the point
3190
+ under the pointer and the way the surface faces, in the object's own space,
3191
+ which is exactly what a decal's `position` and `normal` take.
3192
+ - `addDecal` returns the decal's id (pass your own `id` to choose it), and
3193
+ takes the same targets as the other binding helpers, so
3194
+ `addDecal({ group: "panels" }, …)` prints on each. Decals are plain binding
3195
+ data: you can also set `decals` directly.
3196
+ - A decal wraps onto the surface around its position, reaching about half
3197
+ its size in depth, so it follows gentle curves without printing on the far
3198
+ side of a thin part.
3199
+ - A decal fades with its object when that object is ghosted by isolation or
3200
+ made see-through by a style rule, and is cut by a section cut.
3201
+ - To let the people using your viewer add their own, give a binding the
3202
+ built-in `add-text` and `add-image` actions:
3203
+
3204
+ ```ts
3205
+ actions: [
3206
+ { id: "add-text", label: "Add Text", type: "command" },
3207
+ { id: "add-image", label: "Add Image", type: "command" },
3208
+ ]
3209
+ ```
3210
+
3211
+ Each opens a control under its button in the object panel. **Add Text**
3212
+ takes the text and a colour; **Add Image** takes an image file (sent through
3213
+ `onTextureUpload` when you pass it, so the saved URL lasts; otherwise a
3214
+ blob URL for this session). The decal appears automatically at the centre
3215
+ of the camera-facing surface, ready to drag, resize, turn, or remove. **Move** enables dragging across
3216
+ the object's surface; release to save, or press Escape to cancel. The
3217
+ **Position** arrows move a decal in small steps relative to the current
3218
+ view, without needing to grab it. Click for a fine adjustment or hold an
3219
+ arrow for continuous movement. Each step stays on the nearby visible surface.
3220
+ Every change is saved to the
3221
+ binding's `decals` and reported through `onObjectBindingsChange`. While a
3222
+ decal is in Move mode, a hint shows over the viewer and Escape cancels
3223
+ the current drag without removing the centred decal.
3224
+ - On a rigged (skinned) mesh a decal bends with the animation: it takes on
3225
+ the bone weights of the surface under it and is driven by the same skeleton.
3226
+ A click on a posed mesh is stored at the matching point of its rest shape, so
3227
+ a decal lands where you clicked whatever pose the model is in. Over a joint
3228
+ that bends sharply across few polygons, the decal's outer edge can sit a
3229
+ hair off the surface. Decals don't follow morph targets (blend shapes).
3230
+ - Decals need the binding's object to be a mesh, and aren't drawn on streamed
3231
+ 3D Tiles models.
3232
+
3233
+ ### Isolating objects
3234
+
3235
+ `isolation` focuses the view on some objects: everything else is ghosted
3236
+ (drawn faintly) or hidden, and clicks and hovers pass through to what's
3237
+ isolated. Nothing about the bindings changes, and clearing the isolation puts
3238
+ everything back. It's view state, like the selection, so it isn't part of
3239
+ `sceneConfig`.
3240
+
3241
+ ```tsx
3242
+ const viewer = useViewer();
3243
+
3244
+ <ModelViewer
3245
+ modelUrl="/engine.glb"
3246
+ licenseKey="your-license-key"
3247
+ {...viewer.props}
3248
+ tools={{ isolate: true }}
3249
+ />;
3250
+
3251
+ viewer.isolation.isolate({ group: "gearbox" }); // ghost everything else
3252
+ viewer.isolation.isolate(["piston-1", "piston-2"], { mode: "hide" });
3253
+ viewer.isolation.clearIsolation();
3254
+ ```
3255
+
3256
+ ```ts
3257
+ type ViewerIsolation = {
3258
+ target: ObjectBindingTarget; // an id, a list of ids, { group }, { tag }, or { type }
3259
+ mode?: "ghost" | "hide"; // default "ghost"
3260
+ ghostOpacity?: number; // 0–1, default 0.12
3261
+ };
3262
+
3263
+ isolation?: ViewerIsolation | null;
3264
+ onIsolationChange?: (isolation: ViewerIsolation | null) => void;
3265
+ showIsolateControls?: boolean; // tools.isolate
3266
+ ```
3267
+
3268
+ - Pass `isolation` (including `null`) to control it, and update it from
3269
+ `onIsolationChange`; `useViewer` wires both, as `viewer.isolation`. Leave
3270
+ `isolation` out and `ModelViewer` keeps it itself, clearing it when
3271
+ `modelUrl` changes. `useViewerIsolation` is the same state on its own.
3272
+ - `showIsolateControls` (`tools.isolate`, default `false`) adds an isolate
3273
+ button to each row of the scene objects panel (click again to show all) and
3274
+ a "Show all" pill at the bottom of the viewer while anything is isolated.
3275
+ - An action can isolate with an `isolation` effect:
3276
+ `{ isolation: { target: { group: "engine" } } }`, or `{ isolation: null }`
3277
+ to show everything again. Pass `isolation` to `useViewerActions` for it
3278
+ (`useViewer` does).
3279
+ - A target that matches no object isolates nothing, rather than fading out
3280
+ the whole model. Ghosting never raises an object's own lower opacity, and
3281
+ style rules still apply underneath.
3282
+ - `applyIsolation` (main entry and `/utils`) applies an isolation to bindings
3283
+ the same way, for rendering your own lists.
3284
+
3285
+ ### `showSectionTools`
3286
+
3287
+ Shows a section-cut button over the canvas (`tools.section`). It cuts the model
3288
+ open along an axis, hiding everything on one side of a plane, to look inside
3289
+ assemblies, equipment, and buildings. Default `false`. Not available with
3290
+ [`webgpu`](#webgpu).
3291
+
3292
+ With the cut on, the toolbar picks the axis (X, Y, or Z), the position along
3293
+ it, and which side stays, and a plane appears across the model that you can
3294
+ drag. The cut is stored in `sceneConfig.section`, so you can also set it
3295
+ yourself, author it in BindingBuilder, or switch it from an action:
3296
+
3297
+ ```tsx
3298
+ viewer.scene.updateSceneConfig({
3299
+ section: { enabled: true, axis: "y", position: 0.4, flip: false }, // a floor-plan cut
3300
+ });
3301
+ ```
3302
+
3303
+ ```ts
3304
+ type SceneSectionConfig = {
3305
+ enabled: boolean;
3306
+ axis: "x" | "y" | "z"; // world axis the cut runs across
3307
+ position: number; // 0 at the model's minimum along the axis, 1 at its maximum
3308
+ flip: boolean; // keep the higher side instead of the lower one
3309
+ };
3310
+ ```
3311
+
3312
+ - The cut applies to the model only; lights, helpers, and gizmos stay whole.
3313
+ It also shows without the toolbar whenever `sceneConfig.section.enabled` is
3314
+ set; the draggable plane appears only with `showSectionTools`.
3315
+ - While the cut is on, the model's materials render both sides of each
3316
+ surface, so a cut part shows its inside surfaces instead of disappearing.
3317
+ There are no solid caps over the cut.
3318
+ - Clicking and hovering ignore whatever is cut away, so the parts you can see
3319
+ behind the cut are the ones you select.
3320
+ - Toolbar changes go to `onSceneConfigChange` when you pass it (`useViewer`
3321
+ does); otherwise `ModelViewer` keeps them until `sceneConfig.section` or
3322
+ `modelUrl` changes.
3323
+ - The measuring tool can still pick points on the hidden side of a cut.
3324
+
3325
+ Default:
3326
+
3327
+ ```ts
3328
+ false;
3329
+ ```
3330
+
2406
3331
  ### `cinematic`
2407
3332
 
2408
3333
  Optional 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.
@@ -2414,7 +3339,7 @@ type CinematicConfig = {
2414
3339
  waypoints?: CinematicWaypoint[]; // camera keyframes to glide through
2415
3340
  duration?: number; // seconds for one full pass (auto-scales when omitted)
2416
3341
  loop?: boolean; // default true
2417
- autoPlay?: boolean; // start once the model has loaded and framed (default false)
3342
+ autoPlay?: boolean; // start once the model has loaded and framed (default false); waits for the play button under reduced motion
2418
3343
  };
2419
3344
 
2420
3345
  type CinematicWaypoint = {
@@ -2562,7 +3487,10 @@ See the full variable list in the [`ModelViewer` docs](https://react-immersive.l
2562
3487
  ```ts
2563
3488
  type SimpleModelViewerProps = {
2564
3489
  modelUrl: string;
2565
- modelFormat?: "glb" | "gltf" | "obj" | "fbx" | "usdz";
3490
+ poster?: string; // see ModelViewer's poster
3491
+ loading?: "lazy" | "eager"; // default "lazy"
3492
+ reveal?: "auto" | "interaction"; // default "auto"
3493
+ modelFormat?: "glb" | "gltf" | "obj" | "fbx" | "usdz" | "stl" | "ply";
2566
3494
  dracoDecoderPath?: string | false;
2567
3495
  ktx2TranscoderPath?: string | false;
2568
3496
  meshopt?: boolean;
@@ -2574,6 +3502,7 @@ type SimpleModelViewerProps = {
2574
3502
  highlightOnHover?: boolean;
2575
3503
  showLoadingOverlay?: boolean;
2576
3504
  refitOnResize?: boolean;
3505
+ reducedMotion?: "user" | "always" | "never"; // see ModelViewer's reducedMotion
2577
3506
  // Initial values for the scene settings (environment) panel
2578
3507
  backgroundEnabled?: boolean;
2579
3508
  autoRotate?: boolean;
@@ -2592,13 +3521,21 @@ type SimpleModelViewerProps = {
2592
3521
 
2593
3522
  ### `modelUrl`
2594
3523
 
2595
- URL or public path to the `.glb`, `.gltf`, `.obj`, `.fbx`, or `.usdz` model to inspect. Hosted assets must serve external dependencies at their declared relative paths; USDZ dependencies are packaged internally. Use `modelFormat` when a signed or extensionless URL cannot be auto-detected.
3524
+ URL or public path to the `.glb`, `.gltf`, `.obj`, `.fbx`, `.usdz`, `.stl`, or `.ply` model to inspect. Hosted assets must serve external dependencies at their declared relative paths; USDZ dependencies are packaged internally. Use `modelFormat` when a signed or extensionless URL cannot be auto-detected.
2596
3525
 
2597
3526
  ### `modelFormat`
2598
3527
 
2599
3528
  Optional explicit format for signed or extensionless model URLs. Normal URLs
2600
3529
  ending in a supported extension are detected automatically.
2601
3530
 
3531
+ ### `poster` / `loading` / `reveal`
3532
+
3533
+ Work as on [`ModelViewer`](#lazy-loading-and-posters), sharing the same
3534
+ page-wide count of live 3D views. When a `SimpleModelViewer` is put away, only
3535
+ its canvas goes: the panels and their settings stay, and the canvas comes back
3536
+ with its camera, hidden objects and selection.
3537
+ `onModelLoaded` runs again when it does.
3538
+
2602
3539
  ### `backgroundColor`
2603
3540
 
2604
3541
  Optional canvas background color.
@@ -2700,7 +3637,7 @@ true;
2700
3637
 
2701
3638
  ### `enableModelUpload`
2702
3639
 
2703
- Optional boolean that swaps the fixed `modelUrl` workflow for a built-in upload UI. It accepts `.glb`, standalone/data-URI `.gltf`, `.obj`, `.fbx`, `.usdz`, or a `.zip` bundle. ZIP bundles may contain a GLTF with its buffers/textures, an OBJ with its optional MTL/textures, or an FBX with external textures. A USDZ file is already a self-contained package and is uploaded directly.
3640
+ 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 a `.zip` bundle. ZIP bundles may contain a GLTF with its buffers/textures, an OBJ with its optional MTL/textures, or an FBX with external textures. A USDZ file is already a self-contained package and is uploaded directly.
2704
3641
 
2705
3642
  Default:
2706
3643
 
@@ -2762,6 +3699,7 @@ type ObjectBinding = {
2762
3699
  hoverable?: boolean;
2763
3700
  transform?: ObjectBindingTransform;
2764
3701
  style?: ObjectBindingStyle;
3702
+ decals?: ObjectBindingDecal[];
2765
3703
  actions?: ObjectBindingAction[];
2766
3704
  metrics?: Record<string, number>;
2767
3705
  metadata?: Record<string, unknown>;
@@ -2789,9 +3727,9 @@ Notes:
2789
3727
 
2790
3728
  ## Supported Built-In Actions
2791
3729
 
2792
- `ModelViewer` handles six action ids itself: `change-color`,
2793
- `change-material`, `toggle-visibility`, `toggle-light`, `change-light-color`,
2794
- and `set-brightness`. Any other id does something only when you handle it in
3730
+ `ModelViewer` handles eight action ids itself: `change-color`,
3731
+ `change-material`, `add-text`, `add-image`, `toggle-visibility`,
3732
+ `toggle-light`, `change-light-color`, and `set-brightness`. Any other id does something only when you handle it in
2795
3733
  `onAction` (pass `useViewerActions().handleAction` there to run an action's
2796
3734
  declared `effects`). If a binding has such an action and `ModelViewer` has no
2797
3735
  `onAction`, its button does nothing, and the viewer logs a console warning
@@ -2809,6 +3747,10 @@ findCatalogAction("toggle-light")?.handling; // "viewer"
2809
3747
 
2810
3748
  ### Implemented behaviors
2811
3749
 
3750
+ - `add-text`
3751
+ Opens a text box and colour inline beneath the action; the text appears at the centre of the camera-facing surface, ready to drag, resize, turn, or remove. Saved to `binding.decals` (see [Decals](#decals))
3752
+ - `add-image`
3753
+ Opens an image chooser inline beneath the action; the image is placed and edited the same way. Uses `onTextureUpload` for a durable URL when provided, otherwise a session-scoped blob URL
2812
3754
  - `toggle-visibility`
2813
3755
  Hides or shows the selected mesh by setting `binding.visible` and calling `onObjectBindingsChange`
2814
3756
  - `change-color`
@@ -2882,8 +3824,8 @@ To use this library successfully, your model asset should follow these expectati
2882
3824
  - the mesh node names must match the keys in `objectBindings` (or be referenced via `modelObjectId`)
2883
3825
  - target nodes should have geometry and a supported Three.js mesh material; imported materials are normalized into the editable PBR pipeline
2884
3826
  - the file should be accessible from the browser at `modelUrl`; hosted GLTF, OBJ, and FBX assets must also serve external dependencies at their declared relative paths
2885
- - OBJ does not contain animations; supported FBX and USDZ animation clips are exposed through the same animation controls as glTF
2886
- - OBJ has no standard unit metadata, so set an appropriate measurement label/scene scale when real-world dimensions matter
3827
+ - OBJ, STL, and PLY do not contain animations; supported FBX and USDZ animation clips are exposed through the same animation controls as glTF
3828
+ - OBJ, STL, and PLY have no standard unit metadata, so set an appropriate measurement label/scene scale when real-world dimensions matter
2887
3829
 
2888
3830
  If a node name or material name does not match, that object will not render through the interactive binding flow.
2889
3831
 
@@ -2923,3 +3865,32 @@ Two related picking notes that apply to models of every size:
2923
3865
  ## License
2924
3866
 
2925
3867
  Proprietary, see [LICENSE](./LICENSE). Installing the package does not itself grant a right to use it in production; a valid `licenseKey` from the [Developer Portal](https://react-immersive.liveroom.dev) is required, and usage is bound to the terms of your license tier.
3868
+
3869
+ ### Rich annotations
3870
+
3871
+ `sceneConfig.annotations` supports optional `cameraView` (position, target,
3872
+ orbit angles, fov and zoom), `images: [{ src, alt }]`, `links: [{ href, label }]`,
3873
+ `category`, and `minDistance` / `maxDistance` in scene units. BindingBuilder's
3874
+ Annotations tab authors these fields; new markers use automatic close-up tour
3875
+ framing. Explicitly capture a camera view for custom framing. Marker selection
3876
+ and guided tours restore saved views when provided. The viewer's category chips
3877
+ filter markers and tour steps.
3878
+ Use `annotationCategories` (`null` = all, `[]` = none) and
3879
+ `onAnnotationCategoriesChange` for controlled filtering, and
3880
+ `ui.annotationFilters: false` to hide the built-in chips. Distance limits affect
3881
+ markers only, so their content remains available through tours. Images stay at
3882
+ their HTTP(S) or relative URLs and are not bundled into exports automatically.
3883
+
3884
+ ## Translatable viewer text
3885
+
3886
+ Both viewers accept a partial `labels` dictionary. Omitted keys use English; changing labels updates text without reloading the model. Named placeholders such as `{count}` can be reordered in translations.
3887
+
3888
+ ```tsx
3889
+ <ModelViewer
3890
+ licenseKey="your-license-key"
3891
+ modelUrl="/car.glb"
3892
+ labels={{ guidedTour: "Visite guidée", tapToPlace: "Touchez pour placer", visibleEntries: "{count} éléments visibles" }}
3893
+ />
3894
+ ```
3895
+
3896
+ `ModelViewerLabels`, `ModelViewerLabelKey`, and `DEFAULT_MODEL_VIEWER_LABELS` are exported from the main package, `/simple-model-viewer`, and `/utils`. See the [full catalog](https://react-immersive.liveroom.dev/docs/reference/labels). Annotation content, metadata, and custom panels use your application's translations.