@pascal-app/editor 1.0.4 → 1.0.6

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 +40 -38
  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
@@ -472,3 +472,41 @@ describe('item move history', () => {
472
472
  expect(history()).toEqual({ past: before.past, tracking: true, depth: 0 })
473
473
  })
474
474
  })
475
+
476
+ describe('item placement selection', () => {
477
+ // A surface `:click` is synthesized on pointer-up (use-node-events), and the browser fires its
478
+ // own click after it. By then the editor is back in select mode, where the grid takes that click
479
+ // for one on empty ground and drops the selection of the item just placed.
480
+ test('a drop on pointer-up selects the new item and eats the click that follows', async () => {
481
+ const renderer = await create(<Placement />)
482
+ try {
483
+ await carry(10, 14)
484
+ const dropOnRelease = () =>
485
+ emitter.emit('node:click', {
486
+ node: { id: 'site_placement_history', type: 'site' },
487
+ position: [1.4, 0, 0.5],
488
+ localPosition: [1.4, 0, 0.5],
489
+ nativeEvent: { nativeEvent: {} },
490
+ stopPropagation() {},
491
+ } as never)
492
+ window.addEventListener('pointerup', dropOnRelease)
493
+ await act(async () => {
494
+ window.dispatchEvent(new Event('pointerup'))
495
+ })
496
+ window.removeEventListener('pointerup', dropOnRelease)
497
+ const placed = levelItems().find((node) => node.id !== item.id)
498
+ expect(placed).toBeDefined()
499
+ expect(useViewer.getState().selection.selectedIds).toEqual([placed!.id])
500
+ const click = new Event('click', { cancelable: true })
501
+ window.dispatchEvent(click)
502
+ expect(click.defaultPrevented).toBe(true)
503
+ // Only that one click: once the swallow window closes, a click reaches the editor again.
504
+ await new Promise((resolve) => setTimeout(resolve, 350))
505
+ const next = new Event('click', { cancelable: true })
506
+ window.dispatchEvent(next)
507
+ expect(next.defaultPrevented).toBe(false)
508
+ } finally {
509
+ await renderer.unmount()
510
+ }
511
+ })
512
+ })
@@ -1,6 +1,8 @@
1
1
  import { beforeEach, describe, expect, test } from 'bun:test'
2
2
  import {
3
3
  BlockNode,
4
+ type CeilingEvent,
5
+ type CeilingNode,
4
6
  type GridEvent,
5
7
  ItemNode,
6
8
  type LevelNode,
@@ -10,7 +12,12 @@ import {
10
12
  type WallNode,
11
13
  } from '@pascal-app/core'
12
14
  import { BufferGeometry, Mesh, MeshBasicMaterial, type Object3D, Vector3 } from 'three'
13
- import { faceHostStrategy, floorStrategy, wallStrategy } from './placement-strategies'
15
+ import {
16
+ ceilingStrategy,
17
+ faceHostStrategy,
18
+ floorStrategy,
19
+ wallStrategy,
20
+ } from './placement-strategies'
14
21
  import type { PlacementContext, SpatialValidators } from './placement-types'
15
22
  import { registerTestBlockFaceHost } from './test-face-host'
16
23
 
@@ -492,3 +499,41 @@ describe('floorStrategy.move', () => {
492
499
  expect(result.nodeUpdate?.position?.[1]).toBe(0)
493
500
  })
494
501
  })
