@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,90 @@
|
|
|
1
|
+
import { expect, spyOn, test } from 'bun:test'
|
|
2
|
+
import * as core from '@pascal-app/core'
|
|
3
|
+
import {
|
|
4
|
+
type AnyNode,
|
|
5
|
+
type ItemEvent,
|
|
6
|
+
ItemNode,
|
|
7
|
+
nodeRegistry,
|
|
8
|
+
registerNode,
|
|
9
|
+
sceneRegistry,
|
|
10
|
+
useScene,
|
|
11
|
+
} from '@pascal-app/core'
|
|
12
|
+
import { Group } from 'three'
|
|
13
|
+
import { createRegistryItemSurfaceMove } from './item-surface-move'
|
|
14
|
+
|
|
15
|
+
test('a surface-local provider writes full rotation even when the original rotation was scalar', () => {
|
|
16
|
+
const restoreRegistry = nodeRegistry._snapshot()
|
|
17
|
+
const savedNodes = useScene.getState().nodes
|
|
18
|
+
const savedRaf = globalThis.requestAnimationFrame
|
|
19
|
+
const savedCancelRaf = globalThis.cancelAnimationFrame
|
|
20
|
+
const resolver = spyOn(core, 'resolveSurfacePlacement')
|
|
21
|
+
const asset = {
|
|
22
|
+
id: 'host',
|
|
23
|
+
name: 'Host',
|
|
24
|
+
category: 'furniture',
|
|
25
|
+
thumbnail: '',
|
|
26
|
+
src: '/host.glb',
|
|
27
|
+
}
|
|
28
|
+
const host = ItemNode.parse({ id: 'item_scalar-frame-host', asset })
|
|
29
|
+
const child = {
|
|
30
|
+
...ItemNode.parse({ id: 'item_scalar-frame-child', asset }),
|
|
31
|
+
rotation: 0.3,
|
|
32
|
+
} as unknown as AnyNode
|
|
33
|
+
try {
|
|
34
|
+
globalThis.requestAnimationFrame = (callback) => {
|
|
35
|
+
callback(0)
|
|
36
|
+
return 0
|
|
37
|
+
}
|
|
38
|
+
globalThis.cancelAnimationFrame = () => {}
|
|
39
|
+
nodeRegistry._reset()
|
|
40
|
+
registerNode({
|
|
41
|
+
kind: 'item',
|
|
42
|
+
schemaVersion: 1,
|
|
43
|
+
schema: ItemNode,
|
|
44
|
+
category: 'furnish',
|
|
45
|
+
defaults: () => ({}),
|
|
46
|
+
capabilities: {
|
|
47
|
+
hostable: { parents: ['item'], align: 'face' },
|
|
48
|
+
floorPlaced: {},
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
useScene.setState({ nodes: { [host.id]: host, [child.id]: child } })
|
|
52
|
+
const object = new Group()
|
|
53
|
+
sceneRegistry.nodes.set(host.id, object)
|
|
54
|
+
resolver.mockReturnValue({
|
|
55
|
+
position: [0.25, 1.5, -0.75],
|
|
56
|
+
rotationY: 0.4,
|
|
57
|
+
surfaceId: 'test-surface',
|
|
58
|
+
childFrame: 'surface-local',
|
|
59
|
+
surfaceLocal: { position: [-0.5, 0, 0.25], rotationY: -0.2, rotation: [0.7, -0.2, 0.8] },
|
|
60
|
+
})
|
|
61
|
+
const session = createRegistryItemSurfaceMove(child)!
|
|
62
|
+
expect(
|
|
63
|
+
session.enter(
|
|
64
|
+
{
|
|
65
|
+
node: host,
|
|
66
|
+
object,
|
|
67
|
+
position: [0, 1, 0],
|
|
68
|
+
localPosition: [0, 1, 0],
|
|
69
|
+
normal: [0, 1, 0],
|
|
70
|
+
nativeEvent: {} as ItemEvent['nativeEvent'],
|
|
71
|
+
stopPropagation() {},
|
|
72
|
+
} as ItemEvent,
|
|
73
|
+
[0.5, 1, 0.5],
|
|
74
|
+
0.3,
|
|
75
|
+
),
|
|
76
|
+
).not.toBeNull()
|
|
77
|
+
expect(useScene.getState().nodes[child.id]).toMatchObject({
|
|
78
|
+
parentId: host.id,
|
|
79
|
+
position: [-0.5, 0, 0.25],
|
|
80
|
+
rotation: [0.7, -0.2, 0.8],
|
|
81
|
+
})
|
|
82
|
+
} finally {
|
|
83
|
+
resolver.mockRestore()
|
|
84
|
+
restoreRegistry()
|
|
85
|
+
sceneRegistry.nodes.delete(host.id)
|
|
86
|
+
useScene.setState({ nodes: savedNodes })
|
|
87
|
+
globalThis.requestAnimationFrame = savedRaf
|
|
88
|
+
globalThis.cancelAnimationFrame = savedCancelRaf
|
|
89
|
+
}
|
|
90
|
+
})
|
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
type AnyNode,
|
|
4
|
+
type AnyNodeId,
|
|
5
|
+
type GridEvent,
|
|
6
|
+
getFloorPlacedElevation,
|
|
7
|
+
getFloorPlacedFootprints,
|
|
8
|
+
type ItemEvent,
|
|
9
|
+
ItemNode,
|
|
10
|
+
LevelNode,
|
|
11
|
+
nodeRegistry,
|
|
12
|
+
registerNode,
|
|
13
|
+
resolveSupportSlabPatch,
|
|
14
|
+
sceneRegistry,
|
|
15
|
+
spatialGridManager,
|
|
16
|
+
useLiveTransforms,
|
|
17
|
+
useScene,
|
|
18
|
+
} from '@pascal-app/core'
|
|
19
|
+
import {
|
|
20
|
+
ProceduralItemNode,
|
|
21
|
+
proceduralFootprint,
|
|
22
|
+
shelfRecipe,
|
|
23
|
+
} from '@pascal-app/core/procedural-items'
|
|
24
|
+
import { Group, Vector3 } from 'three'
|
|
25
|
+
import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement'
|
|
26
|
+
import {
|
|
27
|
+
createItemSurfaceGridDispatch,
|
|
28
|
+
createItemSurfacePointerArbitration,
|
|
29
|
+
createRegistryItemSurfaceMove,
|
|
30
|
+
resolveItemSurfaceGrab,
|
|
31
|
+
} from './item-surface-move'
|
|
32
|
+
|
|
33
|
+
const originalRaf = globalThis.requestAnimationFrame
|
|
34
|
+
const originalCancelRaf = globalThis.cancelAnimationFrame
|
|
35
|
+
|
|
36
|
+
const dimensions: [number, number, number] = [1, 1, 0.5]
|
|
37
|
+
const original = ProceduralItemNode.parse({
|
|
38
|
+
id: 'procedural-item_moving',
|
|
39
|
+
parentId: 'level_surface-move',
|
|
40
|
+
recipe: shelfRecipe,
|
|
41
|
+
position: [1, 0, 2],
|
|
42
|
+
rotation: [0.1, 0.3, 0.2],
|
|
43
|
+
supportSlabId: 'ground',
|
|
44
|
+
}) as unknown as AnyNode & {
|
|
45
|
+
position: [number, number, number]
|
|
46
|
+
rotation: [number, number, number]
|
|
47
|
+
}
|
|
48
|
+
const host = ItemNode.parse({
|
|
49
|
+
id: 'item_surface-move',
|
|
50
|
+
parentId: 'level_surface-move',
|
|
51
|
+
position: [4, 0, 5],
|
|
52
|
+
rotation: [0, 0.6, 0],
|
|
53
|
+
asset: {
|
|
54
|
+
id: 'table',
|
|
55
|
+
name: 'Table',
|
|
56
|
+
category: 'furniture',
|
|
57
|
+
thumbnail: '',
|
|
58
|
+
src: '/table.glb',
|
|
59
|
+
dimensions: [3, 1, 3],
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
let restoreRegistry: () => void
|
|
63
|
+
let oldNodes: ReturnType<typeof useScene.getState>['nodes']
|
|
64
|
+
let oldRoots: AnyNodeId[]
|
|
65
|
+
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
globalThis.requestAnimationFrame = (callback) => {
|
|
68
|
+
callback(0)
|
|
69
|
+
return 0
|
|
70
|
+
}
|
|
71
|
+
globalThis.cancelAnimationFrame = () => {}
|
|
72
|
+
restoreRegistry = nodeRegistry._snapshot()
|
|
73
|
+
oldNodes = useScene.getState().nodes
|
|
74
|
+
oldRoots = useScene.getState().rootNodeIds
|
|
75
|
+
nodeRegistry._reset()
|
|
76
|
+
registerNode({
|
|
77
|
+
kind: 'procedural-item',
|
|
78
|
+
schemaVersion: 1,
|
|
79
|
+
schema: ProceduralItemNode,
|
|
80
|
+
category: 'furnish',
|
|
81
|
+
defaults: () => ({}),
|
|
82
|
+
capabilities: {
|
|
83
|
+
hostable: { parents: ['level', 'item'], align: 'face' },
|
|
84
|
+
floorPlaced: {
|
|
85
|
+
applies: (n) => !(n as unknown as ProceduralItemNode).wallId,
|
|
86
|
+
footprint: (n) => proceduralFootprint(n as unknown as ProceduralItemNode),
|
|
87
|
+
collides: true,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
const level = LevelNode.parse({ id: 'level_surface-move', children: [original.id, host.id] })
|
|
92
|
+
useScene.setState({
|
|
93
|
+
nodes: { [level.id]: level, [original.id]: original, [host.id]: host },
|
|
94
|
+
rootNodeIds: [level.id],
|
|
95
|
+
})
|
|
96
|
+
useScene.temporal.getState().clear()
|
|
97
|
+
useScene.temporal.getState().pause()
|
|
98
|
+
const levelMesh = new Group()
|
|
99
|
+
levelMesh.position.set(10, 3, 20)
|
|
100
|
+
levelMesh.rotation.y = 0.4
|
|
101
|
+
const hostMesh = new Group()
|
|
102
|
+
hostMesh.position.set(...host.position)
|
|
103
|
+
hostMesh.rotation.set(...host.rotation)
|
|
104
|
+
levelMesh.add(hostMesh)
|
|
105
|
+
levelMesh.updateMatrixWorld(true)
|
|
106
|
+
sceneRegistry.nodes.set(level.id, levelMesh)
|
|
107
|
+
sceneRegistry.nodes.set(host.id, hostMesh)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
afterEach(() => {
|
|
111
|
+
globalThis.requestAnimationFrame = originalRaf
|
|
112
|
+
globalThis.cancelAnimationFrame = originalCancelRaf
|
|
113
|
+
restoreRegistry()
|
|
114
|
+
useLiveTransforms.getState().clear(original.id)
|
|
115
|
+
for (const id of ['level_surface-move', host.id, 'item_second-host'])
|
|
116
|
+
sceneRegistry.nodes.delete(id)
|
|
117
|
+
useScene.setState({ nodes: oldNodes, rootNodeIds: oldRoots })
|
|
118
|
+
useScene.temporal.getState().clear()
|
|
119
|
+
useScene.temporal.getState().resume()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
function eventFor(item = host, local: [number, number, number] = [0.2, 1, 0.3]) {
|
|
123
|
+
const mesh = sceneRegistry.nodes.get(item.id)!
|
|
124
|
+
let stopped = false
|
|
125
|
+
const event = {
|
|
126
|
+
node: item,
|
|
127
|
+
object: mesh,
|
|
128
|
+
normal: [0, 1, 0],
|
|
129
|
+
localPosition: local,
|
|
130
|
+
position: mesh.localToWorld(new Vector3(...local)).toArray(),
|
|
131
|
+
stopPropagation: () => {
|
|
132
|
+
stopped = true
|
|
133
|
+
},
|
|
134
|
+
nativeEvent: {},
|
|
135
|
+
} as ItemEvent
|
|
136
|
+
return { event, stopped: () => stopped }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
describe('registry item-surface move session', () => {
|
|
140
|
+
test('a hidden fresh draft survives effect replay and can still host and commit', async () => {
|
|
141
|
+
useScene.getState().updateNode(original.id, { metadata: { isNew: true }, visible: false })
|
|
142
|
+
const draft = useScene.getState().nodes[original.id]!
|
|
143
|
+
const session = createRegistryItemSurfaceMove(draft)!
|
|
144
|
+
session.restore()
|
|
145
|
+
await Promise.resolve()
|
|
146
|
+
expect(useScene.getState().nodes[draft.id]).toBe(draft)
|
|
147
|
+
const pose = session.enter(eventFor().event, dimensions, 0.3)!
|
|
148
|
+
expect(session.hosted).toBe(true)
|
|
149
|
+
const finalId = commitFreshPlacementSubtree(draft.id, { visible: true })!
|
|
150
|
+
expect(useScene.getState().nodes[finalId]).toMatchObject({
|
|
151
|
+
parentId: host.id,
|
|
152
|
+
position: pose.position,
|
|
153
|
+
})
|
|
154
|
+
expect(useScene.temporal.getState().pastStates).toHaveLength(1)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('cleanup restores a fresh hosted draft without deleting it or recording history', async () => {
|
|
158
|
+
useScene.getState().updateNode(original.id, { metadata: { isNew: true }, visible: false })
|
|
159
|
+
const draft = useScene.getState().nodes[original.id]!
|
|
160
|
+
const session = createRegistryItemSurfaceMove(draft)!
|
|
161
|
+
session.enter(eventFor().event, dimensions, 0.3)
|
|
162
|
+
session.restore()
|
|
163
|
+
await Promise.resolve()
|
|
164
|
+
expect(useScene.getState().nodes[original.id]).toEqual(draft)
|
|
165
|
+
expect(useScene.temporal.getState().pastStates).toHaveLength(0)
|
|
166
|
+
expect((useScene.getState().nodes[host.id] as ItemNode).children as string[]).not.toContain(
|
|
167
|
+
original.id,
|
|
168
|
+
)
|
|
169
|
+
expect(
|
|
170
|
+
(useScene.getState().nodes['level_surface-move'] as LevelNode).children as string[],
|
|
171
|
+
).toContain(original.id)
|
|
172
|
+
expect(session.enter(eventFor().event, dimensions, 0.3)).not.toBeNull()
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('grid arbitration matches the native pointer event', () => {
|
|
176
|
+
const pointer = createItemSurfacePointerArbitration()
|
|
177
|
+
const native = {}
|
|
178
|
+
expect(pointer.blocksGrid(native)).toBe(false)
|
|
179
|
+
pointer.hit(host.id, native)
|
|
180
|
+
expect(pointer.blocksGrid(native)).toBe(true)
|
|
181
|
+
expect(pointer.blocksGrid({})).toBe(false)
|
|
182
|
+
pointer.clear()
|
|
183
|
+
expect(pointer.blocksGrid(native)).toBe(false)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('canvas grid waits for wrapper host dispatch, including spaced moves; an unmatched grid detaches', async () => {
|
|
187
|
+
const session = createRegistryItemSurfaceMove(original)!
|
|
188
|
+
let yaw = 0.3
|
|
189
|
+
let applied = 0
|
|
190
|
+
const dispatch = createItemSurfaceGridDispatch((grid) => {
|
|
191
|
+
applied += 1
|
|
192
|
+
if (!session.blocksGrid(grid)) session.detach(grid.position, yaw)
|
|
193
|
+
})
|
|
194
|
+
for (let move = 0; move < 3; move += 1) {
|
|
195
|
+
const hit = eventFor().event
|
|
196
|
+
const native = {}
|
|
197
|
+
const grid = { position: hit.position, nativeEvent: native } as GridEvent
|
|
198
|
+
hit.nativeEvent = { nativeEvent: native } as ItemEvent['nativeEvent']
|
|
199
|
+
dispatch.schedule(grid)
|
|
200
|
+
const before = applied
|
|
201
|
+
await Promise.resolve()
|
|
202
|
+
expect(applied).toBe(before)
|
|
203
|
+
const pose = session.enter(hit, dimensions, yaw)!
|
|
204
|
+
yaw = pose.rotationY
|
|
205
|
+
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
206
|
+
expect(session.hosted).toBe(true)
|
|
207
|
+
expect(useScene.getState().nodes[original.id]?.parentId).toBe(host.id)
|
|
208
|
+
}
|
|
209
|
+
expect(applied).toBe(3)
|
|
210
|
+
dispatch.schedule({ position: [0, 0, 0], nativeEvent: {} } as GridEvent)
|
|
211
|
+
dispatch.flush()
|
|
212
|
+
expect(session.hosted).toBe(false)
|
|
213
|
+
expect(applied).toBe(4)
|
|
214
|
+
dispatch.schedule({ position: [0, 0, 0], nativeEvent: {} } as GridEvent)
|
|
215
|
+
dispatch.cancel()
|
|
216
|
+
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
217
|
+
expect(applied).toBe(4)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test('grid after a host hit preserves hosting; a real leave immediately detaches', () => {
|
|
221
|
+
const session = createRegistryItemSurfaceMove(original)!
|
|
222
|
+
const hit = eventFor()
|
|
223
|
+
const pose = session.enter(hit.event, dimensions, 0.3)!
|
|
224
|
+
if (!session.blocksGrid(hit.event as unknown as GridEvent))
|
|
225
|
+
session.detach([0, 0, 0], pose.rotationY)
|
|
226
|
+
expect(useScene.getState().nodes[original.id]?.parentId).toBe(host.id)
|
|
227
|
+
expect(
|
|
228
|
+
session.leave({ ...hit.event, node: { ...host, id: 'item_other' } }, pose.rotationY),
|
|
229
|
+
).toBeNull()
|
|
230
|
+
expect(session.hosted).toBe(true)
|
|
231
|
+
expect(session.leave(hit.event, pose.rotationY)).not.toBeNull()
|
|
232
|
+
expect(session.hosted).toBe(false)
|
|
233
|
+
expect(session.blocksGrid(hit.event as unknown as GridEvent)).toBe(false)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
test('a rejected surface hit permits the paired grid to detach without stopping propagation', () => {
|
|
237
|
+
const session = createRegistryItemSurfaceMove(original)!
|
|
238
|
+
const pose = session.enter(eventFor().event, dimensions, 0.3)!
|
|
239
|
+
const hit = eventFor()
|
|
240
|
+
hit.event.normal = [1, 0, 0]
|
|
241
|
+
expect(session.enter(hit.event, dimensions, pose.rotationY)).toBeNull()
|
|
242
|
+
expect(hit.stopped()).toBe(false)
|
|
243
|
+
expect(session.blocksGrid(hit.event as unknown as GridEvent)).toBe(false)
|
|
244
|
+
session.detach(hit.event.position, pose.rotationY)
|
|
245
|
+
expect(session.hosted).toBe(false)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test('hosted R/T rotates around the stored local position under a translated and rotated host', () => {
|
|
249
|
+
const session = createRegistryItemSurfaceMove(original)!
|
|
250
|
+
const pose = session.enter(eventFor().event, dimensions, 0.3)!
|
|
251
|
+
const initialPlan = session.planPose(pose.position, pose.rotationY)
|
|
252
|
+
for (const yaw of [pose.rotationY + Math.PI / 4, pose.rotationY]) {
|
|
253
|
+
const rotated = session.rotate(yaw)!
|
|
254
|
+
expect(rotated.position).toEqual(pose.position)
|
|
255
|
+
expect(session.planPose(rotated.position, yaw).position).toEqual(initialPlan.position)
|
|
256
|
+
expect(useScene.getState().nodes[original.id]).toMatchObject({
|
|
257
|
+
parentId: host.id,
|
|
258
|
+
position: pose.position,
|
|
259
|
+
rotation: [0.1, yaw, 0.2],
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test('pure grab math preserves offset on the original host and forgets it on a host switch', () => {
|
|
265
|
+
const first = resolveItemSurfaceGrab(
|
|
266
|
+
{ hostId: host.id, start: [0.5, 1, -0.5], anchor: null },
|
|
267
|
+
host.id,
|
|
268
|
+
[0.2, 1, 0.3],
|
|
269
|
+
)
|
|
270
|
+
expect(first.position).toEqual([0.5, 1, -0.5])
|
|
271
|
+
const moved = resolveItemSurfaceGrab(first.grab, host.id, [0.4, 1, 0.6])
|
|
272
|
+
expect(moved.position[0]).toBeCloseTo(0.7)
|
|
273
|
+
expect(moved.position[2]).toBeCloseTo(-0.2)
|
|
274
|
+
const switched = resolveItemSurfaceGrab(moved.grab, 'item_second-host', [1, 1, 2])
|
|
275
|
+
expect(switched).toEqual({ grab: null, position: [1, 1, 2] })
|
|
276
|
+
expect(resolveItemSurfaceGrab(switched.grab, host.id, [0.2, 1, 0.3]).position).toEqual([
|
|
277
|
+
0.2, 1, 0.3,
|
|
278
|
+
])
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
test('moving an existing hosted design preserves its grab until detach, then centers on return', () => {
|
|
282
|
+
useScene
|
|
283
|
+
.getState()
|
|
284
|
+
.updateNode(original.id, { parentId: host.id, position: [0.5, 1, -0.5] } as Partial<AnyNode>)
|
|
285
|
+
const session = createRegistryItemSurfaceMove(useScene.getState().nodes[original.id]!)!
|
|
286
|
+
let pose = session.enter(eventFor().event, dimensions, 0.3)!
|
|
287
|
+
expect(pose.position[0]).toBeCloseTo(0.5)
|
|
288
|
+
expect(pose.position[2]).toBeCloseTo(-0.5)
|
|
289
|
+
pose = session.enter(eventFor(host, [0.4, 1, 0.6]).event, dimensions, pose.rotationY)!
|
|
290
|
+
expect(pose.position[0]).toBeCloseTo(0.7)
|
|
291
|
+
expect(pose.position[2]).toBeCloseTo(-0.2)
|
|
292
|
+
const floor = session.detach(eventFor().event.position, pose.rotationY)!
|
|
293
|
+
pose = session.enter(eventFor().event, dimensions, floor.rotationY)!
|
|
294
|
+
expect(pose.position[0]).toBeCloseTo(0.2)
|
|
295
|
+
expect(pose.position[2]).toBeCloseTo(0.3)
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
test('reparents previews, keeps the box in the level frame, and clears floor support and live transforms', () => {
|
|
299
|
+
const session = createRegistryItemSurfaceMove(original)!
|
|
300
|
+
useLiveTransforms.getState().set(original.id, { position: [7, 0, 9], rotation: 0.3 })
|
|
301
|
+
const hit = eventFor()
|
|
302
|
+
const pose = session.enter(hit.event, dimensions, 0.3)!
|
|
303
|
+
expect(hit.stopped()).toBe(true)
|
|
304
|
+
expect(session.hosted).toBe(true)
|
|
305
|
+
expect(useLiveTransforms.getState().get(original.id)).toBeUndefined()
|
|
306
|
+
const live = useScene.getState().nodes[original.id]!
|
|
307
|
+
expect(live.parentId).toBe(host.id)
|
|
308
|
+
expect((useScene.getState().nodes[host.id] as ItemNode).children as string[]).toContain(
|
|
309
|
+
original.id,
|
|
310
|
+
)
|
|
311
|
+
expect((useScene.getState().nodes['level_surface-move'] as LevelNode).children).not.toContain(
|
|
312
|
+
original.id,
|
|
313
|
+
)
|
|
314
|
+
expect(session.worldYaw(pose.rotationY)).toBeCloseTo(0.7)
|
|
315
|
+
const plan = session.planPose(pose.position, pose.rotationY)
|
|
316
|
+
expect(plan.rotationY).toBeCloseTo(0.3)
|
|
317
|
+
expect(plan.position[0]).toBeCloseTo(4 + 0.2 * Math.cos(0.6) + 0.3 * Math.sin(0.6))
|
|
318
|
+
expect(plan.position[1]).toBeCloseTo(1)
|
|
319
|
+
expect(
|
|
320
|
+
getFloorPlacedElevation({
|
|
321
|
+
node: live,
|
|
322
|
+
nodes: useScene.getState().nodes,
|
|
323
|
+
position: pose.position,
|
|
324
|
+
}),
|
|
325
|
+
).toBe(0)
|
|
326
|
+
expect(
|
|
327
|
+
resolveSupportSlabPatch(live, useScene.getState().nodes, {
|
|
328
|
+
preferredSlabId: 'slab_above',
|
|
329
|
+
pinSupport: true,
|
|
330
|
+
}),
|
|
331
|
+
).toEqual({ supportSlabId: undefined })
|
|
332
|
+
expect(useScene.temporal.getState().pastStates).toHaveLength(0)
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
test('invalid hosts leave propagation and the scene untouched', () => {
|
|
336
|
+
const session = createRegistryItemSurfaceMove(original)!
|
|
337
|
+
expect(session.enter(eventFor().event, [4, 1, 4], 0.3)).not.toBeNull()
|
|
338
|
+
session.restore()
|
|
339
|
+
const restored = useScene.getState().nodes[original.id]
|
|
340
|
+
const hit = eventFor(host, [2, 1, 0])
|
|
341
|
+
expect(session.enter(hit.event, [4, 1, 4], 0.3)).toBeNull()
|
|
342
|
+
expect(hit.stopped()).toBe(false)
|
|
343
|
+
expect(useScene.getState().nodes[original.id]).toBe(restored)
|
|
344
|
+
expect(
|
|
345
|
+
createRegistryItemSurfaceMove({ ...original, wallId: 'wall_test' } as AnyNode),
|
|
346
|
+
).toBeNull()
|
|
347
|
+
expect(createRegistryItemSurfaceMove(host)).toBeNull()
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
test('moves across and between hosts, then detaches at the level-local cursor without rotating', () => {
|
|
351
|
+
const session = createRegistryItemSurfaceMove(original)!
|
|
352
|
+
let pose = session.enter(eventFor().event, dimensions, 0.3)!
|
|
353
|
+
pose = session.enter(eventFor(host, [0.7, 1, -0.4]).event, dimensions, pose.rotationY)!
|
|
354
|
+
expect(pose.position[0]).toBeCloseTo(0.7)
|
|
355
|
+
const second = {
|
|
356
|
+
...host,
|
|
357
|
+
id: 'item_second-host',
|
|
358
|
+
rotation: [0, -0.8, 0],
|
|
359
|
+
position: [-2, 0, 1],
|
|
360
|
+
children: [],
|
|
361
|
+
} as ItemNode
|
|
362
|
+
useScene.getState().createNode(second, host.parentId as AnyNodeId)
|
|
363
|
+
const mesh = new Group()
|
|
364
|
+
mesh.rotation.set(...second.rotation)
|
|
365
|
+
mesh.position.set(...second.position)
|
|
366
|
+
sceneRegistry.nodes.get('level_surface-move')!.add(mesh)
|
|
367
|
+
sceneRegistry.nodes.set(second.id, mesh)
|
|
368
|
+
pose = session.enter(eventFor(second).event, dimensions, pose.rotationY)!
|
|
369
|
+
expect(session.worldYaw(pose.rotationY)).toBeCloseTo(0.7)
|
|
370
|
+
const world = sceneRegistry.nodes.get('level_surface-move')!.localToWorld(new Vector3(8, 2, 9))
|
|
371
|
+
const floor = session.detach(world.toArray(), pose.rotationY)!
|
|
372
|
+
expect(floor.position[0]).toBeCloseTo(8)
|
|
373
|
+
expect(floor.position[1]).toBe(0)
|
|
374
|
+
expect(floor.position[2]).toBeCloseTo(9)
|
|
375
|
+
expect(floor.rotationY).toBeCloseTo(0.3)
|
|
376
|
+
expect(session.hosted).toBe(false)
|
|
377
|
+
expect(useScene.getState().nodes[original.id]?.parentId).toBe('level_surface-move')
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
test('restores parent and full pose for cancellation, including a drag that starts hosted', () => {
|
|
381
|
+
const session = createRegistryItemSurfaceMove(original)!
|
|
382
|
+
session.enter(eventFor().event, dimensions, 0.3)
|
|
383
|
+
const hosted = useScene.getState().nodes[original.id]!
|
|
384
|
+
const hostedSession = createRegistryItemSurfaceMove(hosted)!
|
|
385
|
+
hostedSession.detach([0, 0, 0], -0.3)
|
|
386
|
+
hostedSession.restore()
|
|
387
|
+
expect(useScene.getState().nodes[original.id]).toEqual(hosted)
|
|
388
|
+
session.restore()
|
|
389
|
+
expect(useScene.getState().nodes[original.id]).toEqual(original)
|
|
390
|
+
expect((useScene.getState().nodes[host.id] as ItemNode).children as string[]).not.toContain(
|
|
391
|
+
original.id,
|
|
392
|
+
)
|
|
393
|
+
expect(useScene.temporal.getState().pastStates).toHaveLength(0)
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
test('restoring before the tracked commit makes hosting one undo step with the original parent and pose', () => {
|
|
397
|
+
const session = createRegistryItemSurfaceMove(original)!
|
|
398
|
+
session.enter(eventFor().event, dimensions, 0.3)
|
|
399
|
+
const final = useScene.getState().nodes[original.id]!
|
|
400
|
+
session.restore()
|
|
401
|
+
useScene.temporal.getState().resume()
|
|
402
|
+
useScene.getState().updateNodes([
|
|
403
|
+
{
|
|
404
|
+
id: original.id,
|
|
405
|
+
data: { ...final, ...resolveSupportSlabPatch(final, useScene.getState().nodes) },
|
|
406
|
+
},
|
|
407
|
+
])
|
|
408
|
+
useScene.temporal.getState().pause()
|
|
409
|
+
expect(useScene.temporal.getState().pastStates).toHaveLength(1)
|
|
410
|
+
useScene.temporal.getState().undo()
|
|
411
|
+
expect(useScene.getState().nodes[original.id]).toEqual(original)
|
|
412
|
+
expect((useScene.getState().nodes[host.id] as ItemNode).children as string[]).not.toContain(
|
|
413
|
+
original.id,
|
|
414
|
+
)
|
|
415
|
+
useScene.temporal.getState().redo()
|
|
416
|
+
expect(useScene.getState().nodes[original.id]).toEqual(final)
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
test('fresh procedural placements commit directly to a host and undo removes the entire placement', () => {
|
|
420
|
+
useScene.getState().updateNode(original.id, { metadata: { isNew: true } })
|
|
421
|
+
const session = createRegistryItemSurfaceMove(useScene.getState().nodes[original.id]!)!
|
|
422
|
+
const pose = session.enter(eventFor().event, dimensions, 0.3)!
|
|
423
|
+
const finalId = commitFreshPlacementSubtree(original.id, { parentId: host.id, visible: true })!
|
|
424
|
+
expect(finalId).toBeTruthy()
|
|
425
|
+
expect(useScene.getState().nodes[finalId]?.parentId).toBe(host.id)
|
|
426
|
+
expect(useScene.getState().nodes[finalId]).toMatchObject({
|
|
427
|
+
position: pose.position,
|
|
428
|
+
rotation: [0.1, pose.rotationY, 0.2],
|
|
429
|
+
})
|
|
430
|
+
expect((useScene.getState().nodes[host.id] as ItemNode).children as string[]).toContain(finalId)
|
|
431
|
+
expect(useScene.temporal.getState().pastStates).toHaveLength(1)
|
|
432
|
+
useScene.temporal.getState().undo()
|
|
433
|
+
expect(useScene.getState().nodes[finalId]).toBeUndefined()
|
|
434
|
+
expect(useScene.getState().nodes[original.id]).toBeUndefined()
|
|
435
|
+
expect((useScene.getState().nodes[host.id] as ItemNode).children as string[]).toEqual([])
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
test.each([
|
|
439
|
+
'floor',
|
|
440
|
+
'same host',
|
|
441
|
+
'floor then host',
|
|
442
|
+
])('a hosted move to %s commits once and undo restores the original parent and pose', (destination) => {
|
|
443
|
+
const initial = createRegistryItemSurfaceMove(original)!
|
|
444
|
+
const hostedPose = initial.enter(eventFor().event, dimensions, 0.3)!
|
|
445
|
+
const hosted = useScene.getState().nodes[original.id]!
|
|
446
|
+
const session = createRegistryItemSurfaceMove(hosted)!
|
|
447
|
+
let pose: { position: [number, number, number]; rotationY: number } = session.enter(
|
|
448
|
+
eventFor().event,
|
|
449
|
+
dimensions,
|
|
450
|
+
hostedPose.rotationY,
|
|
451
|
+
)!
|
|
452
|
+
if (destination !== 'same host') {
|
|
453
|
+
const world = sceneRegistry.nodes
|
|
454
|
+
.get('level_surface-move')!
|
|
455
|
+
.localToWorld(new Vector3(8, 0, 9))
|
|
456
|
+
pose = session.detach(world.toArray(), pose.rotationY)!
|
|
457
|
+
const detached = useScene.getState().nodes[original.id]!
|
|
458
|
+
expect(
|
|
459
|
+
getFloorPlacedElevation({
|
|
460
|
+
node: detached,
|
|
461
|
+
nodes: useScene.getState().nodes,
|
|
462
|
+
position: pose.position,
|
|
463
|
+
maxElevation: 0,
|
|
464
|
+
}),
|
|
465
|
+
).toBe(0)
|
|
466
|
+
const footprints = getFloorPlacedFootprints(
|
|
467
|
+
nodeRegistry.get(original.type)!.capabilities.floorPlaced!,
|
|
468
|
+
detached,
|
|
469
|
+
{ nodes: useScene.getState().nodes },
|
|
470
|
+
).map((footprint) => ({ ...footprint, position: footprint.position ?? pose.position }))
|
|
471
|
+
expect(
|
|
472
|
+
spatialGridManager.canPlaceOnFloorFootprints('level_surface-move', footprints, [
|
|
473
|
+
original.id,
|
|
474
|
+
]).valid,
|
|
475
|
+
).toBe(true)
|
|
476
|
+
}
|
|
477
|
+
if (destination !== 'floor') {
|
|
478
|
+
pose = session.enter(eventFor(host, [0.6, 1, -0.4]).event, dimensions, pose.rotationY)!
|
|
479
|
+
}
|
|
480
|
+
const final = useScene.getState().nodes[original.id]!
|
|
481
|
+
const patch = {
|
|
482
|
+
...final,
|
|
483
|
+
...resolveSupportSlabPatch(final, useScene.getState().nodes, { maxElevation: 0 }),
|
|
484
|
+
}
|
|
485
|
+
session.restore()
|
|
486
|
+
useScene.temporal.getState().resume()
|
|
487
|
+
useScene.getState().updateNodes([{ id: original.id, data: patch }])
|
|
488
|
+
useScene.temporal.getState().pause()
|
|
489
|
+
expect(useScene.getState().nodes[original.id]?.parentId).toBe(
|
|
490
|
+
destination === 'floor' ? 'level_surface-move' : host.id,
|
|
491
|
+
)
|
|
492
|
+
expect(useScene.temporal.getState().pastStates).toHaveLength(1)
|
|
493
|
+
useScene.temporal.getState().undo()
|
|
494
|
+
expect(useScene.getState().nodes[original.id]).toEqual(hosted)
|
|
495
|
+
expect((useScene.getState().nodes[host.id] as ItemNode).children as string[]).toContain(
|
|
496
|
+
original.id,
|
|
497
|
+
)
|
|
498
|
+
useScene.temporal.getState().redo()
|
|
499
|
+
expect(useScene.getState().nodes[original.id]).toEqual(patch)
|
|
500
|
+
})
|
|
501
|
+
})
|