@pascal-app/editor 1.0.4 → 1.0.5

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 (309) hide show
  1. package/README.md +106 -0
  2. package/package.json +39 -37
  3. package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
  4. package/src/components/editor/bake-thumbnail.tsx +6 -15
  5. package/src/components/editor/construction-reveal-audio.test.tsx +150 -0
  6. package/src/components/editor/construction-reveal-audio.tsx +40 -0
  7. package/src/components/editor/custom-camera-controls.tsx +81 -8
  8. package/src/components/editor/editor-layout-mobile.tsx +1 -1
  9. package/src/components/editor/editor-layout-v2.tsx +1 -1
  10. package/src/components/editor/export-manager.tsx +14 -19
  11. package/src/components/editor/first-person/build-collider-world.test.ts +269 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +22 -5
  13. package/src/components/editor/first-person/ground-holes.test.ts +128 -0
  14. package/src/components/editor/first-person/ground-holes.ts +130 -0
  15. package/src/components/editor/first-person/mechanism-targets.test.ts +89 -2
  16. package/src/components/editor/first-person/mechanism-targets.ts +52 -0
  17. package/src/components/editor/first-person-controls.tsx +19 -11
  18. package/src/components/editor/floor-aware-camera-controls.test.ts +24 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +2 -2
  20. package/src/components/editor/floorplan-panel.tsx +72 -23
  21. package/src/components/editor/follow-pascal.test.tsx +912 -0
  22. package/src/components/editor/follow-pascal.tsx +354 -0
  23. package/src/components/editor/group-actions.ts +2 -0
  24. package/src/components/editor/group-move-3d.ts +2 -0
  25. package/src/components/editor/group-rotate-handle.tsx +2 -0
  26. package/src/components/editor/group-transform-shared.test.ts +460 -0
  27. package/src/components/editor/index.tsx +166 -79
  28. package/src/components/editor/riser-diagram-panel.tsx +14 -7
  29. package/src/components/editor/room-highlight.tsx +13 -5
  30. package/src/components/editor/room-labels.test.ts +621 -128
  31. package/src/components/editor/room-labels.tsx +1090 -190
  32. package/src/components/editor/selection-manager.tsx +38 -117
  33. package/src/components/editor/slab-hole-highlights.tsx +2 -2
  34. package/src/components/editor/snapshot-capture-overlay.tsx +3 -2
  35. package/src/components/editor/snapshot-capture.test.ts +69 -58
  36. package/src/components/editor/snapshot-capture.ts +7 -1
  37. package/src/components/editor/thumbnail-generator.test.ts +350 -4
  38. package/src/components/editor/thumbnail-generator.tsx +128 -54
  39. package/src/components/editor/use-floorplan-background-placement.ts +3 -1
  40. package/src/components/editor/views/plugins-view.tsx +1 -8
  41. package/src/components/editor/views/view-bar.test.tsx +54 -0
  42. package/src/components/editor/views/view-bar.tsx +140 -17
  43. package/src/components/editor/wall-snap-beacon-layer.tsx +5 -4
  44. package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
  45. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +2 -2
  46. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +3 -4
  47. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +1 -1
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +29 -7
  49. package/src/components/editor-2d/renderers/floorplan-reveal.test.tsx +254 -0
  50. package/src/components/editor-2d/renderers/floorplan-reveal.ts +141 -0
  51. package/src/components/point-ask/bubble.tsx +538 -0
  52. package/src/components/point-ask/hover-chip.tsx +91 -0
  53. package/src/components/point-ask/mode-pill.tsx +92 -0
  54. package/src/components/point-ask/optional-motion.ts +22 -0
  55. package/src/components/point-ask/outline-corners.tsx +93 -0
  56. package/src/components/point-ask/pins.tsx +711 -0
  57. package/src/components/point-ask/point-ask-layer.tsx +83 -0
  58. package/src/components/point-ask/point-ask-styles.ts +105 -0
  59. package/src/components/point-ask/region-marquee-styles.ts +15 -0
  60. package/src/components/point-ask/region-marquee.tsx +64 -0
  61. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
  62. package/src/components/systems/roof/roof-edit-system.tsx +1 -0
  63. package/src/components/tools/distribution/connection-compatibility.test.ts +34 -0
  64. package/src/components/tools/distribution/connection-compatibility.ts +43 -0
  65. package/src/components/tools/distribution/distribution-public.ts +9 -0
  66. package/src/components/tools/distribution/distribution-run-contract.test.ts +26 -0
  67. package/src/components/tools/distribution/distribution-run-contract.ts +94 -0
  68. package/src/components/tools/distribution/distribution-run-tool.test.ts +241 -0
  69. package/src/components/tools/distribution/distribution-run-tool.test.tsx +125 -0
  70. package/src/components/tools/distribution/distribution-run-tool.tsx +1323 -0
  71. package/src/components/tools/distribution/draw-alignment.ts +35 -0
  72. package/src/components/tools/distribution/ports.test.ts +56 -0
  73. package/src/components/tools/distribution/ports.ts +378 -0
  74. package/src/components/tools/distribution/run-cursor.test.ts +135 -0
  75. package/src/components/tools/distribution/run-cursor.ts +36 -0
  76. package/src/components/tools/distribution/run-direction-feedback.test.ts +60 -0
  77. package/src/components/tools/distribution/run-direction-feedback.tsx +251 -0
  78. package/src/components/tools/distribution/run-port-snap.test.ts +79 -0
  79. package/src/components/tools/distribution/run-port-snap.ts +25 -0
  80. package/src/components/tools/distribution/run-screen-direction.test.ts +23 -0
  81. package/src/components/tools/distribution/run-screen-direction.ts +30 -0
  82. package/src/components/tools/elevator/elevator-tool.tsx +3 -6
  83. package/src/components/tools/elevator/move-elevator-tool.tsx +2 -3
  84. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +67 -68
  85. package/src/components/tools/item/placement-history.test.tsx +38 -0
  86. package/src/components/tools/item/placement-strategies.test.ts +46 -1
  87. package/src/components/tools/item/placement-strategies.ts +3 -6
  88. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
  89. package/src/components/tools/item/use-draft-node.test.tsx +94 -94
  90. package/src/components/tools/item/use-placement-coordinator.tsx +55 -49
  91. package/src/components/tools/registry/item-surface-move.test.ts +62 -63
  92. package/src/components/tools/registry/item-surface-move.ts +2 -0
  93. package/src/components/tools/registry/item-surface-resolver.test.ts +26 -26
  94. package/src/components/tools/registry/move-registry-node-tool.test.ts +268 -1
  95. package/src/components/tools/registry/move-registry-node-tool.tsx +63 -29
  96. package/src/components/tools/tool-manager.tsx +14 -5
  97. package/src/components/tools/wall/wall-drafting.test.ts +170 -57
  98. package/src/components/ui/action-menu/action-button.tsx +6 -1
  99. package/src/components/ui/action-menu/camera-actions.tsx +7 -3
  100. package/src/components/ui/action-menu/control-modes.tsx +13 -7
  101. package/src/components/ui/action-menu/follow-pascal-pill.tsx +32 -0
  102. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +36 -0
  103. package/src/components/ui/action-menu/index.tsx +56 -33
  104. package/src/components/ui/action-menu/measurement-control.tsx +11 -9
  105. package/src/components/ui/action-menu/placement.ts +20 -0
  106. package/src/components/ui/action-menu/point-ask-button.tsx +52 -0
  107. package/src/components/ui/action-menu/view-toggles.tsx +47 -42
  108. package/src/components/ui/build-panel/build-panel.tsx +10 -3
  109. package/src/components/ui/command-palette/editor-commands.tsx +3 -12
  110. package/src/components/ui/command-palette/index.tsx +45 -15
  111. package/src/components/ui/command-palette/ranked-groups.test.ts +31 -0
  112. package/src/components/ui/controls/action-button.tsx +5 -2
  113. package/src/components/ui/controls/metric-control.tsx +1 -1
  114. package/src/components/ui/controls/segmented-control.tsx +21 -8
  115. package/src/components/ui/controls/select-control.tsx +1 -1
  116. package/src/components/ui/controls/slider-control.tsx +1 -1
  117. package/src/components/ui/controls/toggle-control.tsx +3 -3
  118. package/src/components/ui/controls/tool-options-panel.tsx +1 -1
  119. package/src/components/ui/floating-level-selector.tsx +150 -45
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +32 -7
  121. package/src/components/ui/helpers/helper-manager.test.tsx +54 -2
  122. package/src/components/ui/helpers/helper-manager.tsx +7 -1
  123. package/src/components/ui/helpers/item-helper.tsx +9 -1
  124. package/src/components/ui/panels/collections/collections-popover.tsx +8 -8
  125. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  126. package/src/components/ui/panels/mobile-selection-bar.tsx +6 -3
  127. package/src/components/ui/panels/multi-selection-panel.tsx +120 -3
  128. package/src/components/ui/panels/panel-wrapper.tsx +53 -27
  129. package/src/components/ui/panels/parametric-field-control.tsx +2 -2
  130. package/src/components/ui/panels/parametric-inspector.tsx +3 -3
  131. package/src/components/ui/panels/room-panel.tsx +65 -0
  132. package/src/components/ui/primitives/badge.tsx +8 -0
  133. package/src/components/ui/primitives/sidebar.tsx +1 -1
  134. package/src/components/ui/right-stack.tsx +21 -2
  135. package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
  136. package/src/components/ui/sidebar/icon-rail.tsx +3 -3
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  138. package/src/components/ui/sidebar/panels/items-panel/index.tsx +6 -6
  139. package/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx +40 -0
  140. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +7 -8
  141. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +170 -0
  142. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +92 -0
  143. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +107 -23
  144. package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
  145. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +4 -10
  146. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -11
  147. package/src/components/ui/sidebar/panels/site-panel/index.tsx +5 -12
  148. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +13 -10
  149. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +2 -2
  150. package/src/components/ui/sidebar/tab-bar.tsx +4 -4
  151. package/src/components/viewer/viewer-controls-bar.tsx +18 -6
  152. package/src/components/viewer/viewer-rooms.tsx +16 -0
  153. package/src/hooks/use-auto-save.integration.test.tsx +96 -38
  154. package/src/hooks/use-auto-save.ts +26 -1
  155. package/src/hooks/use-ceiling-events.test.ts +123 -118
  156. package/src/hooks/use-grid-events.test.ts +105 -105
  157. package/src/hooks/use-keyboard.test.ts +65 -16
  158. package/src/hooks/use-keyboard.ts +29 -12
  159. package/src/hooks/use-placement-notice.test.ts +24 -1
  160. package/src/hooks/use-placement-notice.ts +37 -6
  161. package/src/index.tsx +71 -24
  162. package/src/lib/__fixtures__/owner-victor-seattle-parking.json +1 -0
  163. package/src/lib/__fixtures__/owner-villa-aurelia-basement.json +1 -0
  164. package/src/lib/add-storey.test.ts +177 -0
  165. package/src/lib/add-storey.ts +59 -0
  166. package/src/lib/below-grade.test.tsx +171 -0
  167. package/src/lib/below-grade.ts +133 -0
  168. package/src/lib/camera-facing.test.ts +24 -0
  169. package/src/lib/camera-facing.ts +24 -0
  170. package/src/lib/direct-manipulation.ts +2 -0
  171. package/src/lib/download-project-file.ts +17 -0
  172. package/src/lib/editor-level-display.ts +9 -2
  173. package/src/lib/editor-views.ts +35 -1
  174. package/src/lib/export-texture-utils.ts +2 -4
  175. package/src/lib/floor-foundation-support.test.ts +1 -0
  176. package/src/lib/floorplan/floorplan-pdfkit-document.ts +31 -16
  177. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +39 -0
  178. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +4 -0
  179. package/src/lib/floorplan/pdfkit-standard-fonts.d.ts +5 -0
  180. package/src/lib/floorplan/sheet-export.test.ts +24 -0
  181. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +34 -45
  182. package/src/lib/floorplan/site-plan/coverage.ts +2 -4
  183. package/src/lib/floorplan/site-plan/front-edge.ts +2 -1
  184. package/src/lib/floorplan/site-plan/geometry.test.ts +9 -15
  185. package/src/lib/floorplan/site-plan/geometry.ts +1 -416
  186. package/src/lib/floorplan/site-plan/index.ts +0 -16
  187. package/src/lib/floorplan/site-plan/site-annotations.test.ts +117 -0
  188. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -2
  189. package/src/lib/floorplan/site-plan/site-parts.ts +14 -106
  190. package/src/lib/floorplan/site-plan/yards.test.ts +2 -1
  191. package/src/lib/follow-pascal-exterior.test-fixture.ts +17 -0
  192. package/src/lib/follow-pascal-exterior.test.ts +171 -0
  193. package/src/lib/follow-pascal-exterior.ts +39 -0
  194. package/src/lib/follow-pascal-presentation.test.ts +203 -0
  195. package/src/lib/follow-pascal-presentation.ts +129 -0
  196. package/src/lib/follow-pascal-sight.ts +84 -0
  197. package/src/lib/follow-pascal-targets.test.ts +406 -0
  198. package/src/lib/follow-pascal-targets.ts +198 -0
  199. package/src/lib/follow-pascal.test.ts +350 -0
  200. package/src/lib/follow-pascal.ts +284 -0
  201. package/src/lib/fresh-planar-placement.test.ts +112 -0
  202. package/src/lib/fresh-planar-placement.ts +57 -3
  203. package/src/lib/geometry-script/author.ts +6 -1
  204. package/src/lib/geometry-script/client.ts +8 -1
  205. package/src/lib/geometry-script/finish-scripts.ts +127 -0
  206. package/src/lib/geometry-script/protocol.ts +1 -0
  207. package/src/lib/glb-export.test.ts +187 -162
  208. package/src/lib/glb-export.ts +4 -1
  209. package/src/lib/history.test.ts +12 -7
  210. package/src/lib/interaction/scope.ts +4 -0
  211. package/src/lib/level-selection.test.ts +57 -0
  212. package/src/lib/level-selection.ts +16 -1
  213. package/src/lib/lot/clean-ring.test.ts +2 -1
  214. package/src/lib/lot/drop-in.ts +9 -24
  215. package/src/lib/lot/lot-patch.test.ts +1 -1
  216. package/src/lib/lot/lot-patch.ts +2 -2
  217. package/src/lib/measurement-kind.test.ts +6 -8
  218. package/src/lib/measurements.test.ts +23 -0
  219. package/src/lib/orbit-floor-clearance.test.ts +36 -0
  220. package/src/lib/orbit-floor-clearance.ts +48 -0
  221. package/src/lib/paint-pass-through.test.ts +11 -0
  222. package/src/lib/paint-pass-through.ts +4 -2
  223. package/src/lib/paint-scope.ts +1 -0
  224. package/src/lib/parametric-node-update.ts +16 -7
  225. package/src/lib/plugin-chat.ts +15 -0
  226. package/src/lib/point-ask/__fixtures__/consumer-browser.tsx +76 -0
  227. package/src/lib/point-ask/__fixtures__/house.ts +81 -0
  228. package/src/lib/point-ask/ask-created.test.ts +195 -0
  229. package/src/lib/point-ask/ask-flow.test.ts +380 -0
  230. package/src/lib/point-ask/build-context.test.ts +173 -0
  231. package/src/lib/point-ask/build-context.ts +94 -0
  232. package/src/lib/point-ask/capture-crop.ts +129 -0
  233. package/src/lib/point-ask/choreography.ts +69 -0
  234. package/src/lib/point-ask/compare.test.ts +145 -0
  235. package/src/lib/point-ask/compare.ts +73 -0
  236. package/src/lib/point-ask/describe-target.test.ts +165 -0
  237. package/src/lib/point-ask/describe-target.ts +234 -0
  238. package/src/lib/point-ask/dictation.ts +79 -0
  239. package/src/lib/point-ask/edge-marker.test.ts +61 -0
  240. package/src/lib/point-ask/edge-marker.ts +27 -0
  241. package/src/lib/point-ask/frame-loop.ts +25 -0
  242. package/src/lib/point-ask/history-marks.test.ts +94 -0
  243. package/src/lib/point-ask/history-marks.ts +39 -0
  244. package/src/lib/point-ask/host-api.ts +32 -0
  245. package/src/lib/point-ask/outline-box.test.ts +30 -0
  246. package/src/lib/point-ask/outline-box.ts +11 -0
  247. package/src/lib/point-ask/overlay-metrics.ts +6 -0
  248. package/src/lib/point-ask/pick.test.ts +220 -0
  249. package/src/lib/point-ask/pick.ts +121 -0
  250. package/src/lib/point-ask/pin-machine.test.ts +494 -0
  251. package/src/lib/point-ask/pin-machine.ts +217 -0
  252. package/src/lib/point-ask/placement.test.ts +203 -0
  253. package/src/lib/point-ask/placement.ts +141 -0
  254. package/src/lib/point-ask/pointer-tracker.ts +34 -0
  255. package/src/lib/point-ask/projector.ts +83 -0
  256. package/src/lib/point-ask/region.test.ts +157 -0
  257. package/src/lib/point-ask/region.ts +136 -0
  258. package/src/lib/point-ask/retry.test.ts +217 -0
  259. package/src/lib/point-ask/retry.ts +37 -0
  260. package/src/lib/point-ask/scene-commit.test.ts +312 -0
  261. package/src/lib/point-ask/scene-commit.ts +32 -0
  262. package/src/lib/point-ask/session.ts +1391 -0
  263. package/src/lib/point-ask/spring-keyframes.test.ts +45 -0
  264. package/src/lib/point-ask/spring-keyframes.ts +38 -0
  265. package/src/lib/point-ask/spring.test.ts +63 -0
  266. package/src/lib/point-ask/spring.ts +107 -0
  267. package/src/lib/point-ask/suggestions.test.ts +80 -0
  268. package/src/lib/point-ask/suggestions.ts +125 -0
  269. package/src/lib/point-ask/target-scope.test.ts +622 -0
  270. package/src/lib/point-ask/target-scope.ts +338 -0
  271. package/src/lib/point-ask/types.ts +48 -0
  272. package/src/lib/portable-export.test.ts +34 -1
  273. package/src/lib/portable-export.ts +119 -53
  274. package/src/lib/print-feature-thickness.ts +10 -1
  275. package/src/lib/print-roof-solids.test.ts +39 -1
  276. package/src/lib/print-roof-solids.ts +43 -10
  277. package/src/lib/print-shell-compiler-manifold-core.ts +1 -1
  278. package/src/lib/room-assembly-overlay.ts +2 -3
  279. package/src/lib/room-selection.test.ts +45 -1
  280. package/src/lib/scene-clipboard.test.ts +246 -1
  281. package/src/lib/scene-clipboard.ts +44 -14
  282. package/src/lib/selection-routing.ts +1 -1
  283. package/src/lib/sfx-bus.ts +13 -0
  284. package/src/lib/sfx-player.test.ts +70 -9
  285. package/src/lib/sfx-player.ts +6 -0
  286. package/src/lib/stair-levels.test.ts +25 -0
  287. package/src/lib/stair-levels.ts +22 -3
  288. package/src/lib/terrace-draft.ts +2 -1
  289. package/src/lib/viewer-selection.ts +22 -11
  290. package/src/lib/wall-placement-pointer.test.ts +42 -0
  291. package/src/lib/wall-placement-pointer.ts +57 -0
  292. package/src/lib/window-changes.ts +35 -0
  293. package/src/store/use-audio.test.ts +146 -0
  294. package/src/store/use-audio.tsx +127 -3
  295. package/src/store/use-construction-reveal.test.ts +173 -0
  296. package/src/store/use-construction-reveal.ts +145 -0
  297. package/src/store/use-corner-snap-hint.test.ts +24 -0
  298. package/src/store/use-corner-snap-hint.ts +47 -0
  299. package/src/store/use-follow-pascal.test.ts +62 -0
  300. package/src/store/use-follow-pascal.ts +53 -0
  301. package/src/store/use-point-ask.ts +247 -0
  302. package/src/store/use-stair-build-preview.ts +25 -5
  303. package/src/store/view-layout.test.ts +27 -1
  304. package/src/store/view-layout.ts +4 -1
  305. package/src/test-utils/render-current-markup.tsx +16 -0
  306. package/src/components/tools/stair/stair-click-guard.test.ts +0 -58
  307. package/src/components/tools/stair/stair-click-guard.ts +0 -72
  308. package/src/components/tools/stair/stair-defaults.ts +0 -15
  309. package/src/components/tools/stair/stair-tool.tsx +0 -682
