@liveroom-tech/react-immersive 5.0.2 → 5.1.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 (39) hide show
  1. package/README.md +580 -38
  2. package/dist/{ModelViewer-DOexHkJC.d.ts → ModelViewer-Bj-KjiDL.d.mts} +91 -42
  3. package/dist/{ModelViewer-CbbJT85n.d.mts → ModelViewer-BkQ73y6N.d.ts} +91 -42
  4. package/dist/binding-builder.css +1 -1
  5. package/dist/binding-builder.js +13 -12
  6. package/dist/binding-builder.mjs +1 -1
  7. package/dist/chunk-32WCLPTZ.mjs +1 -0
  8. package/dist/chunk-CSGNL4XU.mjs +3 -0
  9. package/dist/chunk-F2ILTIPG.mjs +28 -0
  10. package/dist/chunk-V6GALFL5.mjs +1 -0
  11. package/dist/chunk-WN6GMMHF.mjs +1 -0
  12. package/dist/hosted.css +1 -1
  13. package/dist/hosted.d.mts +3 -3
  14. package/dist/hosted.d.ts +3 -3
  15. package/dist/hosted.js +12 -11
  16. package/dist/hosted.mjs +1 -1
  17. package/dist/index.css +1 -1
  18. package/dist/index.d.mts +54 -10
  19. package/dist/index.d.ts +54 -10
  20. package/dist/index.js +11 -10
  21. package/dist/index.mjs +1 -1
  22. package/dist/{modelInfo-CEmmvVCH.d.mts → modelInfo-DnkmTGMd.d.mts} +6 -0
  23. package/dist/{modelInfo-CEmmvVCH.d.ts → modelInfo-DnkmTGMd.d.ts} +6 -0
  24. package/dist/{objectCatalog-5W8n5xNQ.d.ts → objectCatalog-BIeXURoq.d.mts} +276 -5
  25. package/dist/{objectCatalog-5W8n5xNQ.d.mts → objectCatalog-BIeXURoq.d.ts} +276 -5
  26. package/dist/simple-model-viewer.d.mts +2 -2
  27. package/dist/simple-model-viewer.d.ts +2 -2
  28. package/dist/simple-model-viewer.js +3 -3
  29. package/dist/simple-model-viewer.mjs +1 -1
  30. package/dist/utils.d.mts +2 -2
  31. package/dist/utils.d.ts +2 -2
  32. package/dist/utils.js +1 -1
  33. package/dist/utils.mjs +1 -1
  34. package/package.json +1 -1
  35. package/dist/chunk-3GX7DOGS.mjs +0 -3
  36. package/dist/chunk-AH5NAZWX.mjs +0 -27
  37. package/dist/chunk-BGYK4Y2Y.mjs +0 -1
  38. package/dist/chunk-WFEYRUZC.mjs +0 -1
  39. package/dist/chunk-XTLSAYAQ.mjs +0 -1
package/README.md CHANGED
@@ -59,13 +59,18 @@ It also exports types for `ObjectBinding`, `ObjectBindingMaterial`, `ObjectBindi
59
59
  - optional hiding of either built-in panel through `showObjectBindingDataPanel` and `showSceneObjectsPanel`
60
60
  - 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
61
  - 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`
62
+ - glTF material variants (`KHR_materials_variants`) with a built-in picker through `showMaterialVariants`, selected by `sceneConfig.model.materialVariant`
63
+ - distance, angle, polygon area, and point-to-plane measurements with vertex/edge snapping and bounding dimensions through `showMeasureTools` and `measurementUnit`
63
64
  - an exploded-view slider that slides each bound part outward from the model center to reveal interior/assembly structure through `showExplodeControls`
65
+ - isolation that ghosts or hides everything but the objects you focus on, through `isolation` / `onIsolationChange` and `showIsolateControls`
66
+ - a section cut that opens the model along an axis, with a draggable plane, through `showSectionTools` and `sceneConfig.section`
64
67
  - 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
68
+ - video export of a cinematic pass or turntable revolution, through the download menu or `captureVideo`, using browser-supported WebM/MP4 encoding
65
69
  - a guided-tour control cluster (Previous/Stop/Next) for stepping through annotations, toggleable via `showAnnotationNavigation`
66
70
  - optional annotation detail popups on marker hover via `showAnnotationOnHover`
67
71
  - PBR, Matcap, and UV Checker renderer modes, with the checker exposing UV stretching, seams, rotation, and missing UV0 data directly on the model
68
72
  - a `sceneConfig` prop accepting the same scene-wide config (lighting, environment, background, post-processing, animations, annotations) authored by `BindingBuilder`'s Scene tab
73
+ - WebGL context-loss recovery with a reload button and automatic rebuild when the context returns
69
74
  - render-loop and perf tuning through `renderMode`, `maxDpr`, `performanceProfile`, and compressed-asset decoder options (`dracoDecoderPath`, `ktx2TranscoderPath`, `meshopt`)
70
75
  - 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
76
  - mesh selection by object name
@@ -75,6 +80,8 @@ It also exports types for `ObjectBinding`, `ObjectBindingMaterial`, `ObjectBindi
75
80
  - 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
81
  - 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
82
  - per-object visibility toggling through `objectBindings.visible`
83
+ - decals: images and text printed on an object's surface through `objectBindings.decals`, placed from the surface point pointer events report
84
+ - data-driven looks through `sceneConfig.styleRules`: colour scales from metrics, material overrides by status or metadata, and pulsing alarms
78
85
  - hover and selected-state highlighting
79
86
  - external selection state control through `selectedObject` and `onObjectSelect`
80
87
  - hover callbacks through `onObjectHover`
@@ -84,6 +91,7 @@ It also exports types for `ObjectBinding`, `ObjectBindingMaterial`, `ObjectBindi
84
91
  - camera change callbacks through `onCameraChange`
85
92
  - viewer-ready callbacks through `onViewerReady`
86
93
  - action event callbacks through `onAction`
94
+ - opt-in engagement analytics through `onEngagement`, with `debugEngagement` console logging
87
95
  - animation playback for GLB/GLTF, FBX, and USDZ models with supported embedded animations through `onAnimationsReady`
88
96
  - annotation marker callbacks through `onAnnotationsChange` and controlled/uncontrolled `activeAnnotation` / `onActiveAnnotationChange`
89
97
 
