@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,441 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
type AnyNodeId,
|
|
4
|
+
createSceneApi,
|
|
5
|
+
findLevelAncestorId,
|
|
6
|
+
type GridEvent,
|
|
7
|
+
NON_PHYSICAL_HOST_KINDS,
|
|
8
|
+
type NodeEvent,
|
|
9
|
+
nodeRegistry,
|
|
10
|
+
resolveSurfacePlacement,
|
|
11
|
+
sceneRegistry,
|
|
12
|
+
useLiveTransforms,
|
|
13
|
+
useScene,
|
|
14
|
+
} from '@pascal-app/core'
|
|
15
|
+
import { boxCorners } from '@pascal-app/core/procedural-items'
|
|
16
|
+
import { type Camera, Euler, Quaternion, Vector3 } from 'three'
|
|
17
|
+
import { isFreshPlacementMetadata } from '../../../lib/placement-metadata'
|
|
18
|
+
import {
|
|
19
|
+
surfaceAttachmentId,
|
|
20
|
+
surfaceFramePose,
|
|
21
|
+
updateSurfaceNode,
|
|
22
|
+
} from '../../../lib/surface-attachment'
|
|
23
|
+
import { snapToGrid, snapToHalf } from '../item/placement-math'
|
|
24
|
+
import { createShelfStickiness } from '../shared/shelf-stickiness'
|
|
25
|
+
import { itemEventToSurfaceHit } from '../shared/surface-hit'
|
|
26
|
+
import { createSurfaceRejectionFeedback } from '../shared/surface-rejection'
|
|
27
|
+
|
|
28
|
+
export function createItemSurfacePointerArbitration() {
|
|
29
|
+
let hostId: string | null = null
|
|
30
|
+
let hitEvent: object | null = null
|
|
31
|
+
return {
|
|
32
|
+
hit(id: string, event: object) {
|
|
33
|
+
hostId = id
|
|
34
|
+
hitEvent = event
|
|
35
|
+
},
|
|
36
|
+
clear() {
|
|
37
|
+
hostId = null
|
|
38
|
+
hitEvent = null
|
|
39
|
+
},
|
|
40
|
+
blocksGrid(event: object) {
|
|
41
|
+
return hostId !== null && event === hitEvent
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function pointerEventOf(event: GridEvent | NodeEvent<AnyNode>): object {
|
|
47
|
+
return event.nativeEvent.nativeEvent ?? event.nativeEvent
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createItemSurfaceGridDispatch(apply: (event: GridEvent) => void) {
|
|
51
|
+
let pending: GridEvent | null = null
|
|
52
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
53
|
+
const cancel = () => {
|
|
54
|
+
clearTimeout(timer)
|
|
55
|
+
timer = undefined
|
|
56
|
+
pending = null
|
|
57
|
+
}
|
|
58
|
+
const flush = () => {
|
|
59
|
+
const event = pending
|
|
60
|
+
cancel()
|
|
61
|
+
if (event) apply(event)
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
schedule(event: GridEvent) {
|
|
65
|
+
pending = event
|
|
66
|
+
// Canvas grid listeners precede R3F's wrapper listener. A task waits for both;
|
|
67
|
+
// a microtask can run between native DOM listeners, before the host hit arrives.
|
|
68
|
+
if (timer === undefined) timer = setTimeout(flush, 0)
|
|
69
|
+
},
|
|
70
|
+
flush,
|
|
71
|
+
cancel,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type ItemSurfaceGrab = {
|
|
76
|
+
hostId: string
|
|
77
|
+
start: [number, number, number]
|
|
78
|
+
anchor: [number, number, number] | null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function resolveItemSurfaceGrab(
|
|
82
|
+
grab: ItemSurfaceGrab | null,
|
|
83
|
+
hostId: string,
|
|
84
|
+
raw: [number, number, number],
|
|
85
|
+
): { grab: ItemSurfaceGrab | null; position: [number, number, number] } {
|
|
86
|
+
if (!grab || grab.hostId !== hostId) return { grab: null, position: raw }
|
|
87
|
+
const anchor = grab.anchor ?? raw
|
|
88
|
+
return {
|
|
89
|
+
grab: { ...grab, anchor },
|
|
90
|
+
position: [grab.start[0] + (raw[0] - anchor[0]), raw[1], grab.start[2] + (raw[2] - anchor[2])],
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parentWorldYaw(parentId: string | null | undefined): number {
|
|
95
|
+
const mesh = parentId ? sceneRegistry.nodes.get(parentId) : undefined
|
|
96
|
+
return mesh
|
|
97
|
+
? new Euler().setFromQuaternion(mesh.getWorldQuaternion(new Quaternion()), 'YXZ').y
|
|
98
|
+
: 0
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function createRegistryItemSurfaceMove(node: AnyNode) {
|
|
102
|
+
const capabilities = nodeRegistry.get(node.type)?.capabilities
|
|
103
|
+
const floorPlaced = capabilities?.floorPlaced
|
|
104
|
+
if (!floorPlaced || (floorPlaced.applies && !floorPlaced.applies(node))) return null
|
|
105
|
+
|
|
106
|
+
const original = node as AnyNode & {
|
|
107
|
+
position: [number, number, number]
|
|
108
|
+
rotation: number | [number, number, number]
|
|
109
|
+
supportSlabId?: string
|
|
110
|
+
}
|
|
111
|
+
const originalSurfaceId = surfaceAttachmentId(node)
|
|
112
|
+
let changed = false
|
|
113
|
+
let valid = true
|
|
114
|
+
const feedback = createSurfaceRejectionFeedback()
|
|
115
|
+
const scene = createSceneApi(useScene)
|
|
116
|
+
const pointer = createItemSurfacePointerArbitration()
|
|
117
|
+
const originalParent = original.parentId
|
|
118
|
+
? useScene.getState().nodes[original.parentId as AnyNodeId]
|
|
119
|
+
: null
|
|
120
|
+
const initialGrab = (): ItemSurfaceGrab | null =>
|
|
121
|
+
originalParent &&
|
|
122
|
+
!NON_PHYSICAL_HOST_KINDS.includes(originalParent.type) &&
|
|
123
|
+
!isFreshPlacementMetadata(original.metadata)
|
|
124
|
+
? {
|
|
125
|
+
hostId: originalParent.id,
|
|
126
|
+
start: surfaceFramePose(
|
|
127
|
+
original.parentId,
|
|
128
|
+
originalSurfaceId,
|
|
129
|
+
{
|
|
130
|
+
position: original.position,
|
|
131
|
+
rotation: Array.isArray(original.rotation)
|
|
132
|
+
? original.rotation
|
|
133
|
+
: [0, original.rotation, 0],
|
|
134
|
+
},
|
|
135
|
+
false,
|
|
136
|
+
).position,
|
|
137
|
+
anchor: null,
|
|
138
|
+
}
|
|
139
|
+
: null
|
|
140
|
+
let grab = initialGrab()
|
|
141
|
+
const cursorRayIntersectsShelf = createShelfStickiness()
|
|
142
|
+
const liveNode = () => useScene.getState().nodes[node.id] ?? node
|
|
143
|
+
const levelId = () => findLevelAncestorId(node.id, useScene.getState().nodes)
|
|
144
|
+
const rotation = (yaw: number) =>
|
|
145
|
+
Array.isArray(original.rotation)
|
|
146
|
+
? ([original.rotation[0], yaw, original.rotation[2]] as [number, number, number])
|
|
147
|
+
: yaw
|
|
148
|
+
const write = (
|
|
149
|
+
parentId: string | null | undefined,
|
|
150
|
+
position: [number, number, number],
|
|
151
|
+
yaw: number,
|
|
152
|
+
fullRotation?: readonly [number, number, number],
|
|
153
|
+
surfaceId?: string | null,
|
|
154
|
+
) => {
|
|
155
|
+
changed = true
|
|
156
|
+
// Hosted renderers and floorplan builders need the same parent-local pose.
|
|
157
|
+
updateSurfaceNode(
|
|
158
|
+
node.id,
|
|
159
|
+
{
|
|
160
|
+
parentId,
|
|
161
|
+
position,
|
|
162
|
+
rotation: fullRotation ? [...fullRotation] : rotation(yaw),
|
|
163
|
+
supportSlabId: undefined,
|
|
164
|
+
} as Partial<AnyNode>,
|
|
165
|
+
surfaceId,
|
|
166
|
+
)
|
|
167
|
+
useLiveTransforms.getState().clear(node.id)
|
|
168
|
+
}
|
|
169
|
+
const session = {
|
|
170
|
+
get rejection() {
|
|
171
|
+
return feedback.reason
|
|
172
|
+
},
|
|
173
|
+
clearRejectionForGrid(event: GridEvent) {
|
|
174
|
+
feedback.grid(pointerEventOf(event))
|
|
175
|
+
},
|
|
176
|
+
get valid() {
|
|
177
|
+
return valid && !feedback.reason
|
|
178
|
+
},
|
|
179
|
+
get hosted() {
|
|
180
|
+
const parentId = liveNode().parentId
|
|
181
|
+
const parent = parentId ? useScene.getState().nodes[parentId as AnyNodeId] : null
|
|
182
|
+
return !!parent && !NON_PHYSICAL_HOST_KINDS.includes(parent.type)
|
|
183
|
+
},
|
|
184
|
+
worldYaw(yaw: number) {
|
|
185
|
+
const live = liveNode() as typeof original
|
|
186
|
+
const pose = surfaceFramePose(
|
|
187
|
+
live.parentId,
|
|
188
|
+
surfaceAttachmentId(live),
|
|
189
|
+
{ position: live.position, rotation: [0, yaw, 0] },
|
|
190
|
+
false,
|
|
191
|
+
)
|
|
192
|
+
return pose.rotation[1] + parentWorldYaw(live.parentId)
|
|
193
|
+
},
|
|
194
|
+
planPose(position: [number, number, number], yaw: number) {
|
|
195
|
+
const parentId = liveNode().parentId
|
|
196
|
+
const parentMesh = parentId ? sceneRegistry.nodes.get(parentId) : undefined
|
|
197
|
+
const level = levelId()
|
|
198
|
+
const levelMesh = level ? sceneRegistry.nodes.get(level) : undefined
|
|
199
|
+
const local = surfaceFramePose(
|
|
200
|
+
parentId,
|
|
201
|
+
surfaceAttachmentId(liveNode()),
|
|
202
|
+
{ position, rotation: [0, yaw, 0] },
|
|
203
|
+
false,
|
|
204
|
+
)
|
|
205
|
+
const point = new Vector3(...local.position)
|
|
206
|
+
parentMesh?.localToWorld(point)
|
|
207
|
+
levelMesh?.worldToLocal(point)
|
|
208
|
+
return {
|
|
209
|
+
position: [point.x, point.y, point.z] as [number, number, number],
|
|
210
|
+
rotationY: session.worldYaw(yaw) - parentWorldYaw(level),
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
enter(event: NodeEvent<AnyNode>, dimensions: [number, number, number], yaw: number) {
|
|
214
|
+
pointer.clear()
|
|
215
|
+
feedback.clear()
|
|
216
|
+
const live = liveNode()
|
|
217
|
+
if (floorPlaced.applies && !floorPlaced.applies(live)) return null
|
|
218
|
+
const host = useScene.getState().nodes[event.node.id]
|
|
219
|
+
if (!host || NON_PHYSICAL_HOST_KINDS.includes(host.type)) return null
|
|
220
|
+
let ancestor: AnyNode | undefined = host
|
|
221
|
+
while (ancestor) {
|
|
222
|
+
if (ancestor.id === node.id) return null
|
|
223
|
+
ancestor = ancestor.parentId ? scene.get(ancestor.parentId as AnyNodeId) : undefined
|
|
224
|
+
}
|
|
225
|
+
const mesh = sceneRegistry.nodes.get(host.id)
|
|
226
|
+
if (!mesh) return null
|
|
227
|
+
const raw = mesh.worldToLocal(new Vector3(...event.position)).toArray()
|
|
228
|
+
const corrected = resolveItemSurfaceGrab(grab, host.id, raw)
|
|
229
|
+
const position = mesh.localToWorld(new Vector3(...corrected.position)).toArray()
|
|
230
|
+
// Keep the legacy world round-trip so existing poses retain identical floating-point values.
|
|
231
|
+
const hit = itemEventToSurfaceHit(host, { ...event, position })
|
|
232
|
+
if (!hit) return null
|
|
233
|
+
const counterHit =
|
|
234
|
+
host.type === 'cabinet' || host.type === 'procedural-item'
|
|
235
|
+
? itemEventToSurfaceHit(host, event)
|
|
236
|
+
: null
|
|
237
|
+
const stayingOnShelf = host.type === 'shelf' && live.parentId === host.id
|
|
238
|
+
const localYaw = session.worldYaw(yaw) - parentWorldYaw(host.id)
|
|
239
|
+
const bounds = capabilities?.dragBounds?.(live, scene.nodes())
|
|
240
|
+
const center = bounds?.center
|
|
241
|
+
const localRotation = rotation(localYaw)
|
|
242
|
+
const offset = center
|
|
243
|
+
? new Vector3(...center).applyEuler(
|
|
244
|
+
new Euler(
|
|
245
|
+
...(Array.isArray(localRotation) ? localRotation : ([0, localYaw, 0] as const)),
|
|
246
|
+
),
|
|
247
|
+
)
|
|
248
|
+
: new Vector3()
|
|
249
|
+
if (host.type !== 'item') {
|
|
250
|
+
const origin = [...hit.point]
|
|
251
|
+
if (!corrected.grab) {
|
|
252
|
+
origin[0]! -= offset.x
|
|
253
|
+
origin[2]! -= offset.z
|
|
254
|
+
}
|
|
255
|
+
hit.point = [
|
|
256
|
+
snapToGrid(origin[0]! + offset.x, dimensions[0]) - offset.x,
|
|
257
|
+
origin[1]!,
|
|
258
|
+
snapToGrid(origin[2]! + offset.z, dimensions[2]) - offset.z,
|
|
259
|
+
]
|
|
260
|
+
if (stayingOnShelf) hit.normalWorldY = 1
|
|
261
|
+
}
|
|
262
|
+
const placement = resolveSurfacePlacement({
|
|
263
|
+
host,
|
|
264
|
+
childKind: node.type,
|
|
265
|
+
childFootprint: {
|
|
266
|
+
size: dimensions,
|
|
267
|
+
rotationY: localYaw,
|
|
268
|
+
rotation: Array.isArray(localRotation) ? localRotation : [0, localYaw, 0],
|
|
269
|
+
localBounds: center
|
|
270
|
+
? {
|
|
271
|
+
min: center.map((v, i) => v - dimensions[i]! / 2) as [number, number, number],
|
|
272
|
+
max: center.map((v, i) => v + dimensions[i]! / 2) as [number, number, number],
|
|
273
|
+
}
|
|
274
|
+
: undefined,
|
|
275
|
+
},
|
|
276
|
+
hit: counterHit ?? hit,
|
|
277
|
+
origin: counterHit ? hit.point : undefined,
|
|
278
|
+
scene,
|
|
279
|
+
snapScalar: host.type !== 'item' ? undefined : snapToGrid,
|
|
280
|
+
checkFootprint: true,
|
|
281
|
+
onReject: (reason) => feedback.reject(reason, pointerEventOf(event)),
|
|
282
|
+
})
|
|
283
|
+
if (!placement) return null
|
|
284
|
+
valid = true
|
|
285
|
+
const childPose =
|
|
286
|
+
placement.childFrame === 'surface-local' ? placement.surfaceLocal : placement
|
|
287
|
+
if (!childPose) return null
|
|
288
|
+
const pose = {
|
|
289
|
+
position: [...childPose.position] as [number, number, number],
|
|
290
|
+
rotationY: childPose.rotationY,
|
|
291
|
+
worldPosition: mesh.localToWorld(new Vector3(...placement.position)).toArray(),
|
|
292
|
+
}
|
|
293
|
+
grab = corrected.grab
|
|
294
|
+
pointer.hit(host.id, pointerEventOf(event))
|
|
295
|
+
event.stopPropagation()
|
|
296
|
+
write(
|
|
297
|
+
host.id,
|
|
298
|
+
pose.position,
|
|
299
|
+
pose.rotationY,
|
|
300
|
+
placement.childFrame === 'surface-local' ? placement.surfaceLocal?.rotation : undefined,
|
|
301
|
+
placement.surfaceId,
|
|
302
|
+
)
|
|
303
|
+
return pose
|
|
304
|
+
},
|
|
305
|
+
blocksGrid(event: GridEvent, camera?: Camera) {
|
|
306
|
+
return (
|
|
307
|
+
session.hosted &&
|
|
308
|
+
(pointer.blocksGrid(pointerEventOf(event)) ||
|
|
309
|
+
Boolean(camera && cursorRayIntersectsShelf(liveNode().parentId, camera, event.position)))
|
|
310
|
+
)
|
|
311
|
+
},
|
|
312
|
+
leave(event: NodeEvent<AnyNode>, yaw: number) {
|
|
313
|
+
if (event.node.id !== liveNode().parentId) return null
|
|
314
|
+
if (event.node.type === 'shelf' || event.node.type === 'cabinet') return null
|
|
315
|
+
return session.detach(event.position, yaw)
|
|
316
|
+
},
|
|
317
|
+
detach(worldPosition: [number, number, number], yaw: number) {
|
|
318
|
+
if (!session.hosted) return null
|
|
319
|
+
const level = levelId()
|
|
320
|
+
if (!level) return null
|
|
321
|
+
pointer.clear()
|
|
322
|
+
feedback.clear()
|
|
323
|
+
valid = true
|
|
324
|
+
grab = null
|
|
325
|
+
const point = new Vector3(...worldPosition)
|
|
326
|
+
sceneRegistry.nodes.get(level)?.worldToLocal(point)
|
|
327
|
+
const rotationY = session.worldYaw(yaw) - parentWorldYaw(level)
|
|
328
|
+
const position: [number, number, number] = [snapToHalf(point.x), 0, snapToHalf(point.z)]
|
|
329
|
+
write(level, position, rotationY)
|
|
330
|
+
return { position, rotationY }
|
|
331
|
+
},
|
|
332
|
+
rotate(yaw: number) {
|
|
333
|
+
if (!session.hosted) return null
|
|
334
|
+
const live = liveNode() as typeof original
|
|
335
|
+
const position: [number, number, number] = [...live.position]
|
|
336
|
+
const host = live.parentId ? scene.get(live.parentId as AnyNodeId) : undefined
|
|
337
|
+
const storedRotation = Array.isArray(live.rotation)
|
|
338
|
+
? ([live.rotation[0], yaw, live.rotation[2]] as [number, number, number])
|
|
339
|
+
: ([0, yaw, 0] as [number, number, number])
|
|
340
|
+
const local = surfaceFramePose(
|
|
341
|
+
live.parentId,
|
|
342
|
+
surfaceAttachmentId(live),
|
|
343
|
+
{ position, rotation: storedRotation },
|
|
344
|
+
false,
|
|
345
|
+
)
|
|
346
|
+
if (host?.type === 'procedural-item') position.splice(0, 3, ...local.position)
|
|
347
|
+
if (
|
|
348
|
+
host?.type === 'cabinet' ||
|
|
349
|
+
host?.type === 'shelf' ||
|
|
350
|
+
host?.type === 'item' ||
|
|
351
|
+
host?.type === 'procedural-item'
|
|
352
|
+
) {
|
|
353
|
+
const footprint = floorPlaced.footprint?.(live, { nodes: scene.nodes() })
|
|
354
|
+
const bounds:
|
|
355
|
+
| { size: [number, number, number]; center?: [number, number, number] }
|
|
356
|
+
| undefined =
|
|
357
|
+
capabilities?.dragBounds?.(live, scene.nodes()) ??
|
|
358
|
+
(footprint ? { size: footprint.dimensions } : undefined)
|
|
359
|
+
if (!bounds) return null
|
|
360
|
+
const center = bounds.center ?? [0, bounds.size[1] / 2, 0]
|
|
361
|
+
const localRotation = host.type === 'procedural-item' ? local.rotation : rotation(yaw)
|
|
362
|
+
const euler = new Euler(
|
|
363
|
+
...(Array.isArray(localRotation) ? localRotation : ([0, yaw, 0] as const)),
|
|
364
|
+
)
|
|
365
|
+
const offset = new Vector3(...center).applyEuler(euler)
|
|
366
|
+
const localBounds = {
|
|
367
|
+
min: center.map((v, i) => v - bounds.size[i]! / 2) as [number, number, number],
|
|
368
|
+
max: center.map((v, i) => v + bounds.size[i]! / 2) as [number, number, number],
|
|
369
|
+
}
|
|
370
|
+
const bottom = Math.min(
|
|
371
|
+
...boxCorners(localBounds.min, localBounds.max).map(
|
|
372
|
+
(point) => new Vector3(...point).applyEuler(euler).y,
|
|
373
|
+
),
|
|
374
|
+
)
|
|
375
|
+
feedback.clear()
|
|
376
|
+
const placement = resolveSurfacePlacement({
|
|
377
|
+
host,
|
|
378
|
+
childKind: live.type,
|
|
379
|
+
childFootprint: {
|
|
380
|
+
size: bounds.size,
|
|
381
|
+
rotationY: Array.isArray(localRotation) ? localRotation[1] : localRotation,
|
|
382
|
+
rotation: Array.isArray(localRotation) ? localRotation : [0, yaw, 0],
|
|
383
|
+
localBounds,
|
|
384
|
+
},
|
|
385
|
+
hit: {
|
|
386
|
+
point:
|
|
387
|
+
host.type === 'item'
|
|
388
|
+
? position
|
|
389
|
+
: [position[0] + offset.x, position[1] + bottom, position[2] + offset.z],
|
|
390
|
+
normalWorldY: 1,
|
|
391
|
+
},
|
|
392
|
+
origin: position,
|
|
393
|
+
scene,
|
|
394
|
+
onReject: (reason) => feedback.reject(reason),
|
|
395
|
+
})
|
|
396
|
+
valid = !!placement
|
|
397
|
+
if (placement && host.type === 'procedural-item') {
|
|
398
|
+
const pose =
|
|
399
|
+
placement.childFrame === 'surface-local'
|
|
400
|
+
? placement.surfaceLocal!
|
|
401
|
+
: { ...placement, rotation: local.rotation }
|
|
402
|
+
write(host.id, [...pose.position], pose.rotationY, pose.rotation, placement.surfaceId)
|
|
403
|
+
return {
|
|
404
|
+
position: [...pose.position] as [number, number, number],
|
|
405
|
+
rotationY: pose.rotationY,
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (placement) position[1] = placement.position[1]
|
|
409
|
+
else if (
|
|
410
|
+
feedback.reason === 'footprint-outside-surface' ||
|
|
411
|
+
feedback.reason === 'footprint-exceeds-host' ||
|
|
412
|
+
feedback.reason === 'no-surface'
|
|
413
|
+
) {
|
|
414
|
+
const mesh = sceneRegistry.nodes.get(host.id)
|
|
415
|
+
if (mesh)
|
|
416
|
+
return session.detach(mesh.localToWorld(new Vector3(...position)).toArray(), yaw)
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
write(live.parentId, position, yaw)
|
|
420
|
+
return { position, rotationY: yaw }
|
|
421
|
+
},
|
|
422
|
+
restore() {
|
|
423
|
+
feedback.clear()
|
|
424
|
+
valid = true
|
|
425
|
+
pointer.clear()
|
|
426
|
+
grab = initialGrab()
|
|
427
|
+
if (!changed || !useScene.getState().nodes[node.id]) return
|
|
428
|
+
updateSurfaceNode(
|
|
429
|
+
node.id,
|
|
430
|
+
{
|
|
431
|
+
parentId: original.parentId,
|
|
432
|
+
position: original.position,
|
|
433
|
+
rotation: original.rotation,
|
|
434
|
+
supportSlabId: original.supportSlabId,
|
|
435
|
+
} as Partial<AnyNode>,
|
|
436
|
+
originalSurfaceId,
|
|
437
|
+
)
|
|
438
|
+
},
|
|
439
|
+
}
|
|
440
|
+
return session
|
|
441
|
+
}
|
|
@@ -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
|
+
type AnyNodeId,
|
|
5
|
+
type GridEvent,
|
|
6
|
+
type ItemEvent,
|
|
7
|
+
ItemNode,
|
|
8
|
+
LevelNode,
|
|
9
|
+
nodeRegistry,
|
|
10
|
+
registerNode,
|
|
11
|
+
type SurfacePlacement,
|
|
12
|
+
sceneRegistry,
|
|
13
|
+
useLiveTransforms,
|
|
14
|
+
useScene,
|
|
15
|
+
} from '@pascal-app/core'
|
|
16
|
+
import { Group, Vector3 } from 'three'
|
|
17
|
+
import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement'
|
|
18
|
+
import useEditor from '../../../store/use-editor'
|
|
19
|
+
import useInteractionScope from '../../../store/use-interaction-scope'
|
|
20
|
+
import { createRegistryItemSurfaceMove } from './item-surface-move'
|
|
21
|
+
|
|
22
|
+
const level = LevelNode.parse({ id: 'level_resolver' })
|
|
23
|
+
const asset = {
|
|
24
|
+
id: 'table',
|
|
25
|
+
name: 'Table',
|
|
26
|
+
category: 'furniture',
|
|
27
|
+
thumbnail: '',
|
|
28
|
+
src: '/table.glb',
|
|
29
|
+
dimensions: [3, 1, 3],
|
|
30
|
+
}
|
|
31
|
+
const host = ItemNode.parse({
|
|
32
|
+
id: 'item_resolver-host',
|
|
33
|
+
parentId: level.id,
|
|
34
|
+
asset,
|
|
35
|
+
position: [4, 0, 5],
|
|
36
|
+
rotation: [0, 0.6, 0],
|
|
37
|
+
})
|
|
38
|
+
const child = ItemNode.parse({
|
|
39
|
+
id: 'item_resolver-child',
|
|
40
|
+
parentId: level.id,
|
|
41
|
+
asset,
|
|
42
|
+
position: [1, 0, 2],
|
|
43
|
+
rotation: [0.1, 0.3, 0.2],
|
|
44
|
+
metadata: { isNew: true },
|
|
45
|
+
visible: false,
|
|
46
|
+
})
|
|
47
|
+
const dimensions: [number, number, number] = [1, 1, 0.5]
|
|
48
|
+
const savedRaf = globalThis.requestAnimationFrame
|
|
49
|
+
const savedCancelRaf = globalThis.cancelAnimationFrame
|
|
50
|
+
let savedNodes: ReturnType<typeof useScene.getState>['nodes']
|
|
51
|
+
let savedRoots: AnyNodeId[]
|
|
52
|
+
let savedEditor: ReturnType<typeof useEditor.getState>
|
|
53
|
+
let savedScope: ReturnType<typeof useInteractionScope.getState>
|
|
54
|
+
let restoreRegistry: () => void
|
|
55
|
+
let resolver: ReturnType<typeof spyOn<typeof core, 'resolveSurfacePlacement'>>
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
globalThis.requestAnimationFrame = (callback) => {
|
|
59
|
+
callback(0)
|
|
60
|
+
return 0
|
|
61
|
+
}
|
|
62
|
+
globalThis.cancelAnimationFrame = () => {}
|
|
63
|
+
savedEditor = useEditor.getState()
|
|
64
|
+
savedScope = useInteractionScope.getState()
|
|
65
|
+
useInteractionScope.setState({ scope: { kind: 'idle' } })
|
|
66
|
+
useEditor.setState({ mode: 'build', tool: 'item' })
|
|
67
|
+
useEditor.getState().setSnappingMode('item', 'off')
|
|
68
|
+
savedNodes = useScene.getState().nodes
|
|
69
|
+
savedRoots = useScene.getState().rootNodeIds
|
|
70
|
+
restoreRegistry = nodeRegistry._snapshot()
|
|
71
|
+
nodeRegistry._reset()
|
|
72
|
+
registerNode({
|
|
73
|
+
kind: 'item',
|
|
74
|
+
schemaVersion: 1,
|
|
75
|
+
schema: ItemNode,
|
|
76
|
+
category: 'furnish',
|
|
77
|
+
defaults: () => ({}),
|
|
78
|
+
capabilities: {
|
|
79
|
+
hostable: { parents: ['level', 'item'], align: 'face' },
|
|
80
|
+
floorPlaced: {},
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
useScene.setState({
|
|
84
|
+
nodes: {
|
|
85
|
+
[level.id]: { ...level, children: [host.id, child.id] },
|
|
86
|
+
[host.id]: host,
|
|
87
|
+
[child.id]: child,
|
|
88
|
+
},
|
|
89
|
+
rootNodeIds: [level.id],
|
|
90
|
+
})
|
|
91
|
+
useScene.temporal.getState().clear()
|
|
92
|
+
useScene.temporal.getState().pause()
|
|
93
|
+
const levelMesh = new Group()
|
|
94
|
+
levelMesh.position.set(10, 3, 20)
|
|
95
|
+
levelMesh.rotation.y = 0.4
|
|
96
|
+
const hostMesh = new Group()
|
|
97
|
+
hostMesh.position.set(...host.position)
|
|
98
|
+
hostMesh.rotation.set(...host.rotation)
|
|
99
|
+
levelMesh.add(hostMesh)
|
|
100
|
+
levelMesh.updateMatrixWorld(true)
|
|
101
|
+
sceneRegistry.nodes.set(level.id, levelMesh)
|
|
102
|
+
sceneRegistry.nodes.set(host.id, hostMesh)
|
|
103
|
+
resolver = spyOn(core, 'resolveSurfacePlacement')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
afterEach(() => {
|
|
107
|
+
resolver.mockRestore()
|
|
108
|
+
useEditor.setState(savedEditor)
|
|
109
|
+
useInteractionScope.setState(savedScope)
|
|
110
|
+
restoreRegistry()
|
|
111
|
+
useLiveTransforms.getState().clear(child.id)
|
|
112
|
+
sceneRegistry.nodes.delete(level.id)
|
|
113
|
+
sceneRegistry.nodes.delete(host.id)
|
|
114
|
+
useScene.setState({ nodes: savedNodes, rootNodeIds: savedRoots })
|
|
115
|
+
useScene.temporal.getState().clear()
|
|
116
|
+
useScene.temporal.getState().resume()
|
|
117
|
+
globalThis.requestAnimationFrame = savedRaf
|
|
118
|
+
globalThis.cancelAnimationFrame = savedCancelRaf
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
function hit(local: [number, number, number] = [0.2, 1, 0.3]): ItemEvent {
|
|
122
|
+
const object = sceneRegistry.nodes.get(host.id)!
|
|
123
|
+
return {
|
|
124
|
+
node: host,
|
|
125
|
+
object,
|
|
126
|
+
normal: [0, 1, 0],
|
|
127
|
+
localPosition: local,
|
|
128
|
+
position: object.localToWorld(new Vector3(...local)).toArray(),
|
|
129
|
+
nativeEvent: {},
|
|
130
|
+
stopPropagation() {},
|
|
131
|
+
} as ItemEvent
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
test('the registry asks the shared resolver with host-local input and keeps the frozen live output', () => {
|
|
135
|
+
const event = hit()
|
|
136
|
+
const session = createRegistryItemSurfaceMove(child)!
|
|
137
|
+
// Captured from resolveItemSurfacePlacement at ad2a57b8 with snapping off.
|
|
138
|
+
const expected: {
|
|
139
|
+
position: [number, number, number]
|
|
140
|
+
worldPosition: [number, number, number]
|
|
141
|
+
rotationY: number
|
|
142
|
+
} = {
|
|
143
|
+
position: [0.20000000000000284, 1, 0.29999999999999716],
|
|
144
|
+
worldPosition: [15.991837444170788, 4, 23.04142809557868],
|
|
145
|
+
rotationY: -0.30000000000000004,
|
|
146
|
+
}
|
|
147
|
+
expect(session.enter(event, dimensions, 0.3)).toEqual(expected)
|
|
148
|
+
expect(resolver).toHaveBeenCalledTimes(1)
|
|
149
|
+
const args = resolver.mock.calls[0]![0]
|
|
150
|
+
expect(args.host).toBe(host)
|
|
151
|
+
expect(args.childKind).toBe(child.type)
|
|
152
|
+
expect(args.childFootprint.size).toEqual(dimensions)
|
|
153
|
+
expect(args.childFootprint.rotationY).toBe(expected!.rotationY)
|
|
154
|
+
expect(args.hit.normalWorldY).toBe(1)
|
|
155
|
+
expect(args.scene.get(host.id)).toBe(useScene.getState().nodes[host.id])
|
|
156
|
+
expect(args.snapScalar).toBeFunction()
|
|
157
|
+
expect(args.checkFootprint).toBe(true)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('a null resolver result leaves the fresh floor draft and live transform untouched', () => {
|
|
161
|
+
resolver.mockReturnValue(null)
|
|
162
|
+
const session = createRegistryItemSurfaceMove(child)!
|
|
163
|
+
const event = hit()
|
|
164
|
+
const stopped = spyOn(event, 'stopPropagation')
|
|
165
|
+
useLiveTransforms.getState().set(child.id, { position: [8, 0, 9], rotation: 0.3 })
|
|
166
|
+
const before = useScene.getState().nodes
|
|
167
|
+
expect(session.enter(event, dimensions, 0.3)).toBeNull()
|
|
168
|
+
expect(resolver).toHaveBeenCalledTimes(1)
|
|
169
|
+
expect(session.hosted).toBe(false)
|
|
170
|
+
expect(session.blocksGrid(event as unknown as GridEvent)).toBe(false)
|
|
171
|
+
expect(stopped).not.toHaveBeenCalled()
|
|
172
|
+
expect(useScene.getState().nodes).toBe(before)
|
|
173
|
+
expect(useLiveTransforms.getState().get(child.id)?.position).toEqual([8, 0, 9])
|
|
174
|
+
session.restore()
|
|
175
|
+
expect(useScene.getState().nodes[child.id]).toBe(child)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('registry entry and subsequent moves keep checking fit; commit uses the accepted stored pose', () => {
|
|
179
|
+
const session = createRegistryItemSurfaceMove(child)!
|
|
180
|
+
const first = session.enter(hit(), dimensions, 0.3)!
|
|
181
|
+
const moved = session.enter(hit([0.7, 1, -0.4]), dimensions, first.rotationY)!
|
|
182
|
+
expect(moved).not.toBeNull()
|
|
183
|
+
const oversized = session.enter(hit(), [4, 1, 4], moved.rotationY)!
|
|
184
|
+
expect(oversized).not.toBeNull()
|
|
185
|
+
const accepted = useScene.getState().nodes[child.id]
|
|
186
|
+
expect(session.enter(hit([2, 1, 0]), [4, 1, 4], moved.rotationY)).toBeNull()
|
|
187
|
+
expect(useScene.getState().nodes[child.id]).toBe(accepted)
|
|
188
|
+
expect(resolver.mock.calls.map(([args]) => args.checkFootprint)).toEqual([true, true, true, true])
|
|
189
|
+
const finalId = commitFreshPlacementSubtree(child.id, { visible: true })!
|
|
190
|
+
expect(useScene.getState().nodes[finalId]).toMatchObject({
|
|
191
|
+
parentId: host.id,
|
|
192
|
+
position: oversized.position,
|
|
193
|
+
rotation: [0.1, oversized.rotationY, 0.2],
|
|
194
|
+
})
|
|
195
|
+
expect(resolver).toHaveBeenCalledTimes(4)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test('the mover rejects itself before asking the resolver', () => {
|
|
199
|
+
const session = createRegistryItemSurfaceMove(host)!
|
|
200
|
+
const before = useScene.getState().nodes
|
|
201
|
+
expect(session.enter(hit(), dimensions, 0.6)).toBeNull()
|
|
202
|
+
expect(resolver).not.toHaveBeenCalled()
|
|
203
|
+
expect(useScene.getState().nodes).toBe(before)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test('the mover rejects an indirect descendant before asking the resolver', () => {
|
|
207
|
+
const intermediate = ItemNode.parse({ id: 'item_resolver-middle', parentId: child.id, asset })
|
|
208
|
+
useScene.setState({
|
|
209
|
+
nodes: {
|
|
210
|
+
...useScene.getState().nodes,
|
|
211
|
+
[intermediate.id]: intermediate,
|
|
212
|
+
[host.id]: { ...host, parentId: intermediate.id },
|
|
213
|
+
},
|
|
214
|
+
})
|
|
215
|
+
const session = createRegistryItemSurfaceMove(child)!
|
|
216
|
+
const before = useScene.getState().nodes
|
|
217
|
+
expect(session.enter(hit(), dimensions, 0.3)).toBeNull()
|
|
218
|
+
expect(resolver).not.toHaveBeenCalled()
|
|
219
|
+
expect(useScene.getState().nodes).toBe(before)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test.each([
|
|
223
|
+
'host-local',
|
|
224
|
+
'surface-local',
|
|
225
|
+
] as const)('writes the resolver pose in its %s child frame', (childFrame) => {
|
|
226
|
+
const placement: SurfacePlacement = {
|
|
227
|
+
position: [0.25, 1.5, -0.75],
|
|
228
|
+
rotationY: 0.4,
|
|
229
|
+
surfaceId: 'test-surface',
|
|
230
|
+
childFrame,
|
|
231
|
+
surfaceLocal: { position: [-0.5, 0, 0.25], rotationY: -0.2, rotation: [0.7, -0.2, 0.8] },
|
|
232
|
+
}
|
|
233
|
+
resolver.mockReturnValue(placement)
|
|
234
|
+
const session = createRegistryItemSurfaceMove(child)!
|
|
235
|
+
const pose = session.enter(hit(), dimensions, 0.3)!
|
|
236
|
+
const expected = childFrame === 'surface-local' ? placement.surfaceLocal! : placement
|
|
237
|
+
expect(pose.position).toEqual([...expected.position])
|
|
238
|
+
expect(pose.rotationY).toBe(expected.rotationY)
|
|
239
|
+
expect(useScene.getState().nodes[child.id]).toMatchObject({
|
|
240
|
+
parentId: host.id,
|
|
241
|
+
position: expected.position,
|
|
242
|
+
rotation:
|
|
243
|
+
childFrame === 'surface-local'
|
|
244
|
+
? placement.surfaceLocal!.rotation
|
|
245
|
+
: [0.1, placement.rotationY, 0.2],
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
test('non-item events never reach the resolver', () => {
|
|
250
|
+
const session = createRegistryItemSurfaceMove(child)!
|
|
251
|
+
const before = useScene.getState().nodes
|
|
252
|
+
expect(
|
|
253
|
+
session.enter({ ...hit(), node: level } as unknown as ItemEvent, dimensions, 0.3),
|
|
254
|
+
).toBeNull()
|
|
255
|
+
expect(resolver).not.toHaveBeenCalled()
|
|
256
|
+
expect(useScene.getState().nodes).toBe(before)
|
|
257
|
+
})
|