@pascal-app/editor 1.0.0 → 1.0.3

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 (177) hide show
  1. package/package.json +12 -6
  2. package/src/components/editor/bake-exporter.tsx +4 -0
  3. package/src/components/editor/bake-thumbnail.tsx +4 -1
  4. package/src/components/editor/custom-camera-controls.tsx +22 -74
  5. package/src/components/editor/editor-layout-mobile.tsx +1 -0
  6. package/src/components/editor/floating-action-menu.tsx +14 -1
  7. package/src/components/editor/floorplan-panel.tsx +233 -166
  8. package/src/components/editor/grid.tsx +31 -7
  9. package/src/components/editor/group-actions.ts +5 -9
  10. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  11. package/src/components/editor/group-move-3d.ts +4 -6
  12. package/src/components/editor/group-rotate-handle.tsx +81 -35
  13. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  14. package/src/components/editor/group-transform-shared.test.ts +126 -1
  15. package/src/components/editor/group-transform-shared.ts +79 -73
  16. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  17. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  18. package/src/components/editor/handles/handle-placement.ts +69 -0
  19. package/src/components/editor/handles/linear-resize-drag.ts +13 -2
  20. package/src/components/editor/handles/use-handle-drag.ts +80 -8
  21. package/src/components/editor/index.tsx +36 -13
  22. package/src/components/editor/node-action-menu.tsx +2 -0
  23. package/src/components/editor/node-arrow-handles.tsx +162 -69
  24. package/src/components/editor/registry-action-contributions.tsx +53 -0
  25. package/src/components/editor/selection-manager.tsx +49 -0
  26. package/src/components/editor/thumbnail-generator.tsx +11 -2
  27. package/src/components/editor/wall-move-side-handles.tsx +71 -21
  28. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  29. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  30. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  31. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  32. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
  33. package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
  34. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
  35. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +113 -24
  36. package/src/components/procedural-items/inspector.tsx +223 -0
  37. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  38. package/src/components/systems/zone/zone-system.tsx +8 -4
  39. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  40. package/src/components/tools/item/item-surface-placement.test.ts +169 -0
  41. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +204 -0
  42. package/src/components/tools/item/placement-strategies.ts +212 -200
  43. package/src/components/tools/item/placement-types.ts +4 -0
  44. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  45. package/src/components/tools/item/use-draft-node.ts +136 -47
  46. package/src/components/tools/item/use-placement-coordinator.tsx +391 -157
  47. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  48. package/src/components/tools/registry/item-surface-move.test.ts +509 -0
  49. package/src/components/tools/registry/item-surface-move.ts +463 -0
  50. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  51. package/src/components/tools/registry/move-registry-node-tool.tsx +237 -78
  52. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  53. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  54. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  55. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  56. package/src/components/tools/shared/pointer-support-cap.ts +27 -13
  57. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  58. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  59. package/src/components/tools/shared/surface-hit.ts +23 -0
  60. package/src/components/tools/shared/surface-rejection.test.tsx +107 -0
  61. package/src/components/tools/shared/surface-rejection.tsx +43 -0
  62. package/src/components/tools/tool-manager.tsx +36 -3
  63. package/src/components/tools/zone/zone-tool.tsx +13 -4
  64. package/src/components/ui/command-palette/editor-commands.tsx +54 -1
  65. package/src/components/ui/controls/material-paint-panel.tsx +6 -67
  66. package/src/components/ui/controls/material-picker.tsx +5 -67
  67. package/src/components/ui/controls/metric-control.tsx +29 -13
  68. package/src/components/ui/controls/slider-control.tsx +87 -35
  69. package/src/components/ui/floating-level-selector.tsx +81 -5
  70. package/src/components/ui/helpers/helper-manager.tsx +24 -6
  71. package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
  72. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  73. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  74. package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
  75. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  76. package/src/components/ui/panels/parametric-inspector.tsx +9 -17
  77. package/src/components/ui/panels/reference-panel.tsx +3 -3
  78. package/src/components/ui/primitives/checkbox.tsx +34 -0
  79. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  80. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  81. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +669 -336
  82. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  83. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
  84. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +30 -11
  85. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  86. package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
  87. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  88. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  89. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
  90. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  91. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  92. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  93. package/src/components/ui/snap-target-badge.tsx +2 -0
  94. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  95. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  96. package/src/components/viewer/floorplan-preview.tsx +1 -12
  97. package/src/components/viewer/viewer-scene-header.tsx +3 -0
  98. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  99. package/src/components/viewer-zone-system.tsx +7 -2
  100. package/src/hooks/use-auto-save.integration.test.tsx +197 -0
  101. package/src/hooks/use-auto-save.ts +21 -6
  102. package/src/hooks/use-ceiling-events.test.ts +25 -2
  103. package/src/hooks/use-ceiling-events.ts +7 -1
  104. package/src/hooks/use-grid-events.test.ts +2 -0
  105. package/src/hooks/use-grid-events.ts +16 -11
  106. package/src/hooks/use-keyboard.test.ts +7 -0
  107. package/src/hooks/use-keyboard.ts +43 -28
  108. package/src/index.tsx +68 -3
  109. package/src/lib/catalog-panel-model.ts +39 -0
  110. package/src/lib/catalog-selection.test.ts +27 -0
  111. package/src/lib/catalog-selection.ts +8 -0
  112. package/src/lib/contextual-help.test.ts +27 -1
  113. package/src/lib/contextual-help.ts +16 -9
  114. package/src/lib/export-texture-utils.test.ts +167 -0
  115. package/src/lib/export-texture-utils.ts +121 -0
  116. package/src/lib/export-user-data.test.ts +48 -0
  117. package/src/lib/export-user-data.ts +69 -0
  118. package/src/lib/floorplan/floorplan-export.test.ts +142 -2
  119. package/src/lib/floorplan/floorplan-export.tsx +9 -3
  120. package/src/lib/floorplan/floorplan-extension.ts +16 -0
  121. package/src/lib/floorplan/floorplan-readonly.ts +4 -0
  122. package/src/lib/fresh-planar-placement.ts +173 -33
  123. package/src/lib/glb-export-serialize.test.ts +280 -0
  124. package/src/lib/glb-export.test.ts +265 -16
  125. package/src/lib/glb-export.ts +197 -37
  126. package/src/lib/grid-frame.test.ts +19 -0
  127. package/src/lib/grid-frame.ts +18 -0
  128. package/src/lib/interaction/scope.ts +9 -1
  129. package/src/lib/keyboard-pan.test.ts +62 -0
  130. package/src/lib/keyboard-pan.ts +89 -0
  131. package/src/lib/level-duplication.test.ts +73 -0
  132. package/src/lib/level-duplication.ts +32 -2
  133. package/src/lib/linear-display.test.ts +59 -0
  134. package/src/lib/linear-display.ts +39 -0
  135. package/src/lib/local-guide-image.test.ts +24 -0
  136. package/src/lib/local-guide-image.ts +34 -1
  137. package/src/lib/material-catalog-model.ts +72 -0
  138. package/src/lib/material-paint-panel-model.ts +76 -0
  139. package/src/lib/node-action-movement.ts +21 -0
  140. package/src/lib/overlay-transform.test.ts +28 -0
  141. package/src/lib/overlay-transform.ts +12 -0
  142. package/src/lib/panel-rows.ts +75 -0
  143. package/src/lib/panel-tool-options.test.ts +34 -0
  144. package/src/lib/panel-tool-options.ts +46 -0
  145. package/src/lib/parametric-node-update.ts +37 -0
  146. package/src/lib/portable-export.test.ts +89 -0
  147. package/src/lib/portable-export.ts +84 -11
  148. package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
  149. package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
  150. package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
  151. package/src/lib/print-shell-compiler-protocol.ts +8 -0
  152. package/src/lib/scene-clipboard.ts +1 -1
  153. package/src/lib/scene.test.ts +99 -0
  154. package/src/lib/scene.ts +21 -3
  155. package/src/lib/send-to-app.test.ts +108 -0
  156. package/src/lib/send-to-app.ts +121 -0
  157. package/src/lib/sfx/movement-tick.ts +37 -0
  158. package/src/lib/snapping-mode.test.ts +13 -2
  159. package/src/lib/snapping-mode.ts +11 -6
  160. package/src/lib/spatial-drag-plane.test.ts +53 -0
  161. package/src/lib/spatial-drag-plane.ts +19 -0
  162. package/src/lib/spatial-pointer-input.test.ts +72 -0
  163. package/src/lib/spatial-pointer-input.ts +62 -0
  164. package/src/lib/spatial-snap.test.ts +47 -0
  165. package/src/lib/surface-attachment.ts +77 -0
  166. package/src/lib/terrain-panel-model.ts +117 -0
  167. package/src/lib/units.ts +254 -0
  168. package/src/lib/usdz-export.ts +43 -16
  169. package/src/lib/use-linear-display.ts +8 -32
  170. package/src/lib/world-grid-snap.test.ts +48 -0
  171. package/src/lib/world-grid-snap.ts +9 -5
  172. package/src/store/rotation-snapping.test.ts +53 -0
  173. package/src/store/use-editor.tsx +10 -2
  174. package/src/store/use-floorplan-draft-preview.ts +10 -1
  175. package/src/store/use-handle-group.ts +4 -0
  176. package/src/store/use-interaction-scope.ts +155 -6
  177. package/src/store/use-send-to-app.ts +21 -0
