@pascal-app/editor 1.0.0 → 1.0.3

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 (177) hide show
  1. package/package.json +12 -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/custom-camera-controls.tsx +22 -74
  5. package/src/components/editor/editor-layout-mobile.tsx +1 -0
  6. package/src/components/editor/floating-action-menu.tsx +14 -1
  7. package/src/components/editor/floorplan-panel.tsx +233 -166
  8. package/src/components/editor/grid.tsx +31 -7
  9. package/src/components/editor/group-actions.ts +5 -9
  10. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  11. package/src/components/editor/group-move-3d.ts +4 -6
  12. package/src/components/editor/group-rotate-handle.tsx +81 -35
  13. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  14. package/src/components/editor/group-transform-shared.test.ts +126 -1
  15. package/src/components/editor/group-transform-shared.ts +79 -73
  16. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  17. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  18. package/src/components/editor/handles/handle-placement.ts +69 -0
  19. package/src/components/editor/handles/linear-resize-drag.ts +13 -2
  20. package/src/components/editor/handles/use-handle-drag.ts +80 -8
  21. package/src/components/editor/index.tsx +36 -13
  22. package/src/components/editor/node-action-menu.tsx +2 -0
  23. package/src/components/editor/node-arrow-handles.tsx +162 -69
  24. package/src/components/editor/registry-action-contributions.tsx +53 -0
  25. package/src/components/editor/selection-manager.tsx +49 -0
  26. package/src/components/editor/thumbnail-generator.tsx +11 -2
  27. package/src/components/editor/wall-move-side-handles.tsx +71 -21
  28. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  29. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  30. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  31. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  32. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
  33. package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
  34. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
  35. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +113 -24
  36. package/src/components/procedural-items/inspector.tsx +223 -0
  37. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  38. package/src/components/systems/zone/zone-system.tsx +8 -4
  39. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  40. package/src/components/tools/item/item-surface-placement.test.ts +169 -0
  41. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +204 -0
  42. package/src/components/tools/item/placement-strategies.ts +212 -200
  43. package/src/components/tools/item/placement-types.ts +4 -0
  44. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  45. package/src/components/tools/item/use-draft-node.ts +136 -47
  46. package/src/components/tools/item/use-placement-coordinator.tsx +391 -157
  47. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  48. package/src/components/tools/registry/item-surface-move.test.ts +509 -0
  49. package/src/components/tools/registry/item-surface-move.ts +463 -0
  50. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  51. package/src/components/tools/registry/move-registry-node-tool.tsx +237 -78
  52. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  53. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  54. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  55. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  56. package/src/components/tools/shared/pointer-support-cap.ts +27 -13
  57. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  58. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  59. package/src/components/tools/shared/surface-hit.ts +23 -0
  60. package/src/components/tools/shared/surface-rejection.test.tsx +107 -0
  61. package/src/components/tools/shared/surface-rejection.tsx +43 -0
  62. package/src/components/tools/tool-manager.tsx +36 -3
  63. package/src/components/tools/zone/zone-tool.tsx +13 -4
  64. package/src/components/ui/command-palette/editor-commands.tsx +54 -1
  65. package/src/components/ui/controls/material-paint-panel.tsx +6 -67
  66. package/src/components/ui/controls/material-picker.tsx +5 -67
  67. package/src/components/ui/controls/metric-control.tsx +29 -13
  68. package/src/components/ui/controls/slider-control.tsx +87 -35
  69. package/src/components/ui/floating-level-selector.tsx +81 -5
  70. package/src/components/ui/helpers/helper-manager.tsx +24 -6
  71. package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
  72. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  73. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  74. package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
  75. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  76. package/src/components/ui/panels/parametric-inspector.tsx +9 -17
  77. package/src/components/ui/panels/reference-panel.tsx +3 -3
  78. package/src/components/ui/primitives/checkbox.tsx +34 -0
  79. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  80. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  81. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +669 -336
  82. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  83. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
  84. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +30 -11
  85. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  86. package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
  87. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  88. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  89. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
  90. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  91. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  92. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  93. package/src/components/ui/snap-target-badge.tsx +2 -0
  94. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  95. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  96. package/src/components/viewer/floorplan-preview.tsx +1 -12
  97. package/src/components/viewer/viewer-scene-header.tsx +3 -0
  98. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  99. package/src/components/viewer-zone-system.tsx +7 -2
  100. package/src/hooks/use-auto-save.integration.test.tsx +197 -0
  101. package/src/hooks/use-auto-save.ts +21 -6
  102. package/src/hooks/use-ceiling-events.test.ts +25 -2
  103. package/src/hooks/use-ceiling-events.ts +7 -1
  104. package/src/hooks/use-grid-events.test.ts +2 -0
  105. package/src/hooks/use-grid-events.ts +16 -11
  106. package/src/hooks/use-keyboard.test.ts +7 -0
  107. package/src/hooks/use-keyboard.ts +43 -28
  108. package/src/index.tsx +68 -3
  109. package/src/lib/catalog-panel-model.ts +39 -0
  110. package/src/lib/catalog-selection.test.ts +27 -0
  111. package/src/lib/catalog-selection.ts +8 -0
  112. package/src/lib/contextual-help.test.ts +27 -1
  113. package/src/lib/contextual-help.ts +16 -9
  114. package/src/lib/export-texture-utils.test.ts +167 -0
  115. package/src/lib/export-texture-utils.ts +121 -0
  116. package/src/lib/export-user-data.test.ts +48 -0
  117. package/src/lib/export-user-data.ts +69 -0
  118. package/src/lib/floorplan/floorplan-export.test.ts +142 -2
  119. package/src/lib/floorplan/floorplan-export.tsx +9 -3
  120. package/src/lib/floorplan/floorplan-extension.ts +16 -0
  121. package/src/lib/floorplan/floorplan-readonly.ts +4 -0
  122. package/src/lib/fresh-planar-placement.ts +173 -33
  123. package/src/lib/glb-export-serialize.test.ts +280 -0
  124. package/src/lib/glb-export.test.ts +265 -16
  125. package/src/lib/glb-export.ts +197 -37
  126. package/src/lib/grid-frame.test.ts +19 -0
  127. package/src/lib/grid-frame.ts +18 -0
  128. package/src/lib/interaction/scope.ts +9 -1
  129. package/src/lib/keyboard-pan.test.ts +62 -0
  130. package/src/lib/keyboard-pan.ts +89 -0
  131. package/src/lib/level-duplication.test.ts +73 -0
  132. package/src/lib/level-duplication.ts +32 -2
  133. package/src/lib/linear-display.test.ts +59 -0
  134. package/src/lib/linear-display.ts +39 -0
  135. package/src/lib/local-guide-image.test.ts +24 -0
  136. package/src/lib/local-guide-image.ts +34 -1
  137. package/src/lib/material-catalog-model.ts +72 -0
  138. package/src/lib/material-paint-panel-model.ts +76 -0
  139. package/src/lib/node-action-movement.ts +21 -0
  140. package/src/lib/overlay-transform.test.ts +28 -0
  141. package/src/lib/overlay-transform.ts +12 -0
  142. package/src/lib/panel-rows.ts +75 -0
  143. package/src/lib/panel-tool-options.test.ts +34 -0
  144. package/src/lib/panel-tool-options.ts +46 -0
  145. package/src/lib/parametric-node-update.ts +37 -0
  146. package/src/lib/portable-export.test.ts +89 -0
  147. package/src/lib/portable-export.ts +84 -11
  148. package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
  149. package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
  150. package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
  151. package/src/lib/print-shell-compiler-protocol.ts +8 -0
  152. package/src/lib/scene-clipboard.ts +1 -1
  153. package/src/lib/scene.test.ts +99 -0
  154. package/src/lib/scene.ts +21 -3
  155. package/src/lib/send-to-app.test.ts +108 -0
  156. package/src/lib/send-to-app.ts +121 -0
  157. package/src/lib/sfx/movement-tick.ts +37 -0
  158. package/src/lib/snapping-mode.test.ts +13 -2
  159. package/src/lib/snapping-mode.ts +11 -6
  160. package/src/lib/spatial-drag-plane.test.ts +53 -0
  161. package/src/lib/spatial-drag-plane.ts +19 -0
  162. package/src/lib/spatial-pointer-input.test.ts +72 -0
  163. package/src/lib/spatial-pointer-input.ts +62 -0
  164. package/src/lib/spatial-snap.test.ts +47 -0
  165. package/src/lib/surface-attachment.ts +77 -0
  166. package/src/lib/terrain-panel-model.ts +117 -0
  167. package/src/lib/units.ts +254 -0
  168. package/src/lib/usdz-export.ts +43 -16
  169. package/src/lib/use-linear-display.ts +8 -32
  170. package/src/lib/world-grid-snap.test.ts +48 -0
  171. package/src/lib/world-grid-snap.ts +9 -5
  172. package/src/store/rotation-snapping.test.ts +53 -0
  173. package/src/store/use-editor.tsx +10 -2
  174. package/src/store/use-floorplan-draft-preview.ts +10 -1
  175. package/src/store/use-handle-group.ts +4 -0
  176. package/src/store/use-interaction-scope.ts +155 -6
  177. package/src/store/use-send-to-app.ts +21 -0
