@pascal-app/editor 1.0.0 → 1.0.1

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 (97) hide show
  1. package/package.json +7 -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/floating-action-menu.tsx +14 -1
  5. package/src/components/editor/floorplan-panel.tsx +23 -3
  6. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  7. package/src/components/editor/handles/handle-placement.ts +69 -0
  8. package/src/components/editor/handles/linear-resize-drag.ts +13 -2
  9. package/src/components/editor/handles/use-handle-drag.ts +5 -2
  10. package/src/components/editor/index.tsx +12 -4
  11. package/src/components/editor/node-arrow-handles.tsx +21 -62
  12. package/src/components/editor/selection-manager.tsx +49 -0
  13. package/src/components/editor/thumbnail-generator.tsx +11 -2
  14. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  15. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +46 -3
  16. package/src/components/procedural-items/inspector.tsx +223 -0
  17. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  18. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  19. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  20. package/src/components/tools/item/placement-strategies.ts +169 -169
  21. package/src/components/tools/item/placement-types.ts +4 -0
  22. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  23. package/src/components/tools/item/use-draft-node.ts +95 -45
  24. package/src/components/tools/item/use-placement-coordinator.tsx +247 -106
  25. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  26. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  27. package/src/components/tools/registry/item-surface-move.ts +441 -0
  28. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  29. package/src/components/tools/registry/move-registry-node-tool.tsx +215 -70
  30. package/src/components/tools/shared/pointer-support-cap.ts +11 -9
  31. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  32. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  33. package/src/components/tools/shared/surface-hit.ts +23 -0
  34. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  35. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  36. package/src/components/tools/zone/zone-tool.tsx +13 -4
  37. package/src/components/ui/command-palette/editor-commands.tsx +54 -1
  38. package/src/components/ui/controls/metric-control.tsx +29 -13
  39. package/src/components/ui/controls/slider-control.tsx +25 -18
  40. package/src/components/ui/floating-level-selector.tsx +81 -5
  41. package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
  42. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  43. package/src/components/ui/panels/parametric-inspector.tsx +6 -2
  44. package/src/components/ui/panels/reference-panel.tsx +3 -3
  45. package/src/components/ui/primitives/checkbox.tsx +34 -0
  46. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +120 -3
  47. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
  48. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +29 -10
  49. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  50. package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
  51. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  52. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  53. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
  54. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  55. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  56. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  57. package/src/components/ui/snap-target-badge.tsx +2 -0
  58. package/src/components/viewer/viewer-scene-header.tsx +3 -0
  59. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  60. package/src/hooks/use-ceiling-events.test.ts +24 -2
  61. package/src/hooks/use-ceiling-events.ts +5 -1
  62. package/src/hooks/use-keyboard.ts +3 -1
  63. package/src/index.tsx +5 -0
  64. package/src/lib/export-texture-utils.test.ts +167 -0
  65. package/src/lib/export-texture-utils.ts +121 -0
  66. package/src/lib/export-user-data.test.ts +48 -0
  67. package/src/lib/export-user-data.ts +69 -0
  68. package/src/lib/floorplan/floorplan-export.test.ts +139 -1
  69. package/src/lib/floorplan/floorplan-export.tsx +9 -3
  70. package/src/lib/floorplan/floorplan-readonly.ts +4 -0
  71. package/src/lib/fresh-planar-placement.ts +13 -5
  72. package/src/lib/glb-export-serialize.test.ts +280 -0
  73. package/src/lib/glb-export.test.ts +206 -0
  74. package/src/lib/glb-export.ts +171 -24
  75. package/src/lib/level-duplication.test.ts +73 -0
  76. package/src/lib/level-duplication.ts +32 -2
  77. package/src/lib/linear-display.test.ts +59 -0
  78. package/src/lib/linear-display.ts +39 -0
  79. package/src/lib/local-guide-image.test.ts +24 -0
  80. package/src/lib/local-guide-image.ts +34 -1
  81. package/src/lib/node-action-movement.ts +21 -0
  82. package/src/lib/portable-export.test.ts +89 -0
  83. package/src/lib/portable-export.ts +84 -11
  84. package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
  85. package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
  86. package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
  87. package/src/lib/print-shell-compiler-protocol.ts +8 -0
  88. package/src/lib/scene-clipboard.ts +1 -1
  89. package/src/lib/send-to-app.test.ts +108 -0
  90. package/src/lib/send-to-app.ts +121 -0
  91. package/src/lib/sfx/movement-tick.ts +37 -0
  92. package/src/lib/surface-attachment.ts +77 -0
  93. package/src/lib/units.ts +254 -0
  94. package/src/lib/usdz-export.ts +43 -16
  95. package/src/lib/use-linear-display.ts +8 -32
  96. package/src/store/use-handle-group.ts +4 -0
  97. 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
