@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,257 @@
1
+ import { afterEach, beforeEach, expect, spyOn, test } from 'bun:test'
2
+ import * as core from '@pascal-app/core'
3
+ import {
4
+ ItemNode,
5
+ nodeRegistry,
6
+ type ShelfEvent,
7
+ ShelfNode,
8
+ sceneRegistry,
9
+ useScene,
10
+ } from '@pascal-app/core'
11
+ import { Euler, Group, Quaternion, Vector3 } from 'three'
12
+ import useEditor from '../../../store/use-editor'
13
+ import useInteractionScope from '../../../store/use-interaction-scope'
14
+ import { registerHostingTestNode } from '../__fixtures__/hosting'
15
+ import { shelfSurfaceStrategy } from './placement-strategies'
16
+ import type { PlacementContext } from './placement-types'
17
+
18
+ const host = ShelfNode.parse({ id: 'shelf_resolver-host', width: 2, depth: 1, height: 2 })
19
+ const child = ItemNode.parse({
20
+ id: 'item_shelf-resolver-child',
21
+ asset: {
22
+ id: 'book',
23
+ name: 'Book',
24
+ category: 'decor',
25
+ thumbnail: '',
26
+ src: '/book.glb',
27
+ dimensions: [0.5, 0.25, 0.25],
28
+ },
29
+ rotation: [0.1, 0.2, 0.3],
30
+ })
31
+ let rows: number[]
32
+ let restoreRegistry: () => void
33
+ let savedNodes: ReturnType<typeof useScene.getState>['nodes']
34
+ let savedEditor: ReturnType<typeof useEditor.getState>
35
+ let savedScope: ReturnType<typeof useInteractionScope.getState>
36
+ let resolver: ReturnType<typeof spyOn<typeof core, 'resolveSurfacePlacement'>>
37
+
38
+ beforeEach(() => {
39
+ restoreRegistry = nodeRegistry._snapshot()
40
+ nodeRegistry._reset()
41
+ rows = [0.5, 1.5]
42
+ registerHostingTestNode({
43
+ kind: 'shelf',
44
+ schemaVersion: 1,
45
+ schema: ShelfNode,
46
+ category: 'furnish',
47
+ defaults: () => ({}),
48
+ capabilities: {
49
+ surfaces: {
50
+ custom: () => rows.map((y) => ({ position: [0, y, 0], normal: [0, 1, 0] })),
51
+ },
52
+ },
53
+ })
54
+ registerHostingTestNode({
55
+ kind: 'item',
56
+ schemaVersion: 1,
57
+ schema: ItemNode,
58
+ category: 'furnish',
59
+ snapProfile: 'item',
60
+ defaults: () => ({}),
61
+ capabilities: {},
62
+ })
63
+ savedNodes = useScene.getState().nodes
64
+ savedEditor = useEditor.getState()
65
+ savedScope = useInteractionScope.getState()
66
+ useScene.setState({ nodes: { [host.id]: host, [child.id]: child } })
67
+ useInteractionScope.setState({ scope: { kind: 'idle' } })
68
+ useEditor.setState({ mode: 'build', tool: 'item', gridSnapStep: 0.5 })
69
+ useEditor.getState().setSnappingMode('item', 'grid')
70
+ const mesh = new Group()
71
+ mesh.updateMatrixWorld(true)
72
+ sceneRegistry.nodes.set(host.id, mesh)
73
+ resolver = spyOn(core, 'resolveSurfacePlacement')
74
+ })
75
+
76
+ afterEach(() => {
77
+ resolver.mockRestore()
78
+ restoreRegistry()
79
+ sceneRegistry.nodes.delete(host.id)
80
+ useScene.setState({ nodes: savedNodes })
81
+ useEditor.setState(savedEditor)
82
+ useInteractionScope.setState(savedScope)
83
+ })
84
+
85
+ function context(draftItem = child): PlacementContext {
86
+ return {
87
+ asset: draftItem.asset,
88
+ draftItem,
89
+ state: {
90
+ surface: 'floor',
91
+ wallId: null,
92
+ roofSegmentId: null,
93
+ ceilingId: null,
94
+ surfaceItemId: null,
95
+ shelfId: null,
96
+ },
97
+ currentCursorRotationY: 0.7,
98
+ gridPosition: new Vector3(),
99
+ levelId: 'level_test',
100
+ } as PlacementContext
101
+ }
102
+
103
+ function onShelf(ctx: PlacementContext): PlacementContext {
104
+ return { ...ctx, state: { ...ctx.state, surface: 'shelf-surface', shelfId: host.id } }
105
+ }
106
+
107
+ function hit(y = 1, z = -0.1): ShelfEvent {
108
+ const object = sceneRegistry.nodes.get(host.id)!
109
+ return {
110
+ node: host,
111
+ object,
112
+ normal: [0, 1, 0],
113
+ position: object.localToWorld(new Vector3(0.37, y, z)).toArray(),
114
+ localPosition: [0.37, y, z],
115
+ nativeEvent: {},
116
+ stopPropagation() {},
117
+ } as ShelfEvent
118
+ }
119
+
120
+ test.each([
121
+ 'grid',
122
+ 'off',
123
+ ] as const)('midpoint keeps the first declared row and preserves X/Z with grid %s', (mode) => {
124
+ useEditor.getState().setSnappingMode('item', mode)
125
+ for (const declared of [
126
+ [0.5, 1.5],
127
+ [1.5, 0.5],
128
+ ]) {
129
+ rows = declared
130
+ const expected: [number, number, number] =
131
+ mode === 'grid' ? [0.25, rows[0]!, 0.125] : [0.37, rows[0]!, -0.1]
132
+ const ctx = context()
133
+ const entered = shelfSurfaceStrategy.enter(ctx, hit())!
134
+ expect(entered.nodeUpdate).toEqual({
135
+ position: expected,
136
+ parentId: host.id,
137
+ rotation: [0.1, 0.7, 0.3],
138
+ })
139
+ expect(entered.gridPosition).toEqual(expected)
140
+ expect(entered.cursorPosition).toEqual(expected)
141
+ const moved = shelfSurfaceStrategy.move(onShelf(ctx), hit())!
142
+ expect(moved.nodeUpdate).toEqual({ position: expected })
143
+ expect(moved.cursorPosition).toEqual(expected)
144
+ const committed = shelfSurfaceStrategy.click(
145
+ { ...onShelf(ctx), gridPosition: new Vector3(...entered.gridPosition) },
146
+ hit(),
147
+ )!
148
+ expect(committed.nodeUpdate.position).toEqual(expected)
149
+ expect(committed.nodeUpdate.parentId).toBe(host.id)
150
+ }
151
+ expect(resolver).toHaveBeenCalledTimes(6)
152
+ })
153
+
154
+ test.each([
155
+ [0.999, 0.5],
156
+ [1.001, 1.5],
157
+ ])('hit Y %s elects nearest row %s on enter and move', (y, expected) => {
158
+ expect(shelfSurfaceStrategy.enter(context(), hit(y))!.gridPosition[1]).toBe(expected)
159
+ expect(shelfSurfaceStrategy.move(onShelf(context()), hit(y))!.gridPosition[1]).toBe(expected)
160
+ })
161
+
162
+ test.each([
163
+ [2.01, 0.25],
164
+ [0.5, 1.01],
165
+ ])('scaled footprint %s by %s overhang is accepted on enter and move', (width, depth) => {
166
+ const draft = ItemNode.parse({
167
+ ...child,
168
+ asset: { ...child.asset, dimensions: [width / 2, 0.25, depth / 2] },
169
+ scale: [2, 1, 2],
170
+ })
171
+ const ctx = context(draft)
172
+ expect(shelfSurfaceStrategy.enter(ctx, hit())).not.toBeNull()
173
+ expect(shelfSurfaceStrategy.move(onShelf(ctx), hit())).not.toBeNull()
174
+ expect(resolver.mock.calls.map(([args]) => args.checkFootprint)).toEqual([true, true])
175
+ })
176
+
177
+ test('a full-board object is refused when grid snapping puts its centre outside the board', () => {
178
+ const draft = ItemNode.parse({
179
+ ...child,
180
+ asset: { ...child.asset, dimensions: [host.width, 0.25, host.depth] },
181
+ })
182
+ expect(shelfSurfaceStrategy.enter(context(draft), hit(1, -0.39))).toBeNull()
183
+ })
184
+
185
+ test.each([
186
+ 'grid',
187
+ 'off',
188
+ ] as const)('shelf frame conversion retains exact poses with grid %s', (mode) => {
189
+ useEditor.getState().setSnappingMode('item', mode)
190
+ const mesh = sceneRegistry.nodes.get(host.id)!
191
+ mesh.position.set(4, 2, -3)
192
+ mesh.rotation.set(0, Math.PI / 3, 0)
193
+ mesh.updateMatrixWorld(true)
194
+ const event = hit(1.2)
195
+ const local = mesh.worldToLocal(new Vector3(...event.position))
196
+ const expected: [number, number, number] =
197
+ mode === 'grid' ? [0.25, 1.5, 0.125] : [local.x, 1.5, local.z]
198
+ const yaw = new Euler().setFromQuaternion(mesh.getWorldQuaternion(new Quaternion()), 'YXZ').y
199
+ const entered = shelfSurfaceStrategy.enter(context(), event)!
200
+ expect(entered.nodeUpdate).toEqual({
201
+ position: expected,
202
+ parentId: host.id,
203
+ rotation: [0.1, 0.7 - yaw, 0.3],
204
+ })
205
+ expect(entered.cursorPosition).toEqual(mesh.localToWorld(new Vector3(...expected)).toArray())
206
+ expect(shelfSurfaceStrategy.move(onShelf(context()), event)!.gridPosition).toEqual(expected)
207
+ })
208
+
209
+ test('only enter requires an upward normal; moves keep electing rows', () => {
210
+ for (const normal of [[1, 0, 0], [0, -1, 0], undefined] as ShelfEvent['normal'][]) {
211
+ const event = { ...hit(), normal }
212
+ expect(shelfSurfaceStrategy.enter(context(), event)).toBeNull()
213
+ expect(shelfSurfaceStrategy.move(onShelf(context()), event)!.gridPosition[1]).toBe(0.5)
214
+ }
215
+ })
216
+
217
+ test('missing rows and resolver rejection leave the draft untouched', () => {
218
+ const before = useScene.getState().nodes
219
+ rows = []
220
+ expect(shelfSurfaceStrategy.enter(context(), hit())).toBeNull()
221
+ expect(shelfSurfaceStrategy.move(onShelf(context()), hit())).toBeNull()
222
+ rows = [0.5, 1.5]
223
+ resolver.mockReturnValue(null)
224
+ expect(shelfSurfaceStrategy.enter(context(), hit())).toBeNull()
225
+ expect(shelfSurfaceStrategy.move(onShelf(context()), hit())).toBeNull()
226
+ expect(useScene.getState().nodes).toBe(before)
227
+ })
228
+
229
+ test('a centred unrotated object exactly matching the board is accepted with epsilon tolerance', () => {
230
+ useEditor.getState().setSnappingMode('item', 'off')
231
+ const region = core.shelfSurfaceProvider.surfaces!(host, {
232
+ scene: core.createSceneApi(useScene),
233
+ })[0]!.region
234
+ const draft = ItemNode.parse({
235
+ ...child,
236
+ rotation: [0, 0, 0],
237
+ asset: { ...child.asset, dimensions: [region.size![0] * 2, 0.25, region.size![1] * 2] },
238
+ })
239
+ const ctx = { ...context(draft), currentCursorRotationY: 0 }
240
+ for (const x of [0, 1e-7]) {
241
+ const event = { ...hit(), position: [x, 0.5, 0], localPosition: [x, 0.5, 0] } as ShelfEvent
242
+ expect(shelfSurfaceStrategy.enter(ctx, event)).not.toBeNull()
243
+ }
244
+ })
245
+
246
+ test('shelf validation uses the current shelf after leaving a different surface host', () => {
247
+ const other = ItemNode.parse({
248
+ ...child,
249
+ id: 'item_previous-host',
250
+ asset: { ...child.asset, dimensions: [0.01, 0.1, 0.01] },
251
+ })
252
+ useScene.setState({ nodes: { ...useScene.getState().nodes, [other.id]: other } })
253
+ const ctx = onShelf(context(ItemNode.parse({ ...child, rotation: [0, 0, 0] })))
254
+ ctx.state.surfaceItemId = other.id
255
+ ctx.gridPosition.set(0, 0.5, 0)
256
+ expect(shelfSurfaceStrategy.click(ctx, hit())).not.toBeNull()
257
+ })
@@ -3,16 +3,29 @@ import {
3
3
  type AssetInput,
4
4
  ItemNode,
5
5
  resolveSupportSlabPatch,
6
+ type SurfaceRejectReason,
6
7
  sceneRegistry,
7
8
  useScene,
8
9
  } from '@pascal-app/core'