502
+
503
+ describe('ceilingStrategy.move', () => {
504
+ const ceiling = { id: 'ceiling_test', type: 'ceiling' } as unknown as CeilingNode
505
+ const ceilingEvent = (): CeilingEvent =>
506
+ ({
507
+ node: ceiling,
508
+ position: [1, 2.5, 1],
509
+ localPosition: [1, 0, 1],
510
+ object: new Mesh(),
511
+ stopPropagation: () => {},
512
+ }) as unknown as CeilingEvent
513
+ const onCeiling = (draftItem: ItemNode): PlacementContext => ({
514
+ ...ceilingContext(),
515
+ asset: draftItem.asset,
516
+ draftItem,
517
+ state: { ...ceilingContext().state, surface: 'ceiling', ceilingId: ceiling.id },
518
+ })
519
+ const hanging = (source?: unknown) =>
520
+ ({
521
+ ...makeDraft(),
522
+ asset: { ...makeDraft().asset, attachTo: 'ceiling', dimensions: [0.9, 1, 0.6] },
523
+ ...(source ? { source } : {}),
524
+ }) as unknown as ItemNode
525
+
526
+ test('hangs a catalog model its full height below the ceiling', () => {
527
+ const result = ceilingStrategy.move(onCeiling(hanging()), ceilingEvent())
528
+ expect(result?.gridPosition[1]).toBeCloseTo(-1, 6)
529
+ })
530
+
531
+ test('hangs a scripted object from its top, where the compiler put its origin', () => {
532
+ const source = {
533
+ kind: 'script',
534
+ manifest: { bounds: { min: [-0.45, -1, -0.3], max: [0.45, 0, 0.3] } },
535
+ }
536
+ const result = ceilingStrategy.move(onCeiling(hanging(source)), ceilingEvent())
537
+ expect(result?.gridPosition[1]).toBeCloseTo(0, 6)
538
+ })
539
+ })
@@ -15,6 +15,7 @@ import type {
15
15
  WallNode,
16
16
  } from '@pascal-app/core'
