@pascal-app/editor 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/package.json +7 -6
  2. package/src/components/editor/bake-exporter.tsx +4 -0
  3. package/src/components/editor/bake-thumbnail.tsx +4 -1
  4. package/src/components/editor/floating-action-menu.tsx +14 -1
  5. package/src/components/editor/floorplan-panel.tsx +23 -3
  6. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  7. package/src/components/editor/handles/handle-placement.ts +69 -0
  8. package/src/components/editor/handles/linear-resize-drag.ts +13 -2
  9. package/src/components/editor/handles/use-handle-drag.ts +5 -2
  10. package/src/components/editor/index.tsx +12 -4
  11. package/src/components/editor/node-arrow-handles.tsx +21 -62
  12. package/src/components/editor/selection-manager.tsx +49 -0
  13. package/src/components/editor/thumbnail-generator.tsx +11 -2
  14. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  15. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +46 -3
  16. package/src/components/procedural-items/inspector.tsx +223 -0
  17. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  18. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  19. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  20. package/src/components/tools/item/placement-strategies.ts +169 -169
  21. package/src/components/tools/item/placement-types.ts +4 -0
  22. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  23. package/src/components/tools/item/use-draft-node.ts +95 -45
  24. package/src/components/tools/item/use-placement-coordinator.tsx +247 -106
  25. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  26. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  27. package/src/components/tools/registry/item-surface-move.ts +441 -0
  28. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  29. package/src/components/tools/registry/move-registry-node-tool.tsx +215 -70
  30. package/src/components/tools/shared/pointer-support-cap.ts +11 -9
  31. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  32. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  33. package/src/components/tools/shared/surface-hit.ts +23 -0
  34. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  35. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  36. package/src/components/tools/zone/zone-tool.tsx +13 -4
  37. package/src/components/ui/command-palette/editor-commands.tsx +54 -1
  38. package/src/components/ui/controls/metric-control.tsx +29 -13
  39. package/src/components/ui/controls/slider-control.tsx +25 -18
  40. package/src/components/ui/floating-level-selector.tsx +81 -5
  41. package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
  42. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  43. package/src/components/ui/panels/parametric-inspector.tsx +6 -2
  44. package/src/components/ui/panels/reference-panel.tsx +3 -3
  45. package/src/components/ui/primitives/checkbox.tsx +34 -0
  46. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +120 -3
  47. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
  48. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +29 -10
  49. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  50. package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
  51. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  52. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  53. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
  54. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  55. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  56. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  57. package/src/components/ui/snap-target-badge.tsx +2 -0
  58. package/src/components/viewer/viewer-scene-header.tsx +3 -0
  59. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  60. package/src/hooks/use-ceiling-events.test.ts +24 -2
  61. package/src/hooks/use-ceiling-events.ts +5 -1
  62. package/src/hooks/use-keyboard.ts +3 -1
  63. package/src/index.tsx +5 -0
  64. package/src/lib/export-texture-utils.test.ts +167 -0
  65. package/src/lib/export-texture-utils.ts +121 -0
  66. package/src/lib/export-user-data.test.ts +48 -0
  67. package/src/lib/export-user-data.ts +69 -0
  68. package/src/lib/floorplan/floorplan-export.test.ts +139 -1
  69. package/src/lib/floorplan/floorplan-export.tsx +9 -3
  70. package/src/lib/floorplan/floorplan-readonly.ts +4 -0
  71. package/src/lib/fresh-planar-placement.ts +13 -5
  72. package/src/lib/glb-export-serialize.test.ts +280 -0
  73. package/src/lib/glb-export.test.ts +206 -0
  74. package/src/lib/glb-export.ts +171 -24
  75. package/src/lib/level-duplication.test.ts +73 -0
  76. package/src/lib/level-duplication.ts +32 -2
  77. package/src/lib/linear-display.test.ts +59 -0
  78. package/src/lib/linear-display.ts +39 -0
  79. package/src/lib/local-guide-image.test.ts +24 -0
  80. package/src/lib/local-guide-image.ts +34 -1
  81. package/src/lib/node-action-movement.ts +21 -0
  82. package/src/lib/portable-export.test.ts +89 -0
  83. package/src/lib/portable-export.ts +84 -11
  84. package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
  85. package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
  86. package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
  87. package/src/lib/print-shell-compiler-protocol.ts +8 -0
  88. package/src/lib/scene-clipboard.ts +1 -1
  89. package/src/lib/send-to-app.test.ts +108 -0
  90. package/src/lib/send-to-app.ts +121 -0
  91. package/src/lib/sfx/movement-tick.ts +37 -0
  92. package/src/lib/surface-attachment.ts +77 -0
  93. package/src/lib/units.ts +254 -0
  94. package/src/lib/usdz-export.ts +43 -16
  95. package/src/lib/use-linear-display.ts +8 -32
  96. package/src/store/use-handle-group.ts +4 -0
  97. package/src/store/use-send-to-app.ts +21 -0
@@ -0,0 +1,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
+ })