@@ -100,7 +108,7 @@ It also exports types for `ObjectBinding`, `ObjectBindingMaterial`, `ObjectBindi
100
108
  - import a previously exported `objectBindings.json` or `sceneConfig.json` and merge it back onto the current model/config
101
109
  - live preview using `ModelViewer`
102
110
  - 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)
111
+ - license-tier gating for some editor features (texture maps, environment lighting/backgrounds, wireframe preview, animation configuration, annotations, style rules, post-processing)
104
112
 
105
113
  `SimpleModelViewer` provides:
106
114
 
@@ -267,7 +275,7 @@ export default function Configurator() {
267
275
 
268
276
  `viewer.bindings`, `viewer.selection`, `viewer.hover`, `viewer.camera`,
269
277
  `viewer.model`, `viewer.animations`, `viewer.actions`, `viewer.scene`,
270
- `viewer.effects`, and `viewer.connection` are the results of the hooks they're
278
+ `viewer.isolation`, `viewer.effects`, and `viewer.connection` are the results of the hooks they're
271
279
  named after (`useObjectBindings`, `useViewerSelection`, and so on), so
272
280
  everything below applies to them. Without a `bindings` option, `useViewer`
273
281
  generates bindings from the model like `objectBindings="auto"`; pass
@@ -462,6 +470,9 @@ const {
462
470
  setTransform,
463
471
  resetTransform,
464
472
  updateMetadata,
473
+ addDecal,
474
+ updateDecal,
475
+ removeDecal,
465
476
  hiddenObjects,
466
477
  hiddenObjectIds,
467
478
  hideObject,
@@ -524,6 +535,7 @@ The hook returns:
524
535
  - `setMaterial`, `setBaseColor`, `setTexture`, `clearTexture`, `copyMaterial`, `resetMaterial`, and `getMaterial`
525
536
  - `setTransform` and `resetTransform` for persistent local-space model transforms
526
537
  - `updateMetadata` for recursively merged application data
538
+ - `addDecal`, `updateDecal`, and `removeDecal` for images and text printed on objects (see [Decals](#decals))
527
539
  - `hiddenObjects`: a derived map of hidden binding keys
528
540
  - `hiddenObjectIds`: the currently hidden binding IDs
529
541
  - `hideObject`, `showObject`, `toggleObjectVisibility`
@@ -661,6 +673,7 @@ const {
661
673
  error,
662
674
  bounds,
663
675
  objectCount,
676
+ materialVariants,
664
677
  objects,
665
678
  handleModelLoaded,
666
679
  handleObjectCatalog,
@@ -684,6 +697,8 @@ const {
684
697
  - `error`: the most recent load/render error, or `null`
685
698
  - `bounds`: the loaded scene bounds as `min`, `max`, `center`, and `size`
686
699
  - `objectCount`: the number of mesh objects in the loaded scene
700
+ - `materialVariants`: the names of the model's glTF material variants, or an
701
+ empty array (see [`showMaterialVariants`](#showmaterialvariants))
687
702
  - `objects`: every bindable object in the model as a `ViewerObjectInfo`, once
688
703
  `handleObjectCatalog` is passed to `onObjectCatalog`
689
704
  - `handleModelLoaded`: a callback you can pass directly to `onModelLoaded`
@@ -751,6 +766,7 @@ Current behavior, Object tab:
751
766
  - 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)
752
767
  - traverse renderable mesh nodes and generate starter bindings automatically
753
768
  - edit binding identity, label, type, status, booleans, style, actions, metrics, metadata, and saved camera state
769
+ - 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
770
  - 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
771
  - apply a one-click material preset (wood, metal, chrome, glass, plastic, fabric, ceramic, concrete, etc.) onto the selected object's material
756
772
  - 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 +777,11 @@ Current behavior, Scene tab:
761
777
 
762
778
  - configure the same `SceneConfig` shape `ModelViewer`'s `sceneConfig` prop accepts (lighting, environment, background, ground shadows, wireframe, post-processing, animations, annotations)
763
779
  - configure auto-rotation and its speed, exported as `sceneConfig.model.autoRotate` and `sceneConfig.model.autoRotateSpeed`
780
+ - 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
781
+ - pick which of the model's glTF material variants the scene starts with, exported as `sceneConfig.model.materialVariant`
782
+ - isolate a node from the Scene Nodes list to see it on its own in the preview while you edit it (not exported)
783
+ - 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
784
+ - 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
785
  - position and rotate directional, point, and spot lights with transform gizmos; spot lights also expose angle and penumbra controls
765
786
  - undo/redo the scene config independently of the Object tab's bindings history
766
787
  - import a previously exported `sceneConfig.json`, merged by top-level section
@@ -785,7 +806,7 @@ Some editor features are gated by the license's plan tier (the server-returned `
785
806
  | Tier | Unlocks |
786
807
  | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
787
808
  | `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 |
809
+ | `starter` | + Texture maps, the specular workflow, reflectivity, environment lighting, environment backgrounds, wireframe preview, animation configuration, annotations, style rules, decals |
789
810
  | `growth` | + Post-processing effects |
790
811
 
791
812
  A locked feature renders in place (not hidden) with a message naming the required plan, so it stays discoverable while editing.
@@ -906,16 +927,17 @@ const {
906
927
 
907
928
  - `clips`: an array of animation clip names found in the GLB/GLTF file
908
929
  - `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
930
+ - `currentClip`: the clip played most recently, while it's active, or `null`
931
+ - `isPlaying`: `true` while any clip is playing (not paused, finished, or stopped)
932
+ - `speed`: the playback speed of `currentClip` (default `1`)
933
+ - `time`: the live playback position of `currentClip`, in seconds (updates while playing)
934
+ - `duration`: the length of `currentClip`, in seconds
935
+ - `active`: every active clip (playing, paused, or finished) with its `status`, `time`, `duration`, `speed`, and `reverse`, most recently played last
936
+ - `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
937
+ - `pause(clip?)`: pauses one clip, or every active clip, at its current time
938
+ - `stop(clip?)`: stops one clip, or every active clip, and resets the pose
939
+ - `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
940
+ - `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
941
  - `handleAnimationsReady`: a callback you can pass directly to `onAnimationsReady`
920
942
 
921
943
  Example with playback controls:
@@ -930,7 +952,7 @@ const { clips, currentClip, isPlaying, play, pause, stop, setSpeed } =
930
952
  </button>
931
953
 
932
954
  // Stop
933
- <button onClick={stop}>Stop</button>
955
+ <button onClick={() => stop()}>Stop</button>
934
956
 
935
957
  // Speed slider
936
958
  <input
@@ -950,16 +972,44 @@ const { clips, currentClip, isPlaying, play, pause, stop, setSpeed } =
950
972
  ))}
951
973
  ```
952
974
 