17
17
  import {
18
+ ceilingSeatY,
18
19
  clampRectToRoofWallFace,
19
20
  clearFaceHostItemFields,
20
21
  createSceneApi,
@@ -747,9 +748,7 @@ export const ceilingStrategy = {
747
748
  // use the ceiling hit's local position rather than world position.
748
749
  const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
749
750
  const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
750
- // Recessed fixtures seat flush with the ceiling plane (body rising into the
751
- // void above); everything else hangs its full height below the ceiling.
752
- const seatY = ctx.asset.recessed ? 0 : -itemHeight
751
+ const seatY = ceilingSeatY(ctx.draftItem ?? { asset: ctx.asset }, itemHeight)
753
752
  const worldSnapped = event.object.localToWorld(new Vector3(x, seatY, z))
754
753
 
755
754
  return {
@@ -781,9 +780,7 @@ export const ceilingStrategy = {
781
780
 
782
781
  const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
783
782
  const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
784
- // Recessed fixtures seat flush with the ceiling plane (body rising into the
785
- // void above); everything else hangs its full height below the ceiling.
786
- const seatY = ctx.draftItem.asset.recessed ? 0 : -itemHeight
783
+ const seatY = ceilingSeatY(ctx.draftItem, itemHeight)
787
784
  const worldSnapped = event.object.localToWorld(new Vector3(x, seatY, z))
788
785
 
789
786
  return {
@@ -117,39 +117,39 @@ function hit(y = 1, z = -0.1): ShelfEvent {
117
117
  } as ShelfEvent
118
118
  }
119
119
 
120
- test.each([
121
- 'grid',
122
- 'off',
123
- ] as const)('midpoint keeps the first declared row and preserves X/Z with grid %s', (mode) => {
124
- useEditor.getState().setSnappingMode('item', mode)
125
- for (const declared of [
126
- [0.5, 1.5],
127
- [1.5, 0.5],
128
- ]) {
129
- rows = declared
130
- const expected: [number, number, number] =
131
- mode === 'grid' ? [0.25, rows[0]!, 0.125] : [0.37, rows[0]!, -0.1]
132
- const ctx = context()
133
- const entered = shelfSurfaceStrategy.enter(ctx, hit())!
134
- expect(entered.nodeUpdate).toEqual({
135
- position: expected,
136
- parentId: host.id,
137
- rotation: [0.1, 0.7, 0.3],
138
- })
139
- expect(entered.gridPosition).toEqual(expected)
140
- expect(entered.cursorPosition).toEqual(expected)
141
- const moved = shelfSurfaceStrategy.move(onShelf(ctx), hit())!
142
- expect(moved.nodeUpdate).toEqual({ position: expected })
143
- expect(moved.cursorPosition).toEqual(expected)
144
- const committed = shelfSurfaceStrategy.click(
145
- { ...onShelf(ctx), gridPosition: new Vector3(...entered.gridPosition) },
146
- hit(),
147
- )!
148
- expect(committed.nodeUpdate.position).toEqual(expected)
149
- expect(committed.nodeUpdate.parentId).toBe(host.id)
150
- }
151
- expect(resolver).toHaveBeenCalledTimes(6)
152
- })
120
+ test.each(['grid', 'off'] as const)(
121
+ 'midpoint keeps the first declared row and preserves X/Z with grid %s',
122
+ (mode) => {
123
+ useEditor.getState().setSnappingMode('item', mode)
124
+ for (const declared of [
125
+ [0.5, 1.5],
126
+ [1.5, 0.5],
127
+ ]) {
128
+ rows = declared
129
+ const expected: [number, number, number] =
130
+ mode === 'grid' ? [0.25, rows[0]!, 0.125] : [0.37, rows[0]!, -0.1]
131
+ const ctx = context()
132
+ const entered = shelfSurfaceStrategy.enter(ctx, hit())!
133
+ expect(entered.nodeUpdate).toEqual({
134
+ position: expected,
135
+ parentId: host.id,
136
+ rotation: [0.1, 0.7, 0.3],
137
+ })
138
+ expect(entered.gridPosition).toEqual(expected)
139
+ expect(entered.cursorPosition).toEqual(expected)
140
+ const moved = shelfSurfaceStrategy.move(onShelf(ctx), hit())!
141
+ expect(moved.nodeUpdate).toEqual({ position: expected })
142
+ expect(moved.cursorPosition).toEqual(expected)
143
+ const committed = shelfSurfaceStrategy.click(
144
+ { ...onShelf(ctx), gridPosition: new Vector3(...entered.gridPosition) },
145
+ hit(),
146
+ )!
147
+ expect(committed.nodeUpdate.position).toEqual(expected)
148
+ expect(committed.nodeUpdate.parentId).toBe(host.id)
149
+ }
150
+ expect(resolver).toHaveBeenCalledTimes(6)
151
+ },
152
+ )
153
153
 
154
154
  test.each([
155
155
  [0.999, 0.5],
@@ -182,29 +182,29 @@ test('a full-board object is refused when grid snapping puts its centre outside
182
182
  expect(shelfSurfaceStrategy.enter(context(draft), hit(1, -0.39))).toBeNull()
183
183
  })
184
184
 
185
- test.each([
186
- 'grid',
187
- 'off',
188
- ] as const)('shelf frame conversion retains exact poses with grid %s', (mode) => {
189
- useEditor.getState().setSnappingMode('item', mode)
190
- const mesh = sceneRegistry.nodes.get(host.id)!
191
- mesh.position.set(4, 2, -3)
192
- mesh.rotation.set(0, Math.PI / 3, 0)
193
- mesh.updateMatrixWorld(true)
194
- const event = hit(1.2)
195
- const local = mesh.worldToLocal(new Vector3(...event.position))
196
- const expected: [number, number, number] =
197
- mode === 'grid' ? [0.25, 1.5, 0.125] : [local.x, 1.5, local.z]
198
- const yaw = new Euler().setFromQuaternion(mesh.getWorldQuaternion(new Quaternion()), 'YXZ').y
199
- const entered = shelfSurfaceStrategy.enter(context(), event)!
200
- expect(entered.nodeUpdate).toEqual({
201
- position: expected,
202
- parentId: host.id,
203
- rotation: [0.1, 0.7 - yaw, 0.3],
204
- })
205
- expect(entered.cursorPosition).toEqual(mesh.localToWorld(new Vector3(...expected)).toArray())
206
- expect(shelfSurfaceStrategy.move(onShelf(context()), event)!.gridPosition).toEqual(expected)
207
- })
185
+ test.each(['grid', 'off'] as const)(
186
+ 'shelf frame conversion retains exact poses with grid %s',
187
+ (mode) => {
188
+ useEditor.getState().setSnappingMode('item', mode)
189
+ const mesh = sceneRegistry.nodes.get(host.id)!
190
+ mesh.position.set(4, 2, -3)
191
+ mesh.rotation.set(0, Math.PI / 3, 0)
192
+ mesh.updateMatrixWorld(true)
193
+ const event = hit(1.2)
194
+ const local = mesh.worldToLocal(new Vector3(...event.position))
195
+ const expected: [number, number, number] =
196
+ mode === 'grid' ? [0.25, 1.5, 0.125] : [local.x, 1.5, local.z]
197
+ const yaw = new Euler().setFromQuaternion(mesh.getWorldQuaternion(new Quaternion()), 'YXZ').y
198
+ const entered = shelfSurfaceStrategy.enter(context(), event)!
199
+ expect(entered.nodeUpdate).toEqual({
200
+ position: expected,
201
+ parentId: host.id,
202
+ rotation: [0.1, 0.7 - yaw, 0.3],
203
+ })
204
+ expect(entered.cursorPosition).toEqual(mesh.localToWorld(new Vector3(...expected)).toArray())
205
+ expect(shelfSurfaceStrategy.move(onShelf(context()), event)!.gridPosition).toEqual(expected)
206
+ },
207
+ )
208
208
 
209
209
  test('only enter requires an upward normal; moves keep electing rows', () => {
210
210
  for (const normal of [[1, 0, 0], [0, -1, 0], undefined] as ShelfEvent['normal'][]) {
@@ -89,105 +89,105 @@ describe('useDraftNode block face commit', () => {
89
89
  finish,
90
90
  })),
91
91
  ),
92
- )('an adopted $failure rejection retains owned history through $finish', ({
93
- failure,
94
- finish,
95
- }) => {
96
- const item = ItemNode.parse({
97
- parentId: LEVEL_ID,
98
- position: [1, 0, 1],
99
- asset: { id: 'box', name: 'Box', category: 'decor', thumbnail: '', src: '/box.glb' },
100
- })
101
- useScene.getState().createNode(item, LEVEL_ID as AnyNodeId)
102
- useScene.temporal.getState().clear()
103
- const originalNodes = structuredClone(useScene.getState().nodes)
104
- const draft = draftNode!
105
- draft.adopt(item)
106
- draft.updateSurface({ position: [2, 0, 2] }, null)
107
- const commit = (position: ItemNode['position']) => {
108
- const drop = beginSceneHistoryPauseSession(useScene, { gesture: item.id })
109
- try {
110
- return drop.commitStep(() => draft.commit({ parentId: LEVEL_ID, position }))
111
- } finally {
112
- drop.end()
113
- }
114
- }
115
- const updateNodes = useScene.getState().updateNodes
116
- try {
117
- if (failure === 'write') {
118
- useScene.setState({
119
- updateNodes: (updates) => {
120
- if (
121
- useScene.temporal.getState().isTracking &&
122
- updates.some(
123
- (update) =>
124
- update.id === item.id &&
125
- 'position' in update.data &&
126
- update.data.position?.[0] === 3,
127
- )
128
- ) {
129
- throw new Error('Rejected tracked write before publication')
130
- }
131
- return updateNodes(updates)
132
- },
133
- })
134
- }
135
- expect(() => commit(failure === 'parse' ? [Number.NaN, 0, 3] : [3, 0, 3])).toThrow()
136
- useScene.setState({ updateNodes })
137
- expect(draft.current?.id).toBe(item.id)
138
- expect(useScene.getState().nodes).toEqual(originalNodes)
139
- expect(useScene.temporal.getState().pastStates).toHaveLength(0)
140
- expect(useScene.temporal.getState().isTracking).toBe(true)
141
- expect(getSceneHistoryPauseDepth()).toBe(0)
142
-
143
- draft.updateSurface({ position: [4, 0, 4] }, null)
144
- useScene.getState().updateNode(item.id, { name: 'Foreign rename' })
145
- expect(useScene.temporal.getState().pastStates).toHaveLength(1)
146
- expect(useScene.temporal.getState().pastStates[0]?.nodes).toEqual(originalNodes)
147
- const renamedNodes = {
148
- ...originalNodes,
149
- [item.id]: { ...originalNodes[item.id]!, name: 'Foreign rename' },
92
+ )(
93
+ 'an adopted $failure rejection retains owned history through $finish',
94
+ ({ failure, finish }) => {
95
+ const item = ItemNode.parse({
96
+ parentId: LEVEL_ID,
97
+ position: [1, 0, 1],
98
+ asset: { id: 'box', name: 'Box', category: 'decor', thumbnail: '', src: '/box.glb' },
99
+ })
100
+ useScene.getState().createNode(item, LEVEL_ID as AnyNodeId)
101
+ useScene.temporal.getState().clear()
102
+ const originalNodes = structuredClone(useScene.getState().nodes)
103
+ const draft = draftNode!
104
+ draft.adopt(item)
105
+ draft.updateSurface({ position: [2, 0, 2] }, null)
106
+ const commit = (position: ItemNode['position']) => {
107
+ const drop = beginSceneHistoryPauseSession(useScene, { gesture: item.id })
108
+ try {
109
+ return drop.commitStep(() => draft.commit({ parentId: LEVEL_ID, position }))
110
+ } finally {
111
+ drop.end()
112
+ }
150
113
  }
114
+ const updateNodes = useScene.getState().updateNodes
115
+ try {
116
+ if (failure === 'write') {
117
+ useScene.setState({
118
+ updateNodes: (updates) => {
119
+ if (
120
+ useScene.temporal.getState().isTracking &&
121
+ updates.some(
122
+ (update) =>
123
+ update.id === item.id &&
124
+ 'position' in update.data &&
125
+ update.data.position?.[0] === 3,
126
+ )
127
+ ) {
128
+ throw new Error('Rejected tracked write before publication')
129
+ }
130
+ return updateNodes(updates)
131
+ },
132
+ })
133
+ }
134
+ expect(() => commit(failure === 'parse' ? [Number.NaN, 0, 3] : [3, 0, 3])).toThrow()
135
+ useScene.setState({ updateNodes })
136
+ expect(draft.current?.id).toBe(item.id)
137
+ expect(useScene.getState().nodes).toEqual(originalNodes)
138
+ expect(useScene.temporal.getState().pastStates).toHaveLength(0)
139
+ expect(useScene.temporal.getState().isTracking).toBe(true)
140
+ expect(getSceneHistoryPauseDepth()).toBe(0)
151
141
 
152
- if (finish === 'retry') {
153
- expect(commit([5, 0, 5])).toBe(item.id)
154
- expect(draft.current).toBeNull()
155
- const committedNodes = structuredClone(useScene.getState().nodes)
156
- expect(committedNodes[item.id]).toMatchObject({
157
- name: 'Foreign rename',
158
- position: [5, 0, 5],
159
- })
160
- expect(useScene.temporal.getState().pastStates).toHaveLength(2)
161
- useScene.temporal.getState().undo()
162
- expect(useScene.getState().nodes).toEqual(renamedNodes)
163
- useScene.temporal.getState().redo()
164
- expect(useScene.getState().nodes).toEqual(committedNodes)
165
- } else if (finish === 'cancel') {
166
- draft.destroy()
167
- expect(useScene.getState().nodes).toEqual(renamedNodes)
142
+ draft.updateSurface({ position: [4, 0, 4] }, null)
143
+ useScene.getState().updateNode(item.id, { name: 'Foreign rename' })
168
144
  expect(useScene.temporal.getState().pastStates).toHaveLength(1)
169
- useScene.temporal.getState().undo()
170
- expect(useScene.getState().nodes).toEqual(originalNodes)
171
- useScene.temporal.getState().redo()
172
- expect(useScene.getState().nodes).toEqual(renamedNodes)
173
- } else {
174
- useScene.getState().updateNode(item.id, { position: [7, 0, 7] })
175
- const foreignNodes = structuredClone(useScene.getState().nodes)
145
+ expect(useScene.temporal.getState().pastStates[0]?.nodes).toEqual(originalNodes)
146
+ const renamedNodes = {
147
+ ...originalNodes,
148
+ [item.id]: { ...originalNodes[item.id]!, name: 'Foreign rename' },
149
+ }
150
+
151
+ if (finish === 'retry') {
152
+ expect(commit([5, 0, 5])).toBe(item.id)
153
+ expect(draft.current).toBeNull()
154
+ const committedNodes = structuredClone(useScene.getState().nodes)
155
+ expect(committedNodes[item.id]).toMatchObject({
156
+ name: 'Foreign rename',
157
+ position: [5, 0, 5],
158
+ })
159
+ expect(useScene.temporal.getState().pastStates).toHaveLength(2)
160
+ useScene.temporal.getState().undo()
161
+ expect(useScene.getState().nodes).toEqual(renamedNodes)
162
+ useScene.temporal.getState().redo()
163
+ expect(useScene.getState().nodes).toEqual(committedNodes)
164
+ } else if (finish === 'cancel') {
165
+ draft.destroy()
166
+ expect(useScene.getState().nodes).toEqual(renamedNodes)
167
+ expect(useScene.temporal.getState().pastStates).toHaveLength(1)
168
+ useScene.temporal.getState().undo()
169
+ expect(useScene.getState().nodes).toEqual(originalNodes)
170
+ useScene.temporal.getState().redo()
171
+ expect(useScene.getState().nodes).toEqual(renamedNodes)
172
+ } else {
173
+ useScene.getState().updateNode(item.id, { position: [7, 0, 7] })
174
+ const foreignNodes = structuredClone(useScene.getState().nodes)
175
+ draft.destroy()
176
+ expect(useScene.getState().nodes).toEqual(foreignNodes)
177
+ expect(useScene.temporal.getState().pastStates).toHaveLength(2)
178
+ useScene.temporal.getState().undo()
179
+ expect(useScene.getState().nodes).toEqual(renamedNodes)
180
+ useScene.temporal.getState().redo()
181
+ expect(useScene.getState().nodes).toEqual(foreignNodes)
182
+ }
183
+ expect(useScene.temporal.getState().isTracking).toBe(true)
184
+ expect(getSceneHistoryPauseDepth()).toBe(0)
185
+ } finally {
186
+ useScene.setState({ updateNodes })
176
187
  draft.destroy()
177
- expect(useScene.getState().nodes).toEqual(foreignNodes)
178
- expect(useScene.temporal.getState().pastStates).toHaveLength(2)
179
- useScene.temporal.getState().undo()
180
- expect(useScene.getState().nodes).toEqual(renamedNodes)
181
- useScene.temporal.getState().redo()
182
- expect(useScene.getState().nodes).toEqual(foreignNodes)
183
188
  }
184
- expect(useScene.temporal.getState().isTracking).toBe(true)
185
- expect(getSceneHistoryPauseDepth()).toBe(0)
186
- } finally {
187
- useScene.setState({ updateNodes })
188
- draft.destroy()
189
- }
190
- })
189
+ },
190
+ )
191
191
 
192
192
  test('a drop on an undone host waits for a new valid placement target', () => {
193
193
  const item = ItemNode.parse({
@@ -59,6 +59,8 @@ import {
59
59
  surfaceFramePose,
60
60
  updateSurfaceNode as writeSurfaceNode,
61
61
  } from '../../../lib/surface-attachment'
62
+ import { isTypingTarget } from '../../../lib/typing-target'
63
+ import { createWallPointerTracker } from '../../../lib/wall-placement-pointer'
62
64
  import {
63
65
  projectAlignmentGuidesWorldToActiveBuildingLocal,
64
66
  resolveAlignmentForActiveBuilding,
@@ -102,6 +104,7 @@ import {
102
104
  import {
103
105
  getDetachedAttachmentPreviewLift,
104
106
  getGridAlignedDimensions,
107
+ getSideFromNormal,
105
108
  snapToGrid,
106
109
  snapToHalf,
107
110
  snapUpToGridStep,
@@ -606,6 +609,29 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
606
609
  if (mesh) reconcileDraftRaycast(mesh)
607
610
  }
608
611
 
612
+ // Eat the click the browser fires after a pointer-up that committed, so it
613
+ // neither commits a second time through a surface `:click` handler nor reaches
614
+ // the grid, in select mode by then, as a click on empty ground.
615
+ const swallowNextClick = () => {
616
+ const swallow = (e: Event) => {
617
+ e.stopPropagation()
618
+ e.preventDefault()
619
+ }
620
+ window.addEventListener('click', swallow, { capture: true, once: true })
621
+ setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 300)
622
+ }
623
+ // True between a press's pointer-up and the click the browser fires after it.
624
+ // Surface `:click` events are synthesized on pointer-up (use-node-events), so a
625
+ // commit they trigger still has that click to come; a `grid:click` commit is
626
+ // that click.
627
+ let releasing = false
628
+ const onPointerUpCapture = () => {
629
+ releasing = true
630
+ }
631
+ const onClickCapture = () => {
632
+ releasing = false
633
+ }
634
+
609
635
  const finishCommittedPlacement = (
610
636
  committedId: string | null,
611
637
  wasAdopted: boolean,
@@ -628,6 +654,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
628
654
  // lands on the just-placed (now selected) node instead of a tool-less build
629
655
  // limbo. Repeat placements took the early return above and stay armed.
630
656
  useEditor.getState().setMode('select')
657
+ if (releasing) swallowNextClick()
631
658
  }
632
659
 
633
660
  const commitDraft = (
@@ -797,10 +824,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
797
824
  // Alignment guides are floor-only; clear them when the cursor moves
798
825
  // onto a wall / ceiling / item surface (only those paths call this).
799
826
  useAlignmentGuides.getState().clear()
800
- // Roof faces carry no grab anchor, but landing on one still counts as
801
- // anchoring elsewhere — a later return to the grabbed wall must center
802
- // under the cursor, not restore the stale grab offset.
803
- if (result.stateUpdate.surface === 'roof-wall') grabForgotten = true
827
+ // A floor transition can be a hit gap; other hosted surfaces are a new anchor.
828
+ if (
829
+ result.stateUpdate.surface &&
830
+ result.stateUpdate.surface !== 'floor' &&
831
+ result.stateUpdate.surface !== 'wall'
832
+ )
833
+ grabForgotten = true
804
834
  Object.assign(placementState.current, result.stateUpdate)
805
835
  gridPosition.current.set(...result.gridPosition)
806
836
 
@@ -924,15 +954,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
924
954
  // for a given host, then offsets every later move by
925
955
  // `start + (raw - anchor)` — mirroring the floor path and the door/window
926
956
  // move tools so the item tracks the grabbed point instead of teleporting
927
- // its origin under the cursor. Reset on host change (re-seeded from the
928
- // item's then-current position) by the surface leave handlers.
929
- let wallDragAnchor: {
930
- wallId: string
931
- rawX: number
932
- rawY: number
933
- startX: number
934
- startY: number
935
- } | null = null
957
+ // its origin under the cursor. Changing hosts or faces clears the offset.
958
+ const wallPointer = createWallPointerTracker(
959
+ preserveDragOffset && grabStartPosition
960
+ ? { wallId: grabWallId, side: draftNode.current?.side, position: grabStartPosition }
961
+ : undefined,
962
+ )
936
963
  let ceilingDragAnchor: {
937
964
  ceilingId: string
938
965
  rawX: number
@@ -1053,16 +1080,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1053
1080
  // replays it. Captured once at setup — a fresh coordinator mounts per move.
1054
1081
  const dragMode = useEditor.getState().placementDragMode
1055
1082
  let releaseCommit: (() => void) | null = null
1056
- // Eat the click the browser fires after pointer-up so the surface
1057
- // `:click` handlers don't commit a second time.
1058
- const swallowNextClick = () => {
1059
- const swallow = (e: Event) => {
1060
- e.stopPropagation()
1061
- e.preventDefault()
1062
- }
1063
- window.addEventListener('click', swallow, { capture: true, once: true })
1064
- setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 300)
1065
- }
1066
1083
  const onReleaseCommit = () => {
1067
1084
  gridDispatch.flush()
1068
1085
  if (!releaseCommit) return
@@ -1150,6 +1167,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1150
1167
  if (!cursorGroupRef.current) return
1151
1168
  const result = floorStrategy.move(getContext(), floorEvent)
1152
1169
  if (!result) return
1170
+ // Attached items cannot land on the floor; its hit can be a gap in their host.
1171
+ if (!asset.attachTo) grabForgotten = true
1153
1172
 
1154
1173
  // Figma-style alignment snap layered on top of the floor strategy's
1155
1174
  // grid snap: when the draft's edge lines up (on X or Z) with another
@@ -1379,28 +1398,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1379
1398
 
1380
1399
  let wallMoveEvent = event
1381
1400
  if (preserveDragOffset && draftNode.current) {
1382
- const rawX = event.localPosition[0]
1383
- const rawY = event.localPosition[1]
1384
- if (!wallDragAnchor || wallDragAnchor.wallId !== event.node.id) {
1385
- // Preserve the grab offset only on the wall the item was grabbed
1386
- // from (no teleport under the pointer at grab time). Any OTHER host
1387
- // centers the item under the cursor — a host change is already a
1388
- // jump, so tracking the pointer exactly is the expected feel, while
1389
- // re-seeding from the carried-over position (the old behavior)
1390
- // baked an arbitrary along-wall offset into the whole stay on that
1391
- // wall. Once anchored elsewhere the grab is forgotten for good, so
1392
- // re-entering the original wall centers under the cursor too.
1393
- const preserveGrab = preserveGrabOn(event.node.id, grabWallId)
1394
- wallDragAnchor = {
1395
- wallId: event.node.id,
1396
- rawX,
1397
- rawY,
1398
- startX: preserveGrab && grabStartPosition ? grabStartPosition[0] : rawX,
1399
- startY: preserveGrab && grabStartPosition ? grabStartPosition[1] : rawY,
1400
- }
1401
- }
1402
- const correctedX = wallDragAnchor.startX + (rawX - wallDragAnchor.rawX)
1403
- const correctedY = wallDragAnchor.startY + (rawY - wallDragAnchor.rawY)
1401
+ preserveGrabOn(event.node.id, grabWallId)
1402
+ if (grabForgotten) wallPointer.forget()
1403
+ const [correctedX, correctedY] = wallPointer.resolve(
1404
+ event.node.id,
1405
+ getSideFromNormal(event.normal),
1406
+ [event.localPosition[0], event.localPosition[1]],
1407
+ )
1404
1408
  wallMoveEvent = {
1405
1409
  ...event,
1406
1410
  localPosition: [correctedX, correctedY, event.localPosition[2]],
@@ -1503,7 +1507,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1503
1507
  }
1504
1508
 
1505
1509
  const onWallLeave = (event: WallEvent) => {
1506
- wallDragAnchor = null
1510
+ wallPointer.leave()
1507
1511
  const result = wallStrategy.leave(getContext())
1508
1512
  if (!result) return
1509
1513
 
@@ -2432,9 +2436,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2432
2436
  }
2433
2437
 
2434
2438
  // Don't intercept keys when focus is inside a text input
2435
- if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
2436
- return
2437
- }
2439
+ if (isTypingTarget(event.target)) return
2438
2440
 
2439
2441
  const draft = draftNode.current
2440
2442
  if (!draft) return
@@ -2798,11 +2800,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2798
2800
  }
2799
2801
  emitter.on('node:click', commitFloorOnSurfaceClick as never)
2800
2802
  if (dragMode) window.addEventListener('pointerup', onReleaseCommit)
2803
+ window.addEventListener('pointerup', onPointerUpCapture, true)
2804
+ window.addEventListener('click', onClickCapture, true)
2801
2805
 
2802
2806
  return () => {
2803
2807
  tearingDown = true
2804
2808
  feedback.clear()
2805
2809
  if (dragMode) window.removeEventListener('pointerup', onReleaseCommit)
2810
+ window.removeEventListener('pointerup', onPointerUpCapture, true)
2811
+ window.removeEventListener('click', onClickCapture, true)
2806
2812
  unsubDraftWatch()
2807
2813
  useAlignmentGuides.getState().clear()
2808
2814
  // Clear every live preview channel before restoring or deleting the draft.