@@ -443,67 +443,66 @@ describe('registry item-surface move session', () => {
443
443
  expect((useScene.getState().nodes[host.id] as ItemNode).children as string[]).toEqual([])
444
444
  })
445
445
 
446
- test.each([
447
- 'floor',
448
- 'same host',
449
- 'floor then host',
450
- ])('a hosted move to %s commits once and undo restores the original parent and pose', (destination) => {
451
- const initial = createRegistryItemSurfaceMove(original)!
452
- const hostedPose = initial.enter(eventFor().event, dimensions, 0.3)!
453
- const hosted = useScene.getState().nodes[original.id]!
454
- const session = createRegistryItemSurfaceMove(hosted)!
455
- let pose: { position: [number, number, number]; rotationY: number } = session.enter(
456
- eventFor().event,
457
- dimensions,
458
- hostedPose.rotationY,
459
- )!
460
- if (destination !== 'same host') {
461
- const world = sceneRegistry.nodes
462
- .get('level_surface-move')!
463
- .localToWorld(new Vector3(8, 0, 9))
464
- pose = session.detach(world.toArray(), pose.rotationY)!
465
- const detached = useScene.getState().nodes[original.id]!
466
- expect(
467
- getFloorPlacedElevation({
468
- node: detached,
469
- nodes: useScene.getState().nodes,
470
- position: pose.position,
471
- maxElevation: 0,
472
- }),
473
- ).toBe(0)
474
- const footprints = getFloorPlacedFootprints(
475
- nodeRegistry.get(original.type)!.capabilities.floorPlaced!,
476
- detached,
477
- { nodes: useScene.getState().nodes },
478
- ).map((footprint) => ({ ...footprint, position: footprint.position ?? pose.position }))
479
- expect(
480
- spatialGridManager.canPlaceOnFloorFootprints('level_surface-move', footprints, [
481
- original.id,
482
- ]).valid,
483
- ).toBe(true)
484
- }
485
- if (destination !== 'floor') {
486
- pose = session.enter(eventFor(host, [0.6, 1, -0.4]).event, dimensions, pose.rotationY)!
487
- }
488
- const final = useScene.getState().nodes[original.id]!
489
- const patch = {
490
- ...final,
491
- ...resolveSupportSlabPatch(final, useScene.getState().nodes, { maxElevation: 0 }),
492
- }
493
- session.restore()
494
- useScene.temporal.getState().resume()
495
- useScene.getState().updateNodes([{ id: original.id, data: patch }])
496
- useScene.temporal.getState().pause()
497
- expect(useScene.getState().nodes[original.id]?.parentId).toBe(
498
- destination === 'floor' ? 'level_surface-move' : host.id,
499
- )
500
- expect(useScene.temporal.getState().pastStates).toHaveLength(1)
501
- useScene.temporal.getState().undo()
502
- expect(useScene.getState().nodes[original.id]).toEqual(hosted)
503
- expect((useScene.getState().nodes[host.id] as ItemNode).children as string[]).toContain(
504
- original.id,
505
- )
506
- useScene.temporal.getState().redo()
507
- expect(useScene.getState().nodes[original.id]).toEqual(patch)
508
- })
446
+ test.each(['floor', 'same host', 'floor then host'])(
447
+ 'a hosted move to %s commits once and undo restores the original parent and pose',
448
+ (destination) => {
449
+ const initial = createRegistryItemSurfaceMove(original)!
450
+ const hostedPose = initial.enter(eventFor().event, dimensions, 0.3)!
451
+ const hosted = useScene.getState().nodes[original.id]!
452
+ const session = createRegistryItemSurfaceMove(hosted)!
453
+ let pose: { position: [number, number, number]; rotationY: number } = session.enter(
454
+ eventFor().event,
455
+ dimensions,
456
+ hostedPose.rotationY,
457
+ )!
458
+ if (destination !== 'same host') {
459
+ const world = sceneRegistry.nodes
460
+ .get('level_surface-move')!
461
+ .localToWorld(new Vector3(8, 0, 9))
462
+ pose = session.detach(world.toArray(), pose.rotationY)!
463
+ const detached = useScene.getState().nodes[original.id]!
464
+ expect(
465
+ getFloorPlacedElevation({
466
+ node: detached,
467
+ nodes: useScene.getState().nodes,
468
+ position: pose.position,
469
+ maxElevation: 0,
470
+ }),
471
+ ).toBe(0)
472
+ const footprints = getFloorPlacedFootprints(
473
+ nodeRegistry.get(original.type)!.capabilities.floorPlaced!,
474
+ detached,
475
+ { nodes: useScene.getState().nodes },
476
+ ).map((footprint) => ({ ...footprint, position: footprint.position ?? pose.position }))
477
+ expect(
478
+ spatialGridManager.canPlaceOnFloorFootprints('level_surface-move', footprints, [
479
+ original.id,
480
+ ]).valid,
481
+ ).toBe(true)
482
+ }
483
+ if (destination !== 'floor') {
484
+ pose = session.enter(eventFor(host, [0.6, 1, -0.4]).event, dimensions, pose.rotationY)!
485
+ }
486
+ const final = useScene.getState().nodes[original.id]!
487
+ const patch = {
488
+ ...final,
489
+ ...resolveSupportSlabPatch(final, useScene.getState().nodes, { maxElevation: 0 }),
490
+ }
491
+ session.restore()
492
+ useScene.temporal.getState().resume()
493
+ useScene.getState().updateNodes([{ id: original.id, data: patch }])
494
+ useScene.temporal.getState().pause()
495
+ expect(useScene.getState().nodes[original.id]?.parentId).toBe(
496
+ destination === 'floor' ? 'level_surface-move' : host.id,
497
+ )
498
+ expect(useScene.temporal.getState().pastStates).toHaveLength(1)
499
+ useScene.temporal.getState().undo()
500
+ expect(useScene.getState().nodes[original.id]).toEqual(hosted)
501
+ expect((useScene.getState().nodes[host.id] as ItemNode).children as string[]).toContain(
502
+ original.id,
503
+ )
504
+ useScene.temporal.getState().redo()
505
+ expect(useScene.getState().nodes[original.id]).toEqual(patch)
506
+ },
507
+ )
509
508
  })