@@ -4,6 +4,7 @@ import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
6
  type Cursor,
7
+ cascadeDirty,
7
8
  createSceneApi,
8
9
  type HandleDragModifiers,
9
10
  runAsSingleSceneHistoryStep,
@@ -16,6 +17,8 @@ import { useEffect, useRef } from 'react'
16
17
  import { type Camera, type Object3D, type Plane, type Ray, Vector2, type Vector3 } from 'three'
17
18
  import { isHistoryShortcut } from '../../../lib/history'
18
19
  import { sfxEmitter } from '../../../lib/sfx-bus'
20
+ import { intersectSpatialDragPlane } from '../../../lib/spatial-drag-plane'
21
+ import { getSpatialPointerId, spatialPointerInput } from '../../../lib/spatial-pointer-input'
19
22
  import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
20
23
  import { commitHandleDragPatch } from './handle-drag-history'
21
24
 
@@ -57,6 +60,7 @@ type HandleDragSession = {
57
60
  commit?: (patch: Partial<AnyNode>) => void
58
61
  markDirty?: boolean
59
62
  onBegin?: () => void
63
+ onCancel?: () => void
60
64
  onEnd?: () => void
61
65
  overrideId?: AnyNodeId
62
66
  }
@@ -106,6 +110,7 @@ function suppressInputDraggingUntilPointerRelease(pointerId: number) {
106
110
  window.addEventListener('pointerup', restore)
107
111
  window.addEventListener('pointercancel', restore)
108
112
  window.addEventListener('blur', onBlur)
113
+ return () => restore()
109
114
  }