9
10
  import { beginPerfAction, commitPerfAction, useViewer } from '@pascal-app/viewer'
10
11
  import { useCallback, useMemo, useRef } from 'react'
11
12
  import type { Vector3 } from 'three'
13
+ import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement'
14
+ import { isFreshPlacementMetadata } from '../../../lib/placement-metadata'
15
+ import {
16
+ surfaceAttachmentId,
17
+ surfaceAttachmentUpdates,
18
+ surfaceFramePose,
19
+ updateSurfaceNode,
20
+ } from '../../../lib/surface-attachment'
21
+ import useInteractionScope, {
22
+ isInteractionSubtreeDraft,
23
+ } from '../../../store/use-interaction-scope'
12
24
  import usePlacementPreview from '../../../store/use-placement-preview'
13
25
  import { stripTransient } from './placement-math'
14
26
 
15
27
  interface OriginalState {
28
+ surfaceId: string | null
16
29
  position: [number, number, number]
17
30
  rotation: [number, number, number]
18
31
  side: ItemNode['side']
@@ -26,6 +39,7 @@ interface OriginalState {
26
39
  }
27
40
 
28
41
  export interface DraftNodeHandle {
42
+ updateSurface: (data: Partial<ItemNode>, surfaceId: string | null) => void
29
43
  /** Current draft item, or null */
30
44
  readonly current: ItemNode | null
31
45
  /** Whether the current draft was adopted (move mode) vs created (create mode) */
@@ -50,6 +64,7 @@ export interface DraftNodeHandle {
50
64
  options?: {
51
65
  supportElevationCap?: number | null
52
66
  preferredSupportSlabId?: string | null
67
+ onReject?: (reason: SurfaceRejectReason) => void
53
68
  pinSupport?: boolean
54
69
  },
55
70
  ) => string | null
@@ -68,6 +83,7 @@ export interface DraftNodeHandle {
68
83
  export function useDraftNode(): DraftNodeHandle {
69
84
  const draftRef = useRef<ItemNode | null>(null)
70
85
  const adoptedRef = useRef(false)
86
+ const ownsSubtreeRef = useRef(false)
71
87
  const originalStateRef = useRef<OriginalState | null>(null)
72
88
 
73
89
  const create = useCallback(
@@ -98,6 +114,7 @@ export function useDraftNode(): DraftNodeHandle {
98
114
  .set(node, useScene.getState().nodes[currentLevelId as AnyNodeId] ?? null)
99
115
  draftRef.current = node
100
116
  adoptedRef.current = false
117
+ ownsSubtreeRef.current = false
101
118
  originalStateRef.current = null
102
119
  return node
103
120
  },
@@ -105,6 +122,8 @@ export function useDraftNode(): DraftNodeHandle {
105
122
  )
106
123
 
107
124
  const adopt = useCallback((node: ItemNode): void => {
125
+ ownsSubtreeRef.current =
126
+ useInteractionScope.getState().adoptSubtree(node.id) || isInteractionSubtreeDraft(node.id)
108
127
  // Save original state so destroy() can restore it
109
128
  const meta =
110
129
  typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata)
@@ -112,6 +131,7 @@ export function useDraftNode(): DraftNodeHandle {
112
131
  : {}
113
132
 
114
133
  originalStateRef.current = {
134
+ surfaceId: surfaceAttachmentId(node),
115
135
  position: [...node.position] as [number, number, number],
116
136
  rotation: [...node.rotation] as [number, number, number],
117
137
  side: node.side,
@@ -122,7 +142,10 @@ export function useDraftNode(): DraftNodeHandle {
122
142
  metadata: node.metadata,
123
143
  }
124
144
 
125
- draftRef.current = node
145
+ draftRef.current = {
146
+ ...node,
147
+ ...surfaceFramePose(node.parentId, surfaceAttachmentId(node), node, false),
148
+ }
126
149
  adoptedRef.current = true
127
150
 
128
151
  // Mark as transient so it renders as a draft
@@ -143,12 +166,44 @@ export function useDraftNode(): DraftNodeHandle {
143
166
  options?: {
144
167
  supportElevationCap?: number | null
145
168
  preferredSupportSlabId?: string | null
169
+ onReject?: (reason: SurfaceRejectReason) => void
146
170
  pinSupport?: boolean
147
171
  },
148
172
  ): string | null => {
149
173
  const draft = draftRef.current
150
174
  if (!draft) return null
151
175
 
176
+ const surfaceId = surfaceAttachmentId(useScene.getState().nodes[draft.id] ?? draft)
177
+ const stored = surfaceFramePose(
178
+ finalUpdate.parentId ?? draft.parentId,
179
+ surfaceId,
180
+ { ...draft, ...finalUpdate },
181
+ true,
182
+ )
183
+ finalUpdate = { ...finalUpdate, ...stored }
184
+ if (isFreshPlacementMetadata(originalStateRef.current?.metadata)) {
185
+ const effectiveNode = ItemNode.parse({ ...draft, ...finalUpdate })
186
+ const id = commitFreshPlacementSubtree(
187
+ draft.id,
188
+ {
189
+ ...finalUpdate,
190
+ ...resolveSupportSlabPatch(effectiveNode, useScene.getState().nodes, {
191
+ maxElevation: options?.supportElevationCap,
192
+ preferredSlabId: options?.preferredSupportSlabId,
193
+ pinSupport: options?.pinSupport,
194
+ }),
195
+ },
196
+ options?.onReject,
197
+ )
198
+ if (!id) return null
199
+ if (usePlacementPreview.getState().node?.id === draft.id) {
200
+ usePlacementPreview.getState().clear()
201
+ }
202
+ draftRef.current = null
203
+ adoptedRef.current = false
204
+ originalStateRef.current = null
205
+ return id
206
+ }
152
207
  if (adoptedRef.current) {
153
208
  // Move mode: update in place (single undoable action)
154
209
  const { parentId: newParentId, ...updateProps } = finalUpdate
@@ -159,16 +214,20 @@ export function useDraftNode(): DraftNodeHandle {
159
214
  const original = originalStateRef.current!
160
215
 
161
216
  // Restore original state while paused — so the undo baseline is clean
162
- useScene.getState().updateNode(draft.id, {
163
- position: original.position,
164
- rotation: original.rotation,
165
- side: original.side,
166
- parentId: original.parentId,
167
- roofSegmentId: original.roofSegmentId,
168
- roofFace: original.roofFace,
169
- blockFaceId: original.blockFaceId,
170
- metadata: original.metadata,
171
- })
217
+ updateSurfaceNode(
218
+ draft.id,
219
+ {
220
+ position: original.position,
221
+ rotation: original.rotation,
222
+ side: original.side,
223
+ parentId: original.parentId,
224
+ roofSegmentId: original.roofSegmentId,
225
+ roofFace: original.roofFace,
226
+ blockFaceId: original.blockFaceId,
227
+ metadata: original.metadata,
228
+ },
229
+ original.surfaceId,
230
+ )
172
231
 
173
232
  // Resume → tracked update (undo reverts to original)
174
233
  useScene.temporal.getState().resume()
@@ -180,28 +239,32 @@ export function useDraftNode(): DraftNodeHandle {
180
239
  metadata: updateProps.metadata ?? stripTransient(draft.metadata),
181
240
  })
182
241
 
183
- useScene.getState().updateNode(draft.id, {
184
- position: updateProps.position ?? draft.position,
185
- rotation: updateProps.rotation ?? draft.rotation,
186
- side: updateProps.side ?? draft.side,
187
- metadata: updateProps.metadata ?? stripTransient(draft.metadata),
188
- parentId: parentId as string,
189
- // Forward the roof host explicitly: strategies set it on every
190
- // commit (segment id on a roof face, undefined elsewhere), and
191
- // dropping it here strands the item in the roof frame without
192
- // the segment transform.
193
- roofSegmentId: updateProps.roofSegmentId,
194
- roofFace: updateProps.roofFace,
195
- blockFaceId: updateProps.blockFaceId,
196
- // Only when the strategy decided about wallId (roof commits clear
197
- // it) — floor/ceiling commits never managed the field.
198
- ...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}),
199
- ...resolveSupportSlabPatch(effectiveNode, useScene.getState().nodes, {
200
- maxElevation: options?.supportElevationCap,
201
- preferredSlabId: options?.preferredSupportSlabId,
202
- pinSupport: options?.pinSupport,
203
- }),
204
- })
242
+ updateSurfaceNode(
243
+ draft.id,
244
+ {
245
+ position: updateProps.position ?? draft.position,
246
+ rotation: updateProps.rotation ?? draft.rotation,
247
+ side: updateProps.side ?? draft.side,
248
+ metadata: updateProps.metadata ?? stripTransient(draft.metadata),
249
+ parentId: parentId as string,
250
+ // Forward the roof host explicitly: strategies set it on every
251
+ // commit (segment id on a roof face, undefined elsewhere), and
252
+ // dropping it here strands the item in the roof frame without
253
+ // the segment transform.
254
+ roofSegmentId: updateProps.roofSegmentId,
255
+ roofFace: updateProps.roofFace,
256
+ blockFaceId: updateProps.blockFaceId,
257
+ // Only when the strategy decided about wallId (roof commits clear
258
+ // it) — floor/ceiling commits never managed the field.
259
+ ...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}),
260
+ ...resolveSupportSlabPatch(effectiveNode, useScene.getState().nodes, {
261
+ maxElevation: options?.supportElevationCap,
262
+ preferredSlabId: options?.preferredSupportSlabId,
263
+ pinSupport: options?.pinSupport,
264
+ }),
265
+ },
266
+ surfaceId,
267
+ )
205
268
 
206
269
  useScene.temporal.getState().pause()
207
270
 
@@ -222,6 +285,7 @@ export function useDraftNode(): DraftNodeHandle {
222
285
 
223
286
  beginPerfAction('place:item', draft.id)
224
287
  // Delete draft while paused (invisible to undo)
288
+ updateSurfaceNode(draft.id, {}, null)
225
289
  useScene.getState().deleteNode(draft.id)
226
290
  draftRef.current = null
227
291
 
@@ -259,7 +323,10 @@ export function useDraftNode(): DraftNodeHandle {
259
323
  },
260
324
  ),
261
325
  })
262
- useScene.getState().createNode(committedNode, parentId)
326
+ useScene.getState().applyNodeChanges({
327
+ create: [{ node: committedNode, parentId }],
328
+ update: surfaceAttachmentUpdates(committedNode.id, parentId, surfaceId),
329
+ })
263
330
  if (usePlacementPreview.getState().node?.id === draft.id) {
264
331
  usePlacementPreview.getState().clear()
265
332
  }
@@ -279,8 +346,12 @@ export function useDraftNode(): DraftNodeHandle {
279
346
  if (!draftRef.current) return
280
347
 
281
348
  const draftId = draftRef.current.id
282
-
283
- if (adoptedRef.current && originalStateRef.current) {
349
+ if (ownsSubtreeRef.current) {
350
+ draftRef.current = null
351
+ adoptedRef.current = false
352
+ originalStateRef.current = null
353
+ return
354
+ } else if (adoptedRef.current && originalStateRef.current) {
284
355
  // Move mode: restore original state instead of deleting — but only
285
356
  // if no other system has already committed a new position for this
286
357
  // node. The 2D `FloorplanRegistryMoveOverlay` commits via
@@ -293,6 +364,7 @@ export function useDraftNode(): DraftNodeHandle {
293
364
  const live = useScene.getState().nodes[id as AnyNodeId] as ItemNode | undefined
294
365
  const livePosition = live?.position
295
366
  const externallyMoved =
367
+ !live?.metadata?.isTransient &&
296
368
  !!livePosition &&
297
369
  (livePosition[0] !== original.position[0] ||
298
370
  livePosition[1] !== original.position[1] ||
@@ -307,16 +379,20 @@ export function useDraftNode(): DraftNodeHandle {
307
379
  return
308
380
  }
309
381
 
310
- useScene.getState().updateNode(id, {
311
- position: original.position,
312
- rotation: original.rotation,
313
- side: original.side,
314
- parentId: original.parentId,
315
- roofSegmentId: original.roofSegmentId,
316
- roofFace: original.roofFace,
317
- blockFaceId: original.blockFaceId,
318
- metadata: original.metadata,
319
- })
382
+ updateSurfaceNode(
383
+ id,
384
+ {
385
+ position: original.position,
386
+ rotation: original.rotation,
387
+ side: original.side,
388
+ parentId: original.parentId,
389
+ roofSegmentId: original.roofSegmentId,
390
+ roofFace: original.roofFace,
391
+ blockFaceId: original.blockFaceId,
392
+ metadata: original.metadata,
393
+ },
394
+ original.surfaceId,
395
+ )
320
396
 
321
397
  // Also reset the Three.js mesh directly — the store update triggers a React
322
398
  // re-render but the mesh position was mutated by useFrame and may not reset
@@ -329,6 +405,7 @@ export function useDraftNode(): DraftNodeHandle {
329
405
  }
330
406
  } else {
331
407
  // Create mode: delete the transient node
408
+ updateSurfaceNode(draftRef.current.id, {}, null)
332
409
  useScene.getState().deleteNode(draftRef.current.id)
333
410
  }
334
411
 
@@ -340,6 +417,17 @@ export function useDraftNode(): DraftNodeHandle {
340
417
  }
341
418
  }, [])
342
419
 
420
+ const updateSurface = useCallback((data: Partial<ItemNode>, surfaceId: string | null) => {
421
+ const draft = draftRef.current
422
+ if (!draft) return
423
+ const pose = { ...draft, ...data }
424
+ updateSurfaceNode(
425
+ draft.id,
426
+ { ...data, ...surfaceFramePose(pose.parentId, surfaceId, pose, true) },
427
+ surfaceId,
428
+ )
429
+ }, [])
430
+
343
431
  return useMemo(
344
432
  () => ({
345
433
  get current() {
@@ -348,11 +436,12 @@ export function useDraftNode(): DraftNodeHandle {
348
436
  get isAdopted() {
349
437
  return adoptedRef.current
350
438
  },
439
+ updateSurface,
351
440
  create,
352
441
  adopt,
353
442
  commit,
354
443
  destroy,
355
444
  }),
356
- [create, adopt, commit, destroy],
445
+ [create, adopt, commit, destroy, updateSurface],
357
446
  )
358
447
  }