@@ -8,6 +8,7 @@ import {
8
8
  type AnyNodeId,
9
9
  analyzePortConnectivity,
10
10
  bboxCornerAnchors,
11
+ cascadeDirty,
11
12
  collectAlignmentAnchors,
12
13
  createSceneApi,
13
14
  emitter,
@@ -18,6 +19,7 @@ import {
18
19
  getFloorPlacedFootprints,
19
20
  type MovableConfig,
20
21
  movingFootprintAnchors,
22
+ NON_PHYSICAL_HOST_KINDS,
21
23
  type NodeEvent,
22
24
  nodeRegistry,
23
25
  type ParentFrameSnapMatch,
@@ -27,6 +29,7 @@ import {
27
29
  resolveFacingIndicator,
28
30
  resolveFrozenFloorPlacementPatch,
29
31
  resolveSupportSlabPatch,
32
+ type SurfaceRejectReason,
30
33
  sceneRegistry,
31
34
  spatialGridManager,
32
35
  useLiveNodeOverrides,
@@ -36,19 +39,26 @@ import {
36
39
  import { useViewer } from '@pascal-app/viewer'
37
40
  import { useFrame, useThree } from '@react-three/fiber'
38
41
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
39
- import type { Group } from 'three'
42
+ import { type Group, Vector3 } from 'three'
40
43
  import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
41
44
  import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement'
42
- import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata'
45
+ import {
46
+ isFreshPlacementMetadata,
47
+ stripPlacementMetadataFlags,
48
+ } from '../../../lib/placement-metadata'
43
49
  import {
44
50
  offsetPlanPositionByLocalCenter,
45
51
  resolvePrioritizedPlanarCursorPosition,
46
52
  } from '../../../lib/planar-cursor-placement'
47
53
  import { resolveAttachmentPreviewRotation } from '../../../lib/rigid-plan-svg-transform'
48
- import { movementSfxStepKey } from '../../../lib/sfx/movement-tick'
54
+ import { createMovementSfxTick } from '../../../lib/sfx/movement-tick'
49
55
  import { sfxEmitter } from '../../../lib/sfx-bus'
50
56
  import { resolveSnapFlags } from '../../../lib/snapping-mode'
51
-
57
+ import {
58
+ surfaceAttachmentId,
59
+ surfaceAttachmentUpdates,
60
+ updateSurfaceNode,
61
+ } from '../../../lib/surface-attachment'
52
62
  import useAlignmentGuides from '../../../store/use-alignment-guides'
53
63
  import useEditor, {
54
64
  getActiveSnappingMode,
@@ -56,8 +66,8 @@ import useEditor, {
56
66
  isGridSnapActive,
57
67
  isMagneticSnapActive,
58
68
  } from '../../../store/use-editor'
59
-
60
69
  import useFacingPose from '../../../store/use-facing-pose'
70
+ import useInteractionScope from '../../../store/use-interaction-scope'
61
71
  import { swallowNextClick } from '../../editor/node-arrow-handles'
62
72
  import { CursorSphere } from '../shared/cursor-sphere'
63
73
  import { DragBoundingBox } from '../shared/drag-bounding-box'
@@ -68,6 +78,7 @@ import {
68
78
  type PointerSupportSurface,
69
79
  resolvePointerSupportSurface,
70
80
  } from '../shared/pointer-support-cap'
81
+ import { createItemSurfaceGridDispatch, createRegistryItemSurfaceMove } from './item-surface-move'
71
82
 
72
83
  /** Snap a world-plan coordinate to the editor's active grid step (0.5 / 0.25
73
84
  * / 0.1 / 0.05), read live so changing the step mid-drag takes effect. */
@@ -197,18 +208,6 @@ const ALIGNMENT_THRESHOLD_M = 0.08
197
208
  /**
198
209
  * Generic move tool for any registry-backed kind.
199
210
  *
200
- * Imperative-only motion during drag:
201
- * - On every `grid:move` we mutate `sceneRegistry.nodes.get(id).position`
202
- * directly. The node's store data is unchanged → the renderer doesn't
203
- * re-render → R3F doesn't reapply `position={node.position}` → the
204
- * imperative mutation sticks. Movement is smooth, framerate-locked,
205
- * and React-free.
206
- *
207
- * Store update happens only on commit (single undoable action).
208
- *
209
- * Cancel imperatively snaps the mesh back to its original position and
210
- * resumes history without ever having touched the store mid-drag.
211
- *
212
211
  * **Commit triggers**: the tool listens for `grid:click` and the generic
213
212
  * `node:click` event. A click on the grid plane fires `grid:click`; a click
214
213
  * on the moved node itself (or any other 3D geometry the ray happens to land
@@ -223,9 +222,19 @@ const ALIGNMENT_THRESHOLD_M = 0.08
223
222
  */
224
223
  type ClickTriggerEvent = GridEvent | NodeEvent<AnyNode>
225
224
 
226
- export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
225
+ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) {
226
+ const node = useMemo(
227
+ () =>
228
+ isFreshPlacementMetadata(source.metadata)
229
+ ? (useScene.getState().nodes[source.id] ?? source)
230
+ : source,
231
+ [source],
232
+ )
233
+ const itemSurfaceMove = useMemo(() => createRegistryItemSurfaceMove(node), [node])
234
+ const suppressRaycastsRef = useRef<(() => void) | null>(null)
227
235
  const previewGroupRef = useRef<Group>(null)
228
236
  useFrame(() => {
237
+ suppressRaycastsRef.current?.()
229
238
  if (!previewGroupRef.current) return
230
239
  const nodes = useScene.getState().nodes
231
240
  const parentId = nodes[node.id]?.parentId ?? node.parentId
@@ -282,7 +291,6 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
282
291
  return 0
283
292
  }, [node])
284
293
  const [cursorPosition, setCursorPosition] = useState<[number, number, number]>(originalPosition)
285
- const previousSnapRef = useRef<string | null>(null)
286
294
  /**
287
295
  * The latest snapped cursor position from `grid:move`. We commit at
288
296
  * THIS position regardless of which event variant fires the click —
@@ -362,20 +370,24 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
362
370
  [collides, parentFrameCollides, resolvedFootprint],
363
371
  )
364
372
  const [valid, setValid] = useState(true)
373
+ const [surfaceRejection, setSurfaceRejection] = useState<SurfaceRejectReason | null>(null)
365
374
  const previewRotationY = useCallback(
366
375
  (rotationY = rotationRef.current) =>
367
376
  parentFrame && frameParent
368
377
  ? parentFrame.parentRotationY(frameParent, useScene.getState().nodes) + rotationY
369
- : rotationY,
370
- [parentFrame, frameParent],
378
+ : itemSurfaceMove?.hosted
379
+ ? itemSurfaceMove.planPose(lastCursorRef.current, rotationY).rotationY
380
+ : rotationY,
381
+ [parentFrame, frameParent, itemSurfaceMove],
371
382
  )
372
383
  const visualPositionFor = useCallback(
373
384
  (position: [number, number, number], rotationY = rotationRef.current) => {
374
385
  if (parentFrame && frameParent) {
375
386
  return parentFrame.localToPlan(frameParent, position, useScene.getState().nodes)
376
387
  }
388
+ if (itemSurfaceMove?.hosted) return itemSurfaceMove.planPose(position, rotationY).position
377
389
  return getFloorStackPreviewPosition({
378
- node,
390
+ node: useScene.getState().nodes[node.id] ?? node,
379
391
  position,
380
392
  rotation: (() => {
381
393
  const r = (node as { rotation?: unknown }).rotation
@@ -386,7 +398,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
386
398
  maxElevation: supportCapRef.current,
387
399
  })
388
400
  },
389
- [parentFrame, frameParent, node],
401
+ [parentFrame, frameParent, node, itemSurfaceMove],
390
402
  )
391
403
  const canonicalPositionFromPlan = useCallback(
392
404
  (planX: number, localY: number, planZ: number): [number, number, number] =>
@@ -431,8 +443,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
431
443
  }, [])
432
444
 
433
445
  useEffect(() => {
446
+ const ownsSubtree = useInteractionScope.getState().adoptSubtree(node.id)
434
447
  useScene.temporal.getState().pause()
435
- previousSnapRef.current = null
448
+ const movementSfx = createMovementSfxTick()
436
449
  dragAnchorRef.current = null
437
450
  hasMovedRef.current = false
438
451
  rotationRef.current = originalRotationY
@@ -451,14 +464,22 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
451
464
  // would keep the previous clone's rotation/position until the next R/T.
452
465
  setCursorRotationY(previewRotationY(originalRotationY))
453
466
  lastCursorRef.current = originalPosition
467
+ let floorY = originalPosition[1]
468
+ let centeredAfterHost = false
454
469
  let committed = false
455
470
  const isNew = isFreshPlacement
456
471
 
457
472
  const baseRotation = (node as { rotation?: unknown }).rotation
458
- const toCommitRotation = (y: number): number | [number, number, number] =>
459
- Array.isArray(baseRotation)
460
- ? [(baseRotation[0] as number) ?? 0, y, (baseRotation[2] as number) ?? 0]
461
- : y
473
+ const toCommitRotation = (y: number): number | [number, number, number] => {
474
+ const live = useScene.getState().nodes[node.id]
475
+ const rotation =
476
+ live && surfaceAttachmentId(live) && 'rotation' in live ? live.rotation : baseRotation
477
+ return Array.isArray(rotation) ? [rotation[0] ?? 0, y, rotation[2] ?? 0] : y
478
+ }
479
+
480
+ const currentLevelId = () =>
481
+ useViewer.getState().selection.levelId ??
482
+ (itemSurfaceMove ? findLevelAncestorId(node.id, useScene.getState().nodes) : node.parentId)
462
483
 
463
484
  const getVisualPosition = visualPositionFor
464
485
  const applyMeshPose = (position: [number, number, number], rotationY = rotationRef.current) => {
@@ -469,7 +490,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
469
490
  }
470
491
  const markMovedNodeDirty = () => {
471
492
  if (useScene.getState().nodes[node.id]) {
472
- useScene.getState().markDirty(node.id as AnyNodeId)
493
+ for (const id of cascadeDirty(node.id as AnyNodeId, { scene: createSceneApi(useScene) }))
494
+ useScene.getState().markDirty(id)
473
495
  }
474
496
  }
475
497
 
@@ -563,10 +585,18 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
563
585
  // override so the user can drop on top of an existing item on purpose. Only
564
586
  // shelves show the box, so this no-ops for every other movable kind.
565
587
  const recomputeValidity = () => {
588
+ const rejection = itemSurfaceMove?.rejection ?? null
589
+ setSurfaceRejection(rejection)
590
+ if (rejection) {
591
+ validRef.current = false
592
+ setValid(false)
593
+ return
594
+ }
566
595
  if (!boxDimensions && !movableValidityConfig) return
567
- if (altRef.current) {
568
- validRef.current = true
569
- setValid(true)
596
+ if (altRef.current || itemSurfaceMove?.hosted) {
597
+ const valid = !itemSurfaceMove?.hosted || itemSurfaceMove.valid
598
+ validRef.current = valid
599
+ setValid(valid)
570
600
  return
571
601
  }
572
602
  if (parentFrameCollides && frameParent && parentFrame?.isValidPosition) {
@@ -584,7 +614,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
584
614
  setValid(validPosition)
585
615
  return
586
616
  }
587
- const levelId = useViewer.getState().selection.levelId ?? node.parentId
617
+ const levelId = currentLevelId()
588
618
  if (!levelId) {
589
619
  validRef.current = true
590
620
  setValid(true)
@@ -594,7 +624,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
594
624
  const liveRotation = previewRotationY(rotationRef.current)
595
625
  const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced
596
626
  const effectiveNode = {
597
- ...(node as Record<string, unknown>),
627
+ ...((useScene.getState().nodes[node.id] ?? node) as Record<string, unknown>),
598
628
  position: livePosition,
599
629
  rotation: Array.isArray((node as { rotation?: unknown }).rotation)
600
630
  ? [
@@ -651,10 +681,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
651
681
  // passes through the moved mesh and continues to the grid plane,
652
682
  // so `grid:move` keeps firing and the cursor tracks correctly.
653
683
  // We restore the original raycast on cleanup.
654
- const mesh = sceneRegistry.nodes.get(node.id)
655
684
  const restoreRaycasts: Array<() => void> = []
656
- if (mesh) {
657
- mesh.traverse((child) => {
685
+ const suppressed = new WeakSet<object>()
686
+ const suppressRaycasts = () => {
687
+ sceneRegistry.nodes.get(node.id)?.traverse((child) => {
688
+ if (suppressed.has(child)) return
689
+ suppressed.add(child)
658
690
  const original = child.raycast
659
691
  child.raycast = () => {}
660
692
  restoreRaycasts.push(() => {
@@ -662,6 +694,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
662
694
  })
663
695
  })
664
696
  }
697
+ // Reparenting remounts the renderer, so new meshes must also let the cursor ray through.
698
+ suppressRaycastsRef.current = suppressRaycasts
699
+ suppressRaycasts()
665
700
 
666
701
  // Static alignment candidates — anchors of every OTHER alignable object
667
702
  // (items, walls, fences, slabs, ceilings, columns) ON THE SAME LEVEL,
@@ -673,7 +708,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
673
708
  const alignmentCandidates = collectAlignmentAnchors(
674
709
  useScene.getState().nodes,
675
710
  node.id,
676
- useViewer.getState().selection.levelId ?? node.parentId,
711
+ currentLevelId(),
677
712
  )
678
713
 
679
714
  // Connectivity snapshot (existing port-bearing nodes only — fresh
@@ -686,7 +721,76 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
686
721
  if (snapshot.connections.length > 0) connectivityRef.current = snapshot
687
722
  }
688
723
 
724
+ const applySurfacePose = (pose: { position: [number, number, number]; rotationY: number }) => {
725
+ lastCursorRef.current = pose.position
726
+ rotationRef.current = pose.rotationY
727
+ freeRotationRef.current = pose.rotationY
728
+ attachmentRotationRef.current = null
729
+ centeredAfterHost = true
730
+ dragAnchorRef.current = null
731
+ hasMovedRef.current = true
732
+ revealFreshPlacement()
733
+ setCursorPosition(getVisualPosition(pose.position))
734
+ setCursorRotationY(previewRotationY(pose.rotationY))
735
+ useAlignmentGuides.getState().clear()
736
+ recomputeValidity()
737
+ }
738
+ const detachSurface = (
739
+ worldPosition: [number, number, number],
740
+ leaveEvent?: NodeEvent<AnyNode>,
741
+ ) => {
742
+ const pose = leaveEvent
743
+ ? itemSurfaceMove?.leave(leaveEvent, rotationRef.current)
744
+ : itemSurfaceMove?.detach(worldPosition, rotationRef.current)
745
+ if (!pose) return
746
+ floorY = 0
747
+ const pointed = resolvePointerSupportSurface(cameraRef.current, worldPosition)
748
+ supportCapRef.current = pointed?.elevation ?? null
749
+ supportSurfaceRef.current = pointed
750
+ applySurfacePose(pose)
751
+ applyMeshPose(pose.position)
752
+ }
753
+ let lastSurfaceEvent: NodeEvent<AnyNode> | null = null
754
+ const onItemMove = (event: NodeEvent<AnyNode>) => {
755
+ if (committed || !resolvedFootprint || !itemSurfaceMove) return
756
+ lastSurfaceEvent = event
757
+ const pose = itemSurfaceMove.enter(event, resolvedFootprint, rotationRef.current)
758
+ if (pose) {
759
+ applySurfacePose(pose)
760
+ // Compare floor and host movement in the same plan frame, never host-local storage.
761
+ const point = new Vector3(...pose.worldPosition)
762
+ const levelId = currentLevelId()
763
+ if (levelId) sceneRegistry.nodes.get(levelId)?.worldToLocal(point)
764
+ movementSfx.tick({
765
+ coords: [point.x, point.z],
766
+ gridSnapActive: isGridSnapActive(),
767
+ gridStep: useEditor.getState().gridSnapStep,
768
+ })
769
+ } else {
770
+ if (
771
+ itemSurfaceMove.hosted &&
772
+ (itemSurfaceMove.rejection === 'footprint-outside-surface' ||
773
+ itemSurfaceMove.rejection === 'footprint-exceeds-host' ||
774
+ itemSurfaceMove.rejection === 'no-surface')
775
+ )
776
+ detachSurface(event.position)
777
+ recomputeValidity()
778
+ }
779
+ }
780
+ const onItemLeave = (event: NodeEvent<AnyNode>) => {
781
+ if (!itemSurfaceMove?.hosted || committed) return
782
+ if (event.node.id !== useScene.getState().nodes[node.id]?.parentId) return
783
+ event.stopPropagation()
784
+ detachSurface(event.position, event)
785
+ }
786
+
689
787
  const onGridMove = (event: GridEvent) => {
788
+ const blocked = Boolean(itemSurfaceMove?.blocksGrid(event, cameraRef.current))
789
+ if (!committed && !blocked) {
790
+ itemSurfaceMove?.clearRejectionForGrid(event)
791
+ detachSurface(event.position)
792
+ }
793
+ if (committed || blocked) return
690
794
  // The pointer decides the target surface AND the cursor plan point,
691
795
  // both resolved from the true camera ray in one place. The event's
692
796
  // own hit can't be used directly: its plane rides at the ghost's
@@ -707,7 +811,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
707
811
 
708
812
  const magnetic = isMagneticSnapActive()
709
813
  const attachmentEnabled = magnetic || isGridSnapActive()
710
- const absolute = useAbsoluteCursorPlacement || cursorAttached
814
+ const absolute = useAbsoluteCursorPlacement || cursorAttached || centeredAfterHost
711
815
  const centerOffset: [number, number, number] =
712
816
  absolute && dragBounds?.center
713
817
  ? offsetPlanPositionByLocalCenter(
@@ -734,16 +838,13 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
734
838
  // Kind-owned grid hooks exchange origins and apply their own footprint offsets.
735
839
  candidatePosition: canonicalPositionFromPlan(
736
840
  planX - centerOffset[0],
737
- originalPosition[1],
841
+ floorY,
738
842
  planZ - centerOffset[2],
739
843
  ),
740
844
  candidateRotation: freeRotationRef.current,
741
845
  movingIds: [node.id as AnyNodeId],
742
846
  nodes: useScene.getState().nodes as Record<string, AnyNode>,
743
- levelId:
744
- (useViewer.getState().selection.levelId as AnyNodeId | null) ??
745
- (node.parentId as AnyNodeId | undefined) ??
746
- null,
847
+ levelId: (currentLevelId() as AnyNodeId | null) ?? null,
747
848
  gridStep: useEditor.getState().gridSnapStep,
748
849
  })
749
850
  const snappedPlanPosition = getVisualPosition(
@@ -761,15 +862,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
761
862
  ? ([planX, planZ]) => {
762
863
  const snapArgs: Parameters<NonNullable<typeof groupMoveSnapPoseConfig>>[0] = {
763
864
  node,
764
- candidatePosition: canonicalPositionFromPlan(planX, originalPosition[1], planZ),
865
+ candidatePosition: canonicalPositionFromPlan(planX, floorY, planZ),
765
866
  candidateRotation:
766
867
  absolute && dragBounds?.center ? freeRotationRef.current : rotationRef.current,
767
868
  movingIds: [node.id as AnyNodeId],
768
869
  nodes: useScene.getState().nodes as Record<string, AnyNode>,
769
- levelId:
770
- (useViewer.getState().selection.levelId as AnyNodeId | null) ??
771
- (node.parentId as AnyNodeId | undefined) ??
772
- null,
870
+ levelId: (currentLevelId() as AnyNodeId | null) ?? null,
773
871
  }
774
872
  const snappedPosition: GroupMoveSnapResult | null = groupMoveSnapPoseConfig
775
873
  ? groupMoveSnapPoseConfig(snapArgs)
@@ -845,7 +943,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
845
943
  }
846
944
  }
847
945
 
848
- let position = canonicalPositionFromPlan(x, originalPosition[1], z)
946
+ let position = canonicalPositionFromPlan(x, floorY, z)
849
947
  if (
850
948
  !attachmentSnapped &&
851
949
  (magnetic || isGridSnapActive()) &&
@@ -885,7 +983,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
885
983
  if (!parentFrame && pointed?.sourceNodeId) {
886
984
  const rotation = toCommitRotation(rotationRef.current)
887
985
  const effectiveNode = {
888
- ...(node as Record<string, unknown>),
986
+ ...((useScene.getState().nodes[node.id] ?? node) as Record<string, unknown>),
889
987
  position,
890
988
  rotation,
891
989
  } as AnyNode
@@ -930,15 +1028,26 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
930
1028
  // Carry connected ductwork along (preview only — committed on drop).
931
1029
  previewConnectivity(position, rotationRef.current)
932
1030
 
933
- const nextSnapKey = movementSfxStepKey({
1031
+ const soundStep = {
934
1032
  coords: [x, z],
935
1033
  gridSnapActive: isGridSnapActive() && !attachmentSnapped,
936
1034
  gridStep: useEditor.getState().gridSnapStep,
937
- })
938
- const prev = previousSnapRef.current
939
- if (prev !== nextSnapKey) {
940
- sfxEmitter.emit('sfx:grid-snap')
941
- previousSnapRef.current = nextSnapKey
1035
+ }
1036
+ if (itemSurfaceMove) {
1037
+ movementSfx.schedule(
1038
+ soundStep,
1039
+ () => !itemSurfaceMove.blocksGrid(event, cameraRef.current),
1040
+ true,
1041
+ )
1042
+ } else movementSfx.tick(soundStep, true)
1043
+ }
1044
+
1045
+ const gridDispatch = createItemSurfaceGridDispatch(onGridMove)
1046
+ const receiveGridMove = (event: GridEvent) => {
1047
+ if (itemSurfaceMove?.hosted) gridDispatch.schedule(event)
1048
+ else {
1049
+ gridDispatch.cancel()
1050
+ onGridMove(event)
942
1051
  }
943
1052
  }
944
1053
 
@@ -967,6 +1076,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
967
1076
  // finds the fresh draft already deleted and takes the orphan re-create
968
1077
  // path below, minting a hidden ghost copy and replaying the SFX.
969
1078
  if (committed) return
1079
+ gridDispatch.flush()
1080
+ movementSfx.flush()
1081
+ if (itemSurfaceMove?.rejection || (itemSurfaceMove?.hosted && !itemSurfaceMove.valid)) return
970
1082
  // Ignore a commit that fires before the cursor has moved into place —
971
1083
  // it's the stray trailing click of whatever armed this move, not a
972
1084
  // deliberate drop. Prevents preset re-arm from double-placing.
@@ -982,11 +1094,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
982
1094
 
983
1095
  if (useScene.getState().nodes[node.id]) {
984
1096
  const effectiveNode = {
985
- ...(node as Record<string, unknown>),
1097
+ ...((useScene.getState().nodes[node.id] ?? node) as Record<string, unknown>),
986
1098
  position,
987
1099
  rotation,
988
1100
  } as AnyNode
989
1101
  const data = {
1102
+ ...(itemSurfaceMove ? { parentId: effectiveNode.parentId } : {}),
990
1103
  position,
991
1104
  rotation,
992
1105
  // The pointer cap makes the persisted host reproduce the capped
@@ -1013,11 +1126,13 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1013
1126
  } as Partial<AnyNode>
1014
1127
 
1015
1128
  if (isNew) {
1016
- const finalId = commitFreshPlacementSubtree(node.id as AnyNodeId, data)
1017
- if (finalId) {
1018
- committed = true
1019
- committedId = finalId
1020
- }
1129
+ const finalId = commitFreshPlacementSubtree(node.id as AnyNodeId, data, (reason) => {
1130
+ setSurfaceRejection(reason)
1131
+ setValid(false)
1132
+ })
1133
+ if (!finalId) return
1134
+ committed = true
1135
+ committedId = finalId
1021
1136
  } else {
1022
1137
  // Fold the connected-ductwork follow-updates into the SAME
1023
1138
  // batch as the moved node so the whole thing is one undo step.
@@ -1027,10 +1142,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1027
1142
  buildPreviewNode(position, rotationRef.current),
1028
1143
  ).filter((u) => useScene.getState().nodes[u.id])
1029
1144
  : []
1145
+ const surfaceId = surfaceAttachmentId(effectiveNode)
1146
+ itemSurfaceMove?.restore()
1030
1147
  useScene.temporal.getState().resume()
1031
1148
  useScene
1032
1149
  .getState()
1033
- .updateNodes([{ id: node.id as AnyNodeId, data }, ...connectivityUpdates])
1150
+ .updateNodes([
1151
+ { id: node.id as AnyNodeId, data },
1152
+ ...surfaceAttachmentUpdates(node.id, effectiveNode.parentId, surfaceId),
1153
+ ...connectivityUpdates,
1154
+ ])
1034
1155
  // Kind-owned derived-state maintenance after a parent-frame move
1035
1156
  // (cabinet run re-flow + linked corner-run re-anchor). Runs in the
1036
1157
  // resumed window so its writes are undoable alongside the move.
@@ -1144,8 +1265,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1144
1265
  )
1145
1266
  if (nextFreeRotation === null) return
1146
1267
  sfxEmitter.emit('sfx:item-rotate')
1147
- let position = lastCursorRef.current
1268
+ const hostedPose = itemSurfaceMove?.rotate(nextFreeRotation)
1269
+ let position = hostedPose?.position ?? lastCursorRef.current
1148
1270
  if (
1271
+ !hostedPose &&
1149
1272
  hasMovedRef.current &&
1150
1273
  (useAbsoluteCursorPlacement || cursorAttached) &&
1151
1274
  dragBounds?.center
@@ -1161,25 +1284,28 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1161
1284
  previewRotationY(nextFreeRotation),
1162
1285
  )
1163
1286
  position = canonicalPositionFromPlan(planOrigin[0], position[1], planOrigin[2])
1164
- lastCursorRef.current = position
1165
1287
  }
1166
- freeRotationRef.current = nextFreeRotation
1288
+ lastCursorRef.current = position
1289
+ freeRotationRef.current = hostedPose?.rotationY ?? nextFreeRotation
1167
1290
  rotationRef.current = freeRotationRef.current
1168
1291
  setCursorRotationY(previewRotationY(rotationRef.current))
1169
1292
  const visualPosition = getVisualPosition(position)
1170
1293
  setCursorPosition(visualPosition)
1171
1294
  applyMeshPose(position)
1172
- useLiveTransforms.getState().set(node.id, {
1173
- position,
1174
- rotation: rotationRef.current,
1175
- supportElevationCap: supportCapRef.current ?? undefined,
1176
- })
1295
+ if (!itemSurfaceMove?.hosted)
1296
+ useLiveTransforms.getState().set(node.id, {
1297
+ position,
1298
+ rotation: rotationRef.current,
1299
+ supportElevationCap: supportCapRef.current ?? undefined,
1300
+ })
1177
1301
  syncParentFramePreview(position)
1178
1302
  markMovedNodeDirty()
1179
1303
  // Rotating the fitting swings its collars — connected ducts follow.
1180
1304
  previewConnectivity(position, rotationRef.current)
1181
1305
  // Rotation changes the footprint's collision span — re-check validity.
1182
- recomputeValidity()
1306
+ if (!itemSurfaceMove?.hosted && itemSurfaceMove?.rejection && lastSurfaceEvent)
1307
+ onItemMove(lastSurfaceEvent)
1308
+ else recomputeValidity()
1183
1309
  }
1184
1310
  const onKeyUp = (e: KeyboardEvent) => {
1185
1311
  if (e.key === 'Alt') {
@@ -1190,7 +1316,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1190
1316
  window.addEventListener('keydown', onKeyDown)
1191
1317
  window.addEventListener('keyup', onKeyUp)
1192
1318
 
1193
- emitter.on('grid:move', onGridMove)
1319
+ const surfaceKinds = Array.from(nodeRegistry.entries(), ([kind]) => kind).filter(
1320
+ (kind) => !NON_PHYSICAL_HOST_KINDS.includes(kind),
1321
+ )
1322
+ for (const kind of surfaceKinds) {
1323
+ emitter.on(`${kind}:enter` as never, onItemMove)
1324
+ emitter.on(`${kind}:move` as never, onItemMove)
1325
+ emitter.on(`${kind}:leave` as never, onItemLeave)
1326
+ emitter.on(`${kind}:click` as never, commitAtCursor)
1327
+ }
1328
+ emitter.on('grid:move', receiveGridMove)
1194
1329
  emitter.on('grid:click', commitAtCursor)
1195
1330
 
1196
1331
  const onPlacementDragPointerUp = (event: PointerEvent) => {
@@ -1211,12 +1346,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1211
1346
  emitter.on('node:click', commitAtCursor)
1212
1347
 
1213
1348
  const onCancel = () => {
1349
+ movementSfx.cancel()
1350
+ gridDispatch.cancel()
1214
1351
  useLiveTransforms.getState().clear(node.id)
1215
1352
  clearConnectivityOverrides()
1216
1353
  clearParentFramePreview()
1217
1354
  if (isNew) {
1218
- useScene.getState().deleteNode(node.id as AnyNodeId)
1355
+ updateSurfaceNode(node.id, {}, null)
1356
+ if (!ownsSubtree) useScene.getState().deleteNode(node.id)
1219
1357
  } else {
1358
+ itemSurfaceMove?.restore()
1220
1359
  applyMeshPose(originalPosition, originalRotationY)
1221
1360
  markMovedNodeDirty()
1222
1361
  }
@@ -1228,9 +1367,18 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1228
1367
  emitter.on('tool:cancel', onCancel)
1229
1368
 
1230
1369
  return () => {
1370
+ movementSfx.cancel()
1371
+ gridDispatch.cancel()
1231
1372
  window.removeEventListener('keydown', onKeyDown)
1232
1373
  window.removeEventListener('keyup', onKeyUp)
1233
- emitter.off('grid:move', onGridMove)
1374
+ suppressRaycastsRef.current = null
1375
+ for (const kind of surfaceKinds) {
1376
+ emitter.off(`${kind}:enter` as never, onItemMove)
1377
+ emitter.off(`${kind}:move` as never, onItemMove)
1378
+ emitter.off(`${kind}:leave` as never, onItemLeave)
1379
+ emitter.off(`${kind}:click` as never, commitAtCursor)
1380
+ }
1381
+ emitter.off('grid:move', receiveGridMove)
1234
1382
  emitter.off('grid:click', commitAtCursor)
1235
1383
  window.removeEventListener('pointerup', onPlacementDragPointerUp)
1236
1384
  emitter.off('node:click', commitAtCursor)
@@ -1242,10 +1390,11 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1242
1390
  // unmount / commit paths uniformly.
1243
1391
  useAlignmentGuides.getState().clear()
1244
1392
  const finalisedBy2D = useEditor.getState().movingNodeOrigin === '2d'
1245
- if (!(committed || isNew || finalisedBy2D)) {
1393
+ if (!committed && !finalisedBy2D && (!isNew || (!ownsSubtree && itemSurfaceMove))) {
1246
1394
  useLiveTransforms.getState().clear(node.id)
1247
1395
  clearConnectivityOverrides()
1248
1396
  clearParentFramePreview()
1397
+ itemSurfaceMove?.restore()
1249
1398
  applyMeshPose(originalPosition, originalRotationY)
1250
1399
  markMovedNodeDirty()
1251
1400
  }
@@ -1253,6 +1402,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1253
1402
  }
1254
1403
  }, [
1255
1404
  boxDimensions,
1405
+ resolvedFootprint,
1406
+ itemSurfaceMove,
1256
1407
  dragBounds,
1257
1408
  canonicalPositionFromPlan,
1258
1409
  parentFrame,
@@ -1320,7 +1471,15 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1320
1471
  <DragBoundingBox
1321
1472
  center={dragBounds?.center}
1322
1473
  centerY={dragBounds?.centerY}
1323
- color={boxDimensions ? (valid ? VALID_COLOR : INVALID_COLOR) : undefined}
1474
+ color={
1475
+ surfaceRejection
1476
+ ? INVALID_COLOR
1477
+ : boxDimensions
1478
+ ? valid
1479
+ ? VALID_COLOR
1480
+ : INVALID_COLOR
1481
+ : undefined
1482
+ }
1324
1483
  nodeId={node.id}
1325
1484
  position={cursorPosition}
1326
1485
  rotationY={cursorRotationY}
@@ -0,0 +1,24 @@
1
+ import { afterAll, afterEach, expect, spyOn, test } from 'bun:test'
2
+ import { type AnyNodeDefinition, nodeRegistry } from '@pascal-app/core'
3
+ import { getRegistryAffordanceTool, preloadRegistryAffordanceTools } from './affordance-dispatch'
4
+
5
+ const get = spyOn(nodeRegistry, 'get')
6
+ afterEach(() => get.mockReset())
7
+ afterAll(() => get.mockRestore())
8
+
9
+ test('a preloaded drag tool mounts directly without first-gesture suspense', async () => {
10
+ const Tool = () => null
11
+ const loader = async () => ({ default: Tool })
12
+ get.mockReturnValue({ affordanceTools: { move: loader } } as unknown as AnyNodeDefinition)
13
+ await preloadRegistryAffordanceTools('wall')
14
+ expect(getRegistryAffordanceTool('wall', 'move')).toBe(Tool)
15
+ expect(getRegistryAffordanceTool('wall', 'move')).toBe(Tool)
16
+ })
17
+
18
+ test('preloading does not change an already mounted lazy component identity', async () => {
19
+ const loader = async () => ({ default: () => null })
20
+ get.mockReturnValue({ affordanceTools: { move: loader } } as unknown as AnyNodeDefinition)
21
+ const mounted = getRegistryAffordanceTool('wall', 'move')
22
+ await preloadRegistryAffordanceTools('wall')
23
+ expect(getRegistryAffordanceTool('wall', 'move')).toBe(mounted)
24
+ })