110
115
 
111
116
  export function useHandleDrag(args: UseHandleDragArgs) {
@@ -120,9 +125,17 @@ export function useHandleDrag(args: UseHandleDragArgs) {
120
125
  if (event.button !== 0) return
121
126
  event.stopPropagation()
122
127
  suppressBoxSelectForPointer(event)
128
+ const spatialPointerId = getSpatialPointerId(event.nativeEvent)
123
129
 
124
130
  if (args.kind === 'tap') {
125
- suppressInputDraggingUntilPointerRelease(event.nativeEvent.pointerId)
131
+ const restoreInputDragging = suppressInputDraggingUntilPointerRelease(event.pointerId)
132
+ if (spatialPointerId) {
133
+ spatialPointerInput.capture(spatialPointerId, {
134
+ onMove: () => undefined,
135
+ onRelease: restoreInputDragging,
136
+ onCancel: restoreInputDragging,
137
+ })
138
+ }
126
139
  swallowNextClick()
127
140
  sfxEmitter.emit('sfx:item-pick')
128
141
  document.body.style.cursor = ''
@@ -132,6 +145,7 @@ export function useHandleDrag(args: UseHandleDragArgs) {
132
145
 
133
146
  const { cursor, dragControls, handleIndex, node, rideObject, setIsDragging } = args
134
147
  rideObject.updateMatrixWorld()
148
+ const spatialRay = spatialPointerId ? event.ray.clone() : null
135
149
 
136
150
  const ndc = new Vector2()
137
151
  const setPointerRay = (clientX: number, clientY: number) => {
@@ -143,10 +157,12 @@ export function useHandleDrag(args: UseHandleDragArgs) {
143
157
  raycaster.setFromCamera(ndc, camera)
144
158
  }
145
159
  const getPointerRay: GetPointerRay = (clientX, clientY, target) => {
160
+ if (spatialRay) return target.copy(spatialRay)
146
161
  setPointerRay(clientX, clientY)
147
162
  return target.copy(raycaster.ray)
148
163
  }
149
164
  const intersectPlane: IntersectPlane = (clientX, clientY, plane, target) => {
165
+ if (spatialRay) return intersectSpatialDragPlane(spatialRay, plane, target)
150
166
  setPointerRay(clientX, clientY)
151
167
  return raycaster.ray.intersectPlane(plane, target)
152
168
  }
@@ -179,7 +195,11 @@ export function useHandleDrag(args: UseHandleDragArgs) {
179
195
 
180
196
  let lastPatch: Partial<AnyNode> | null = null
181
197
  let historyPaused = true
198
+ let releaseSpatialCapture: (() => void) | null = null
182
199
  let altKey = event.nativeEvent.altKey
200
+ let shiftKey = event.nativeEvent.shiftKey
201
+ let pendingMoveEvent: PointerEvent | null = null
202
+ let moveFrame = 0
183
203
 
184
204
  const resumeHistory = () => {
185
205
  if (!historyPaused) return
@@ -187,10 +207,10 @@ export function useHandleDrag(args: UseHandleDragArgs) {
187
207
  useScene.temporal.getState().resume()
188
208
  }
189
209
 
190
- const onMove = (moveEvent: PointerEvent) => {
210
+ const processMove = (moveEvent: PointerEvent) => {
191
211
  const patch = session.move({
192
212
  event: moveEvent,
193
- modifiers: { altKey },
213
+ modifiers: { altKey, shiftKey },
194
214
  getPointerRay,
195
215
  intersectPlane,
196
216
  })
@@ -198,16 +218,40 @@ export function useHandleDrag(args: UseHandleDragArgs) {
198
218
  lastPatch = patch
199
219
  useLiveNodeOverrides.getState().set(overrideId, patch as Record<string, unknown>)
200
220
  if (markDirty) {
201
- useScene.getState().markDirty(overrideId)
221
+ for (const id of cascadeDirty(overrideId, { scene: createSceneApi(useScene) }))
222
+ useScene.getState().markDirty(id)
223
+ }
224
+ }
225
+
226
+ // Coalesce high-frequency pointer events so live React/store updates happen
227
+ // at most once per animation frame while retaining the newest position.
228
+ const flushMove = () => {
229
+ moveFrame = 0
230
+ const moveEvent = pendingMoveEvent
231
+ pendingMoveEvent = null
232
+ if (moveEvent) processMove(moveEvent)
233
+ }
234
+ const onMove = (moveEvent: PointerEvent) => {
235
+ pendingMoveEvent = moveEvent
236
+ if (moveFrame !== 0) return
237
+ if (typeof window.requestAnimationFrame === 'function') {
238
+ moveFrame = window.requestAnimationFrame(flushMove)
239
+ } else {
240
+ flushMove()
202
241
  }
203
242
  }
204
243
 
205
244
  const cleanup = () => {
245
+ if (moveFrame !== 0) window.cancelAnimationFrame?.(moveFrame)
246
+ moveFrame = 0
247
+ pendingMoveEvent = null
206
248
  window.removeEventListener('pointermove', onMove)
207
249
  window.removeEventListener('pointerup', onUp)
208
250
  window.removeEventListener('pointercancel', onCancel)
209
251
  window.removeEventListener('keydown', onKeyDown, true)
210
252
  window.removeEventListener('keyup', onKeyUp, true)
253
+ releaseSpatialCapture?.()
254
+ releaseSpatialCapture = null
211
255
  if (document.body.style.cursor === cursor) {
212
256
  document.body.style.cursor = ''
213
257
  }
@@ -222,11 +266,14 @@ export function useHandleDrag(args: UseHandleDragArgs) {
222
266
  const clearOverride = () => {
223
267
  useLiveNodeOverrides.getState().clear(overrideId)
224
268
  if (markDirty) {
225
- useScene.getState().markDirty(overrideId)
269
+ for (const id of cascadeDirty(overrideId, { scene: createSceneApi(useScene) }))
270
+ useScene.getState().markDirty(id)
226
271
  }
227
272
  }
228
273
 
229
274
  const onUp = () => {
275
+ if (moveFrame !== 0) window.cancelAnimationFrame?.(moveFrame)
276
+ flushMove()
230
277
  swallowNextClick()
231
278
  sfxEmitter.emit('sfx:item-place')
232
279
  if (lastPatch) {
@@ -242,6 +289,7 @@ export function useHandleDrag(args: UseHandleDragArgs) {
242
289
  }
243
290
 
244
291
  const onCancel = () => {
292
+ session.onCancel?.()
245
293
  clearOverride()
246
294
  cleanup()
247
295
  }
@@ -253,6 +301,10 @@ export function useHandleDrag(args: UseHandleDragArgs) {
253
301
  altKey = true
254
302
  return
255
303
  }
304
+ if (e.key === 'Shift') {
305
+ shiftKey = true
306
+ return
307
+ }
256
308
  if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
257
309
  e.preventDefault()
258
310
  e.stopPropagation()
@@ -261,12 +313,32 @@ export function useHandleDrag(args: UseHandleDragArgs) {
261
313
  }
262
314
  const onKeyUp = (e: KeyboardEvent) => {
263
315
  if (e.key === 'Alt') altKey = false
316
+ if (e.key === 'Shift') shiftKey = false
264
317
  }
265
318
 
266
319
  dragCleanupRef.current = onCancel
267
- window.addEventListener('pointermove', onMove)
268
- window.addEventListener('pointerup', onUp)
269
- window.addEventListener('pointercancel', onCancel)
320
+ if (spatialPointerId && spatialRay) {
321
+ releaseSpatialCapture = spatialPointerInput.capture(spatialPointerId, {
322
+ onMove: (ray) => {
323
+ spatialRay.copy(ray)
324
+ processMove(
325
+ new PointerEvent('pointermove', {
326
+ button: 0,
327
+ buttons: 1,
328
+ altKey,
329
+ pointerId: event.pointerId,
330
+ pointerType: 'xr',
331
+ }),
332
+ )
333
+ },
334
+ onRelease: onUp,
335
+ onCancel,
336
+ })
337
+ } else {
338
+ window.addEventListener('pointermove', onMove)
339
+ window.addEventListener('pointerup', onUp)
340
+ window.addEventListener('pointercancel', onCancel)
341
+ }
270
342
  window.addEventListener('keydown', onKeyDown, true)
271
343
  window.addEventListener('keyup', onKeyUp, true)
272
344
  }
@@ -19,6 +19,7 @@ import {
19
19
  SceneEnvironment,
20
20
  useViewer,
21
21
  Viewer,
22
+ type ViewerImmersiveSession,
22
23
  ViewerPresentations,
23
24
  } from '@pascal-app/viewer'
24
25
  import {
@@ -49,6 +50,7 @@ import {
49
50
  writePersistedSelection,
50
51
  } from '../../lib/scene'
51
52
  import { disposeSFXBus, initSFXBus } from '../../lib/sfx-bus'
53
+ import { useUnitFocusRules } from '../../lib/units'
52
54
  import { type CameraHintAction, useCameraHintFocus } from '../../store/use-camera-hint-focus'
53
55
  import useEditor from '../../store/use-editor'
54
56
  import useFloorplanMode from '../../store/use-floorplan-mode'
@@ -201,6 +203,7 @@ export interface EditorProps {
201
203
  projectId?: string | null
202
204
 
203
205
  // Persistence — defaults to localStorage when omitted
206
+ guardAgainstSceneWipe?: boolean
204
207
  onLoad?: () => Promise<SceneGraph | null>
205
208
  onSave?: (scene: SceneGraph, options?: { keepalive?: boolean }) => Promise<void>
206
209
  /**
@@ -233,6 +236,9 @@ export interface EditorProps {
233
236
  */
234
237
  disablePostFx?: boolean
235
238
 
239
+ /** Host-provided immersive XR runtime for the main 3D canvas. */
240
+ immersive?: ViewerImmersiveSession
241
+
236
242
  // Version preview overlays (rendered by host app)
237
243
  sidebarOverlay?: ReactNode
238
244
  viewerBanner?: ReactNode
@@ -782,6 +788,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
782
788
  isVersionPreviewMode,
783
789
  isLoading,
784
790
  isFirstPersonMode,
791
+ isXRMode,
785
792
  isStudioMode,
786
793
  onThumbnailCapture,
787
794
  viewerSceneSlot,
@@ -790,6 +797,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
790
797
  isVersionPreviewMode: boolean
791
798
  isLoading: boolean
792
799
  isFirstPersonMode: boolean
800
+ isXRMode: boolean
793
801
  isStudioMode: boolean
794
802
  onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
795
803
  viewerSceneSlot?: ReactNode
@@ -806,7 +814,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
806
814
  <>
807
815
  <SceneEnvironment />
808
816
  {!(isFirstPersonMode || isStudioMode || isCaptureMode) && <SelectionManager />}
809
- {!noEditing && <BoxSelectTool />}
817
+ {!(noEditing || isXRMode) && <BoxSelectTool />}
810
818
  {!noEditing && <NodeArrowHandles />}
811
819
  {!noEditing && <GroupRotateHandle />}
812
820
  {!noEditing && <GroupSelectionBox3D />}
@@ -814,10 +822,10 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
814
822
  {!noEditing && <SlabHoleHighlights />}
815
823
  {!noEditing && <WallMoveSideHandles />}
816
824
  {!noEditing && <FenceTangentLines3D />}
817
- {!noEditing && <FloatingActionMenu />}
818
- {!noEditing && <GroupFloatingActionMenu />}
819
- {!noEditing && <FloatingBuildingActionMenu />}
820
- {!isFirstPersonMode && <WallMeasurementLabel />}
825
+ {!(noEditing || isXRMode) && <FloatingActionMenu />}
826
+ {!(noEditing || isXRMode) && <GroupFloatingActionMenu />}
827
+ {!(noEditing || isXRMode) && <FloatingBuildingActionMenu />}
828
+ {!(isFirstPersonMode || isXRMode) && <WallMeasurementLabel />}
821
829
  <ExportManager />
822
830
  {isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
823
831
  <CeilingSystem />
@@ -828,10 +836,10 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
828
836
  {!(isLoading || isFirstPersonMode) && <SnapAwareGrid />}
829
837
  {!(isLoading || noEditing) && <ToolManager />}
830
838
  {isFirstPersonMode && <FirstPersonControls />}
831
- {isCaptureMode && <CaptureCameraRig />}
832
- <CustomCameraControls />
833
- <ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
834
- {!isFirstPersonMode && <SiteEdgeLabels />}
839
+ {isCaptureMode && !isXRMode && <CaptureCameraRig />}
840
+ {!isXRMode && <CustomCameraControls />}
841
+ {!isXRMode && <ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />}
842
+ {!(isFirstPersonMode || isXRMode) && <SiteEdgeLabels />}
835
843
  <InteractiveSystem />
836
844
  {presentationsReady ? <ViewerPresentations /> : null}
837
845
  {!noEditing && viewerSceneSlot}
@@ -1021,6 +1029,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
1021
1029
  viewerSceneSlot,
1022
1030
  floorplanSceneSlot,
1023
1031
  disablePostFx = false,
1032
+ immersive,
1024
1033
  }: {
1025
1034
  isVersionPreviewMode: boolean
1026
1035
  isLoading: boolean
@@ -1035,12 +1044,17 @@ const ViewerCanvas = memo(function ViewerCanvas({
1035
1044
  viewerSceneSlot?: ReactNode
1036
1045
  floorplanSceneSlot?: ReactNode
1037
1046
  disablePostFx?: boolean
1047
+ immersive?: ViewerImmersiveSession
1038
1048
  }) {
1039
1049
  const viewMode = useEditor((s) => s.viewMode)
1040
1050
  const floorplanPaneRatio = useEditor((s) => s.floorplanPaneRatio)
1041
1051
  const setFloorplanPaneRatio = useEditor((s) => s.setFloorplanPaneRatio)
1042
1052
  const isPreviewMode = useEditor((s) => s.isPreviewMode)
1043
1053
  const isCaptureMode = useEditor((s) => s.isCaptureMode)
1054
+ useUnitFocusRules()
1055
+ const presetIsolation = useEditor((s) =>
1056
+ s.captureMode.mode === 'preset' ? s.captureMode.isolated : null,
1057
+ )
1044
1058
 
1045
1059
  const [isCameraControlsHintVisible, setIsCameraControlsHintVisible] = useState<boolean | null>(
1046
1060
  null,
@@ -1151,10 +1165,15 @@ const ViewerCanvas = memo(function ViewerCanvas({
1151
1165
  defaultRender={EDITOR_DEFAULT_RENDER}
1152
1166
  disablePostFx={disablePostFx}
1153
1167
  hoverStyles={EDITOR_HOVER_STYLES}
1168
+ isolate={presetIsolation}
1169
+ // Preset captures isolate one subtree and keep the exterior transparent.
1170
+ // Other modes retain the viewer's configured background policy.
1171
+ transparent={presetIsolation === null ? undefined : true}
1154
1172
  onSceneReadyChange={onSceneReadyChange}
1155
1173
  renderContext="editor"
1156
1174
  renderPaused={!show3d && !showLoader}
1157
1175
  sceneReadyKey={sceneReadyKey}
1176
+ immersive={immersive}
1158
1177
  // Walk/drone framing during snapshot capture is camera-only: the
1159
1178
  // viewer's default selection manager would hover-highlight whatever
1160
1179
  // the cursor crosses, which orbit capture never does.
@@ -1163,6 +1182,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
1163
1182
  <ViewerSceneContent
1164
1183
  isFirstPersonMode={isFirstPersonMode}
1165
1184
  isLoading={showLoader}
1185
+ isXRMode={immersive != null}
1166
1186
  isStudioMode={isStudioMode}
1167
1187
  isVersionPreviewMode={isVersionPreviewMode}
1168
1188
  onThumbnailCapture={onThumbnailCapture}
@@ -1232,6 +1252,7 @@ function PreviewStage({
1232
1252
  }
1233
1253
 
1234
1254
  function EditorContent({
1255
+ guardAgainstSceneWipe,
1235
1256
  layoutVersion = 'v1',
1236
1257
  appMenuButton,
1237
1258
  sidebarTop,
@@ -1256,6 +1277,7 @@ function EditorContent({
1256
1277
  onLoaderChange,
1257
1278
  onThumbnailCapture,
1258
1279
  disablePostFx = false,
1280
+ immersive,
1259
1281
  sidebarOverlay,
1260
1282
  viewerBanner,
1261
1283
  settingsPanelProps,
@@ -1291,6 +1313,7 @@ function EditorContent({
1291
1313
  useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode || isStudioMode })
1292
1314
 
1293
1315
  const { isLoadingSceneRef, saveNow } = useAutoSave({
1316
+ guardAgainstSceneWipe,
1294
1317
  onSave,
1295
1318
  onDirty,
1296
1319
  onSaveStatusChange,
@@ -1347,10 +1370,9 @@ function EditorContent({
1347
1370
 
1348
1371
  // Load on mount, project switches, and explicit retry attempts.
1349
1372
  useEffect(() => {
1350
- void sceneLoadAttempt
1351
1373
  let cancelled = false
1352
1374
 
1353
- async function load() {
1375
+ async function load(attempt: number) {
1354
1376
  isLoadingSceneRef.current = true
1355
1377
  setSceneLoadError(null)
1356
1378
  setHasLoadedInitialScene(false)
@@ -1364,7 +1386,7 @@ function EditorContent({
1364
1386
  let failed = false
1365
1387
  try {
1366
1388
  const sceneGraph = onLoad ? await onLoad() : loadSceneFromLocalStorage()
1367
- if (!cancelled) {
1389
+ if (!cancelled && attempt === sceneLoadAttempt) {
1368
1390
  applySceneGraphToEditor(sceneGraph)
1369
1391
  setIsViewerSceneReady(false)
1370
1392
  setSceneReadyKey((key) => key + 1)
@@ -1390,7 +1412,7 @@ function EditorContent({
1390
1412
  }
1391
1413
  }
1392
1414
 
1393
- load()
1415
+ load(sceneLoadAttempt)
1394
1416
 
1395
1417
  return () => {
1396
1418
  cancelled = true
@@ -1534,6 +1556,7 @@ function EditorContent({
1534
1556
  showLoader={showLoader}
1535
1557
  viewerSceneSlot={viewerSceneSlot}
1536
1558
  floorplanSceneSlot={floorplanSceneSlot}
1559
+ immersive={immersive}
1537
1560
  />
1538
1561
  )
1539
1562
 
@@ -3,6 +3,7 @@
3
3
  import { Icon } from '@iconify/react'
4
4
  import { Copy, Group, Move, PencilRuler, Search, Spline, Trash2, Ungroup } from 'lucide-react'
5
5
  import type { MouseEventHandler, PointerEventHandler } from 'react'
6
+ import { RegistryActionContributions } from './registry-action-contributions'
6
7
 
7
8
  type NodeActionMenuProps = {
8
9
  onFind?: MouseEventHandler<HTMLButtonElement>
@@ -111,6 +112,7 @@ export function NodeActionMenu({
111
112
  <Spline className="h-4 w-4" />
112
113
  </button>
113
114
  )}
115
+ <RegistryActionContributions />
114
116
  {onDuplicate && (
115
117
  <button
116
118
  aria-label="Duplicate"