@@ -105,6 +105,8 @@ function parentWorldYaw(parentId: string | null | undefined): number {
105
105
  export function createRegistryItemSurfaceMove(node: AnyNode) {
106
106
  const capabilities = nodeRegistry.get(node.type)?.capabilities
107
107
  const floorPlaced = capabilities?.floorPlaced
108
+ if (capabilities?.movable?.preserveLevelAndElevation && !isFreshPlacementMetadata(node.metadata))
109
+ return null
108
110
  if (!floorPlaced || (floorPlaced.applies && !floorPlaced.applies(node))) return null
109
111
 
110
112
  const original = node as AnyNode & {
@@ -219,32 +219,32 @@ test('the mover rejects an indirect descendant before asking the resolver', () =
219
219
  expect(useScene.getState().nodes).toBe(before)
220
220
  })
221
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
- })
222
+ test.each(['host-local', 'surface-local'] as const)(
223
+ 'writes the resolver pose in its %s child frame',
224
+ (childFrame) => {
225
+ const placement: SurfacePlacement = {
226
+ position: [0.25, 1.5, -0.75],
227
+ rotationY: 0.4,
228
+ surfaceId: 'test-surface',
229
+ childFrame,
230
+ surfaceLocal: { position: [-0.5, 0, 0.25], rotationY: -0.2, rotation: [0.7, -0.2, 0.8] },
231
+ }
232
+ resolver.mockReturnValue(placement)
233
+ const session = createRegistryItemSurfaceMove(child)!
234
+ const pose = session.enter(hit(), dimensions, 0.3)!
235
+ const expected = childFrame === 'surface-local' ? placement.surfaceLocal! : placement
236
+ expect(pose.position).toEqual([...expected.position])
237
+ expect(pose.rotationY).toBe(expected.rotationY)
238
+ expect(useScene.getState().nodes[child.id]).toMatchObject({
239
+ parentId: host.id,
240
+ position: expected.position,
241
+ rotation:
242
+ childFrame === 'surface-local'
243
+ ? placement.surfaceLocal!.rotation
244
+ : [0.1, placement.rotationY, 0.2],
245
+ })
246
+ },
247
+ )
248
248
 
