@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
@@ -0,0 +1,53 @@
1
+ import { afterEach, expect, test } from 'bun:test'
2
+ import { GROUP_ROTATE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from '../lib/contextual-help'
3
+ import useEditor, {
4
+ getActiveSnapContext,
5
+ isAngleSnapActive,
6
+ normalizePersistedEditorLayoutState,
7
+ } from './use-editor'
8
+ import useInteractionScope from './use-interaction-scope'
9
+
10
+ const initialEditor = useEditor.getState()
11
+ const initialScope = useInteractionScope.getState()
12
+ afterEach(() => {
13
+ useEditor.setState(initialEditor)
14
+ useInteractionScope.setState(initialScope)
15
+ })
16
+
17
+ for (const handle of [ROTATE_HANDLE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL]) {
18
+ test(`${handle} reads and cycles the shared mode without changing placement modes`, () => {
19
+ useInteractionScope.getState().begin({ kind: 'handle-drag', nodeId: 'item_test', handle })
20
+ const placementModes = useEditor.getState().snappingModeByContext
21
+ expect(getActiveSnapContext()).toBe('rotation')
22
+ expect(isAngleSnapActive()).toBe(true)
23
+ expect(useEditor.getState().cycleSnappingMode()).toBe('off')
24
+ expect(isAngleSnapActive()).toBe(false)
25
+ expect(useEditor.getState().snappingModeByContext.wall).toBe(placementModes.wall)
26
+ expect(useEditor.getState().snappingModeByContext.item).toBe(placementModes.item)
27
+ expect(useEditor.getState().cycleSnappingMode()).toBe('angles')
28
+ expect(isAngleSnapActive()).toBe(true)
29
+ })
30
+ }
31
+
32
+ test('older persisted preferences receive the rotation default and retain other modes', () => {
33
+ const merged = normalizePersistedEditorLayoutState({
34
+ snappingModeByContext: { wall: 'off', item: 'lines', polygon: 'grid' },
35
+ })
36
+ expect(merged?.snappingModeByContext).toEqual({
37
+ wall: 'off',
38
+ item: 'lines',
39
+ polygon: 'grid',
40
+ rotation: 'angles',
41
+ })
42
+ })
43
+
44
+ test('persisted rotation mode is retained when valid and reset when invalid', () => {
45
+ expect(
46
+ normalizePersistedEditorLayoutState({ snappingModeByContext: { rotation: 'off' } })
47
+ .snappingModeByContext.rotation,
48
+ ).toBe('off')
49
+ expect(
50
+ normalizePersistedEditorLayoutState({ snappingModeByContext: { rotation: 'grid' } })
51
+ .snappingModeByContext.rotation,
52
+ ).toBe('angles')
53
+ })
@@ -570,6 +570,7 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
570
570
  wall: defaultSnappingModeFor('wall'),
571
571
  item: defaultSnappingModeFor('item'),
572
572
  polygon: defaultSnappingModeFor('polygon'),
573
+ rotation: defaultSnappingModeFor('rotation'),
573
574
  },
574
575
  continuationByContext: {
575
576
  wall: CONTINUATION_PROFILES.wall.default,
@@ -800,8 +801,14 @@ function normalizeContinuationByContext(
800
801
  }
801
802
  }
802
803
 