+ registerNode,
7
+ type ShelfEvent,
8
+ ShelfNode,
9
+ sceneRegistry,
10
+ useScene,
11
+ } from '@pascal-app/core'
12
+ import { Euler, Group, Quaternion, Vector3 } from 'three'
13
+ import useEditor from '../../../store/use-editor'
14
+ import useInteractionScope from '../../../store/use-interaction-scope'
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
+ registerNode({
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
+ registerNode({
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
+ })
@@ -9,10 +9,17 @@ import {
9
9
  import { beginPerfAction, commitPerfAction, useViewer } from '@pascal-app/viewer'
10
10
  import { useCallback, useMemo, useRef } from 'react'
11
11
  import type { Vector3 } from 'three'
12
+ import {
13
+ surfaceAttachmentId,
14
+ surfaceAttachmentUpdates,
15
+ surfaceFramePose,
16
+ updateSurfaceNode,
17
+ } from '../../../lib/surface-attachment'
12
18
  import usePlacementPreview from '../../../store/use-placement-preview'
13
19
  import { stripTransient } from './placement-math'
14
20
 
15
21
  interface OriginalState {
22
+ surfaceId: string | null
16
23
  position: [number, number, number]
17
24
  rotation: [number, number, number]
18
25
  side: ItemNode['side']
@@ -26,6 +33,7 @@ interface OriginalState {
26
33
  }
27
34
 
28
35
  export interface DraftNodeHandle {
36
+ updateSurface: (data: Partial<ItemNode>, surfaceId: string | null) => void
29
37
  /** Current draft item, or null */
30
38
  readonly current: ItemNode | null
31
39
  /** Whether the current draft was adopted (move mode) vs created (create mode) */
@@ -112,6 +120,7 @@ export function useDraftNode(): DraftNodeHandle {
112
120
  : {}
113
121
 
114
122
  originalStateRef.current = {
123
+ surfaceId: surfaceAttachmentId(node),
115
124
  position: [...node.position] as [number, number, number],
116
125
  rotation: [...node.rotation] as [number, number, number],
117
126
  side: node.side,
@@ -122,7 +131,10 @@ export function useDraftNode(): DraftNodeHandle {
122
131
  metadata: node.metadata,
123
132
  }
124
133
 
125
- draftRef.current = node
134
+ draftRef.current = {
135
+ ...node,
136
+ ...surfaceFramePose(node.parentId, surfaceAttachmentId(node), node, false),
137
+ }
126
138
  adoptedRef.current = true
127
139
 
128
140
  // Mark as transient so it renders as a draft
@@ -149,6 +161,14 @@ export function useDraftNode(): DraftNodeHandle {
149
161
  const draft = draftRef.current
150
162
  if (!draft) return null
151
163
 
164
+ const surfaceId = surfaceAttachmentId(useScene.getState().nodes[draft.id] ?? draft)
165
+ const stored = surfaceFramePose(
166
+ finalUpdate.parentId ?? draft.parentId,
167
+ surfaceId,
168
+ { ...draft, ...finalUpdate },
169
+ true,
170
+ )
171
+ finalUpdate = { ...finalUpdate, ...stored }
152
172
  if (adoptedRef.current) {
153
173
  // Move mode: update in place (single undoable action)
154
174
  const { parentId: newParentId, ...updateProps } = finalUpdate
@@ -159,16 +179,20 @@ export function useDraftNode(): DraftNodeHandle {
159
179
  const original = originalStateRef.current!
160
180
 
161
181
  // 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
- })
182
+ updateSurfaceNode(
183
+ draft.id,
184
+ {
185
+ position: original.position,
186
+ rotation: original.rotation,
187
+ side: original.side,
188
+ parentId: original.parentId,
189
+ roofSegmentId: original.roofSegmentId,
190
+ roofFace: original.roofFace,
191
+ blockFaceId: original.blockFaceId,
192
+ metadata: original.metadata,
193
+ },
194
+ original.surfaceId,
195
+ )
172
196
 
173
197
  // Resume → tracked update (undo reverts to original)
174
198
  useScene.temporal.getState().resume()
@@ -180,28 +204,32 @@ export function useDraftNode(): DraftNodeHandle {
180
204
  metadata: updateProps.metadata ?? stripTransient(draft.metadata),
181
205
  })
182
206
 
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
- })
207
+ updateSurfaceNode(
208
+ draft.id,
209
+ {
210
+ position: updateProps.position ?? draft.position,
211
+ rotation: updateProps.rotation ?? draft.rotation,
212
+ side: updateProps.side ?? draft.side,
213
+ metadata: updateProps.metadata ?? stripTransient(draft.metadata),
214
+ parentId: parentId as string,
215
+ // Forward the roof host explicitly: strategies set it on every
216
+ // commit (segment id on a roof face, undefined elsewhere), and
217
+ // dropping it here strands the item in the roof frame without
218
+ // the segment transform.
219
+ roofSegmentId: updateProps.roofSegmentId,
220
+ roofFace: updateProps.roofFace,
221
+ blockFaceId: updateProps.blockFaceId,
222
+ // Only when the strategy decided about wallId (roof commits clear
223
+ // it) — floor/ceiling commits never managed the field.
224
+ ...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}),
225
+ ...resolveSupportSlabPatch(effectiveNode, useScene.getState().nodes, {
226
+ maxElevation: options?.supportElevationCap,
227
+ preferredSlabId: options?.preferredSupportSlabId,
228
+ pinSupport: options?.pinSupport,
229
+ }),
230
+ },
231
+ surfaceId,
232
+ )
205
233
 
206
234
  useScene.temporal.getState().pause()
207
235
 
@@ -222,6 +250,7 @@ export function useDraftNode(): DraftNodeHandle {
222
250
 
223
251
  beginPerfAction('place:item', draft.id)
224
252
  // Delete draft while paused (invisible to undo)
253
+ updateSurfaceNode(draft.id, {}, null)
225
254
  useScene.getState().deleteNode(draft.id)
226
255
  draftRef.current = null
227
256
 
@@ -259,7 +288,10 @@ export function useDraftNode(): DraftNodeHandle {
259
288
  },
260
289
  ),
261
290
  })
262
- useScene.getState().createNode(committedNode, parentId)
291
+ useScene.getState().applyNodeChanges({
292
+ create: [{ node: committedNode, parentId }],
293
+ update: surfaceAttachmentUpdates(committedNode.id, parentId, surfaceId),
294
+ })
263
295
  if (usePlacementPreview.getState().node?.id === draft.id) {
264
296
  usePlacementPreview.getState().clear()
265
297
  }
@@ -293,6 +325,7 @@ export function useDraftNode(): DraftNodeHandle {
293
325
  const live = useScene.getState().nodes[id as AnyNodeId] as ItemNode | undefined
294
326
  const livePosition = live?.position
295
327
  const externallyMoved =
328
+ !live?.metadata?.isTransient &&
296
329
  !!livePosition &&
297
330
  (livePosition[0] !== original.position[0] ||
298
331
  livePosition[1] !== original.position[1] ||
@@ -307,16 +340,20 @@ export function useDraftNode(): DraftNodeHandle {
307
340
  return
308
341
  }
309
342
 
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
- })
343
+ updateSurfaceNode(
344
+ id,
345
+ {
346
+ position: original.position,
347
+ rotation: original.rotation,
348
+ side: original.side,
349
+ parentId: original.parentId,
350
+ roofSegmentId: original.roofSegmentId,
351
+ roofFace: original.roofFace,
352
+ blockFaceId: original.blockFaceId,
353
+ metadata: original.metadata,
354
+ },
355
+ original.surfaceId,
356
+ )
320
357
 
321
358
  // Also reset the Three.js mesh directly — the store update triggers a React
322
359
  // re-render but the mesh position was mutated by useFrame and may not reset
@@ -329,6 +366,7 @@ export function useDraftNode(): DraftNodeHandle {
329
366
  }
330
367
  } else {
331
368
  // Create mode: delete the transient node
369
+ updateSurfaceNode(draftRef.current.id, {}, null)
332
370
  useScene.getState().deleteNode(draftRef.current.id)
333
371
  }
334
372
 
@@ -340,6 +378,17 @@ export function useDraftNode(): DraftNodeHandle {
340
378
  }
341
379
  }, [])
342
380
 
381
+ const updateSurface = useCallback((data: Partial<ItemNode>, surfaceId: string | null) => {
382
+ const draft = draftRef.current
383
+ if (!draft) return
384
+ const pose = { ...draft, ...data }
385
+ updateSurfaceNode(
386
+ draft.id,
387
+ { ...data, ...surfaceFramePose(pose.parentId, surfaceId, pose, true) },
388
+ surfaceId,
389
+ )
390
+ }, [])
391
+
343
392
  return useMemo(
344
393
  () => ({
345
394
  get current() {
@@ -348,11 +397,12 @@ export function useDraftNode(): DraftNodeHandle {
348
397
  get isAdopted() {
349
398
  return adoptedRef.current
350
399
  },
400
+ updateSurface,
351
401
  create,
352
402
  adopt,
353
403
  commit,
354
404
  destroy,
355
405
  }),
356
- [create, adopt, commit, destroy],
406
+ [create, adopt, commit, destroy, updateSurface],
357
407
  )
358
408
  }