975
+ Several clips can play at once. `play` replaces the clips already playing
976
+ unless you pass `alongside: true`, and the other controls act on one clip when
977
+ you name it, or on every active clip when you don't:
978
+
979
+ ```tsx
980
+ const animations = useViewerAnimations();
981
+
982
+ // Open both doors together, then close the left one.
983
+ animations.play("DoorLeftOpen", { alongside: true });
984
+ animations.play("DoorRightOpen", { alongside: true });
985
+ animations.play("DoorLeftOpen", { alongside: true, reverse: true });
986
+
987
+ // Blend from walking to running over half a second.
988
+ animations.play("Run", { fade: 0.5 });
989
+
990
+ animations.pause("Wheels"); // one clip
991
+ animations.stop(); // every clip
992
+
993
+ // What's active, and how far along each clip is
994
+ animations.active.map(({ clip, status, time }) => `${clip}: ${status} at ${time}s`);
995
+ ```
996
+
997
+ A clip whose `loopMode` is `"once"` holds its last pose when it finishes
998
+ (`status: "finished"`) until it's stopped or played again, so an opened door
999
+ stays open. `"ping-pong"` plays forwards then backwards, repeatedly. Playing a
1000
+ clip that's already playing in the same direction leaves it running; playing
1001
+ it the other way turns it around where it is.
1002
+
953
1003
  ## Presets and grouped props
954
1004
 
955
1005
  `preset` sets `ModelViewer` up for a common kind of page:
956
1006
 
957
1007
  | Preset | For | What it sets |
958
1008
  | --- | --- | --- |
959
- | `minimal` | Just the model | Hides both panels, the download and reset buttons, and tour controls |
1009
+ | `minimal` | Just the model | Hides both panels, the download and reset buttons, tour controls, and the material variant buttons |
960
1010
  | `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 |
1011
+ | `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 |
1012
+ | `inspector` | Checking a model | Every panel, the view gizmo, the measure, UV checker, exploded-view, section-cut, and isolate tools, and keyboard navigation |
963
1013
 
964
1014
  `MODEL_VIEWER_PRESETS` lists exactly which props each preset sets.
965
1015
 
@@ -981,8 +1031,8 @@ readable:
981
1031
 
982
1032
  | Group | Fields, and the flat prop each one sets |
983
1033
  | --- | --- |
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`) |
1034
+ | `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`) |
1035
+ | `tools` | `measure` (`showMeasureTools`; `{ unit }` also sets `measurementUnit`), `uvChecker` (`showUvCheckerButton`), `explode` (`showExplodeControls`), `section` (`showSectionTools`), `isolate` (`showIsolateControls`), `texturePositioning` (`texturePositioning`) |
986
1036
  | `controls` | `enabled` (`enableCameraControls`), `keyboard` (`enableKeyboardNavigation`), `zoom` (the opposite of `disableZoom`), `zoomOnSelect` (`zoomOnSelected`), `autoRotate` (a number also sets `autoRotateSpeed`), `refitOnResize`, `limits` (`cameraLimits`), `moveSensitivity`, `zoomSensitivity` |
987
1037
  | `perf` | `renderMode`, `maxDpr`, `profile` (`performanceProfile`), `preserveDrawingBuffer` |
988
1038
  | `tiles` | `url` (`tilesetUrl`), `errorTarget` (`tilesErrorTarget`), `cacheSize` (`tilesCacheSize`) |
@@ -1029,6 +1079,8 @@ type Props = {
1029
1079
  controller: "orbit" | "pointerLock",
1030
1080
  ) => void;
1031
1081
  onAction?: (event: ObjectActionEvent) => void;
1082
+ onEngagement?: (event: ViewerEngagementEvent) => void | Promise<void>;
1083
+ debugEngagement?: boolean;
1032
1084
  onHiddenObjectsChange?: (next: Record<string, boolean>) => void;
1033
1085
  onCameraChange?: (state: ViewerCameraState) => void;
1034
1086
  onViewerReady?: (viewer: ViewerReadyState) => void;
@@ -1053,6 +1105,7 @@ type Props = {
1053
1105
  showDownloadButton?: boolean;
1054
1106
  downloadFilename?: string;
1055
1107
  showResetButton?: boolean;
1108
+ showFullscreenButton?: boolean;
1056
1109
  showLoadingOverlay?: boolean;
1057
1110
  showMouseController?: boolean;
1058
1111
  mouseControllerPosition?:
@@ -1086,6 +1139,11 @@ type Props = {
1086
1139
  showMeasureTools?: boolean;
1087
1140
  showUvCheckerButton?: boolean;
1088
1141
  showExplodeControls?: boolean;
1142
+ showSectionTools?: boolean;
1143
+ isolation?: ViewerIsolation | null;
1144
+ onIsolationChange?: (isolation: ViewerIsolation | null) => void;
1145
+ showIsolateControls?: boolean;
1146
+ showMaterialVariants?: boolean;
1089
1147
  cinematic?: boolean | CinematicConfig;
1090
1148
  measurementUnit?: string;
1091
1149
  enableXR?: boolean;
@@ -1312,6 +1370,7 @@ type ObjectPointerEvent = {
1312
1370
  clientX: number; // viewport coordinates
1313
1371
  clientY: number;
1314
1372
  pointerType: string; // "mouse" | "pen" | "touch"
1373
+ surface?: { position: Vec3; normal: Vec3 }; // where on the object, in its own space
1315
1374
  };
1316
1375
  ```
1317
1376
 
@@ -1403,6 +1462,7 @@ count:
1403
1462
  type ViewerModelInfo = {
1404
1463
  bounds: { min: Vec3; max: Vec3; center: Vec3; size: Vec3 }; // world space
1405
1464
  objectCount: number; // meshes in the loaded scene
1465
+ materialVariants: string[]; // glTF material variant names, if any
1406
1466
  };
1407
1467
  ```
1408
1468
 
@@ -1450,6 +1510,60 @@ type ObjectActionEvent = {
1450
1510
  };
1451
1511
  ```
1452
1512
 