803
- function normalizePersistedEditorLayoutState(
804
- state: (Partial<PersistedEditorLayoutState> & LegacyContinuationState) | null | undefined,
804
+ export function normalizePersistedEditorLayoutState(
805
+ state:
806
+ | (Omit<Partial<PersistedEditorLayoutState>, 'snappingModeByContext'> &
807
+ LegacyContinuationState & {
808
+ snappingModeByContext?: Partial<Record<SnapContext, unknown>>
809
+ })
810
+ | null
811
+ | undefined,
805
812
  ): PersistedEditorLayoutState {
806
813
  return {
807
814
  activeSidebarPanel:
@@ -821,6 +828,7 @@ function normalizePersistedEditorLayoutState(
821
828
  wall: migrateSnappingMode(state?.snappingModeByContext?.wall, 'wall'),
822
829
  item: migrateSnappingMode(state?.snappingModeByContext?.item, 'item'),
823
830
  polygon: migrateSnappingMode(state?.snappingModeByContext?.polygon, 'polygon'),
831
+ rotation: migrateSnappingMode(state?.snappingModeByContext?.rotation, 'rotation'),
824
832
  },
825
833
  continuationByContext: normalizeContinuationByContext(state),
826
834
  showReferenceFloor: state?.showReferenceFloor === true,
@@ -40,6 +40,9 @@ type FloorplanDraftPreviewState = {
40
40
  wallDraftStart: WallPlanPoint | null
41
41
  fenceDraftStart: WallPlanPoint | null
42
42
  roofDraftStart: WallPlanPoint | null
43
+ /** First corner of an open rectangle-wall draft; the live cursor is the
44
+ * opposite corner, so the rectangle tool publishes only per click. */
45
+ wallRectangleDraftStart: WallPlanPoint | null
43
46
  roofDraftQuarterTurn: boolean
44
47
  polygonDraftType: FloorplanPolygonDraftType | null
45
48
  polygonDraftPoints: WallPlanPoint[]
@@ -55,6 +58,7 @@ type FloorplanDraftPreviewState = {
55
58
  setWallDraftStart(point: WallPlanPoint | null): void
56
59
  setFenceDraftStart(point: WallPlanPoint | null): void
57
60
  setRoofDraftStart(point: WallPlanPoint | null): void
61
+ setWallRectangleDraftStart(point: WallPlanPoint | null): void
58
62
  setRoofDraftQuarterTurn(quarterTurn: boolean): void
59
63
  setPolygonDraft(type: FloorplanPolygonDraftType | null, points: readonly WallPlanPoint[]): void
60
64
  reset(): void
@@ -74,7 +78,8 @@ function setPlanPointField(
74
78
  | 'roofDraftEnd'
75
79
  | 'roofDraftStart'
76
80
  | 'wallDraftEnd'
77
- | 'wallDraftStart',
81
+ | 'wallDraftStart'
82
+ | 'wallRectangleDraftStart',
78
83
  point: WallPlanPoint | null,
79
84
  ) {
80
85
  return (
@@ -96,6 +101,7 @@ export const useFloorplanDraftPreview = create<FloorplanDraftPreviewState>((set)
96
101
  wallDraftStart: null,
97
102
  fenceDraftStart: null,
98
103
  roofDraftStart: null,
104
+ wallRectangleDraftStart: null,
99
105
  roofDraftQuarterTurn: false,
100
106
  polygonDraftType: null,
101
107
  polygonDraftPoints: [],
@@ -119,6 +125,7 @@ export const useFloorplanDraftPreview = create<FloorplanDraftPreviewState>((set)
119
125
  setWallDraftStart: (point) => set(setPlanPointField('wallDraftStart', point)),
120
126
  setFenceDraftStart: (point) => set(setPlanPointField('fenceDraftStart', point)),
121
127
  setRoofDraftStart: (point) => set(setPlanPointField('roofDraftStart', point)),
128
+ setWallRectangleDraftStart: (point) => set(setPlanPointField('wallRectangleDraftStart', point)),
122
129
  setRoofDraftQuarterTurn: (quarterTurn) =>
123
130
  set((state) =>
124
131
  state.roofDraftQuarterTurn === quarterTurn ? state : { roofDraftQuarterTurn: quarterTurn },
@@ -139,6 +146,7 @@ export const useFloorplanDraftPreview = create<FloorplanDraftPreviewState>((set)
139
146
  state.wallDraftStart === null &&
140
147
  state.fenceDraftStart === null &&
141
148
  state.roofDraftStart === null &&
149
+ state.wallRectangleDraftStart === null &&
142
150
  state.roofDraftQuarterTurn === false &&
143
151
  state.polygonDraftType === null &&
144
152
  state.polygonDraftPoints.length === 0
@@ -152,6 +160,7 @@ export const useFloorplanDraftPreview = create<FloorplanDraftPreviewState>((set)
152
160
  wallDraftStart: null,
153
161
  fenceDraftStart: null,
154
162
  roofDraftStart: null,
163
+ wallRectangleDraftStart: null,
155
164
  roofDraftQuarterTurn: false,
156
165
  polygonDraftType: null,
157
166
  polygonDraftPoints: [],
@@ -0,0 +1,4 @@
1
+ import { create } from 'zustand'
2
+ export const useHandleGroup = create<{ active: { nodeId: string; group: string } | null }>(() => ({
3
+ active: null,
4
+ }))
@@ -1,6 +1,13 @@
1
1
  'use client'
2
2
 
3
- import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core'
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ collectSubtree,
7
+ useLiveNodeOverrides,
8
+ useLiveTransforms,
9
+ useScene,
10
+ } from '@pascal-app/core'
4
11
  import {
5
12
  beginPerfAction,
6
13
  commitPerfAction,
@@ -10,6 +17,7 @@ import {
10
17
  import { useRef } from 'react'
11
18
  import { create } from 'zustand'
12
19
  import { useShallow } from 'zustand/react/shallow'
20
+ import { discardFreshPlacementSubtree } from '../lib/fresh-planar-placement'
13
21
  import {
14
22
  type ActiveInteractionScope,
15
23
  controlPointReshapeInfo,
@@ -25,6 +33,7 @@ import {
25
33
  reshapingNodeId,
26
34
  tangentReshapeInfo,
27
35
  } from '../lib/interaction/scope'
36
+ import usePlacementPreview from './use-placement-preview'
28
37
 
29
38
  // The authoritative interaction state machine. A single owner holds exactly one
30
39
  // scope at a time. `begin` enters an interaction (atomically replacing any prior
@@ -33,8 +42,21 @@ import {
33
42
  // the end of its interaction. There is no setter that can leave the store in an
34
43
  // illegal half-state: the only writable shape is `InteractionScope`.
35
44
 
45
+ type SubtreeCreation = {
46
+ rootId: AnyNodeId
47
+ node: AnyNode
48
+ hydrationId: object | null
49
+ }
50
+ type OwnedSubtree = { creation: SubtreeCreation; gesture: object }
51
+
36
52
  export type InteractionScopeState = {
37
53
  scope: InteractionScope
54
+ gesture: object | null
55
+ pendingSubtree: SubtreeCreation | null
56
+ ownedSubtree: OwnedSubtree | null
57
+ noteSubtreeCreation: (node: AnyNode) => void
58
+ adoptSubtree: (rootId: AnyNodeId) => boolean
59
+ finishSubtree: (rootId: AnyNodeId) => void
38
60
  // Enter an interaction. If one is already active it is ended first, so the
39
61
  // store is always single-owner.
40
62
  begin: (scope: ActiveInteractionScope) => void
@@ -101,9 +123,62 @@ function commitScopePerfAction(): void {
101
123
 
102
124
  const useInteractionScope = create<InteractionScopeState>((set, get) => ({
103
125
  scope: IDLE_SCOPE,
126
+ gesture: null,
127
+ pendingSubtree: null,
128
+ ownedSubtree: null,
129
+ noteSubtreeCreation: (node) =>
130
+ set({
131
+ pendingSubtree: { rootId: node.id, node, hydrationId: useScene.getState().hydrationId },
132
+ }),
133
+ adoptSubtree: (rootId) => {
134
+ const state = get()
135
+ if (state.ownedSubtree?.creation.rootId === rootId)
136
+ return (
137
+ state.ownedSubtree.gesture === state.gesture &&
138
+ isCurrentCreation(state.ownedSubtree.creation)
139
+ )
140
+ const creation = state.pendingSubtree
141
+ if (
142
+ !creation ||
143
+ creation.rootId !== rootId ||
144
+ !state.gesture ||
145
+ movingNodeOf(state.scope)?.id !== rootId ||
146
+ !isCurrentCreation(creation)
147
+ )
148
+ return false
149
+ set({ ownedSubtree: { creation, gesture: state.gesture }, pendingSubtree: null })
150
+ return true
151
+ },
152
+ finishSubtree: (rootId) =>
153
+ set((state) => {
154
+ if (state.ownedSubtree?.creation.rootId !== rootId && state.pendingSubtree?.rootId !== rootId)
155
+ return state
156
+ return {
157
+ ownedSubtree: state.ownedSubtree?.creation.rootId === rootId ? null : state.ownedSubtree,
158
+ pendingSubtree: state.pendingSubtree?.rootId === rootId ? null : state.pendingSubtree,
159
+ }
160
+ }),
104
161
  begin: (scope) => {
162
+ const state = get()
163
+ const nodeId = movingNodeOf(scope)?.id
164
+ const creation = state.ownedSubtree?.creation ?? state.pendingSubtree
165
+ if (
166
+ nodeId &&
167
+ nodeId === movingNodeOf(state.scope)?.id &&
168
+ creation?.rootId === nodeId &&
169
+ isCurrentCreation(creation)
170
+ ) {
171
+ set({ scope })
172
+ return
173
+ }
105
174
  beginScopePerfAction(scope)
106
- set({ scope })
175
+ set({
176
+ scope,
177
+ gesture: {},
178
+ ownedSubtree: null,
179
+ pendingSubtree:
180
+ get().pendingSubtree?.rootId === movingNodeOf(scope)?.id ? get().pendingSubtree : null,
181
+ })
107
182
  },
108
183
  update: (patch) =>
109
184
  set((state) => {
@@ -114,18 +189,92 @@ const useInteractionScope = create<InteractionScopeState>((set, get) => ({
114
189
  end: () => {
115
190
  if (get().scope.kind === 'idle') return
116
191
  commitScopePerfAction()
117
- set({ scope: IDLE_SCOPE })
192
+ set({ scope: IDLE_SCOPE, gesture: null, ownedSubtree: null, pendingSubtree: null })
118
193
  },
119
194
  endIf: (match) => {
120
195
  const scope = get().scope
121
196
  if (scope.kind === 'idle') return
122
197
  if (match(scope)) {
123
198
  commitScopePerfAction()
124
- set({ scope: IDLE_SCOPE })
199
+ set({ scope: IDLE_SCOPE, gesture: null, ownedSubtree: null, pendingSubtree: null })
125
200
  }
126
201
  },
127
202
  }))
128
203
 
204
+ function isCurrentCreation(creation: SubtreeCreation): boolean {
205
+ const scene = useScene.getState()
206
+ return (
207
+ scene.hydrationId === creation.hydrationId && scene.nodes[creation.rootId] === creation.node
208
+ )
209
+ }
210
+
211
+ export function isInteractionSubtreeDraft(
212
+ rootId = movingNodeOf(useInteractionScope.getState().scope)?.id,
213
+ ): boolean {
214
+ const state = useInteractionScope.getState()
215
+ const creation = state.ownedSubtree?.creation ?? state.pendingSubtree
216
+ return Boolean(
217
+ creation &&
218
+ state.gesture &&
219
+ rootId === movingNodeOf(state.scope)?.id &&
220
+ creation.rootId === rootId &&
221
+ (!state.ownedSubtree || state.ownedSubtree.gesture === state.gesture) &&
222
+ isCurrentCreation(creation),
223
+ )
224
+ }
225
+
226
+ // Track the identity of the factory-created node through scene patches, never through metadata.
227
+ // Hydration or deletion invalidates the creation even if a later scene reuses the same id.
228
+ useScene.subscribe((scene, previous) => {
229
+ const state = useInteractionScope.getState()
230
+ for (const creation of [state.pendingSubtree, state.ownedSubtree?.creation]) {
231
+ if (!creation) continue
232
+ if (
233
+ scene.hydrationId !== creation.hydrationId ||
234
+ previous.nodes[creation.rootId] !== creation.node ||
235
+ !scene.nodes[creation.rootId]
236
+ ) {
237
+ state.finishSubtree(creation.rootId)
238
+ } else creation.node = scene.nodes[creation.rootId]!
239
+ }
240
+ })
241
+
242
+ // Factory drafts belong to their gesture even before a mover mounts to adopt them.
243
+ useInteractionScope.subscribe((state, previous) => {
244
+ if (
245
+ state.gesture === previous.gesture &&
246
+ movingNodeOf(state.scope)?.id === movingNodeOf(previous.scope)?.id
247
+ )
248
+ return
249
+ const owned =
250
+ previous.ownedSubtree ??
251
+ (previous.pendingSubtree && previous.gesture
252
+ ? { creation: previous.pendingSubtree, gesture: previous.gesture }
253
+ : null)
254
+ if (
255
+ !owned ||
256
+ owned.gesture !== previous.gesture ||
257
+ movingNodeOf(previous.scope)?.id !== owned.creation.rootId ||
258
+ !isCurrentCreation(owned.creation)
259
+ )
260
+ return
261
+ const node = owned.creation.node
262
+ const subtree = collectSubtree(useScene.getState().nodes, node.id)
263
+ const temporal = useScene.temporal.getState()
264
+ const wasTracking = temporal.isTracking
265
+ temporal.pause()
266
+ try {
267
+ discardFreshPlacementSubtree(node.id)
268
+ for (const entry of subtree ? [subtree.root, ...subtree.descendants] : []) {
269
+ useLiveTransforms.getState().clear(entry.id)
270
+ useLiveNodeOverrides.getState().clear(entry.id)
271
+ }
272
+ if (usePlacementPreview.getState().node?.id === node.id) usePlacementPreview.getState().clear()
273
+ } finally {
274
+ if (wasTracking) temporal.resume()
275
+ }
276
+ })
277
+
129
278
  // Derived, reference-stable views of the active scope, replacing the legacy
130
279
  // `useEditor.activeHandleDrag` / `useEditor.editingHole` flags. `useShallow`
131
280
  // keeps the result reference-stable across unrelated scope changes, so hot-path
@@ -190,8 +339,8 @@ export const useReshapingNode = (): AnyNode | null => {
190
339
  // The node currently being placed or moved. Replaces the legacy
191
340
  // `useEditor.movingNode` flag. Unlike `useReshapingNode`, no `useRef` snapshot is
192
341
  // needed: the node is carried inline in the scope and set once at `begin`, so it
193
- // is already a stable reference for the whole gesture (nothing calls `begin` mid
194
- // drag). Returns null whenever no placing/moving interaction is active.
342
+ // remains stable until a caller explicitly re-arms the same interaction.
343
+ // Returns null whenever no placing/moving interaction is active.
195
344
  export const useMovingNode = (): AnyNode | null => useInteractionScope((s) => movingNodeOf(s.scope))
196
345
 
197
346
  // Imperative (non-React) read for event handlers / effects.
@@ -0,0 +1,21 @@
1
+ import { create } from 'zustand'
2
+
3
+ /** Progress of a "Send to Blender" hand-off. Lives outside the settings panel so
4
+ * switching tabs mid-send neither drops the lock nor loses the outcome. */
5
+ export type SendToAppStep = 'probing' | 'exporting' | 'sending' | 'importing'
6
+
7
+ export type SendToAppMessage = { tone: 'info' | 'error'; text: string }
8
+
9
+ type SendToAppState = {
10
+ step: SendToAppStep | null
11
+ message: SendToAppMessage | null
12
+ setStep: (step: SendToAppStep | null) => void
13
+ setMessage: (message: SendToAppMessage | null) => void
14
+ }
15
+
16
+ export const useSendToApp = create<SendToAppState>((set) => ({
17
+ step: null,
18
+ message: null,
19
+ setStep: (step) => set({ step }),
20
+ setMessage: (message) => set({ message }),
21
+ }))