@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
@@ -0,0 +1,60 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { resolveRunDirectionCandidates, run3DDirectionCandidates } from './run-direction-feedback'
3
+
4
+ describe('distribution run direction feedback', () => {
5
+ test('shows eight world-axis and diagonal candidates from a free start', () => {
6
+ const candidates = resolveRunDirectionCandidates([0, 0, 0], [2, 0, 1], null, 'free')
7
+
8
+ expect(candidates).toHaveLength(8)
9
+ expect(candidates.some((candidate) => candidate.active)).toBe(false)
10
+ })
11
+
12
+ test('highlights the winning angle-locked candidate', () => {
13
+ const candidates = resolveRunDirectionCandidates([0, 0, 0], [2, 0, 1], null, 'angle')
14
+ const active = candidates.find((candidate) => candidate.active)
15
+
16
+ expect(active?.direction[0]).toBeCloseTo(Math.SQRT1_2)
17
+ expect(active?.direction[2]).toBeCloseTo(Math.SQRT1_2)
18
+ })
19
+
20
+ test('uses the connected run direction and its valid forward turns', () => {
21
+ const candidates = resolveRunDirectionCandidates(
22
+ [0, 0, 0],
23
+ [2, 0, 0],
24
+ [Math.SQRT1_2, 0, Math.SQRT1_2],
25
+ 'angle',
26
+ )
27
+
28
+ expect(candidates).toHaveLength(9)
29
+ expect(candidates[0]?.direction[0]).toBeCloseTo(Math.SQRT1_2)
30
+ expect(candidates[0]?.direction[2]).toBeCloseTo(Math.SQRT1_2)
31
+ })
32
+
33
+ test('offers vertical, lateral, and rising directions from a horizontal run', () => {
34
+ const directions = run3DDirectionCandidates([1, 0, 0])
35
+
36
+ expect(directions).toContainEqual([0, 1, 0])
37
+ expect(directions).toContainEqual([0, -1, 0])
38
+ expect(directions).toContainEqual([0, 0, 1])
39
+ expect(directions).toContainEqual([0, 0, -1])
40
+ const rising = directions.find(
41
+ (direction) => direction[0] > 0 && direction[1] > 0 && direction[2] === 0,
42
+ )
43
+ const falling = directions.find(
44
+ (direction) => direction[0] > 0 && direction[1] < 0 && direction[2] === 0,
45
+ )
46
+ expect(rising?.[0]).toBeCloseTo(Math.SQRT1_2)
47
+ expect(rising?.[1]).toBeCloseTo(Math.SQRT1_2)
48
+ expect(falling?.[0]).toBeCloseTo(Math.SQRT1_2)
49
+ expect(falling?.[1]).toBeCloseTo(-Math.SQRT1_2)
50
+ })
51
+
52
+ test('switches to an up/down candidate pair for vertical routing', () => {
53
+ const candidates = resolveRunDirectionCandidates([1, 2, 3], [1, 0.5, 3], null, 'vertical')
54
+
55
+ expect(candidates).toEqual([
56
+ { direction: [0, 1, 0], active: false },
57
+ { direction: [0, -1, 0], active: true },
58
+ ])
59
+ })
60
+ })
@@ -0,0 +1,251 @@
1
+ 'use client'
2
+
3
+ import { EDITOR_LAYER } from '@pascal-app/editor'
4
+ import { useThree } from '@react-three/fiber'
5
+ import { useMemo } from 'react'
6
+ import { OrthographicCamera, Quaternion, Vector3 } from 'three'
7
+ import type { RunPoint } from './distribution-run-tool'
8
+
9
+ export type RunDirectionMode = 'free' | 'angle' | 'snap' | 'vertical'
10
+
11
+ export type RunDirectionCandidate = {
12
+ direction: RunPoint
13
+ active: boolean
14
+ }
15
+
16
+ type RunVector = readonly [number, number, number]
17
+
18
+ const WORLD_DIRECTIONS: RunPoint[] = Array.from({ length: 8 }, (_, index) => {
19
+ const angle = (index * Math.PI) / 4
20
+ return [Math.cos(angle), 0, Math.sin(angle)]
21
+ })
22
+
23
+ function horizontalDirection(direction: RunVector): RunPoint | null {
24
+ const length = Math.hypot(direction[0], direction[2])
25
+ return length < 1e-6 ? null : [direction[0] / length, 0, direction[2] / length]
26
+ }
27
+
28
+ function normalizedDirection(direction: RunVector): RunPoint | null {
29
+ const length = Math.hypot(direction[0], direction[1], direction[2])
30
+ if (length < 1e-6) return null
31
+ const normalized = direction.map((component) => component / length) as RunPoint
32
+ return normalized.map((component) => (Math.abs(component) < 1e-12 ? 0 : component)) as RunPoint
33
+ }
34
+
35
+ function cross(a: RunVector, b: RunVector): RunPoint {
36
+ return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
37
+ }
38
+
39
+ export function run3DDirectionCandidates(sourceDirection: RunVector): RunPoint[] {
40
+ const source = normalizedDirection(sourceDirection)
41
+ if (!source) return []
42
+ const reference: RunPoint = Math.abs(source[1]) < 0.95 ? [0, 1, 0] : [1, 0, 0]
43
+ const lateral = normalizedDirection(cross(source, reference))
44
+ if (!lateral) return [source]
45
+ const upright = normalizedDirection(cross(lateral, source))
46
+ if (!upright) return [source]
47
+
48
+ const candidates: RunPoint[] = [source]
49
+ for (const perpendicular of [upright, lateral]) {
50
+ for (const sign of [1, -1] as const) {
51
+ const turn: RunPoint = [
52
+ perpendicular[0] * sign,
53
+ perpendicular[1] * sign,
54
+ perpendicular[2] * sign,
55
+ ].map((component) => (Math.abs(component) < 1e-12 ? 0 : component)) as RunPoint
56
+ candidates.push(turn)
57
+ const diagonal = normalizedDirection([
58
+ source[0] + turn[0],
59
+ source[1] + turn[1],
60
+ source[2] + turn[2],
61
+ ])
62
+ if (diagonal) candidates.push(diagonal)
63
+ }
64
+ }
65
+ return candidates
66
+ }
67
+
68
+ export function runHorizontalDirectionCandidates(sourceDirection: RunVector | null): RunPoint[] {
69
+ if (!sourceDirection) return WORLD_DIRECTIONS
70
+ const source = horizontalDirection(sourceDirection)
71
+ if (!source) return WORLD_DIRECTIONS
72
+ const angle = Math.atan2(source[2], source[0])
73
+ return [-Math.PI / 2, -Math.PI / 4, 0, Math.PI / 4, Math.PI / 2].map((offset) => [
74
+ Math.cos(angle + offset),
75
+ 0,
76
+ Math.sin(angle + offset),
77
+ ])
78
+ }
79
+
80
+ export function resolveRunDirectionCandidates(
81
+ start: RunPoint,
82
+ cursor: RunPoint,
83
+ sourceDirection: RunVector | null,
84
+ mode: RunDirectionMode,
85
+ ): RunDirectionCandidate[] {
86
+ const delta: RunPoint = [cursor[0] - start[0], cursor[1] - start[1], cursor[2] - start[2]]
87
+ if (mode === 'vertical') {
88
+ const activeSign = delta[1] < 0 ? -1 : 1
89
+ return ([1, -1] as const).map((sign) => ({
90
+ direction: [0, sign, 0],
91
+ active: sign === activeSign,
92
+ }))
93
+ }
94
+
95
+ const directions = sourceDirection
96
+ ? run3DDirectionCandidates(sourceDirection)
97
+ : runHorizontalDirectionCandidates(null)
98
+ let activeIndex = -1
99
+ const pointerDirection = normalizedDirection(delta)
100
+ if (pointerDirection && mode === 'angle') {
101
+ let bestDot = Number.NEGATIVE_INFINITY
102
+ for (let index = 0; index < directions.length; index++) {
103
+ const direction = directions[index]!
104
+ const dot =
105
+ direction[0] * pointerDirection[0] +
106
+ direction[1] * pointerDirection[1] +
107
+ direction[2] * pointerDirection[2]
108
+ if (dot > bestDot) {
109
+ bestDot = dot
110
+ activeIndex = index
111
+ }
112
+ }
113
+ }
114
+ return directions.map((direction, index) => ({ direction, active: index === activeIndex }))
115
+ }
116
+
117
+ export function RunDirectionFeedback({
118
+ start,
119
+ cursor,
120
+ sourceDirection,
121
+ mode,
122
+ snapped,
123
+ onDirectionSelect,
124
+ }: {
125
+ start: RunPoint
126
+ cursor: RunPoint
127
+ sourceDirection: RunVector | null
128
+ mode: RunDirectionMode
129
+ snapped: boolean
130
+ onDirectionSelect?: (direction: RunPoint) => void
131
+ }) {
132
+ const candidates = useMemo(
133
+ () => resolveRunDirectionCandidates(start, cursor, sourceDirection, mode),
134
+ [cursor, mode, sourceDirection, start],
135
+ )
136
+ const distance = Math.hypot(cursor[0] - start[0], cursor[1] - start[1], cursor[2] - start[2])
137
+ const candidateLength = Math.min(1.8, Math.max(0.7, distance * 0.7))
138
+ const activeColor = snapped ? '#22c55e' : '#818cf8'
139
+
140
+ return (
141
+ <group>
142
+ {candidates.map((candidate, index) => (
143
+ <DirectionRay
144
+ active={candidate.active}
145
+ color={candidate.active ? activeColor : '#818cf8'}
146
+ direction={candidate.direction}
147
+ key={`${index}:${candidate.direction.join(':')}`}
148
+ length={candidate.active ? Math.max(distance, candidateLength) : candidateLength}
149
+ origin={start}
150
+ onDirectionSelect={onDirectionSelect}
151
+ />
152
+ ))}
153
+ {distance > 0.01 && mode !== 'angle' && mode !== 'vertical' && (
154
+ <DirectionRay
155
+ active
156
+ color={activeColor}
157
+ direction={[
158
+ (cursor[0] - start[0]) / distance,
159
+ (cursor[1] - start[1]) / distance,
160
+ (cursor[2] - start[2]) / distance,
161
+ ]}
162
+ length={distance}
163
+ origin={start}
164
+ onDirectionSelect={onDirectionSelect}
165
+ />
166
+ )}
167
+ </group>
168
+ )
169
+ }
170
+
171
+ function DirectionRay({
172
+ origin,
173
+ direction,
174
+ length,
175
+ active,
176
+ color,
177
+ onDirectionSelect,
178
+ }: {
179
+ origin: RunPoint
180
+ direction: RunPoint
181
+ length: number
182
+ active: boolean
183
+ color: string
184
+ onDirectionSelect?: (direction: RunPoint) => void
185
+ }) {
186
+ const { camera } = useThree()
187
+ const zoomScale = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
188
+ const placement = useMemo(() => {
189
+ const axis = new Vector3(direction[0], direction[1], direction[2]).normalize()
190
+ const start = new Vector3(origin[0], origin[1] + 0.015, origin[2])
191
+ const end = start.clone().addScaledVector(axis, length)
192
+ return {
193
+ midpoint: start.clone().add(end).multiplyScalar(0.5).toArray() as RunPoint,
194
+ tip: end.toArray() as RunPoint,
195
+ rotation: new Quaternion().setFromUnitVectors(new Vector3(0, 1, 0), axis),
196
+ }
197
+ }, [direction, length, origin])
198
+ const radius = (active ? 0.012 : 0.006) * zoomScale
199
+ const arrowLength = (active ? 0.1 : 0.07) * zoomScale
200
+ const arrowRadius = (active ? 0.04 : 0.025) * zoomScale
201
+ const opacity = active ? 0.9 : 0.18
202
+
203
+ return (
204
+ <group>
205
+ <mesh
206
+ layers={EDITOR_LAYER}
207
+ position={placement.midpoint}
208
+ quaternion={placement.rotation}
209
+ scale={[radius, length, radius]}
210
+ renderOrder={active ? 4 : 2}
211
+ onPointerDown={(event) => {
212
+ if (!onDirectionSelect) return
213
+ event.stopPropagation()
214
+ event.nativeEvent.stopImmediatePropagation()
215
+ onDirectionSelect([...direction])
216
+ }}
217
+ >
218
+ <cylinderGeometry args={[1, 1, 1, 8]} />
219
+ <meshBasicMaterial
220
+ color={color}
221
+ depthTest={false}
222
+ depthWrite={false}
223
+ opacity={opacity}
224
+ transparent
225
+ />
226
+ </mesh>
227
+ <mesh
228
+ layers={EDITOR_LAYER}
229
+ position={placement.tip}
230
+ quaternion={placement.rotation}
231
+ scale={[arrowRadius, arrowLength, arrowRadius]}
232
+ renderOrder={active ? 4 : 2}
233
+ onPointerDown={(event) => {
234
+ if (!onDirectionSelect) return
235
+ event.stopPropagation()
236
+ event.nativeEvent.stopImmediatePropagation()
237
+ onDirectionSelect([...direction])
238
+ }}
239
+ >
240
+ <coneGeometry args={[1, 1, 10]} />
241
+ <meshBasicMaterial
242
+ color={color}
243
+ depthTest={false}
244
+ depthWrite={false}
245
+ opacity={opacity}
246
+ transparent
247
+ />
248
+ </mesh>
249
+ </group>
250
+ )
251
+ }
@@ -0,0 +1,79 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { PerspectiveCamera, Vector3 } from 'three'
3
+ import type { ScenePort } from './ports'
4
+ import { findScreenPort } from './run-port-snap'
5
+
6
+ const low: ScenePort = {
7
+ nodeId: 'pipe-segment_low',
8
+ id: 'end',
9
+ position: [0, 0, 0],
10
+ diameter: 0.1,
11
+ direction: [1, 0, 0],
12
+ }
13
+ const high: ScenePort = {
14
+ nodeId: 'duct-segment_high',
15
+ id: 'start',
16
+ position: [0, 3, 0],
17
+ diameter: 0.1,
18
+ direction: [1, 0, 0],
19
+ }
20
+ const camera = new PerspectiveCamera(50, 1, 0.1, 100)
21
+ camera.position.set(0, 2, 10)
22
+ camera.lookAt(0, 2, 0)
23
+ camera.updateMatrixWorld()
24
+ const project = (point: ScenePort['position']) => {
25
+ const projected = new Vector3(...point).project(camera)
26
+ if (projected.z < -1 || projected.z > 1) return null
27
+ return { x: (projected.x + 1) * 400, y: (1 - projected.y) * 400, depth: projected.z }
28
+ }
29
+
30
+ test('picks an elevated socket under the pointer independently of the drawing plane', () => {
31
+ const screen = project(high.position)!
32
+ const hit = findScreenPort([low, high], [screen.x + 5, screen.y - 3], project, null)
33
+ expect(hit?.port).toBe(high)
34
+ expect(hit?.port.position).toEqual([0, 3, 0])
35
+ })
36
+
37
+ test('uses a pixel threshold rather than attracting distant sockets', () => {
38
+ const screen = project(high.position)!
39
+ expect(findScreenPort([high], [screen.x + 17, screen.y], project, null)).toBeNull()
40
+ })
41
+
42
+ test('excludes the source before ranking and keeps other sockets on that item eligible', () => {
43
+ const other = { ...high, id: 'end' }
44
+ const screen = project(high.position)!
45
+ expect(findScreenPort([high, other], [screen.x, screen.y], project, high)?.port).toBe(other)
46
+ })
47
+
48
+ test('ignores occluded, clipped and invalid projections', () => {
49
+ const behind = { ...high, position: [0, 2, 20] as const }
50
+ expect(findScreenPort([behind], [400, 400], project, null)).toBeNull()
51
+ expect(findScreenPort([high], [400, 400], () => null, null)).toBeNull()
52
+ expect(findScreenPort([high], [NaN, NaN], project, null)).toBeNull()
53
+ })
54
+
55
+ test('chooses the front socket when projected points coincide', () => {
56
+ const far = { ...high, id: 'far' }
57
+ const near = { ...high, position: [0, 3, 1] as const }
58
+ expect(
59
+ findScreenPort(
60
+ [far, near],
61
+ [10, 10],
62
+ (point) => ({
63
+ x: 10,
64
+ y: 10,
65
+ depth: point[2] === 1 ? 1 : 5,
66
+ }),
67
+ null,
68
+ )?.port,
69
+ ).toBe(near)
70
+ })
71
+
72
+ test('supports a rotated and zoomed floorplan projection while retaining socket elevation', () => {
73
+ const planProject = (point: ScenePort['position']) => ({
74
+ x: -point[2] * 40 + 300,
75
+ y: point[0] * 40 + 200,
76
+ depth: 0,
77
+ })
78
+ expect(findScreenPort([high], [302, 203], planProject, null)?.port.position[1]).toBe(3)
79
+ })
@@ -0,0 +1,25 @@
1
+ import type { ScenePort } from './ports'
2
+
3
+ export type PortScreenPoint = { x: number; y: number; depth: number }
4
+
5
+ export function findScreenPort(
6
+ ports: readonly ScenePort[],
7
+ pointer: readonly [number, number],
8
+ project: (point: ScenePort['position']) => PortScreenPoint | null,
9
+ source: Pick<ScenePort, 'nodeId' | 'id'> | null,
10
+ radius = 16,
11
+ ): { port: ScenePort; screen: PortScreenPoint } | null {
12
+ let best: { port: ScenePort; screen: PortScreenPoint } | null = null
13
+ let distance = radius
14
+ for (const port of ports) {
15
+ if (source?.nodeId === port.nodeId && source.id === port.id) continue
16
+ const screen = project(port.position)
17
+ if (!screen) continue
18
+ const nextDistance = Math.hypot(screen.x - pointer[0], screen.y - pointer[1])
19
+ if (!Number.isFinite(nextDistance) || nextDistance > distance) continue
20
+ if (best && nextDistance === distance && screen.depth >= best.screen.depth) continue
21
+ best = { port, screen }
22
+ distance = nextDistance
23
+ }
24
+ return best
25
+ }
@@ -0,0 +1,23 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { chooseScreenDirection, screenDirectionScore } from './run-screen-direction'
3
+
4
+ test('picks the visually closest guide including diagonals', () => {
5
+ const pointer = [75, -60] as const
6
+ const scores = [
7
+ [100, 0],
8
+ [70, -70],
9
+ [0, -100],
10
+ ].map((tip) => screenDirectionScore([0, 0], tip as [number, number], pointer))
11
+ expect(chooseScreenDirection(scores, -1)).toBe(1)
12
+ })
13
+
14
+ test('small jitter keeps the guide but deliberate movement switches', () => {
15
+ expect(chooseScreenDirection([12, 10], 0)).toBe(0)
16
+ expect(chooseScreenDirection([20, 10], 0)).toBe(1)
17
+ })
18
+
19
+ test('camera-facing and backwards guides do not capture the cursor', () => {
20
+ expect(screenDirectionScore([0, 0], [0.1, 0.2], [50, 50])).toBe(Infinity)
21
+ expect(screenDirectionScore([0, 0], [-100, 0], [50, 0])).toBe(Infinity)
22
+ expect(chooseScreenDirection([Infinity, Infinity], 0)).toBe(-1)
23
+ })
@@ -0,0 +1,30 @@
1
+ type Point2 = readonly [number, number]
2
+
3
+ export function screenDirectionScore(origin: Point2, tip: Point2, pointer: Point2): number {
4
+ const dx = tip[0] - origin[0],
5
+ dy = tip[1] - origin[1]
6
+ const length = Math.hypot(dx, dy)
7
+ if (length < 2) return Infinity
8
+ const px = pointer[0] - origin[0],
9
+ py = pointer[1] - origin[1]
10
+ const distance = Math.hypot(px, py)
11
+ if (px * dx + py * dy <= 0 || distance < 4) return Infinity
12
+ return (
13
+ Math.hypot(px / distance - dx / length, py / distance - dy / length) * Math.min(distance, 100)
14
+ )
15
+ }
16
+
17
+ export function chooseScreenDirection(scores: readonly number[], previous: number): number {
18
+ let best = -1
19
+ for (let i = 0; i < scores.length; i++) {
20
+ if (Number.isFinite(scores[i]) && (best < 0 || scores[i]! < scores[best]!)) best = i
21
+ }
22
+ if (
23
+ best >= 0 &&
24
+ previous >= 0 &&
25
+ Number.isFinite(scores[previous]) &&
26
+ scores[previous]! <= scores[best]! + 4
27
+ )
28
+ return previous
29
+ return best
30
+ }
@@ -13,14 +13,13 @@ import { useEffect, useMemo, useRef } from 'react'
13
13
  import * as THREE from 'three'