1513
+ ### `onEngagement` / `debugEngagement`
1514
+
1515
+ Optional interaction analytics. Set `onEngagement` to receive typed
1516
+ `ViewerEngagementEvent` objects, or set `debugEngagement` to log them in the
1517
+ browser's developer console with the `[react-immersive engagement]` prefix.
1518
+ Both are disabled by default. The viewer does not send telemetry or persist
1519
+ analytics identifiers; your callback decides where events go.
1520
+
1521
+ ```tsx
1522
+ import { ModelViewer, type ViewerEngagementEvent } from "@liveroom-tech/react-immersive";
1523
+
1524
+ function handleEngagement(event: ViewerEngagementEvent) {
1525
+ if (event.type === "part-viewed") {
1526
+ console.info("Part viewed:", event.objectKey, event.source);
1527
+ }
1528
+ if (event.type === "ar-ended") {
1529
+ console.info("Time in AR:", event.durationMs);
1530
+ }
1531
+ // Connect this callback to your application's analytics integration.
1532
+ }
1533
+
1534
+ <ModelViewer
1535
+ modelUrl="/car.glb"
1536
+ licenseKey={licenseKey}
1537
+ onEngagement={handleEngagement}
1538
+ debugEngagement
1539
+ />
1540
+ ```
1541
+
1542
+ Every event includes `sessionId` (an ephemeral ID for this viewer/model),
1543
+ `timestamp` (ISO format), `elapsedMs`, `modelUrl`, and `tilesetUrl` (or `null`).
1544
+ Durations use a monotonic clock and are expressed in milliseconds. Session
1545
+ elapsed time includes loading and background time; it is not an attention metric.
1546
+
1547
+ | Event `type` | Details and trigger |
1548
+ | --- | --- |
1549
+ | `part-viewed` | `objectId`, `objectKey`, optional `label`, and `source` (`"canvas"` or `"panel"`). Selecting/focusing a different part; reselecting the current part is ignored. |
1550
+ | `variant-chosen` | `variant` and `previousVariant` (names or `null` for the default). Choosing a different built-in material variant. |
1551
+ | `ar-started` | A confirmed immersive WebXR AR session begins. |
1552
+ | `ar-ended` | `durationMs` and `reason` (`"session-ended"` or `"interrupted"`). A confirmed AR session ends, or tracking stops on model change, unmount, or context loss. |
1553
+ | `ar-launch-requested` | `channel` (`"quick-look"` or `"mobile-handoff"`). Opening external AR or its QR handoff; this does not claim an AR session started. |
1554
+ | `tour-started` | `annotationCount`. Starting the guided tour. |
1555
+ | `tour-completed` | `annotationCount` and `durationMs`. Advancing past its final annotation; emitted once per completed tour. |
1556
+ | `tour-stopped` | `annotationCount`, `durationMs`, and `reason` (`"stopped"`, `"restarted"`, `"annotations-changed"`, or `"interrupted"`). Leaving a tour before completion. |
1557
+
1558
+ Hover, camera motion, renders, and externally supplied selection/variant updates
1559
+ do not create engagement events. WebXR duration measures the session interval;
1560
+ Quick Look runs outside the page and has no reliable duration callback. Turning
1561
+ tracking on during an existing AR session measures from attachment. Callback
1562
+ changes do not restart timing, and synchronous or asynchronous callback failures are logged without
1563
+ interrupting the viewer. A new model or re-enabling tracking creates a new
1564
+ analytics session; WebGL recovery keeps the session ID; context loss ends active intervals
1565
+ as interrupted.
1566
+
1453
1567
  ### `onViewerReady`
1454
1568
 
1455
1569
  Optional callback fired once camera controls are available and again when the