249
249
  test('non-item events never reach the resolver', () => {
250
250
  const session = createRegistryItemSurfaceMove(child)!
@@ -1,4 +1,12 @@
1
- import { expect, test } from 'bun:test'
1
+ import { expect, jest, test } from 'bun:test'
2
+ import type {
3
+ AnyNode,
4
+ AnyNodeDefinition,
5
+ AnyNodeId,
6
+ BuildingNode,
7
+ LevelNode,
8
+ } from '@pascal-app/core'
9
+ import type { Catalogue } from '@react-three/fiber'
2
10
  import { resolveMoveRotationStep } from './move-registry-node-tool'
3
11
 
4
12
  test('applies a free rotation step while the move is unattached', () => {
@@ -8,3 +16,262 @@ test('applies a free rotation step while the move is unattached', () => {
8
16
  test('rejects rotation steps while the move is wall-attached', () => {
9
17
  expect(resolveMoveRotationStep(0.5, 0.25, Math.PI / 2)).toBeNull()
10
18
  })
19
+
20
+ // Contract-derived controller regression, not a helper replay: editable keys
21
+ // leave a live move alone; outside R/T and Alt still affect the real controller.
22
+ // R3F reconciles real Three objects with a rendererless context. No drawing,
23
+ // native typing, canvas hit-testing or GPU evidence is claimed by this test.
24
+ test('focus completion: mounted registry mover owns only outside R T and Alt', async () => {
25
+ const core = await import('@pascal-app/core')
26
+ const { useViewer } = await import('@pascal-app/viewer')
27
+ const { createRoot, extend } = await import('@react-three/fiber')
28
+ const { act, createElement } = await import('react')
29
+ const three = await import('three')
30
+ const { z } = await import('zod')
31
+ const { MoveRegistryNodeTool } = await import('./move-registry-node-tool')
32
+ const { default: useEditor } = await import('../../../store/use-editor')
33
+ const { default: useInteractionScope } = await import('../../../store/use-interaction-scope')
34
+ const { default: useAlignmentGuides } = await import('../../../store/use-alignment-guides')
35
+ const { default: useFacingPose } = await import('../../../store/use-facing-pose')
36
+ const { default: usePlacementPreview } = await import('../../../store/use-placement-preview')
37
+ const globals = [
38
+ 'window',
39
+ 'document',
40
+ 'requestAnimationFrame',
41
+ 'cancelAnimationFrame',
42
+ 'IS_REACT_ACT_ENVIRONMENT',
43
+ ] as const
44
+ const descriptors = globals.map((key) => Object.getOwnPropertyDescriptor(globalThis, key))
45
+ const stores = [
46
+ core.useScene,
47
+ core.useScene.temporal,
48
+ useViewer,
49
+ useEditor,
50
+ useInteractionScope,
51
+ core.useLiveTransforms,
52
+ core.useLiveNodeOverrides,
53
+ useAlignmentGuides,
54
+ useFacingPose,
55
+ usePlacementPreview,
56
+ ]
57
+ const originalStates = stores.map((store) => store.getState())
58
+ const restoreRegistry = core.nodeRegistry._snapshot()
59
+ const sceneObjects = new Map(core.sceneRegistry.nodes)
60
+ const frames = new Map<number, FrameRequestCallback>()
61
+ let nextFrame = 0
62
+ let time = 0
63
+ const flushFrames = () => {
64
+ const pending = [...frames.values()]
65
+ frames.clear()
66
+ for (const callback of pending) {
67
+ time += 16
68
+ callback(time)
69
+ }
70
+ }
71
+ let root: ReturnType<typeof createRoot> | undefined
72
+ let mountedScene: import('three').Scene | undefined
73
+ const mesh = new three.Mesh(new three.BoxGeometry(1, 1, 1), new three.MeshBasicMaterial())
74
+ const id = 'completion_registry' as AnyNodeId
75
+ const buildingId = 'building_completion_registry' as BuildingNode['id']
76
+ const levelId = 'level_completion_registry' as LevelNode['id']
77
+ const node = {
78
+ id,
79
+ type: 'completion:registry',
80
+ object: 'node',
81
+ parentId: levelId,
82
+ children: [],
83
+ metadata: {},
84
+ visible: true,
85
+ position: [2, 0, 3],
86
+ rotation: 0.25,
87
+ } as unknown as AnyNode
88
+ jest.useFakeTimers()
89
+ try {
90
+ const target = new EventTarget()
91
+ const documentFixture = { body: { style: { cursor: '' } }, querySelector: () => null }
92
+ Object.defineProperties(globalThis, {
93
+ window: {
94
+ configurable: true,
95
+ value: Object.assign(target, {
96
+ matchMedia: () => Object.assign(new EventTarget(), { matches: false }),
97
+ }),
98
+ },
99
+ document: { configurable: true, value: documentFixture },
100
+ requestAnimationFrame: {
101
+ configurable: true,
102
+ value: (callback: FrameRequestCallback) => {
103
+ frames.set(++nextFrame, callback)
104
+ return nextFrame
105
+ },
106
+ },
107
+ cancelAnimationFrame: { configurable: true, value: (key: number) => frames.delete(key) },
108
+ IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true },
109
+ })
110
+ core.registerNode({
111
+ kind: node.type,
112
+ schemaVersion: 1,
113
+ category: 'utility',
114
+ schema: z.object({ type: z.literal(node.type) }).passthrough(),
115
+ defaults: () => ({}),
116
+ capabilities: {
117
+ movable: { isValidPosition: () => false },
118
+ floorPlaced: {
119
+ collides: true,
120
+ footprint: (candidate: AnyNode) => ({
121
+ dimensions: [1, 1, 1],
122
+ rotation: [0, (candidate as unknown as { rotation: number }).rotation, 0],
123
+ }),
124
+ },
125
+ },
126
+ } as unknown as AnyNodeDefinition)
127
+ core.useScene.temporal.getState().pause()
128
+ // Placement validity is level-scoped. Without a real selected level the
129
+ // mover deliberately treats placement as valid before consulting the kind.
130
+ core.useScene.setState({
131
+ nodes: {
132
+ [buildingId]: {
133
+ id: buildingId,
134
+ type: 'building',
135
+ parentId: null,
136
+ children: [levelId],
137
+ position: [0, 0, 0],
138
+ rotation: 0,
139
+ } as unknown as AnyNode,
140
+ [levelId]: {
141
+ id: levelId,
142
+ type: 'level',
143
+ parentId: buildingId,
144
+ children: [id],
145
+ elevation: 0,
146
+ height: 3,
147
+ } as unknown as AnyNode,
148
+ [id]: node,
149
+ },
150
+ rootNodeIds: [buildingId],
151
+ })
152
+ core.useScene.temporal.getState().resume()
153
+ core.clearSceneHistory()
154
+ core.useScene.getState().updateNode(id, { name: 'history sentinel' } as never)
155
+ flushFrames()
156
+ useViewer.getState().setSelection({ selectedIds: [id], levelId, buildingId, zoneId: null })
157
+ useEditor.setState({ mode: 'select', tool: null })
158
+ useEditor.getState().setMovingNode(core.useScene.getState().nodes[id]!)
159
+ mesh.position.set(2, 0, 3)
160
+ mesh.rotation.y = 0.25
161
+ core.sceneRegistry.nodes.set(id, mesh)
162
+ extend(three as unknown as Catalogue)
163
+ // Supplying a renderer-shaped context prevents WebGLRenderer construction.
164
+ // Drawing is a hard failure, never a stub returning fake pixels.
165
+ const gl = {
166
+ render: () => {
167
+ throw new Error('CPU controller acceptance must not render')
168
+ },
169
+ setSize: () => {},
170
+ setPixelRatio: () => {},
171
+ domElement: {},
172
+ }
173
+ root = createRoot({} as HTMLCanvasElement)
174
+ await root.configure({
175
+ gl: gl as never,
176
+ frameloop: 'never',
177
+ size: { width: 300, height: 300, top: 0, left: 0 },
178
+ onCreated: (state) => {
179
+ mountedScene = state.scene
180
+ },
181
+ })
182
+ await act(async () => {
183
+ root!.render(
184
+ createElement(MoveRegistryNodeTool, { node: core.useScene.getState().nodes[id]! }),
185
+ )
186
+ })
187
+ const colours = () => {
188
+ const result: number[] = []
189
+ mountedScene!.traverse((object) => {
190
+ const material = (object as import('three').Mesh).material
191
+ for (const entry of Array.isArray(material) ? material : [material]) {
192
+ if (entry && 'color' in entry)
193
+ result.push((entry.color as import('three').Color).getHex())
194
+ }
195
+ })
196
+ return result.sort()
197
+ }
198
+ const snapshot = () =>
199
+ JSON.parse(
200
+ JSON.stringify({
201
+ nodes: core.useScene.getState().nodes,
202
+ live: [...core.useLiveTransforms.getState().transforms],
203
+ overrides: [...core.useLiveNodeOverrides.getState().overrides],
204
+ scope: useInteractionScope.getState().scope,
205
+ history: {
206
+ past: core.useScene.temporal.getState().pastStates,
207
+ future: core.useScene.temporal.getState().futureStates,
208
+ tracking: core.useScene.temporal.getState().isTracking,
209
+ pauseDepth: core.getSceneHistoryPauseDepth(),
210
+ },
211
+ pose: { position: mesh.position.toArray(), rotation: mesh.rotation.y },
212
+ colours: colours(),
213
+ }),
214
+ )
215
+ const key = async (value: string, editable: boolean, type = 'keydown') => {
216
+ const event = new Event(type, { cancelable: true })
217
+ Object.defineProperties(event, {
218
+ key: { value },
219
+ target: {
220
+ value: editable
221
+ ? { tagName: 'TEXTAREA', closest: () => ({ tagName: 'TEXTAREA' }) }
222
+ : target,
223
+ },
224
+ altKey: { value: value === 'Alt' },
225
+ ctrlKey: { value: false },
226
+ metaKey: { value: false },
227
+ })
228
+ await act(async () => {
229
+ target.dispatchEvent(event)
230
+ flushFrames()
231
+ })
232
+ return event
233
+ }
234
+ expect((await key('r', false)).defaultPrevented).toBe(true)
235
+ expect(mesh.rotation.y).toBeCloseTo(0.25 + Math.PI / 4)
236
+ expect(colours()).toContain(0xef4444)
237
+ const before = snapshot()
238
+ expect(before.live.length).toBeGreaterThan(0)
239
+ expect(before.history.past.length).toBeGreaterThan(0)
240
+ expect(before.history.tracking).toBe(false)
241
+ expect(before.scope.kind).toBe('moving')
242
+ for (const value of ['r', 't', 'Alt']) {
243
+ expect((await key(value, true)).defaultPrevented).toBe(false)
244
+ expect(snapshot()).toEqual(before)
245
+ }
246
+ // Key-up reset remains active even if focus moved into a field.
247
+ await key('Alt', false)
248
+ expect(colours()).toContain(0x22c55e)
249
+ await key('Alt', true, 'keyup')
250
+ expect(colours()).toContain(0xef4444)
251
+ expect((await key('t', false)).defaultPrevented).toBe(true)
252
+ expect(mesh.rotation.y).toBeCloseTo(0.25)
253
+ expect(core.useLiveTransforms.getState().transforms.get(id)?.rotation).toBeCloseTo(0.25)
254
+ } finally {
255
+ if (root)
256
+ await act(async () => {
257
+ root!.unmount()
258
+ })
259
+ // R3F's delayed disposal is advanced, not slept through, before globals go.
260
+ jest.runOnlyPendingTimers()
261
+ frames.clear()
262
+ restoreRegistry()
263
+ core.sceneRegistry.nodes.clear()
264
+ for (const [key, value] of sceneObjects) core.sceneRegistry.nodes.set(key, value)
265
+ stores.forEach((store, index) => {
266
+ ;(store.setState as (snapshot: unknown, replace: true) => void)(originalStates[index], true)
267
+ })
268
+ mesh.geometry.dispose()
269
+ mesh.material.dispose()
270
+ jest.useRealTimers()
271
+ globals.forEach((key, index) => {
272
+ const descriptor = descriptors[index]
273
+ if (descriptor) Object.defineProperty(globalThis, key, descriptor)
274
+ else Reflect.deleteProperty(globalThis, key)
275
+ })
276
+ }
277
+ }, 15_000)
@@ -17,6 +17,7 @@ import {
17
17
  footprintAABBFrom,
18
18
  type GridEvent,
19
19
  type GroupMoveSnapResult,
20
+ getFloorPlacedElevation,
20
21
  getFloorPlacedFootprints,
21
22
  type MovableConfig,
22
23
  movingFootprintAnchors,
@@ -60,6 +61,7 @@ import {
60
61
  surfaceAttachmentUpdates,
61
62
  updateSurfaceNode,
62
63
  } from '../../../lib/surface-attachment'
64
+ import { isTypingTarget } from '../../../lib/typing-target'
63
65
  import useAlignmentGuides from '../../../store/use-alignment-guides'
64
66
  import useEditor, {
65
67
  getActiveSnappingMode,
@@ -416,6 +418,9 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) {
416
418
  const [cursorRotationY, setCursorRotationY] = useState(() => previewRotationY(originalRotationY))
417
419
  const { isFreshPlacement, previewVisible, revealFreshPlacement, useAbsoluteCursorPlacement } =
418
420
  useFreshPlacementVisibility({ node })
421
+ const preserveLevelAndElevation =
422
+ !isFreshPlacement &&
423
+ nodeRegistry.get(node.type)?.capabilities?.movable?.preserveLevelAndElevation === true
419
424
  // Kinds that declare `movable.cursorAttached` (duct fittings) pin to the
420
425
  // cursor instead of preserving the grab offset — small connector-like
421
426
  // nodes read an offset drag as "lagging behind the mouse".
@@ -479,7 +484,24 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) {
479
484
  return Array.isArray(rotation) ? [rotation[0] ?? 0, y, rotation[2] ?? 0] : y
480
485
  }
481
486
 
487
+ const keepStartingElevation = (position: [number, number, number], yaw: number) => {
488
+ if (!preserveLevelAndElevation) return position
489
+ const rotation = toCommitRotation(yaw)
490
+ const currentNodes = useScene.getState().nodes
491
+ const effectiveNode = { ...node, position, rotation } as AnyNode
492
+ const lift = getFloorPlacedElevation({
493
+ node: effectiveNode,
494
+ nodes: { ...currentNodes, [node.id]: effectiveNode },
495
+ position,
496
+ rotation,
497
+ })
498
+ return [position[0], originalPlanPosition[1] - lift, position[2]] as [number, number, number]
499
+ }
500
+
482
501
  const currentLevelId = () =>
502
+ (preserveLevelAndElevation
503
+ ? findLevelAncestorId(node.id, useScene.getState().nodes)
504
+ : null) ??
483
505
  useViewer.getState().selection.levelId ??
484
506
  (itemSurfaceMove ? findLevelAncestorId(node.id, useScene.getState().nodes) : node.parentId)
485
507
 
@@ -805,10 +827,14 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) {
805
827
  // test are plane-height independent, so the elected surface is a
806
828
  // single fixed point per pointer ray.
807
829
  const pointed = resolvePointerSupportSurface(cameraRef.current, event.position)
808
- supportCapRef.current = pointed?.elevation ?? null
809
- supportSurfaceRef.current = pointed
810
- const rawX = pointed?.localPoint?.[0] ?? event.localPosition[0]
811
- const rawZ = pointed?.localPoint?.[2] ?? event.localPosition[2]
830
+ supportCapRef.current = preserveLevelAndElevation ? null : (pointed?.elevation ?? null)
831
+ supportSurfaceRef.current = preserveLevelAndElevation ? null : pointed
832
+ const rawX = preserveLevelAndElevation
833
+ ? event.localPosition[0]
834
+ : (pointed?.localPoint?.[0] ?? event.localPosition[0])
835
+ const rawZ = preserveLevelAndElevation
836
+ ? event.localPosition[2]
837
+ : (pointed?.localPoint?.[2] ?? event.localPosition[2])
812
838
  revealFreshPlacement()
813
839
 
814
840
  const magnetic = isMagneticSnapActive()
@@ -982,7 +1008,7 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) {
982
1008
  if (guides.length > 0) useAlignmentGuides.getState().set(guides)
983
1009
  }
984
1010
  }
985
- if (!parentFrame && pointed?.sourceNodeId) {
1011
+ if (!preserveLevelAndElevation && !parentFrame && pointed?.sourceNodeId) {
986
1012
  const rotation = toCommitRotation(rotationRef.current)
987
1013
  const effectiveNode = {
988
1014
  ...((useScene.getState().nodes[node.id] ?? node) as Record<string, unknown>),
@@ -1000,6 +1026,7 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) {
1000
1026
  },
1001
1027
  ).position
1002
1028
  }
1029
+ position = keepStartingElevation(position, rotationRef.current)
1003
1030
  const visualPosition = getVisualPosition(position)
1004
1031
  hasMovedRef.current = true
1005
1032
  setCursorPosition(visualPosition)
@@ -1107,18 +1134,20 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) {
1107
1134
  // The pointer cap makes the persisted host reproduce the capped
1108
1135
  // election — a drop under a deck stores the aimed-at lower slab
1109
1136
  // (or the ground), not the deck hanging above.
1110
- ...resolveSupportSlabPatch(
1111
- effectiveNode,
1112
- {
1113
- ...useScene.getState().nodes,
1114
- [node.id]: effectiveNode,
1115
- },
1116
- {
1117
- maxElevation: supportCapRef.current,
1118
- preferredSlabId: supportSurfaceRef.current?.supportSlabId,
1119
- pinSupport: supportSurfaceRef.current?.sourceNodeId != null,
1120
- },
1121
- ),
1137
+ ...(preserveLevelAndElevation
1138
+ ? {}
1139
+ : resolveSupportSlabPatch(
1140
+ effectiveNode,
1141
+ {
1142
+ ...useScene.getState().nodes,
1143
+ [node.id]: effectiveNode,
1144
+ },
1145
+ {
1146
+ maxElevation: supportCapRef.current,
1147
+ preferredSlabId: supportSurfaceRef.current?.supportSlabId,
1148
+ pinSupport: supportSurfaceRef.current?.sourceNodeId != null,
1149
+ },
1150
+ )),
1122
1151
  ...(isNew
1123
1152
  ? {
1124
1153
  metadata: stripPlacementMetadataFlags(node.metadata),
@@ -1191,18 +1220,20 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) {
1191
1220
  const committedNode = def.schema.parse({
1192
1221
  ...reparsed,
1193
1222
  parentId: node.parentId,
1194
- ...resolveSupportSlabPatch(
1195
- { ...reparsed, parentId: node.parentId } as AnyNode,
1196
- {
1197
- ...useScene.getState().nodes,
1198
- [reparsed.id]: reparsed,
1199
- },
1200
- {
1201
- maxElevation: supportCapRef.current,
1202
- preferredSlabId: supportSurfaceRef.current?.supportSlabId,
1203
- pinSupport: supportSurfaceRef.current?.sourceNodeId != null,
1204
- },
1205
- ),
1223
+ ...(preserveLevelAndElevation
1224
+ ? {}
1225
+ : resolveSupportSlabPatch(
1226
+ { ...reparsed, parentId: node.parentId } as AnyNode,
1227
+ {
1228
+ ...useScene.getState().nodes,
1229
+ [reparsed.id]: reparsed,
1230
+ },
1231
+ {
1232
+ maxElevation: supportCapRef.current,
1233
+ preferredSlabId: supportSurfaceRef.current?.supportSlabId,
1234
+ pinSupport: supportSurfaceRef.current?.sourceNodeId != null,
1235
+ },
1236
+ )),
1206
1237
  }) as AnyNode
1207
1238
  useScene.temporal.getState().resume()
1208
1239
  useScene.getState().createNode(committedNode, node.parentId as AnyNodeId)
@@ -1247,6 +1278,7 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) {
1247
1278
  // item placement keys (and the "Rotate" hints the move HUD shows). Applied
1248
1279
  // imperatively + mirrored to the live transform; committed on drop.
1249
1280
  const onKeyDown = (e: KeyboardEvent) => {
1281
+ if (isTypingTarget(e.target)) return
1250
1282
  // Hold Alt (free place) to force placement on an invalid (red) footprint,
1251
1283
  // matching the GLB item tool. Recolour the box to green while held.
1252
1284
  if (e.key === 'Alt') {
@@ -1287,6 +1319,7 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) {
1287
1319
  )
1288
1320
  position = canonicalPositionFromPlan(planOrigin[0], position[1], planOrigin[2])
1289
1321
  }
1322
+ position = keepStartingElevation(position, hostedPose?.rotationY ?? nextFreeRotation)
1290
1323
  lastCursorRef.current = position
1291
1324
  freeRotationRef.current = hostedPose?.rotationY ?? nextFreeRotation
1292
1325
  rotationRef.current = freeRotationRef.current
@@ -1423,6 +1456,7 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) {
1423
1456
  originalPosition,
1424
1457
  originalPlanPosition,
1425
1458
  originalRotationY,
1459
+ preserveLevelAndElevation,
1426
1460
  previewRotationY,
1427
1461
  revealFreshPlacement,
1428
1462
  useAbsoluteCursorPlacement,