@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.
- package/package.json +7 -6
- package/src/components/editor/bake-exporter.tsx +4 -0
- package/src/components/editor/bake-thumbnail.tsx +4 -1
- package/src/components/editor/floating-action-menu.tsx +14 -1
- package/src/components/editor/floorplan-panel.tsx +23 -3
- package/src/components/editor/handles/handle-placement.test.ts +111 -0
- package/src/components/editor/handles/handle-placement.ts +69 -0
- package/src/components/editor/handles/linear-resize-drag.ts +13 -2
- package/src/components/editor/handles/use-handle-drag.ts +5 -2
- package/src/components/editor/index.tsx +12 -4
- package/src/components/editor/node-arrow-handles.tsx +21 -62
- package/src/components/editor/selection-manager.tsx +49 -0
- package/src/components/editor/thumbnail-generator.tsx +11 -2
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +46 -3
- package/src/components/procedural-items/inspector.tsx +223 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
- package/src/components/tools/item/item-surface-placement.test.ts +175 -0
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
- package/src/components/tools/item/placement-strategies.ts +169 -169
- package/src/components/tools/item/placement-types.ts +4 -0
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
- package/src/components/tools/item/use-draft-node.ts +95 -45
- package/src/components/tools/item/use-placement-coordinator.tsx +247 -106
- package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
- package/src/components/tools/registry/item-surface-move.test.ts +501 -0
- package/src/components/tools/registry/item-surface-move.ts +441 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +215 -70
- package/src/components/tools/shared/pointer-support-cap.ts +11 -9
- package/src/components/tools/shared/shelf-stickiness.ts +54 -0
- package/src/components/tools/shared/surface-hit.test.ts +56 -0
- package/src/components/tools/shared/surface-hit.ts +23 -0
- package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
- package/src/components/tools/shared/surface-rejection.tsx +29 -0
- package/src/components/tools/zone/zone-tool.tsx +13 -4
- package/src/components/ui/command-palette/editor-commands.tsx +54 -1
- package/src/components/ui/controls/metric-control.tsx +29 -13
- package/src/components/ui/controls/slider-control.tsx +25 -18
- package/src/components/ui/floating-level-selector.tsx +81 -5
- package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
- package/src/components/ui/panels/parametric-field-control.tsx +1 -1
- package/src/components/ui/panels/parametric-inspector.tsx +6 -2
- package/src/components/ui/panels/reference-panel.tsx +3 -3
- package/src/components/ui/primitives/checkbox.tsx +34 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +120 -3
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +29 -10
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
- package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
- package/src/components/ui/snap-target-badge.tsx +2 -0
- package/src/components/viewer/viewer-scene-header.tsx +3 -0
- package/src/components/viewer/viewer-units-panel.tsx +116 -0
- package/src/hooks/use-ceiling-events.test.ts +24 -2
- package/src/hooks/use-ceiling-events.ts +5 -1
- package/src/hooks/use-keyboard.ts +3 -1
- package/src/index.tsx +5 -0
- package/src/lib/export-texture-utils.test.ts +167 -0
- package/src/lib/export-texture-utils.ts +121 -0
- package/src/lib/export-user-data.test.ts +48 -0
- package/src/lib/export-user-data.ts +69 -0
- package/src/lib/floorplan/floorplan-export.test.ts +139 -1
- package/src/lib/floorplan/floorplan-export.tsx +9 -3
- package/src/lib/floorplan/floorplan-readonly.ts +4 -0
- package/src/lib/fresh-planar-placement.ts +13 -5
- package/src/lib/glb-export-serialize.test.ts +280 -0
- package/src/lib/glb-export.test.ts +206 -0
- package/src/lib/glb-export.ts +171 -24
- package/src/lib/level-duplication.test.ts +73 -0
- package/src/lib/level-duplication.ts +32 -2
- package/src/lib/linear-display.test.ts +59 -0
- package/src/lib/linear-display.ts +39 -0
- package/src/lib/local-guide-image.test.ts +24 -0
- package/src/lib/local-guide-image.ts +34 -1
- package/src/lib/node-action-movement.ts +21 -0
- package/src/lib/portable-export.test.ts +89 -0
- package/src/lib/portable-export.ts +84 -11
- package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
- package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
- package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
- package/src/lib/print-shell-compiler-protocol.ts +8 -0
- package/src/lib/scene-clipboard.ts +1 -1
- package/src/lib/send-to-app.test.ts +108 -0
- package/src/lib/send-to-app.ts +121 -0
- package/src/lib/sfx/movement-tick.ts +37 -0
- package/src/lib/surface-attachment.ts +77 -0
- package/src/lib/units.ts +254 -0
- package/src/lib/usdz-export.ts +43 -16
- package/src/lib/use-linear-display.ts +8 -32
- package/src/store/use-handle-group.ts +4 -0
- 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 =
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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().
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
}
|