@@ -1469,6 +1583,7 @@ type ViewerReadyState = {
1469
1583
  objectBindings: Record<string, ObjectBinding>;
1470
1584
  homeCameraState: { position: Vec3; target: Vec3 } | null;
1471
1585
  captureImage: (options?: CaptureImageOptions) => Promise<string>;
1586
+ captureVideo: (options?: CaptureVideoOptions) => Promise<Blob>;
1472
1587
  invalidate: () => void;
1473
1588
  raw: {
1474
1589
  controls: CameraControls; // camera-controls
@@ -1521,6 +1636,46 @@ const connection = useViewerConnection(camera, effects);
1521
1636
  const dataUrl = await connection.captureImage({ width: 1920, height: 1080 });
1522
1637
  ```
1523
1638
 
1639
+ `captureVideo` records one complete cinematic pass or turntable revolution and
1640
+ resolves with a video `Blob`. It is also available on `useViewerConnection` and
1641
+ `useViewer().connection`. Call it from a button handler after the model has
1642
+ finished loading and framing; ready-state callbacks run repeatedly as the camera
1643
+ moves and should not start recordings automatically.
1644
+
1645
+ ```tsx
1646
+ const viewer = useViewer();
1647
+ const abort = new AbortController();
1648
+
1649
+ <ModelViewer {...viewer.props} cinematic showDownloadButton />;
1650
+
1651
+ // Run in an event handler. Upload the Blob or preview it with an object URL.
1652
+ const clip = await viewer.connection.captureVideo({
1653
+ mode: "turntable", // or "cinematic" to use the enabled camera path
1654
+ format: "auto", // prefers WebM; falls back to MP4
1655
+ duration: 12, // seconds; defaults to path timing or a 24-second orbit
1656
+ fps: 30, // 1–60; actual throughput depends on rendering/encoding
1657
+ videoBitsPerSecond: 8_000_000,
1658
+ signal: abort.signal,
1659
+ });
1660
+ // abort.abort() cancels an in-progress export and discards its data.
1661
+ ```
1662
+
1663
+ The download menu offers **Record turntable video** and, when cinematic is
1664
+ enabled, **Record cinematic video**, plus Auto/WebM/MP4 format selection. A
1665
+ recording shows progress and a Cancel control. The filename and Blob MIME type
1666
+ match the encoded format; an explicitly requested unsupported format rejects
1667
+ instead of silently changing formats. With `loop: true`, a cinematic export
1668
+ includes the return segment and stops after one cycle.
1669
+
1670
+ Recording runs in real time at the canvas's current pixel resolution, includes
1671
+ post-processing, and hides editor gizmos. It pauses cinematic playback, blocks
1672
+ viewer gestures, then restores the previous camera pose and controls on success,
1673
+ cancellation, or failure. Keep the tab visible until recording completes.
1674
+ `preserveDrawingBuffer` is unnecessary for video. No audio, HTML overlays,
1675
+ GIF encoding, or format transcoding is included. The browser must support
1676
+ `canvas.captureStream` and `MediaRecorder`; exports reject before framing,
1677
+ during XR, during another recording, or for durations outside (0, 300] seconds.
1678
+
1524
1679
  ### `onTextureUpload`
1525
1680
 
1526
1681
  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 +1760,39 @@ Current callback shape:
1605
1760
  Where `AnimationControls` is:
1606
1761
 
1607
1762
  ```ts
1608
- type AnimationPlaybackState = {
1609
- currentClip: string | null;
1610
- isPlaying: boolean;
1763
+ type AnimationPlayOptions = {
1764
+ alongside?: boolean; // keep the clips already playing (default false: replace them)
1765
+ fade?: number; // seconds to fade in, and to fade out the clips it replaces
1766
+ reverse?: boolean; // play backwards, from where the clip is or from its end
1767
+ };
1768
+
1769
+ type AnimationClipState = {
1770
+ clip: string;
1771
+ status: "playing" | "paused" | "finished"; // finished: a play-once clip holding its last pose
1772
+ time: number; // seconds
1773
+ duration: number; // seconds
1611
1774
  speed: number;
1612
- time: number; // live position of the current clip, in seconds
1613
- duration: number; // length of the current clip, in seconds
1775
+ reverse: boolean;
1776
+ };
1777
+
1778
+ type AnimationPlaybackState = {
1779
+ currentClip: string | null; // the clip played most recently, while it's active
1780
+ isPlaying: boolean; // whether any clip is playing
1781
+ speed: number; // speed, position and length of currentClip
1782
+ time: number;
1783
+ duration: number;
1784
+ active: AnimationClipState[]; // every active clip, most recently played last
1614
1785
  };
1615
1786
 
1787
+ // Leave out clipName to act on every active clip.
1616
1788
  type AnimationControls = {
1617
1789
  clips: string[];
1618
1790
  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)
1791
+ play: (clipName: string, options?: AnimationPlayOptions) => void;
1792
+ pause: (clipName?: string) => void;
1793
+ stop: (clipName?: string) => void; // stops and resets the pose
1794
+ setSpeed: (speed: number, clipName?: string) => void;
1795
+ seek?: (time: number, clipName?: string) => void; // absolute time, in seconds
1624
1796
  getState?: () => AnimationPlaybackState;
1625
1797
  subscribe?: (listener: (state: AnimationPlaybackState) => void) => () => void;
1626
1798
  };
@@ -1628,8 +1800,8 @@ type AnimationControls = {
1628
1800
 
1629
1801
  Wire this to `useViewerAnimations().handleAnimationsReady` for the simplest integration.
1630
1802
  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.
1803
+ that clip on load and respect per-clip `loopMode` (`"repeat"`, `"once"`, or
1804
+ `"ping-pong"`), `speed`, `displayName`, and soft-delete (`hidden`) settings.
1633
1805
 
1634
1806
  ### Lighting
1635
1807
 
@@ -1679,6 +1851,81 @@ An attached light follows the object, offset from the object's origin in its
1679
1851
  local space (the origin is often at its base, not its center). Lighting set up
1680
1852
  in `BindingBuilder`'s Scene tab exports as the same `sceneConfig`.
1681
1853
 
1854
+ ### Style rules
1855
+
1856
+ `sceneConfig.styleRules` restyles objects from their own data while the app
1857
+ runs: a colour for a temperature, grey for a disabled part, a pulsing glow for
1858
+ an alarm. Each rule picks objects (`target`), checks their data (`when`), and
1859
+ sets a look (`material`, `colorScale`, `pulse`):
1860
+
1861
+ ```tsx
1862
+ const viewer = useViewer({
1863
+ bindings,
1864
+ sceneConfig: {
1865
+ ...DEFAULT_SCENE_CONFIG,
1866
+ styleRules: [
1867
+ {
1868
+ id: "temperature",
1869
+ target: { group: "pumps" },
1870
+ colorScale: {
1871
+ metric: "temperature",
1872
+ stops: [[18, "#3b82f6"], [24, "#22c55e"], [32, "#ef4444"]],
1873
+ },
1874
+ },
1875
+ {
1876
+ id: "offline",
1877
+ when: { status: "disabled" },
1878
+ material: { baseColor: "#9ca3af", opacity: 0.4 },
1879
+ },
1880
+ {
1881
+ id: "alarm",
1882
+ when: [{ metric: "temperature", above: 32 }, { metadata: "alarm.acknowledged", equals: false }],
1883
+ pulse: { color: "#ff3b30", speed: 1.5 },
1884
+ },
1885
+ ],
1886
+ },
1887
+ });
1888
+
1889
+ <ModelViewer modelUrl="/plant.glb" licenseKey="your-license-key" {...viewer.props} />;
1890
+
1891
+ // Live data: update the bindings and the looks follow.
1892
+ viewer.bindings.updateObjectBindings("pump-3", { metrics: { temperature: 34 } });
1893
+ viewer.bindings.updateObjectBindings("valve-1", { status: "disabled" });
1894
+ ```
1895
+
1896
+ - `target` takes the same targets as the binding helpers: an id, a list of
1897
+ ids, `{ group }`, `{ tag }`, or `{ type }`. Left out, the rule considers every
1898
+ bound object.
1899
+ - `when` is a condition, or a list that must all match; left out, the rule
1900
+ always applies. Conditions: `{ metric, above?, below? }` (the value must be
1901
+ greater than `above` and less than `below`; an object without the metric
1902
+ doesn't match), `{ status }` with one status or a list (a binding without
1903
+ one counts as `"normal"`), and `{ metadata, equals }`, where dots in the key
1904
+ reach into nested metadata.
1905
+ - `material` accepts any `style.material` field. `colorScale` sets
1906
+ `baseColor` (or `emissive`, with `channel: "emissive"`) from a metric,
1907
+ blending between hex colour stops and taking the nearest stop outside them.
1908
+ `pulse` makes the glow fade in and out (`true`, or `{ color, speed }` in
1909
+ pulses per second).
1910
+ - Rules apply in order over each object's own `style.material`, so a later
1911
+ rule wins where two set the same field. Hover and selection highlighting
1912
+ still show on top.
1913
+ - Rules only change how objects look. `objectBindings`, `onObjectBindingsChange`,
1914
+ and every callback keep the object's own values, so nothing a rule sets is
1915
+ saved with the bindings.
1916
+ - The pulse isn't drawn on streamed 3D Tiles models (`tilesetUrl`); the other
1917
+ looks are.
1918
+
1919
+ BindingBuilder's Scene tab has a **Rules** sub-tab for building these rules
1920
+ visually. It suggests your groups, tags, types, metric names, and metadata
1921
+ keys, shows how many objects each rule matches right now, and previews the
1922
+ result live. Rules export and import with the rest of `sceneConfig`.
1923
+
1924
+ `applyStyleRules(bindings, rules)` (from the main entry or `/utils`) returns
1925
+ the styled bindings, for example to colour your own list the way the viewer
1926
+ does, and `colorScaleValue(stops, value)` the colour a scale gives a value, for
1927
+ a legend.
1928
+
1682
1929
  ### `camera`
1683
1930
 
1684
1931
  Optional starting camera: where it is and its lens.
@@ -1942,7 +2189,7 @@ SVG texture template per material. Meshes without a `uv` attribute are omitted;
1942
2189
  when the model has no UV coordinates, the viewer reports that no layout is
1943
2190
  available.
1944
2191
 
1945
- The bottom-right action bar renders when at least one of `showResetButton`, `showDownloadButton`, or (`showAnnotationNavigation` with annotations present) is `true`.
2192
+ The bottom-right action bar renders when at least one of `showResetButton`, `showDownloadButton`, `showFullscreenButton` (when supported), or (`showAnnotationNavigation` with annotations present) is `true`.
1946
2193
 
1947
2194
  ### `downloadFilename`
1948
2195
 
@@ -1964,6 +2211,26 @@ Default:
1964
2211
  true;
1965
2212
  ```
1966
2213
 
2214
+ ### `showFullscreenButton`
2215
+
2216
+ Optional boolean that adds an enter/exit fullscreen toggle to the bottom-right
2217
+ action bar. Its grouped form is `ui.fullscreen`. Default: `false`.
2218
+
2219
+ ```tsx
2220
+ <ModelViewer
2221
+ modelUrl="/model.glb"
2222
+ licenseKey={licenseKey}
2223
+ ui={{ fullscreen: true }}
2224
+ />
2225
+ ```
2226
+
2227
+ Fullscreen expands the whole viewer, including its panels and overlays. The
2228
+ button reflects browser exits such as Escape, and is hidden when fullscreen is
2229
+ unavailable. It is temporarily disabled during video recording. Embedded viewers
2230
+ need an iframe with `allow="fullscreen"` (or `allowFullScreen` in React). A rejected
2231
+ request displays an error beside the button; see the
2232
+ [Fullscreen API permissions](https://developer.mozilla.org/en-US/docs/Web/API/Element/requestFullscreen#security).
2233
+
1967
2234
  ### `showLoadingOverlay`
1968
2235
 
1969
2236
  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)).
@@ -2311,6 +2578,18 @@ Default:
2311
2578
  "auto";
2312
2579
  ```
2313
2580
 
2581
+ If the WebGL context is lost, rendering pauses and the viewer displays a
2582
+ **Reload viewer** button. This message appears even with
2583
+ `ui={{ loadingOverlay: false }}`. When the browser fires `webglcontextrestored`,
2584
+ the viewer automatically rebuilds its canvas and scene; the reload button starts
2585
+ the same rebuild without waiting for browser restoration.
2586
+
2587
+ Recovery uses the current props and cached model assets, cancels video recording,
2588
+ and resets transient viewer state (camera, selection, measurements, and playback).
2589
+ Keep configuration edits in controlled props to retain them across a rebuild.
2590
+ `onSceneLoaded`, `onModelLoaded`, `onAnimationsReady`, and `onViewerReady` run again
2591
+ for the rebuilt scene, so integrations receive fresh controls and object refs.
2592
+
2314
2593
  ### `dracoDecoderPath` / `ktx2TranscoderPath` / `meshopt`
2315
2594
 
2316
2595
  Optional controls for the compressed-asset decoders used when loading the GLB/GLTF asset.
@@ -2375,7 +2654,33 @@ false;
2375
2654
 
2376
2655
  ### `showMeasureTools`
2377
2656
 
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.
2657
+ Optional boolean that shows measurement tools and a toggleable bounding-box
2658
+ dimensions overlay. Start measuring, then choose a tool:
2659
+
2660
+ - **Distance:** pick points for distances between consecutive picks.
2661
+ - **Angle:** pick an arm, the angle's vertex, and the other arm. The readout is
2662
+ in degrees, with an arc around the middle pick.
2663
+ - **Area:** pick polygon corners in boundary order, then **Finish area**. The
2664
+ closed polygon is shaded and labeled in square units. Concave polygons are
2665
+ supported; nonplanar, crossing, touching, duplicate, or collinear boundaries
2666
+ show an error. Use Undo to correct a pick.
2667
+ - **Point to plane:** pick a reference face, then a point. The tool highlights
2668
+ the picked triangle and shows the shortest distance to its infinite plane,
2669
+ with a perpendicular segment to the projected point.
2670
+
2671
+ **No snapping** keeps picks on the surface. **Snap: vertex**, **Snap: edge**,
2672
+ and **Snap: auto** snap within 12 CSS pixels to vertices or triangle edges on
2673
+ the picked face. Auto prioritizes nearby vertices. Hover markers identify the
2674
+ snap target; snapped picks are yellow. Snapping respects world transforms,
2675
+ instances, and the current pose of skinned/morph geometry. Mesh triangulation
2676
+ edges can also be snap targets.
2677
+
2678
+ Undo removes the last pick; Clear removes the current measurement. Switching
2679
+ tools clears picks. A new pick after a completed angle, area, or point-to-plane
2680
+ measurement starts another measurement. Picks are snapshots in world space;
2681
+ model changes, display rotation, and binding transforms clear them. Pause
2682
+ animated models before measuring a pose. Hidden objects, decals, and editor
2683
+ helpers are excluded from picking.
2379
2684
 
2380
2685
  Default:
2381
2686
 
@@ -2385,7 +2690,10 @@ false;
2385
2690
 
2386
2691
  ### `measurementUnit`
2387
2692
 
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.
2693
+ Optional unit suffix appended to distance and dimension readouts; area uses its
2694
+ squared form (for example `m²`). Angles always use degrees. glTF models are
2695
+ authored in meters by spec, so values are not converted; this only changes the
2696
+ displayed label.
2389
2697
 
2390
2698
  Default:
2391
2699
 
@@ -2403,6 +2711,235 @@ Default:
2403
2711
  false;
2404
2712
  ```
2405
2713
 
2714
+ ### `showMaterialVariants`
2715
+
2716
+ Shows a row of buttons, centered at the bottom of the viewer, for switching
2717
+ between the model's glTF material variants (the `KHR_materials_variants`
2718
+ extension): the colorways, finishes, or trims a product file ships with. It
2719
+ appears only for a glTF/GLB with more than one variant. Default `true`.
2720
+
2721
+ Picking a variant sets `sceneConfig.model.materialVariant`, which is also how
2722
+ you choose one yourself:
2723
+
2724
+ ```tsx
2725
+ const viewer = useViewer();
2726
+
2727
+ <ModelViewer modelUrl="/shoe.glb" licenseKey="your-license-key" {...viewer.props} />;
2728
+
2729
+ // The model's variant names, once it has loaded
2730
+ viewer.model.materialVariants; // ["midnight", "beach", "street"]
2731
+
2732
+ viewer.scene.updateSceneConfig({ model: { materialVariant: "beach" } });
2733
+ viewer.scene.updateSceneConfig({ model: { materialVariant: null } }); // the defaults
2734
+ ```
2735
+
2736
+ - A variant sets each object's starting material. `objectBindings` material
2737
+ overrides (`style.material`) still apply on top of it, and clearing an
2738
+ override returns to the variant's material rather than the file's default.
2739
+ - Objects the variant doesn't map keep their default material, bound or not.
2740
+ - A variant's materials and textures load the first time it's chosen; the
2741
+ current look stays until they're ready.
2742
+ - With `onSceneConfigChange`, a pick goes to your scene config (`useViewer`
2743
+ wires this up). Without it, `ModelViewer` keeps the pick itself until
2744
+ `sceneConfig.model.materialVariant` or `modelUrl` changes.
2745
+ - A name the model doesn't have shows the defaults and logs a console warning
2746
+ listing the model's variants.
2747
+ - An action can switch variants with a scene-config effect:
2748
+ `{ sceneConfig: { model: { materialVariant: "beach" } } }`.
2749
+ - `onModelLoaded` reports the names as `materialVariants`, and BindingBuilder's
2750
+ Scene tab (**General → Material variant**) picks the one the exported scene
2751
+ config starts with. OBJ, FBX, USDZ, and streamed 3D Tiles models have no
2752
+ variants.
2753
+
2754
+ ### Decals
2755
+
2756
+ `binding.decals` prints images and text onto an object's surface: a logo on a
2757
+ shirt, a name on a mug, a label on a machine. Each decal is projected onto the
2758
+ object and follows it as it moves, and it's saved with the binding like any
2759
+ other edit.
2760
+
2761
+ ```tsx
2762
+ const viewer = useViewer();
2763
+
2764
+ <ModelViewer
2765
+ modelUrl="/mug.glb"
2766
+ licenseKey="your-license-key"
2767
+ {...viewer.props}
2768
+ // Put the customer's text where they double-click.
2769
+ onObjectDoubleClick={({ binding, surface }) => {
2770
+ if (!surface) return;
2771
+ viewer.bindings.addDecal(binding.id, {
2772
+ text: customerName,
2773
+ color: "#1e293b",
2774
+ ...surface, // position and normal, in the object's own space
2775
+ size: 0.06, // width in model units
2776
+ });
2777
+ return true; // skip the viewer's own double-click behavior
2778
+ }}
2779
+ />;
2780
+
2781
+ const id = viewer.bindings.addDecal("mug", { image: "/logos/acme.png", position, normal, size: 0.05 });
2782
+ viewer.bindings.updateDecal("mug", id, { size: 0.08, rotation: Math.PI / 12 });
2783
+ viewer.bindings.removeDecal("mug", id);
2784
+ ```
2785
+
2786
+ ```ts
2787
+ type ObjectBindingDecal = {
2788
+ id: string;
2789
+ image?: string; // a URL or public path
2790
+ text?: string; // shown when there's no image
2791
+ color?: string; // text colour, default "#ffffff"
2792
+ fontFamily?: string; // CSS font family, default "sans-serif"
2793
+ fontWeight?: "normal" | "bold"; // default "bold"
2794
+ position: [number, number, number]; // on the object, in its own space
2795
+ normal: [number, number, number]; // the way the surface faces there
2796
+ size: number; // width in model units; the height follows the image or text
2797
+ rotation?: number; // radians around the normal, default 0
2798
+ opacity?: number; // 0–1, default 1
2799
+ };
2800
+ ```
2801
+
2802
+ - `onObjectDoubleClick` and `onObjectContextMenu` report `surface`: the point
2803
+ under the pointer and the way the surface faces, in the object's own space,
2804
+ which is exactly what a decal's `position` and `normal` take.
2805
+ - `addDecal` returns the decal's id (pass your own `id` to choose it), and
2806
+ takes the same targets as the other binding helpers, so
2807
+ `addDecal({ group: "panels" }, …)` prints on each. Decals are plain binding
2808
+ data: you can also set `decals` directly.
2809
+ - A decal wraps onto the surface around its position, reaching about half
2810
+ its size in depth, so it follows gentle curves without printing on the far
2811
+ side of a thin part.
2812
+ - A decal fades with its object when that object is ghosted by isolation or
2813
+ made see-through by a style rule, and is cut by a section cut.
2814
+ - To let the people using your viewer add their own, give a binding the
2815
+ built-in `add-text` and `add-image` actions:
2816
+
2817
+ ```ts
2818
+ actions: [
2819
+ { id: "add-text", label: "Add Text", type: "command" },
2820
+ { id: "add-image", label: "Add Image", type: "command" },
2821
+ ]
2822
+ ```
2823
+
2824
+ Each opens a control under its button in the object panel. **Add Text**
2825
+ takes the text and a colour; **Add Image** takes an image file (sent through
2826
+ `onTextureUpload` when you pass it, so the saved URL lasts; otherwise a
2827
+ blob URL for this session). The decal appears automatically at the centre
2828
+ of the camera-facing surface, ready to drag, resize, turn, or remove. **Move** enables dragging across
2829
+ the object's surface; release to save, or press Escape to cancel. The
2830
+ **Position** arrows move a decal in small steps relative to the current
2831
+ view, without needing to grab it. Click for a fine adjustment or hold an
2832
+ arrow for continuous movement. Each step stays on the nearby visible surface.
2833
+ Every change is saved to the
2834
+ binding's `decals` and reported through `onObjectBindingsChange`. While a
2835
+ decal is in Move mode, a hint shows over the viewer and Escape cancels
2836
+ the current drag without removing the centred decal.
2837
+ - On a rigged (skinned) mesh a decal bends with the animation: it takes on
2838
+ the bone weights of the surface under it and is driven by the same skeleton.
2839
+ A click on a posed mesh is stored at the matching point of its rest shape, so
2840
+ a decal lands where you clicked whatever pose the model is in. Over a joint
2841
+ that bends sharply across few polygons, the decal's outer edge can sit a
2842
+ hair off the surface. Decals don't follow morph targets (blend shapes).
2843
+ - Decals need the binding's object to be a mesh, and aren't drawn on streamed
2844
+ 3D Tiles models.
2845
+
2846
+ ### Isolating objects
2847
+
2848
+ `isolation` focuses the view on some objects: everything else is ghosted
2849
+ (drawn faintly) or hidden, and clicks and hovers pass through to what's
2850
+ isolated. Nothing about the bindings changes, and clearing the isolation puts
2851
+ everything back. It's view state, like the selection, so it isn't part of
2852
+ `sceneConfig`.
2853
+
2854
+ ```tsx
2855
+ const viewer = useViewer();
2856
+
2857
+ <ModelViewer
2858
+ modelUrl="/engine.glb"
2859
+ licenseKey="your-license-key"
2860
+ {...viewer.props}
2861
+ tools={{ isolate: true }}
2862
+ />;
2863
+
2864
+ viewer.isolation.isolate({ group: "gearbox" }); // ghost everything else
2865
+ viewer.isolation.isolate(["piston-1", "piston-2"], { mode: "hide" });
2866
+ viewer.isolation.clearIsolation();
2867
+ ```
2868
+
2869
+ ```ts
2870
+ type ViewerIsolation = {
2871
+ target: ObjectBindingTarget; // an id, a list of ids, { group }, { tag }, or { type }
2872
+ mode?: "ghost" | "hide"; // default "ghost"
2873
+ ghostOpacity?: number; // 0–1, default 0.12
2874
+ };
2875
+
2876
+ isolation?: ViewerIsolation | null;
2877
+ onIsolationChange?: (isolation: ViewerIsolation | null) => void;
2878
+ showIsolateControls?: boolean; // tools.isolate
2879
+ ```
2880
+
2881
+ - Pass `isolation` (including `null`) to control it, and update it from
2882
+ `onIsolationChange`; `useViewer` wires both, as `viewer.isolation`. Leave
2883
+ `isolation` out and `ModelViewer` keeps it itself, clearing it when
2884
+ `modelUrl` changes. `useViewerIsolation` is the same state on its own.
2885
+ - `showIsolateControls` (`tools.isolate`, default `false`) adds an isolate
2886
+ button to each row of the scene objects panel (click again to show all) and
2887
+ a "Show all" pill at the bottom of the viewer while anything is isolated.
2888
+ - An action can isolate with an `isolation` effect:
2889
+ `{ isolation: { target: { group: "engine" } } }`, or `{ isolation: null }`
2890
+ to show everything again. Pass `isolation` to `useViewerActions` for it
2891
+ (`useViewer` does).
2892
+ - A target that matches no object isolates nothing, rather than fading out
2893
+ the whole model. Ghosting never raises an object's own lower opacity, and
2894
+ style rules still apply underneath.
2895
+ - `applyIsolation` (main entry and `/utils`) applies an isolation to bindings
2896
+ the same way, for rendering your own lists.
2897
+
2898
+ ### `showSectionTools`
2899
+
2900
+ Shows a section-cut button over the canvas (`tools.section`). It cuts the model
2901
+ open along an axis, hiding everything on one side of a plane, to look inside
2902
+ assemblies, equipment, and buildings. Default `false`.
2903
+
2904
+ With the cut on, the toolbar picks the axis (X, Y, or Z), the position along
2905
+ it, and which side stays, and a plane appears across the model that you can
2906
+ drag. The cut is stored in `sceneConfig.section`, so you can also set it
2907
+ yourself, author it in BindingBuilder, or switch it from an action:
2908
+
2909
+ ```tsx
2910
+ viewer.scene.updateSceneConfig({
2911
+ section: { enabled: true, axis: "y", position: 0.4, flip: false }, // a floor-plan cut
2912
+ });
2913
+ ```
2914
+
2915
+ ```ts
2916
+ type SceneSectionConfig = {
2917
+ enabled: boolean;
2918
+ axis: "x" | "y" | "z"; // world axis the cut runs across
2919
+ position: number; // 0 at the model's minimum along the axis, 1 at its maximum
2920
+ flip: boolean; // keep the higher side instead of the lower one
2921
+ };
2922
+ ```
2923
+
2924
+ - The cut applies to the model only; lights, helpers, and gizmos stay whole.
2925
+ It also shows without the toolbar whenever `sceneConfig.section.enabled` is
2926
+ set; the draggable plane appears only with `showSectionTools`.
2927
+ - While the cut is on, the model's materials render both sides of each
2928
+ surface, so a cut part shows its inside surfaces instead of disappearing.
2929
+ There are no solid caps over the cut.
2930
+ - Clicking and hovering ignore whatever is cut away, so the parts you can see
2931
+ behind the cut are the ones you select.
2932
+ - Toolbar changes go to `onSceneConfigChange` when you pass it (`useViewer`
2933
+ does); otherwise `ModelViewer` keeps them until `sceneConfig.section` or
2934
+ `modelUrl` changes.
2935
+ - The measuring tool can still pick points on the hidden side of a cut.
2936
+
2937
+ Default:
2938
+
2939
+ ```ts
2940
+ false;
2941
+ ```
2942
+
2406
2943
  ### `cinematic`
2407
2944
 
2408
2945
  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.
@@ -2762,6 +3299,7 @@ type ObjectBinding = {
2762
3299
  hoverable?: boolean;
2763
3300
  transform?: ObjectBindingTransform;
2764
3301
  style?: ObjectBindingStyle;
3302
+ decals?: ObjectBindingDecal[];
2765
3303
  actions?: ObjectBindingAction[];
2766
3304
  metrics?: Record<string, number>;
2767
3305
  metadata?: Record<string, unknown>;
@@ -2789,9 +3327,9 @@ Notes:
2789
3327
 
2790
3328
  ## Supported Built-In Actions
2791
3329
 
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
3330
+ `ModelViewer` handles eight action ids itself: `change-color`,
3331
+ `change-material`, `add-text`, `add-image`, `toggle-visibility`,
3332
+ `toggle-light`, `change-light-color`, and `set-brightness`. Any other id does something only when you handle it in
2795
3333
  `onAction` (pass `useViewerActions().handleAction` there to run an action's
2796
3334
  declared `effects`). If a binding has such an action and `ModelViewer` has no
2797
3335
  `onAction`, its button does nothing, and the viewer logs a console warning
@@ -2809,6 +3347,10 @@ findCatalogAction("toggle-light")?.handling; // "viewer"
2809
3347
 
2810
3348
  ### Implemented behaviors
2811
3349
 
3350
+ - `add-text`
3351
+ 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))
3352
+ - `add-image`
3353
+ 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
3354
  - `toggle-visibility`
2813
3355
  Hides or shows the selected mesh by setting `binding.visible` and calling `onObjectBindingsChange`
2814
3356
  - `change-color`