@pascal-app/editor 1.0.0-beta.5 → 1.0.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 (245) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +13 -7
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +1 -0
  6. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  7. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  8. package/src/components/editor/capture-camera-rig.tsx +59 -0
  9. package/src/components/editor/custom-camera-controls.tsx +20 -11
  10. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  11. package/src/components/editor/editor-layout-v2.tsx +9 -2
  12. package/src/components/editor/export-manager.tsx +175 -33
  13. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  14. package/src/components/editor/first-person-controls.tsx +180 -12
  15. package/src/components/editor/floating-action-menu.tsx +42 -51
  16. package/src/components/editor/floorplan-panel.tsx +76 -12
  17. package/src/components/editor/grid.tsx +12 -7
  18. package/src/components/editor/group-actions.ts +34 -50
  19. package/src/components/editor/group-move-3d.ts +51 -21
  20. package/src/components/editor/group-transform-shared.test.ts +78 -0
  21. package/src/components/editor/group-transform-shared.ts +74 -7
  22. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  23. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  24. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  25. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  26. package/src/components/editor/handles/resize-snap.ts +10 -1
  27. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  28. package/src/components/editor/index.tsx +140 -21
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +47 -58
  31. package/src/components/editor/selection-manager.tsx +104 -42
  32. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  33. package/src/components/editor/snapshot-capture.test.ts +422 -0
  34. package/src/components/editor/snapshot-capture.ts +145 -0
  35. package/src/components/editor/thumbnail-generator.tsx +284 -237
  36. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  37. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  38. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  39. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  41. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  42. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  43. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  44. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  45. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  46. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +180 -32
  47. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  48. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  49. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  51. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  52. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  53. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  54. package/src/components/systems/selection-affordance-services.ts +19 -0
  55. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  56. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  57. package/src/components/tools/fence/fence-drafting.ts +6 -25
  58. package/src/components/tools/item/draft-creation.test.ts +12 -0
  59. package/src/components/tools/item/draft-creation.ts +10 -0
  60. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  61. package/src/components/tools/item/face-host-commit.ts +56 -0
  62. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  63. package/src/components/tools/item/face-host-preview.ts +82 -0
  64. package/src/components/tools/item/move-tool.tsx +4 -3
  65. package/src/components/tools/item/placement-math.test.ts +11 -1
  66. package/src/components/tools/item/placement-math.ts +0 -20
  67. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  68. package/src/components/tools/item/placement-strategies.ts +135 -2
  69. package/src/components/tools/item/placement-surface.test.ts +61 -0
  70. package/src/components/tools/item/placement-surface.ts +24 -0
  71. package/src/components/tools/item/placement-types.ts +6 -0
  72. package/src/components/tools/item/test-face-host.ts +121 -0
  73. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  74. package/src/components/tools/item/use-draft-node.ts +26 -4
  75. package/src/components/tools/item/use-placement-coordinator.tsx +428 -153
  76. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  77. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  78. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  79. package/src/components/tools/registry-tool-context.tsx +30 -0
  80. package/src/components/tools/select/box-select-tool.tsx +3 -1
  81. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  82. package/src/components/tools/select/marquee-footprint.ts +29 -0
  83. package/src/components/tools/shared/placement-box.tsx +57 -7
  84. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  85. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  86. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  89. package/src/components/tools/stair/stair-tool.tsx +127 -74
  90. package/src/components/tools/tool-manager.tsx +25 -8
  91. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  92. package/src/components/tools/wall/wall-drafting.ts +23 -114
  93. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  94. package/src/components/ui/action-menu/index.tsx +11 -1
  95. package/src/components/ui/command-palette/editor-commands.tsx +9 -12
  96. package/src/components/ui/command-palette/index.tsx +7 -2
  97. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  98. package/src/components/ui/controls/material-picker.tsx +4 -4
  99. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  100. package/src/components/ui/controls/segmented-control.tsx +8 -1
  101. package/src/components/ui/controls/slider-control.tsx +19 -3
  102. package/src/components/ui/controls/toggle-control.tsx +18 -5
  103. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  104. package/src/components/ui/floating-level-selector.tsx +22 -5
  105. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  106. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  107. package/src/components/ui/helpers/item-helper.tsx +1 -1
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  109. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  110. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  111. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  112. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  113. package/src/components/ui/panels/multi-field-value.ts +163 -0
  114. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  115. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  116. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  117. package/src/components/ui/panels/node-display.ts +4 -0
  118. package/src/components/ui/panels/panel-manager.tsx +77 -3
  119. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  120. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  121. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  122. package/src/components/ui/panels/reference-panel.tsx +31 -1
  123. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  124. package/src/components/ui/scene-loader.tsx +35 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  127. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  128. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  129. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  130. package/src/components/ui/sidebar/panels/site-panel/index.tsx +116 -46
  131. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  132. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  134. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +14 -6
  135. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  136. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  137. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  138. package/src/components/viewer/viewer-stage.tsx +4 -1
  139. package/src/components/viewer-overlay.tsx +1 -22
  140. package/src/components/viewer-zone-system.tsx +6 -2
  141. package/src/components/walkthrough-hud.tsx +19 -10
  142. package/src/hooks/use-auto-save.test.ts +73 -1
  143. package/src/hooks/use-auto-save.ts +76 -5
  144. package/src/hooks/use-ceiling-events.test.ts +122 -0
  145. package/src/hooks/use-drag-action.ts +17 -3
  146. package/src/hooks/use-grid-events.test.ts +240 -0
  147. package/src/hooks/use-grid-events.ts +190 -11
  148. package/src/hooks/use-keyboard.test.ts +244 -0
  149. package/src/hooks/use-keyboard.ts +129 -62
  150. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  151. package/src/hooks/use-save-shortcut.ts +33 -0
  152. package/src/index.tsx +82 -4
  153. package/src/lib/active-placement-surface.test.ts +16 -0
  154. package/src/lib/active-placement-surface.ts +14 -0
  155. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  156. package/src/lib/ceiling-surface-raycast.ts +12 -0
  157. package/src/lib/contextual-help-extension.ts +17 -0
  158. package/src/lib/continuation.test.ts +11 -0
  159. package/src/lib/continuation.ts +8 -1
  160. package/src/lib/direct-manipulation.test.ts +132 -0
  161. package/src/lib/direct-manipulation.ts +38 -1
  162. package/src/lib/editor-api.ts +6 -3
  163. package/src/lib/elevation-guides.test.ts +28 -1
  164. package/src/lib/elevation-guides.ts +22 -7
  165. package/src/lib/floating-menu-scale.test.ts +29 -0
  166. package/src/lib/floating-menu-scale.ts +14 -0
  167. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  168. package/src/lib/floorplan/apply-alignment.ts +10 -1
  169. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  170. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  171. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  172. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  173. package/src/lib/floorplan/floorplan-readonly.ts +1 -0
  174. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  175. package/src/lib/floorplan-grid-event-point.ts +13 -0
  176. package/src/lib/glb-export.test.ts +1169 -21
  177. package/src/lib/glb-export.ts +629 -101
  178. package/src/lib/grid-event-presentation.ts +16 -0
  179. package/src/lib/history.test.ts +528 -147
  180. package/src/lib/history.ts +81 -7
  181. package/src/lib/host-tree-children.test.ts +27 -0
  182. package/src/lib/host-tree-children.ts +79 -0
  183. package/src/lib/interaction/hot-set.test.ts +10 -1
  184. package/src/lib/interaction/hot-set.ts +3 -1
  185. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  186. package/src/lib/interaction/overlay-policy.ts +16 -0
  187. package/src/lib/interaction/registered-drafting.ts +36 -0
  188. package/src/lib/interaction/scope.ts +31 -0
  189. package/src/lib/level-print-export.test.ts +484 -0
  190. package/src/lib/level-print-export.ts +526 -0
  191. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  192. package/src/lib/local-project-presentation-persistence.ts +335 -0
  193. package/src/lib/material-paint.ts +2 -0
  194. package/src/lib/model-export.ts +29 -0
  195. package/src/lib/paint-preview-owner.ts +71 -0
  196. package/src/lib/planar-cursor-placement.test.ts +178 -1
  197. package/src/lib/planar-cursor-placement.ts +61 -5
  198. package/src/lib/plugin-panels.test.ts +92 -1
  199. package/src/lib/plugin-panels.ts +48 -0
  200. package/src/lib/portable-export.test.ts +366 -0
  201. package/src/lib/portable-export.ts +969 -0
  202. package/src/lib/print-3mf.test.ts +86 -0
  203. package/src/lib/print-3mf.ts +161 -0
  204. package/src/lib/print-content-scope.test.ts +107 -0
  205. package/src/lib/print-content-scope.ts +90 -0
  206. package/src/lib/print-export.test.ts +294 -0
  207. package/src/lib/print-export.ts +735 -0
  208. package/src/lib/print-feature-thickness.test.ts +147 -0
  209. package/src/lib/print-feature-thickness.ts +180 -0
  210. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  211. package/src/lib/print-golden-house.test.ts +249 -0
  212. package/src/lib/print-roof-solids.test.ts +133 -0
  213. package/src/lib/print-roof-solids.ts +630 -0
  214. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  215. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  216. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  217. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  218. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  219. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  220. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  221. package/src/lib/print-shell-compiler.ts +273 -0
  222. package/src/lib/print-wall-solids.test.ts +194 -0
  223. package/src/lib/print-wall-solids.ts +328 -0
  224. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  225. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  226. package/src/lib/scene.ts +22 -8
  227. package/src/lib/selection-routing.test.ts +105 -1
  228. package/src/lib/selection-routing.ts +19 -0
  229. package/src/lib/sfx-bus.ts +4 -0
  230. package/src/lib/sfx-player.ts +8 -0
  231. package/src/lib/snapping-mode.test.ts +32 -4
  232. package/src/lib/snapping-mode.ts +11 -7
  233. package/src/lib/usdz-export.ts +38 -0
  234. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  235. package/src/store/live-draft-preview-stores.test.ts +3 -0
  236. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  237. package/src/store/tool-mode.test.ts +87 -0
  238. package/src/store/use-camera-hint-focus.ts +48 -0
  239. package/src/store/use-editor.tsx +299 -188
  240. package/src/store/use-floorplan-draft-preview.ts +9 -0
  241. package/src/store/use-interaction-scope.test.ts +23 -1
  242. package/src/store/use-interaction-scope.ts +63 -2
  243. package/src/store/use-placement-preview.ts +56 -3
  244. package/src/components/tools/roof/roof-tool.tsx +0 -656
  245. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -14,15 +14,33 @@ import {
14
14
  import {
15
15
  type HoverStyles,
16
16
  InteractiveSystem,
17
+ PERF_OVERLAY_ENABLED,
18
+ recordPerfSample,
17
19
  SceneEnvironment,
18
20
  useViewer,
19
21
  Viewer,
22
+ ViewerPresentations,
20
23
  } from '@pascal-app/viewer'
21
- import { memo, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
24
+ import {
25
+ memo,
26
+ Profiler,
27
+ type ProfilerOnRenderCallback,
28
+ type ReactNode,
29
+ useCallback,
30
+ useEffect,
31
+ useLayoutEffect,
32
+ useRef,
33
+ useState,
34
+ } from 'react'
22
35
  import { ViewerOverlay } from '../../components/viewer-overlay'
23
36
  import { ViewerZoneSystem } from '../../components/viewer-zone-system'
24
37
  import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save'
25
38
  import { useKeyboard } from '../../hooks/use-keyboard'
39
+ import { useSaveShortcut } from '../../hooks/use-save-shortcut'
40
+ import {
41
+ createLocalProjectPresentationPersistence,
42
+ type LocalProjectPresentationPersistence,
43
+ } from '../../lib/local-project-presentation-persistence'
26
44
  import { type ActivePaintMaterial, hasActivePaintMaterial } from '../../lib/material-paint'
27
45
  import {
28
46
  applySceneGraphToEditor,
@@ -31,6 +49,7 @@ import {
31
49
  writePersistedSelection,
32
50
  } from '../../lib/scene'
33
51
  import { disposeSFXBus, initSFXBus } from '../../lib/sfx-bus'
52
+ import { type CameraHintAction, useCameraHintFocus } from '../../store/use-camera-hint-focus'
34
53
  import useEditor from '../../store/use-editor'
35
54
  import useFloorplanMode from '../../store/use-floorplan-mode'
36
55
  import useSessionGroups from '../../store/use-session-groups'
@@ -52,7 +71,7 @@ import { PanelManager } from '../ui/panels/panel-manager'
52
71
  import { ErrorBoundary } from '../ui/primitives/error-boundary'
53
72
  import { useSidebarStore } from '../ui/primitives/sidebar'
54
73
  import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip'
55
- import { SceneLoader } from '../ui/scene-loader'
74
+ import { SceneLoader, SceneLoadFailed } from '../ui/scene-loader'
56
75
  import { AppSidebar } from '../ui/sidebar/app-sidebar'
57
76
  import type { ExtraPanel } from '../ui/sidebar/icon-rail'
58
77
  import { SettingsPanel, type SettingsPanelProps } from '../ui/sidebar/panels/settings-panel'
@@ -61,6 +80,7 @@ import type { SidebarTab } from '../ui/sidebar/tab-bar'
61
80
  import { useHostPanels } from '../ui/sidebar/use-plugin-panels'
62
81
  import { ViewerStage } from '../viewer/viewer-stage'
63
82
  import type { ViewerStageMode } from '../viewer/viewer-stage-modes'
83
+ import { CaptureCameraRig } from './capture-camera-rig'
64
84
  import { CustomCameraControls } from './custom-camera-controls'
65
85
  import { DeleteConfirmationDialog } from './delete-confirmation-dialog'
66
86
  import { EditorLayoutV2 } from './editor-layout-v2'
@@ -98,7 +118,12 @@ const PAINT_CURSOR_BADGE_DISABLED_COLOR = '#94a3b8'
98
118
  const PAINT_CURSOR_BADGE_OFFSET_X = 14
99
119
  const PAINT_CURSOR_BADGE_OFFSET_Y = 14
100
120
  const SCENE_READY_FALLBACK_MS = 8000
121
+ const PRESENTATION_PROJECT_NOT_RESTORED = Symbol('presentation-project-not-restored')
122
+ const useClientLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect
101
123
  type PaintCursorBadgeState = 'empty' | 'ready' | 'blocked'
124
+ const recordEditorRender: ProfilerOnRenderCallback = (_id, _phase, actualDuration) => {
125
+ if (PERF_OVERLAY_ENABLED) recordPerfSample('react-render', actualDuration)
126
+ }
102
127
  const EDITOR_HOVER_STYLES: HoverStyles = {
103
128
  default: { visibleColor: 0x00_aa_ff, hiddenColor: 0xf3_ff_47, strength: 5, pulse: true },
104
129
  delete: { visibleColor: 0xef_44_44, hiddenColor: 0x99_1b_1b, strength: 6, pulse: false },
@@ -178,6 +203,12 @@ export interface EditorProps {
178
203
  // Persistence — defaults to localStorage when omitted
179
204
  onLoad?: () => Promise<SceneGraph | null>
180
205
  onSave?: (scene: SceneGraph, options?: { keepalive?: boolean }) => Promise<void>
206
+ /**
207
+ * Cmd/Ctrl+S. Return true when the host handled the save (the community
208
+ * version checkpoint); anything else falls through to flushing the autosave,
209
+ * so the chord still saves when the host's control isn't mounted.
210
+ */
211
+ onSaveShortcut?: () => boolean | undefined
181
212
  onDirty?: () => void
182
213
  onSaveStatusChange?: (status: SaveStatus) => void
183
214
 
@@ -380,7 +411,7 @@ type ShortcutKey = {
380
411
  }
381
412
 
382
413
  type CameraControlHint = {
383
- action: string
414
+ action: CameraHintAction
384
415
  keys: ShortcutKey[]
385
416
  alternativeKeys?: ShortcutKey[]
386
417
  }
@@ -514,7 +545,15 @@ function ViewerCanvasControlsHint({
514
545
  isPreviewMode: boolean
515
546
  onDismiss: () => void
516
547
  }) {
517
- const hints = isPreviewMode ? PREVIEW_CAMERA_CONTROL_HINTS : EDITOR_CAMERA_CONTROL_HINTS
548
+ const all = isPreviewMode ? PREVIEW_CAMERA_CONTROL_HINTS : EDITOR_CAMERA_CONTROL_HINTS
549
+ // A host teaching one gesture at a time narrows this to the one it is asking
550
+ // for, and to nothing once it is done. Null — the default — is all of them.
551
+ const focus = useCameraHintFocus((state) => state.actions)
552
+ const hints = focus === null ? all : all.filter((hint) => focus.includes(hint.action))
553
+
554
+ if (hints.length === 0) {
555
+ return null
556
+ }
518
557
 
519
558
  return (
520
559
  <div className="pointer-events-none absolute top-14 left-1/2 z-40 max-w-[calc(100%-2rem)] -translate-x-1/2">
@@ -522,7 +561,10 @@ function ViewerCanvasControlsHint({
522
561
  aria-label="Camera controls hint"
523
562
  className="pointer-events-auto flex items-start gap-3 rounded-2xl border border-border/35 bg-background/90 px-3.5 py-2.5 shadow-elevation-4 backdrop-blur-xl"
524
563
  >
525
- <div className="grid min-w-0 flex-1 grid-cols-3 items-start divide-x divide-border/18">
564
+ <div
565
+ className="grid min-w-0 flex-1 items-start divide-x divide-border/18"
566
+ style={{ gridTemplateColumns: `repeat(${hints.length}, minmax(0, 1fr))` }}
567
+ >
526
568
  {hints.map((hint) => (
527
569
  <CameraControlHintItem hint={hint} key={hint.action} />
528
570
  ))}
@@ -743,6 +785,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
743
785
  isStudioMode,
744
786
  onThumbnailCapture,
745
787
  viewerSceneSlot,
788
+ presentationsReady,
746
789
  }: {
747
790
  isVersionPreviewMode: boolean
748
791
  isLoading: boolean
@@ -750,6 +793,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
750
793
  isStudioMode: boolean
751
794
  onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
752
795
  viewerSceneSlot?: ReactNode
796
+ presentationsReady: boolean
753
797
  }) {
754
798
  // Studio mode is a clean render/snapshot surface — no selection or editing
755
799
  // affordances. It mirrors version-preview's chrome gating on the canvas.
@@ -784,10 +828,12 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
784
828
  {!(isLoading || isFirstPersonMode) && <SnapAwareGrid />}
785
829
  {!(isLoading || noEditing) && <ToolManager />}
786
830
  {isFirstPersonMode && <FirstPersonControls />}
831
+ {isCaptureMode && <CaptureCameraRig />}
787
832
  <CustomCameraControls />
788
833
  <ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
789
834
  {!isFirstPersonMode && <SiteEdgeLabels />}
790
835
  <InteractiveSystem />
836
+ {presentationsReady ? <ViewerPresentations /> : null}
791
837
  {!noEditing && viewerSceneSlot}
792
838
  </>
793
839
  )
@@ -971,6 +1017,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
971
1017
  sceneReadyKey,
972
1018
  onSceneReadyChange,
973
1019
  onThumbnailCapture,
1020
+ presentationsReady,
974
1021
  viewerSceneSlot,
975
1022
  floorplanSceneSlot,
976
1023
  disablePostFx = false,
@@ -984,6 +1031,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
984
1031
  sceneReadyKey: number
985
1032
  onSceneReadyChange: (ready: boolean) => void
986
1033
  onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
1034
+ presentationsReady: boolean
987
1035
  viewerSceneSlot?: ReactNode
988
1036
  floorplanSceneSlot?: ReactNode
989
1037
  disablePostFx?: boolean
@@ -992,6 +1040,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
992
1040
  const floorplanPaneRatio = useEditor((s) => s.floorplanPaneRatio)
993
1041
  const setFloorplanPaneRatio = useEditor((s) => s.setFloorplanPaneRatio)
994
1042
  const isPreviewMode = useEditor((s) => s.isPreviewMode)
1043
+ const isCaptureMode = useEditor((s) => s.isCaptureMode)
995
1044
 
996
1045
  const [isCameraControlsHintVisible, setIsCameraControlsHintVisible] = useState<boolean | null>(
997
1046
  null,
@@ -1106,7 +1155,10 @@ const ViewerCanvas = memo(function ViewerCanvas({
1106
1155
  renderContext="editor"
1107
1156
  renderPaused={!show3d && !showLoader}
1108
1157
  sceneReadyKey={sceneReadyKey}
1109
- selectionManager={isFirstPersonMode ? 'default' : 'custom'}
1158
+ // Walk/drone framing during snapshot capture is camera-only: the
1159
+ // viewer's default selection manager would hover-highlight whatever
1160
+ // the cursor crosses, which orbit capture never does.
1161
+ selectionManager={isFirstPersonMode && !isCaptureMode ? 'default' : 'custom'}
1110
1162
  >
1111
1163
  <ViewerSceneContent
1112
1164
  isFirstPersonMode={isFirstPersonMode}
@@ -1114,6 +1166,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
1114
1166
  isStudioMode={isStudioMode}
1115
1167
  isVersionPreviewMode={isVersionPreviewMode}
1116
1168
  onThumbnailCapture={onThumbnailCapture}
1169
+ presentationsReady={presentationsReady}
1117
1170
  viewerSceneSlot={viewerSceneSlot}
1118
1171
  />
1119
1172
  </Viewer>
@@ -1178,7 +1231,7 @@ function PreviewStage({
1178
1231
  )
1179
1232
  }
1180
1233
 
1181
- export default function Editor({
1234
+ function EditorContent({
1182
1235
  layoutVersion = 'v1',
1183
1236
  appMenuButton,
1184
1237
  sidebarTop,
@@ -1194,6 +1247,7 @@ export default function Editor({
1194
1247
  projectId,
1195
1248
  onLoad,
1196
1249
  onSave,
1250
+ onSaveShortcut,
1197
1251
  onDirty,
1198
1252
  onSaveStatusChange,
1199
1253
  previewScene,
@@ -1211,18 +1265,51 @@ export default function Editor({
1211
1265
  }: EditorProps) {
1212
1266
  const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
1213
1267
  const isStudioMode = useEditor((s) => s.workspaceMode === 'studio')
1268
+ const presentationProjectId = projectId ?? null
1269
+ const presentationPersistenceRef = useRef<LocalProjectPresentationPersistence | null>(null)
1270
+ const [restoredPresentationProjectId, setRestoredPresentationProjectId] = useState<
1271
+ string | null | typeof PRESENTATION_PROJECT_NOT_RESTORED
1272
+ >(PRESENTATION_PROJECT_NOT_RESTORED)
1273
+ const presentationsReady = restoredPresentationProjectId === presentationProjectId
1274
+
1275
+ useClientLayoutEffect(() => {
1276
+ const persistence = createLocalProjectPresentationPersistence()
1277
+ presentationPersistenceRef.current = persistence
1278
+ return () => {
1279
+ presentationPersistenceRef.current = null
1280
+ persistence.dispose()
1281
+ }
1282
+ }, [])
1283
+
1284
+ useClientLayoutEffect(() => {
1285
+ const persistence = presentationPersistenceRef.current
1286
+ if (!persistence) return
1287
+ persistence.switchProject(presentationProjectId)
1288
+ setRestoredPresentationProjectId(presentationProjectId)
1289
+ }, [presentationProjectId])
1214
1290
 
1215
1291
  useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode || isStudioMode })
1216
1292
 
1217
- const { isLoadingSceneRef } = useAutoSave({
1293
+ const { isLoadingSceneRef, saveNow } = useAutoSave({
1218
1294
  onSave,
1219
1295
  onDirty,
1220
1296
  onSaveStatusChange,
1221
1297
  isVersionPreviewMode,
1222
1298
  })
1223
1299
 
1300
+ const handleSaveShortcut = useCallback(() => {
1301
+ if (onSaveShortcut?.() === true) return
1302
+ saveNow()
1303
+ }, [onSaveShortcut, saveNow])
1304
+ useSaveShortcut(handleSaveShortcut)
1305
+
1224
1306
  const [isSceneLoading, setIsSceneLoading] = useState(false)
1225
1307
  const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false)
1308
+ // A failed `onLoad` is shown as an error with a retry, never as an empty
1309
+ // scene: an editor that renders the default scaffold after a failed load
1310
+ // autosaves that scaffold over the real project.
1311
+ const [sceneLoadError, setSceneLoadError] = useState<unknown>(null)
1312
+ const [sceneLoadAttempt, setSceneLoadAttempt] = useState(0)
1226
1313
  const [sceneReadyKey, setSceneReadyKey] = useState(0)
1227
1314
  const [isViewerSceneReady, setIsViewerSceneReady] = useState(false)
1228
1315
  const [previewStageMode, setPreviewStageMode] = useState<ViewerStageMode>('3d')
@@ -1258,12 +1345,14 @@ export default function Editor({
1258
1345
  }
1259
1346
  }, [projectId])
1260
1347
 
1261
- // Load scene on mount (or when onLoad identity changes, e.g. project switch)
1348
+ // Load on mount, project switches, and explicit retry attempts.
1262
1349
  useEffect(() => {
1350
+ void sceneLoadAttempt
1263
1351
  let cancelled = false
1264
1352
 
1265
1353
  async function load() {
1266
1354
  isLoadingSceneRef.current = true
1355
+ setSceneLoadError(null)
1267
1356
  setHasLoadedInitialScene(false)
1268
1357
  setIsViewerSceneReady(false)
1269
1358
  setIsSceneLoading(true)
@@ -1272,6 +1361,7 @@ export default function Editor({
1272
1361
  // Session groups are not scene-graph state — clear on every load/switch.
1273
1362
  useSessionGroups.getState().clearGroups()
1274
1363
 
1364
+ let failed = false
1275
1365
  try {
1276
1366
  const sceneGraph = onLoad ? await onLoad() : loadSceneFromLocalStorage()
1277
1367
  if (!cancelled) {
@@ -1279,19 +1369,23 @@ export default function Editor({
1279
1369
  setIsViewerSceneReady(false)
1280
1370
  setSceneReadyKey((key) => key + 1)
1281
1371
  }
1282
- } catch {
1372
+ } catch (error) {
1373
+ // Leave the store unloaded and the autosave loop in its loading
1374
+ // state: nothing may be written until a load actually succeeds.
1375
+ failed = true
1283
1376
  if (!cancelled) {
1284
- applySceneGraphToEditor(null)
1285
- setIsViewerSceneReady(false)
1286
- setSceneReadyKey((key) => key + 1)
1377
+ console.error('[editor] scene load failed', error)
1378
+ setSceneLoadError(error ?? new Error('Scene load failed'))
1287
1379
  }
1288
1380
  } finally {
1289
1381
  if (!cancelled) {
1290
1382
  setIsSceneLoading(false)
1291
- setHasLoadedInitialScene(true)
1292
- requestAnimationFrame(() => {
1293
- isLoadingSceneRef.current = false
1294
- })
1383
+ if (!failed) {
1384
+ setHasLoadedInitialScene(true)
1385
+ requestAnimationFrame(() => {
1386
+ isLoadingSceneRef.current = false
1387
+ })
1388
+ }
1295
1389
  }
1296
1390
  }
1297
1391
  }
@@ -1301,7 +1395,11 @@ export default function Editor({
1301
1395
  return () => {
1302
1396
  cancelled = true
1303
1397
  }
1304
- }, [onLoad, isLoadingSceneRef])
1398
+ }, [onLoad, isLoadingSceneRef, sceneLoadAttempt])
1399
+
1400
+ const retrySceneLoad = useCallback(() => {
1401
+ setSceneLoadAttempt((attempt) => attempt + 1)
1402
+ }, [])
1305
1403
 
1306
1404
  // Apply preview scene when version preview mode changes
1307
1405
  useEffect(() => {
@@ -1417,6 +1515,7 @@ export default function Editor({
1417
1515
  <CustomCameraControls />
1418
1516
  <ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
1419
1517
  <InteractiveSystem />
1518
+ {presentationsReady ? <ViewerPresentations /> : null}
1420
1519
  </Viewer>
1421
1520
  )
1422
1521
 
@@ -1430,6 +1529,7 @@ export default function Editor({
1430
1529
  isVersionPreviewMode={isVersionPreviewMode}
1431
1530
  onSceneReadyChange={handleSceneReadyChange}
1432
1531
  onThumbnailCapture={onThumbnailCapture}
1532
+ presentationsReady={presentationsReady}
1433
1533
  sceneReadyKey={sceneReadyKey}
1434
1534
  showLoader={showLoader}
1435
1535
  viewerSceneSlot={viewerSceneSlot}
@@ -1490,7 +1590,11 @@ export default function Editor({
1490
1590
  <FloorplanModeCoordinator />
1491
1591
  {visibleLoader && (
1492
1592
  <div className="fixed inset-0 z-60">
1493
- <SceneLoader className="bg-background" />
1593
+ {sceneLoadError ? (
1594
+ <SceneLoadFailed className="bg-background" onRetry={retrySceneLoad} />
1595
+ ) : (
1596
+ <SceneLoader className="bg-background" />
1597
+ )}
1494
1598
  </div>
1495
1599
  )}
1496
1600
 
@@ -1527,7 +1631,10 @@ export default function Editor({
1527
1631
  <HelperManager />
1528
1632
  </div>
1529
1633
  )}
1530
- {isFirstPersonMode && (
1634
+ {/* Capture mode drives walk / drone from its own overlay, which
1635
+ owns the framing chrome — the walkthrough HUD would both
1636
+ clutter the frame and offer a second, conflicting exit. */}
1637
+ {isFirstPersonMode && !isCaptureMode && (
1531
1638
  <FirstPersonOverlay
1532
1639
  onExit={() => useEditor.getState().setFirstPersonMode(false)}
1533
1640
  />
@@ -1563,7 +1670,11 @@ export default function Editor({
1563
1670
  <FloorplanModeCoordinator />
1564
1671
  {visibleLoader && (
1565
1672
  <div className="fixed inset-0 z-60">
1566
- <SceneLoader className="bg-background" />
1673
+ {sceneLoadError ? (
1674
+ <SceneLoadFailed className="bg-background" onRetry={retrySceneLoad} />
1675
+ ) : (
1676
+ <SceneLoader className="bg-background" />
1677
+ )}
1567
1678
  </div>
1568
1679
  )}
1569
1680
 
@@ -1613,3 +1724,11 @@ export default function Editor({
1613
1724
  </div>
1614
1725
  )
1615
1726
  }
1727
+
1728
+ export default function Editor(props: EditorProps) {
1729
+ return (
1730
+ <Profiler id="editor" onRender={recordEditorRender}>
1731
+ <EditorContent {...props} />
1732
+ </Profiler>
1733
+ )
1734
+ }
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { Icon } from '@iconify/react'
4
- import { Copy, Group, Move, Search, Spline, Trash2, Ungroup } from 'lucide-react'
4
+ import { Copy, Group, Move, PencilRuler, Search, Spline, Trash2, Ungroup } from 'lucide-react'
5
5
  import type { MouseEventHandler, PointerEventHandler } from 'react'
6
6
 
7
7
  type NodeActionMenuProps = {
@@ -10,6 +10,7 @@ type NodeActionMenuProps = {
10
10
  onDelete?: MouseEventHandler<HTMLButtonElement>
11
11
  onDuplicate?: MouseEventHandler<HTMLButtonElement>
12
12
  onMove?: MouseEventHandler<HTMLButtonElement>
13
+ onEditMesh?: MouseEventHandler<HTMLButtonElement>
13
14
  onCurve?: MouseEventHandler<HTMLButtonElement>
14
15
  /** Session group (Ctrl/Cmd+G) — multi-selection floating pill. */
15
16
  onGroup?: MouseEventHandler<HTMLButtonElement>
@@ -27,6 +28,7 @@ export function NodeActionMenu({
27
28
  onDelete,
28
29
  onDuplicate,
29
30
  onMove,
31
+ onEditMesh,
30
32
  onCurve,
31
33
  onGroup,
32
34
  onUngroup,
@@ -65,6 +67,17 @@ export function NodeActionMenu({
65
67
  <Move className="h-4 w-4" />
66
68
  </button>
67
69
  )}
70
+ {onEditMesh && (
71
+ <button
72
+ aria-label="Edit mesh"
73
+ className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
74
+ onClick={onEditMesh}
75
+ title="Edit mesh"
76
+ type="button"
77
+ >
78
+ <PencilRuler className="h-4 w-4" />
79
+ </button>
80
+ )}
68
81
  {onGroup && (
69
82
  <button
70
83
  aria-label="Group selection"
@@ -62,7 +62,7 @@ import {
62
62
  HandleArrow,
63
63
  NO_RAYCAST,
64
64
  } from './handles/handle-arrow'
65
- import { replacePreviewOverrideIds } from './handles/preview-overrides'
65
+ import { createLinearResizeDragBinding } from './handles/linear-resize-drag'
66
66
  import { resolveResizeSnapValue } from './handles/resize-snap'
67
67
  import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag'
68
68
 
@@ -235,16 +235,15 @@ export function NodeArrowHandles() {
235
235
  typeof def.handles === 'function'
236
236
  ? def.handles(node as never, descriptorSceneApi)
237
237
  : (def.handles as HandleDescriptor[])
238
- // The whole-node move-cross gizmo is gone: moving is now click-to-move on
239
- // the selected node body (see selection-manager). Drop both flavours — the
240
- // `translate` ground cross (column/roof/shelf/spawn) and the `tap-action`
241
- // `move-cross` (item/door/window/elevator/stair) — keep rotate/resize.
242
- return all.filter(
243
- (d) =>
244
- d.kind !== 'translate' &&
245
- !('shape' in d && d.shape === 'move-cross') &&
246
- (d.kind !== 'linear-resize' || d.visible?.(node as never, descriptorSceneApi) !== false),
247
- )
238
+ return all.filter((descriptor) => {
239
+ if (descriptor.kind === 'translate') return false
240
+ const visible =
241
+ 'visible' in descriptor
242
+ ? descriptor.visible?.(node as never, descriptorSceneApi)
243
+ : undefined
244
+ if ('shape' in descriptor && descriptor.shape === 'move-cross') return visible === true
245
+ return visible !== false
246
+ })
248
247
  }, [node, def, descriptorSceneApi])
249
248
 
250
249
  const shouldRender =
@@ -293,18 +292,25 @@ function NodeArrowHandlesForNode({
293
292
  descriptors: HandleDescriptor[]
294
293
  }) {
295
294
  const parentId = node.parentId ?? null
296
- const grandparentId = useScene((state) => {
297
- if (!parentId) return null
298
- const parent = state.nodes[parentId as AnyNodeId]
299
- return parent?.parentId ?? null
300
- })
301
295
 
302
296
  const portalMode: HandlePortal = descriptors.some((d) => d.portal === 'grandparent')
303
297
  ? 'grandparent'
304
298
  : 'parent'
305
299
 
300
+ const portalTargetResolver = descriptors.find(
301
+ (descriptor) => descriptor.portalTarget !== undefined,
302
+ )?.portalTarget
303
+ const descriptorSceneApi = useMemo(() => createSceneApi(useScene), [])
304
+
306
305
  // Portal target: the mesh we createPortal into.
307
- const portalTargetId = portalMode === 'grandparent' ? grandparentId : parentId
306
+ const portalTargetId = useScene((state) => {
307
+ if (portalTargetResolver) {
308
+ return portalTargetResolver(node as never, descriptorSceneApi) ?? null
309
+ }
310
+ const parentId = node.parentId ?? null
311
+ if (!parentId || portalMode === 'parent') return parentId
312
+ return state.nodes[parentId as AnyNodeId]?.parentId ?? null
313
+ })
308
314
  // Outer wrapper mirrors this mesh's local pose. For 'parent' mode the
309
315
  // outer IS the node (so handles + drag math both live in node-local).
310
316
  // For 'grandparent' the outer rides the parent and an inner group adds
@@ -710,10 +716,17 @@ function LinearArrow({
710
716
  getPointerRay(event.nativeEvent.clientX, event.nativeEvent.clientY, _resizeRay),
711
717
  ) / localToWorldScale
712
718
 
713
- const overrideId =
714
- (descriptor.kind === 'linear-resize'
715
- ? descriptor.overrideTarget?.(initialNode as never, sceneApi)
716
- : undefined) ?? nodeId
719
+ const linearBinding =
720
+ descriptor.kind === 'linear-resize'
721
+ ? createLinearResizeDragBinding({
722
+ descriptor,
723
+ initialNode,
724
+ nodeId,
725
+ sceneApi,
726
+ initialModifiers: { altKey: event.nativeEvent.altKey },
727
+ })
728
+ : null
729
+ const overrideId = linearBinding?.overrideId ?? nodeId
717
730
  const initialValue = descriptor.currentValue(initialNode)
718
731
  const minBound = resolveBound(descriptor.min, Number.NEGATIVE_INFINITY, initialNode, sceneApi)
719
732
  const maxBound = resolveBound(descriptor.max, Number.POSITIVE_INFINITY, initialNode, sceneApi)
@@ -730,14 +743,9 @@ function LinearArrow({
730
743
  // when the (snapped + clamped) value actually changes, so the cue
731
744
  // tracks real size steps instead of every sub-pixel pointer jitter.
732
745
  let lastTickValue = initialValue
733
- let previewOverrideIds = new Set<AnyNodeId>()
734
-
735
746
  return {
736
747
  overrideId,
737
- commit:
738
- descriptor.kind === 'linear-resize' && descriptor.commit
739
- ? (patch) => descriptor.commit?.(initialNode, patch, sceneApi)
740
- : undefined,
748
+ commit: linearBinding?.commit,
741
749
  onBegin: () => {
742
750
  // Always claim the handle-drag scope so the HUD knows a resize is the
743
751
  // active interaction (keeps the idle select hints off-screen). The
@@ -755,12 +763,9 @@ function LinearArrow({
755
763
  descriptor.onDragEnd?.(initialNode as never, sceneApi)
756
764
  }
757
765
  if (onDrag) useOpeningGuides.getState().clear()
758
- for (const previewId of previewOverrideIds) {
759
- useLiveNodeOverrides.getState().clear(previewId)
760
- useScene.getState().markDirty(previewId)
761
- }
766
+ linearBinding?.clearPreview()
762
767
  },
763
- move: ({ event: moveEvent, getPointerRay: getMovePointerRay }) => {
768
+ move: ({ event: moveEvent, modifiers, getPointerRay: getMovePointerRay }) => {
764
769
  const currentPointer =
765
770
  closestAxisParameterToRay(
766
771
  _resizeOriginW,
@@ -772,43 +777,27 @@ function LinearArrow({
772
777
  const linearDescriptor = descriptor.kind === 'linear-resize' ? descriptor : null
773
778
  const snappedNext = resolveResizeSnapValue({
774
779
  rawValue: rawNext,
780
+ fallbackValue: lastTickValue,
775
781
  gridSnapEnabled: linearDescriptor?.gridSnap === true,
776
- gridSnapActive: isGridSnapActive(),
782
+ gridSnapActive: isGridSnapActive() && !modifiers.altKey,
777
783
  gridSnapStep: useEditor.getState().gridSnapStep,
778
- magneticSnapActive: isMagneticSnapActive(),
784
+ magneticSnapActive: isMagneticSnapActive() && !modifiers.altKey,
779
785
  magneticSnap: linearDescriptor?.magneticSnap
780
786
  ? (value) => linearDescriptor.magneticSnap?.(initialNode, value, sceneApi) ?? value
781
787
  : undefined,
788
+ connectionSnapActive: !modifiers.altKey,
789
+ connectionSnap: linearDescriptor?.connectionSnap
790
+ ? (value) => linearDescriptor.connectionSnap?.(initialNode, value, sceneApi) ?? value
791
+ : undefined,
782
792
  })
783
793
  const next = Math.min(maxBound, Math.max(minBound, snappedNext))
784
794
  if (next !== lastTickValue) {
785
795
  lastTickValue = next
786
796
  sfxEmitter.emit('sfx:resize')
787
797
  }
788
- const patch = descriptor.apply(initialNode as never, next, sceneApi) as Partial<AnyNode>
789
- if (descriptor.kind === 'linear-resize' && descriptor.previewOverrides) {
790
- const previewEntries = descriptor.previewOverrides(initialNode as never, next, sceneApi)
791
- const nextPreviewOverrideIds = replacePreviewOverrideIds(
792
- previewOverrideIds,
793
- previewEntries,
794
- (previewId) => {
795
- useLiveNodeOverrides.getState().clear(previewId)
796
- useScene.getState().markDirty(previewId)
797
- },
798
- )
799
- useLiveNodeOverrides
800
- .getState()
801
- .setMany(
802
- previewEntries.map(([id, previewPatch]) => [
803
- id,
804
- previewPatch as Record<string, unknown>,
805
- ]),
806
- )
807
- for (const [previewId] of previewEntries) {
808
- useScene.getState().markDirty(previewId)
809
- }
810
- previewOverrideIds = nextPreviewOverrideIds
811
- }
798
+ const patch = linearBinding
799
+ ? linearBinding.apply(next, modifiers)
800
+ : (descriptor.apply(initialNode as never, next, sceneApi) as Partial<AnyNode>)
812
801
  // Let the kind publish live guides for the edge being resized.
813
802
  onDrag?.({ ...(initialNode as object), ...patch } as AnyNode, sceneApi)
814
803
  return patch