14
14
  import { resolveCurrentBuildingId, resolveElevatorSupportY } from '../../../lib/elevator-support'
15
15
  import { sfxEmitter } from '../../../lib/sfx-bus'
16
-
16
+ import { isTypingTarget } from '../../../lib/typing-target'
17
17
  import useAlignmentGuides from '../../../store/use-alignment-guides'
18
18
  import useEditor, {
19
19
  isAlignmentGuideActive,
20
20
  isGridSnapActive,
21
21
  isMagneticSnapActive,
22
22
  } from '../../../store/use-editor'
23
-
24
23
  import usePlacementPreview from '../../../store/use-placement-preview'
25
24
  import { CursorSphere } from '../shared/cursor-sphere'
26
25
  import {
@@ -63,7 +62,7 @@ function resolveDefaultServiceRange(
63
62
  .filter((node): node is LevelNode => node?.type === 'level')
64
63
  .sort((left, right) => left.level - right.level)
65
64
  const selectedLevelIndex = levels.findIndex((level) => level.id === selectedLevelId)
66
- const fromIndex = selectedLevelIndex >= 0 ? selectedLevelIndex : 0
65
+ const fromIndex = Math.max(selectedLevelIndex, 0)
67
66
  const fromLevel = levels[fromIndex]
68
67
  const toLevel = levels[Math.min(fromIndex + 1, levels.length - 1)] ?? fromLevel
69
68
 
@@ -286,9 +285,7 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
286
285
  }
287
286
 
288
287
  const onKeyDown = (event: KeyboardEvent) => {
289
- if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
290
- return
291
- }
288
+ if (isTypingTarget(event.target)) return
292
289
 
293
290
  const ROTATION_STEP = Math.PI / 4
294
291
  let rotationDelta = 0
@@ -17,6 +17,7 @@ import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
17
17
  import { resolveElevatorSupportY } from '../../../lib/elevator-support'
18
18
  import { consumePlacementDragRelease } from '../../../lib/placement-drag-release'
19
19
  import { sfxEmitter } from '../../../lib/sfx-bus'
20
+ import { isTypingTarget } from '../../../lib/typing-target'
20
21
  import useEditor from '../../../store/use-editor'
21
22
  import { CursorSphere } from '../shared/cursor-sphere'
22
23
 
@@ -217,9 +218,7 @@ export function MoveElevatorTool({
217
218
  }
218
219
 
219
220
  const onKeyDown = (event: KeyboardEvent) => {
220
- if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
221
- return
222
- }
221
+ if (isTypingTarget(event.target)) return
223
222
 
224
223
  const ROTATION_STEP = Math.PI / 4
225
224
  let rotationDelta = 0
@@ -104,75 +104,74 @@ function onHost(ctx: PlacementContext): PlacementContext {
104
104
  test.each([
105
105
  [4.01, 1, 1.5],
106
106
  [4, 1, 1.51],
107
- ] as [
108
- number,
109
- number,
110
- number,
111
- ][])('enter and move accept oversized objects with their centres on the host: %j', (width, height, depth) => {
112
- const draft = ItemNode.parse({
113
- ...child,
114
- asset: { ...asset, dimensions: [width, height, depth] },
115
- })
116
- const ctx = context(draft)
117
- const event = hit()
118
- const before = useScene.getState().nodes
119
- expect(itemSurfaceStrategy.enter(ctx, event)).not.toBeNull()
120
- const expected: {
121
- position: [number, number, number]
122
- worldPosition: [number, number, number]
123
- } =
124
- width === 4.01
125
- ? {
126
- position: [0.5049999999999999, 0.8099999999999996, -0.25],
127
- worldPosition: [4.434656461734313, 2.8017166019018256, -3.927442316539199],
128
- }
129
- : {
130
- position: [0.5, 0.8099999999999996, -0.245],
131
- worldPosition: [4.4318465044173845, 2.8017248933478855, -3.9175249544338326],
132
- }
133
- const moved = itemSurfaceStrategy.move(onHost(ctx), event)!
134
- expect(moved.nodeUpdate).toEqual({ position: expected.position })
135
- expect(moved.cursorPosition).toEqual(expected.worldPosition)
136
- expect(moved.cursorRotationY).toBe(ctx.currentCursorRotationY)
137
- expect(resolver.mock.calls.map(([args]) => args.checkFootprint)).toEqual([true, true])
138
- expect(useScene.getState().nodes).toBe(before)
139
- })
107
+ ] as [number, number, number][])(
108
+ 'enter and move accept oversized objects with their centres on the host: %j',
109
+ (width, height, depth) => {
110
+ const draft = ItemNode.parse({
111
+ ...child,
112
+ asset: { ...asset, dimensions: [width, height, depth] },
113
+ })
114
+ const ctx = context(draft)
115
+ const event = hit()
116
+ const before = useScene.getState().nodes
117
+ expect(itemSurfaceStrategy.enter(ctx, event)).not.toBeNull()
118
+ const expected: {
119
+ position: [number, number, number]
120
+ worldPosition: [number, number, number]
121
+ } =
122
+ width === 4.01
123
+ ? {
124
+ position: [0.5049999999999999, 0.8099999999999996, -0.25],
125
+ worldPosition: [4.434656461734313, 2.8017166019018256, -3.927442316539199],
126
+ }
127
+ : {
128
+ position: [0.5, 0.8099999999999996, -0.245],
129
+ worldPosition: [4.4318465044173845, 2.8017248933478855, -3.9175249544338326],
130
+ }
131
+ const moved = itemSurfaceStrategy.move(onHost(ctx), event)!
132
+ expect(moved.nodeUpdate).toEqual({ position: expected.position })
133
+ expect(moved.cursorPosition).toEqual(expected.worldPosition)
134
+ expect(moved.cursorRotationY).toBe(ctx.currentCursorRotationY)
135
+ expect(resolver.mock.calls.map(([args]) => args.checkFootprint)).toEqual([true, true])
136
+ expect(useScene.getState().nodes).toBe(before)
137
+ },
138
+ )
140
139
 
141
- test.each([
142
- 'grid',
143
- 'off',
144
- ] as const)('accepted poses match the frozen live capture with snapping %s', (mode) => {
145
- useEditor.getState().setSnappingMode('item', mode)
146
- const ctx = context()
147
- const event = hit()
148
- const expected: {
149
- position: [number, number, number]
150
- worldPosition: [number, number, number]
151
- rotationY: number
152
- } = {
153
- ...(mode === 'grid'
154
- ? {
155
- position: [0.25, 0.8099999999999996, -0.25],
156
- worldPosition: [4.180930399588417, 2.808503845962115, -3.4850883647019826],
157
- }
158
- : {
159
- position: [0.36999999999999966, 0.8099999999999996, -0.39000000000000146],
160
- worldPosition: [4.239709121156868, 2.808804018339912, -3.728080076057168],
161
- }),
162
- rotationY: -0.84936351290154,
163
- }
164
- const entered = itemSurfaceStrategy.enter(ctx, event)!
165
- expect(entered.nodeUpdate).toEqual({
166
- position: expected.position,
167
- parentId: host.id,
168
- rotation: [0.1, expected.rotationY, 0.3],
169
- })
170
- expect(entered.cursorPosition).toEqual(expected.worldPosition)
171
- expect(itemSurfaceStrategy.move(onHost(ctx), event)!.nodeUpdate).toEqual({
172
- position: expected.position,
173
- })
174
- expect(resolver).toHaveBeenCalledTimes(2)
175
- })
140
+ test.each(['grid', 'off'] as const)(
141
+ 'accepted poses match the frozen live capture with snapping %s',
142
+ (mode) => {
143
+ useEditor.getState().setSnappingMode('item', mode)
144
+ const ctx = context()
145
+ const event = hit()
146
+ const expected: {
147
+ position: [number, number, number]
148
+ worldPosition: [number, number, number]
149
+ rotationY: number
150
+ } = {
151
+ ...(mode === 'grid'
152
+ ? {
153
+ position: [0.25, 0.8099999999999996, -0.25],
154
+ worldPosition: [4.180930399588417, 2.808503845962115, -3.4850883647019826],
155
+ }
156
+ : {
157
+ position: [0.36999999999999966, 0.8099999999999996, -0.39000000000000146],
158
+ worldPosition: [4.239709121156868, 2.808804018339912, -3.728080076057168],
159
+ }),
160
+ rotationY: -0.84936351290154,
161
+ }
162
+ const entered = itemSurfaceStrategy.enter(ctx, event)!
163
+ expect(entered.nodeUpdate).toEqual({
164
+ position: expected.position,
165
+ parentId: host.id,
166
+ rotation: [0.1, expected.rotationY, 0.3],
167
+ })
168
+ expect(entered.cursorPosition).toEqual(expected.worldPosition)
169
+ expect(itemSurfaceStrategy.move(onHost(ctx), event)!.nodeUpdate).toEqual({
170
+ position: expected.position,
171
+ })
172
+ expect(resolver).toHaveBeenCalledTimes(2)
173
+ },
174
+ )
176
175
 
177
176
  test('resolver rejection leaves both enter and move drafts alone', () => {
178
177
  resolver.mockReturnValue(null)