@pascal-app/editor 0.9.1 → 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (366) hide show
  1. package/package.json +14 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +49 -0
  4. package/src/components/editor/bake-thumbnail.tsx +95 -0
  5. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  6. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  7. package/src/components/editor/custom-camera-controls.tsx +332 -199
  8. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  9. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  10. package/src/components/editor/editor-layout-v2.tsx +18 -0
  11. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  12. package/src/components/editor/export-manager.tsx +71 -63
  13. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  14. package/src/components/editor/first-person/build-collider-world.test.ts +102 -5
  15. package/src/components/editor/first-person/build-collider-world.ts +52 -5
  16. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  17. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  18. package/src/components/editor/first-person-controls.tsx +410 -136
  19. package/src/components/editor/floating-action-menu.tsx +435 -64
  20. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  21. package/src/components/editor/floorplan-background-selection.test.ts +72 -0
  22. package/src/components/editor/floorplan-background-selection.ts +7 -20
  23. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  24. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  25. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  26. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  27. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  28. package/src/components/editor/floorplan-panel.tsx +2517 -1492
  29. package/src/components/editor/grid.tsx +166 -39
  30. package/src/components/editor/group-actions.ts +597 -0
  31. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  32. package/src/components/editor/group-move-3d.ts +397 -0
  33. package/src/components/editor/group-rotate-handle.tsx +102 -64
  34. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  35. package/src/components/editor/group-transform-shared.test.ts +192 -1
  36. package/src/components/editor/group-transform-shared.ts +240 -7
  37. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  38. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  39. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  40. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  41. package/src/components/editor/handles/preview-overrides.ts +13 -0
  42. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  43. package/src/components/editor/handles/resize-snap.ts +23 -0
  44. package/src/components/editor/handles/use-handle-drag.ts +49 -6
  45. package/src/components/editor/index.tsx +289 -35
  46. package/src/components/editor/measurement-pill.tsx +68 -23
  47. package/src/components/editor/node-action-menu.tsx +14 -1
  48. package/src/components/editor/node-arrow-handles.tsx +409 -180
  49. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  50. package/src/components/editor/quick-measurement-card.tsx +76 -0
  51. package/src/components/editor/quick-measurement-hud.tsx +34 -0
  52. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  53. package/src/components/editor/selection-manager.tsx +814 -466
  54. package/src/components/editor/site-edge-labels.tsx +28 -6
  55. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  56. package/src/components/editor/snapshot-capture-overlay.tsx +258 -115
  57. package/src/components/editor/three-context-bridge.ts +22 -0
  58. package/src/components/editor/thumbnail-generator.tsx +118 -253
  59. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  60. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  61. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  62. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  63. package/src/components/editor/use-mesh-settle-epoch.ts +27 -0
  64. package/src/components/editor/wall-measurement-label.tsx +10 -28
  65. package/src/components/editor/wall-move-side-handles.tsx +229 -39
  66. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  67. package/src/components/editor/wall-snap-beacon-layer.tsx +162 -6
  68. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  69. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  70. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +40 -9
  71. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  72. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  73. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  74. package/src/components/editor-2d/floorplan-group-move.tsx +744 -0
  75. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  76. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1672 -0
  77. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  78. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  79. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +219 -20
  80. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +327 -59
  81. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  82. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  83. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  84. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  85. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  86. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  87. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  88. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  89. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  90. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +89 -0
  91. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +106 -0
  92. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +94 -26
  93. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +587 -0
  94. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +2280 -578
  95. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  96. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  97. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +425 -61
  98. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  99. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  100. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  101. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  102. package/src/components/systems/zone/zone-system.tsx +42 -13
  103. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  104. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  105. package/src/components/tools/fence/fence-drafting.ts +107 -8
  106. package/src/components/tools/item/move-tool.tsx +17 -13
  107. package/src/components/tools/item/placement-math.test.ts +13 -1
  108. package/src/components/tools/item/placement-math.ts +28 -2
  109. package/src/components/tools/item/placement-strategies.ts +246 -5
  110. package/src/components/tools/item/placement-types.ts +13 -1
  111. package/src/components/tools/item/use-draft-node.ts +146 -57
  112. package/src/components/tools/item/use-placement-coordinator.tsx +813 -125
  113. package/src/components/tools/registry/move-registry-node-tool.tsx +636 -103
  114. package/src/components/tools/roof/roof-tool.tsx +336 -94
  115. package/src/components/tools/select/box-select-state.ts +18 -7
  116. package/src/components/tools/select/box-select-tool.tsx +176 -40
  117. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  118. package/src/components/tools/select/marquee-geometry.ts +155 -0
  119. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  120. package/src/components/tools/select/select-candidates.ts +1 -1
  121. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  122. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  123. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  124. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  125. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  126. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  127. package/src/components/tools/shared/placement-box.tsx +183 -1
  128. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  129. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  130. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  131. package/src/components/tools/site/site-boundary-editor.tsx +101 -42
  132. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  133. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  134. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  135. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  136. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  137. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  138. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  139. package/src/components/tools/stair/stair-defaults.ts +1 -0
  140. package/src/components/tools/stair/stair-tool.tsx +164 -42
  141. package/src/components/tools/tool-manager.tsx +149 -30
  142. package/src/components/tools/wall/wall-drafting.test.ts +447 -0
  143. package/src/components/tools/wall/wall-drafting.ts +304 -108
  144. package/src/components/tools/wall/wall-snap-geometry.test.ts +52 -0
  145. package/src/components/tools/wall/wall-snap-geometry.ts +137 -9
  146. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  147. package/src/components/tools/zone/zone-tool.tsx +98 -88
  148. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  149. package/src/components/ui/action-menu/control-modes.tsx +44 -94
  150. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  151. package/src/components/ui/action-menu/index.tsx +2 -4
  152. package/src/components/ui/action-menu/measurement-control.tsx +267 -0
  153. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  154. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  155. package/src/components/ui/command-palette/editor-commands.tsx +16 -3
  156. package/src/components/ui/command-palette/index.tsx +4 -3
  157. package/src/components/ui/controls/material-paint-panel.tsx +93 -18
  158. package/src/components/ui/controls/material-picker.tsx +162 -152
  159. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  160. package/src/components/ui/controls/metric-control.tsx +118 -44
  161. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  162. package/src/components/ui/controls/slider-control.tsx +88 -38
  163. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  164. package/src/components/ui/floating-level-selector.tsx +74 -20
  165. package/src/components/ui/helpers/building-helper.tsx +10 -23
  166. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  167. package/src/components/ui/helpers/helper-manager.tsx +273 -14
  168. package/src/components/ui/helpers/item-helper.tsx +28 -32
  169. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  170. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  171. package/src/components/ui/icon-ref.tsx +47 -0
  172. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  173. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  174. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  175. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  176. package/src/components/ui/panels/node-display.ts +17 -17
  177. package/src/components/ui/panels/panel-manager.tsx +36 -14
  178. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  179. package/src/components/ui/panels/parametric-inspector.tsx +19 -4
  180. package/src/components/ui/panels/reference-panel.tsx +54 -19
  181. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  182. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  183. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  184. package/src/components/ui/primitives/sidebar.tsx +1 -0
  185. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  186. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  187. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  188. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +82 -41
  189. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +76 -6
  190. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  191. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  192. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  193. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  194. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  195. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  196. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  197. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  198. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  199. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  200. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -17
  201. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  202. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  203. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  204. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  205. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  206. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  207. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  208. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  209. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  210. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  211. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  212. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  213. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  214. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  215. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  216. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  217. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  218. package/src/components/ui/snap-target-badge.tsx +88 -0
  219. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  220. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  221. package/src/components/viewer-overlay.tsx +36 -596
  222. package/src/components/viewer-zone-system.tsx +6 -1
  223. package/src/components/walkthrough-hud.tsx +111 -0
  224. package/src/hooks/use-auto-save.test.ts +14 -0
  225. package/src/hooks/use-auto-save.ts +54 -7
  226. package/src/hooks/use-ceiling-events.ts +3 -2
  227. package/src/hooks/use-drag-action.ts +4 -1
  228. package/src/hooks/use-grid-events.ts +24 -0
  229. package/src/hooks/use-keyboard.ts +438 -70
  230. package/src/index.tsx +355 -9
  231. package/src/lib/active-placement-surface.test.ts +24 -0
  232. package/src/lib/active-placement-surface.ts +42 -0
  233. package/src/lib/camera-pose.test.ts +237 -0
  234. package/src/lib/camera-pose.ts +172 -0
  235. package/src/lib/ceiling-plan-snap.ts +24 -0
  236. package/src/lib/contextual-help.test.ts +112 -0
  237. package/src/lib/contextual-help.ts +144 -0
  238. package/src/lib/continuation.ts +64 -0
  239. package/src/lib/direct-manipulation.test.ts +275 -0
  240. package/src/lib/direct-manipulation.ts +118 -0
  241. package/src/lib/door-interaction.ts +1 -1
  242. package/src/lib/editor-api.ts +23 -35
  243. package/src/lib/elevation-guides.test.ts +107 -0
  244. package/src/lib/elevation-guides.ts +236 -0
  245. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  246. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  247. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  248. package/src/lib/floorplan/apply-alignment.ts +18 -12
  249. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  250. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  251. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  252. package/src/lib/floorplan/floorplan-export.tsx +978 -0
  253. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  254. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  255. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  256. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  257. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  258. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  259. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  260. package/src/lib/floorplan/geometry.ts +52 -0
  261. package/src/lib/floorplan/index.ts +3 -0
  262. package/src/lib/floorplan/items.ts +5 -2
  263. package/src/lib/floorplan/plan-coords.ts +21 -0
  264. package/src/lib/fresh-planar-placement.test.ts +283 -3
  265. package/src/lib/fresh-planar-placement.ts +85 -1
  266. package/src/lib/glb-export.test.ts +556 -0
  267. package/src/lib/glb-export.ts +1071 -0
  268. package/src/lib/ground-surface.ts +95 -0
  269. package/src/lib/history.test.ts +96 -0
  270. package/src/lib/history.ts +31 -0
  271. package/src/lib/interaction/hot-set.test.ts +133 -0
  272. package/src/lib/interaction/hot-set.ts +67 -0
  273. package/src/lib/interaction/overlay-policy.test.ts +52 -0
  274. package/src/lib/interaction/overlay-policy.ts +59 -0
  275. package/src/lib/interaction/scope.ts +243 -0
  276. package/src/lib/level-duplication.test.ts +2 -1
  277. package/src/lib/level-selection.ts +2 -2
  278. package/src/lib/material-paint.ts +36 -49
  279. package/src/lib/measurement-kind.test.ts +30 -0
  280. package/src/lib/measurement-kind.ts +19 -0
  281. package/src/lib/measurement-label.test.ts +47 -0
  282. package/src/lib/measurement-label.ts +68 -0
  283. package/src/lib/measurement-parser.ts +116 -0
  284. package/src/lib/measurements.test.ts +188 -0
  285. package/src/lib/measurements.ts +210 -0
  286. package/src/lib/paint-scope.test.ts +454 -0
  287. package/src/lib/paint-scope.ts +461 -0
  288. package/src/lib/placement-drag-release.ts +23 -0
  289. package/src/lib/planar-cursor-placement.test.ts +57 -0
  290. package/src/lib/plugin-panels.test.ts +19 -0
  291. package/src/lib/plugin-panels.ts +91 -0
  292. package/src/lib/quick-action-feedback.ts +33 -0
  293. package/src/lib/quick-action-nodes.test.ts +94 -0
  294. package/src/lib/quick-action-nodes.ts +47 -0
  295. package/src/lib/quick-measurement.test.ts +77 -0
  296. package/src/lib/quick-measurement.ts +109 -0
  297. package/src/lib/roof-duplication.ts +6 -6
  298. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  299. package/src/lib/roof-wall-hit.ts +164 -0
  300. package/src/lib/scene-clipboard.test.ts +325 -0
  301. package/src/lib/scene-clipboard.ts +310 -21
  302. package/src/lib/scene.ts +28 -5
  303. package/src/lib/selection-routing.test.ts +313 -0
  304. package/src/lib/selection-routing.ts +180 -0
  305. package/src/lib/sfx/index.ts +1 -0
  306. package/src/lib/sfx/movement-tick.test.ts +65 -0
  307. package/src/lib/sfx/movement-tick.ts +18 -0
  308. package/src/lib/sfx-bus.ts +82 -14
  309. package/src/lib/sfx-player.test.ts +266 -0
  310. package/src/lib/sfx-player.ts +262 -69
  311. package/src/lib/site-boundary.test.ts +53 -0
  312. package/src/lib/site-boundary.ts +27 -0
  313. package/src/lib/slab-plan-snap.test.ts +93 -0
  314. package/src/lib/slab-plan-snap.ts +108 -0
  315. package/src/lib/snapping-mode.test.ts +165 -0
  316. package/src/lib/snapping-mode.ts +191 -0
  317. package/src/lib/stair-duplication.test.ts +103 -0
  318. package/src/lib/stair-duplication.ts +58 -86
  319. package/src/lib/stair-levels.ts +2 -0
  320. package/src/lib/surface-plan-snap.test.ts +71 -0
  321. package/src/lib/surface-plan-snap.ts +256 -0
  322. package/src/lib/terrain-sculpt.test.ts +429 -0
  323. package/src/lib/terrain-sculpt.ts +301 -0
  324. package/src/lib/terrain-verb-color.test.ts +51 -0
  325. package/src/lib/terrain-verb-color.ts +58 -0
  326. package/src/lib/touch-gesture-priority.test.ts +45 -0
  327. package/src/lib/touch-gesture-priority.ts +30 -0
  328. package/src/lib/use-linear-display.ts +40 -0
  329. package/src/lib/window-interaction.ts +1 -1
  330. package/src/lib/world-grid-snap.ts +37 -5
  331. package/src/store/camera-pose-store.test.ts +29 -0
  332. package/src/store/camera-pose-store.ts +27 -0
  333. package/src/store/live-draft-preview-stores.test.ts +136 -0
  334. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  335. package/src/store/navigation-sync-pose-store.ts +27 -0
  336. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  337. package/src/store/use-delete-confirmation.ts +27 -0
  338. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  339. package/src/store/use-drawing-view.test.ts +23 -0
  340. package/src/store/use-drawing-view.ts +84 -0
  341. package/src/store/use-editor.tsx +665 -126
  342. package/src/store/use-elevation-guides.ts +40 -0
  343. package/src/store/use-facing-pose.ts +44 -0
  344. package/src/store/use-fence-curve-draft.ts +51 -0
  345. package/src/store/use-first-person-hud.ts +40 -0
  346. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  347. package/src/store/use-floorplan-draft-preview.ts +151 -0
  348. package/src/store/use-floorplan-marquee.ts +50 -0
  349. package/src/store/use-floorplan-mode.ts +110 -0
  350. package/src/store/use-interaction-scope.test.ts +206 -0
  351. package/src/store/use-interaction-scope.ts +140 -0
  352. package/src/store/use-measurement-draft.test.ts +559 -0
  353. package/src/store/use-measurement-draft.ts +570 -0
  354. package/src/store/use-opening-guides.ts +41 -0
  355. package/src/store/use-path-draft-preview.ts +90 -0
  356. package/src/store/use-placement-preview.ts +11 -3
  357. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  358. package/src/store/use-quick-measurement-hud.ts +77 -0
  359. package/src/store/use-segment-draft-chain.ts +28 -0
  360. package/src/store/use-stair-build-preview.ts +53 -0
  361. package/src/store/use-wall-snap-indicator.ts +2 -0
  362. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  363. package/src/components/editor/group-move-handle.tsx +0 -316
  364. package/src/components/ui/panels/paint-panel.tsx +0 -163
  365. package/src/lib/floorplan/selection-tool.ts +0 -271
  366. package/src/lib/level-name.ts +0 -11
@@ -0,0 +1,64 @@
1
+ export type ContinuationContext = 'wall' | 'fence' | 'point' | 'cabinet'
2
+ export type ContinuationMode = string
3
+
4
+ export const CONTINUATION_PROFILES: Record<
5
+ ContinuationContext,
6
+ {
7
+ options: ContinuationMode[]
8
+ default: ContinuationMode
9
+ labels: Record<string, string>
10
+ icons: Record<string, string>
11
+ }
12
+ > = {
13
+ wall: {
14
+ options: ['room', 'single'],
15
+ default: 'room',
16
+ labels: { room: 'Room (auto-close)', single: 'Single wall' },
17
+ icons: { room: 'lucide:square', single: 'lucide:minus' },
18
+ },
19
+ fence: {
20
+ options: ['single', 'continuous', 'curved'],
21
+ default: 'continuous',
22
+ labels: {
23
+ continuous: 'Continuous',
24
+ single: 'Single fence',
25
+ curved: 'Curved fence',
26
+ },
27
+ icons: {
28
+ continuous: 'lucide:waypoints',
29
+ single: 'lucide:minus',
30
+ curved: 'lucide:spline',
31
+ },
32
+ },
33
+ point: {
34
+ options: ['once', 'repeat'],
35
+ default: 'once',
36
+ labels: { once: 'Place once', repeat: 'Place multiple' },
37
+ icons: { once: 'lucide:target', repeat: 'lucide:copy-plus' },
38
+ },
39
+ cabinet: {
40
+ options: ['single', 'continuous'],
41
+ default: 'single',
42
+ labels: { single: 'Single cabinet', continuous: 'Continuous run' },
43
+ icons: { single: 'lucide:minus', continuous: 'lucide:waypoints' },
44
+ },
45
+ }
46
+
47
+ const POINT_KINDS = new Set(['item', 'door', 'window', 'shelf', 'column'])
48
+
49
+ export function nextContinuation(
50
+ context: ContinuationContext,
51
+ current: ContinuationMode,
52
+ ): ContinuationMode {
53
+ const profile = CONTINUATION_PROFILES[context]
54
+ const index = profile.options.indexOf(current)
55
+ if (index === -1) return profile.default
56
+ return profile.options[(index + 1) % profile.options.length] ?? profile.default
57
+ }
58
+
59
+ export function continuationContextOf(kind: string): ContinuationContext | null {
60
+ if (kind === 'wall') return 'wall'
61
+ if (kind === 'fence') return 'fence'
62
+ if (kind === 'cabinet') return 'cabinet'
63
+ return POINT_KINDS.has(kind) ? 'point' : null
64
+ }
@@ -0,0 +1,275 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeDefinition,
5
+ DEFAULT_ANGLE_STEP,
6
+ nodeRegistry,
7
+ registerNode,
8
+ } from '@pascal-app/core'
9
+ import { z } from 'zod'
10
+ import {
11
+ canDirectMoveNode,
12
+ resolveDirectManipulationNode,
13
+ resolveDirectRotationDragDelta,
14
+ resolveMoveActionNode,
15
+ snapDirectRotationDelta,
16
+ } from './direct-manipulation'
17
+
18
+ function registerTestDefinition(kind: string, overrides: Partial<AnyNodeDefinition>) {
19
+ if (nodeRegistry.has(kind)) return
20
+ registerNode({
21
+ kind,
22
+ schemaVersion: 1,
23
+ schema: z.object({ type: z.literal(kind) }) as never,
24
+ category: 'structure',
25
+ defaults: () => ({ type: kind }) as never,
26
+ capabilities: {},
27
+ renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
28
+ ...overrides,
29
+ } as AnyNodeDefinition)
30
+ }
31
+
32
+ describe('snapDirectRotationDelta', () => {
33
+ test('snaps rotation deltas to the default angle increment', () => {
34
+ expect(snapDirectRotationDelta(DEFAULT_ANGLE_STEP * 0.49, false)).toBe(0)
35
+ expect(snapDirectRotationDelta(DEFAULT_ANGLE_STEP * 0.51, false)).toBeCloseTo(
36
+ DEFAULT_ANGLE_STEP,
37
+ )
38
+ expect(snapDirectRotationDelta(DEFAULT_ANGLE_STEP * -1.49, false)).toBeCloseTo(
39
+ -DEFAULT_ANGLE_STEP,
40
+ )
41
+ })
42
+
43
+ test('keeps the raw rotation delta while free-rotating', () => {
44
+ const rawDelta = DEFAULT_ANGLE_STEP * 0.42
45
+ expect(snapDirectRotationDelta(rawDelta, true)).toBe(rawDelta)
46
+ })
47
+ })
48
+
49
+ describe('resolveDirectRotationDragDelta', () => {
50
+ test('maps horizontal pointer motion to the direct rotation delta direction', () => {
51
+ const radiansPerPixel = DEFAULT_ANGLE_STEP / 12
52
+
53
+ expect(resolveDirectRotationDragDelta(100, 112, radiansPerPixel, false)).toBeCloseTo(
54
+ -DEFAULT_ANGLE_STEP,
55
+ )
56
+ expect(resolveDirectRotationDragDelta(100, 88, radiansPerPixel, false)).toBeCloseTo(
57
+ DEFAULT_ANGLE_STEP,
58
+ )
59
+ })
60
+
61
+ test('keeps unsnapped drag deltas while free-rotating', () => {
62
+ expect(resolveDirectRotationDragDelta(100, 103, 0.1, true)).toBeCloseTo(-0.3)
63
+ })
64
+ })
65
+
66
+ describe('canDirectMoveNode', () => {
67
+ // Accepts kinds with a 3D-mountable move tool (`movable` or
68
+ // `affordanceTools.move`); floorplan-only movers (zone) are excluded.
69
+ test('rejects floorplan-only move targets (no 3D tool mounts)', () => {
70
+ const kind = 'direct-move-floorplan-only-test'
71
+ registerTestDefinition(kind, { floorplanMoveTarget: {} as never })
72
+
73
+ expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
74
+ })
75
+
76
+ test('rejects MEP kinds that own move through bespoke selection affordances', () => {
77
+ for (const kind of [
78
+ 'duct-segment',
79
+ 'duct-fitting',
80
+ 'pipe-segment',
81
+ 'pipe-fitting',
82
+ 'lineset',
83
+ 'liquid-line',
84
+ ]) {
85
+ expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
86
+ }
87
+ })
88
+
89
+ test('accepts kinds with a bespoke move tool', () => {
90
+ const kind = 'direct-move-bespoke-tool-test'
91
+ registerTestDefinition(kind, {
92
+ affordanceTools: {
93
+ move: async () => ({ default: () => null }),
94
+ } as never,
95
+ })
96
+
97
+ expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(true)
98
+ })
99
+
100
+ test('accepts nodes with the generic movable capability', () => {
101
+ const kind = 'direct-move-movable-test'
102
+ registerTestDefinition(kind, {
103
+ capabilities: {
104
+ movable: { axes: ['x', 'z'], gridSnap: true },
105
+ },
106
+ } as Partial<AnyNodeDefinition>)
107
+
108
+ expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(true)
109
+ })
110
+
111
+ test('rejects kinds with no registered move path', () => {
112
+ const kind = 'direct-move-none-test'
113
+ registerTestDefinition(kind, {})
114
+
115
+ expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
116
+ })
117
+ })
118
+
119
+ describe('resolveDirectManipulationNode', () => {
120
+ test('routes proxied members to their assembly for direct transforms', () => {
121
+ const group = {
122
+ id: 'direct_manipulation_group',
123
+ type: 'direct-manipulation-group-test',
124
+ } as unknown as AnyNode
125
+ const member = {
126
+ id: 'direct_manipulation_member',
127
+ type: 'direct-manipulation-member-test',
128
+ metadata: { nodeSelectionProxyId: group.id },
129
+ } as unknown as AnyNode
130
+
131
+ expect(
132
+ resolveDirectManipulationNode(member, {
133
+ [group.id]: group,
134
+ [member.id]: member,
135
+ }),
136
+ ).toBe(group)
137
+ })
138
+
139
+ test('falls back to the selected node when the proxy target is missing', () => {
140
+ const member = {
141
+ id: 'direct_manipulation_orphan_member',
142
+ type: 'direct-manipulation-member-test',
143
+ metadata: { nodeSelectionProxyId: 'missing_group' },
144
+ } as unknown as AnyNode
145
+
146
+ expect(resolveDirectManipulationNode(member, { [member.id]: member })).toBe(member)
147
+ })
148
+
149
+ test('routes parent-frame children to their rotatable parent', () => {
150
+ const parentKind = 'direct-manipulation-parent-frame-parent-test'
151
+ const childKind = 'direct-manipulation-parent-frame-child-test'
152
+ registerTestDefinition(parentKind, {
153
+ capabilities: { rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] } },
154
+ })
155
+ registerTestDefinition(childKind, {
156
+ capabilities: {
157
+ movable: {
158
+ axes: ['x', 'z'],
159
+ gridSnap: true,
160
+ parentFrame: {
161
+ resolveParent: (node: AnyNode, nodes: Readonly<Record<string, AnyNode>>) =>
162
+ (node.parentId ? nodes[node.parentId] : null) ?? null,
163
+ parentRotationY: () => 0,
164
+ localToPlan: (_parent: AnyNode, local: readonly [number, number, number]) => [
165
+ local[0],
166
+ local[1],
167
+ local[2],
168
+ ],
169
+ planToLocal: (_parent: AnyNode, planX: number, localY: number, planZ: number) => [
170
+ planX,
171
+ localY,
172
+ planZ,
173
+ ],
174
+ },
175
+ },
176
+ },
177
+ })
178
+
179
+ const parent = { id: 'direct_manipulation_parent', type: parentKind } as unknown as AnyNode
180
+ const child = {
181
+ id: 'direct_manipulation_child',
182
+ type: childKind,
183
+ parentId: parent.id,
184
+ } as unknown as AnyNode
185
+
186
+ expect(
187
+ resolveDirectManipulationNode(child, {
188
+ [parent.id]: parent,
189
+ [child.id]: child,
190
+ }),
191
+ ).toBe(parent)
192
+ })
193
+ })
194
+
195
+ describe('resolveMoveActionNode', () => {
196
+ test('routes a nested same-kind child move to its host', () => {
197
+ const kind = 'move-action-nested-kind-test'
198
+ registerTestDefinition(kind, {
199
+ capabilities: {
200
+ movable: {
201
+ axes: ['x', 'z'],
202
+ parentFrame: {
203
+ resolveParent: (node: AnyNode, nodes: Readonly<Record<string, AnyNode>>) =>
204
+ (node.parentId ? nodes[node.parentId] : null) ?? null,
205
+ parentRotationY: () => 0,
206
+ localToPlan: (_parent: AnyNode, local: readonly [number, number, number]) => [
207
+ local[0],
208
+ local[1],
209
+ local[2],
210
+ ],
211
+ planToLocal: (_parent: AnyNode, planX: number, localY: number, planZ: number) => [
212
+ planX,
213
+ localY,
214
+ planZ,
215
+ ],
216
+ },
217
+ },
218
+ },
219
+ })
220
+ const parent = { id: 'move_action_parent', type: kind } as unknown as AnyNode
221
+ const child = {
222
+ id: 'move_action_child',
223
+ type: kind,
224
+ parentId: parent.id,
225
+ } as unknown as AnyNode
226
+
227
+ expect(
228
+ resolveMoveActionNode(child, {
229
+ [parent.id]: parent,
230
+ [child.id]: child,
231
+ }),
232
+ ).toBe(parent)
233
+ })
234
+
235
+ test('keeps a child independently movable when its parent is a different kind', () => {
236
+ const parentKind = 'move-action-parent-kind-test'
237
+ const childKind = 'move-action-child-kind-test'
238
+ registerTestDefinition(parentKind, {})
239
+ registerTestDefinition(childKind, {
240
+ capabilities: {
241
+ movable: {
242
+ axes: ['x', 'z'],
243
+ parentFrame: {
244
+ resolveParent: (node: AnyNode, nodes: Readonly<Record<string, AnyNode>>) =>
245
+ (node.parentId ? nodes[node.parentId] : null) ?? null,
246
+ parentRotationY: () => 0,
247
+ localToPlan: (_parent: AnyNode, local: readonly [number, number, number]) => [
248
+ local[0],
249
+ local[1],
250
+ local[2],
251
+ ],
252
+ planToLocal: (_parent: AnyNode, planX: number, localY: number, planZ: number) => [
253
+ planX,
254
+ localY,
255
+ planZ,
256
+ ],
257
+ },
258
+ },
259
+ },
260
+ })
261
+ const parent = { id: 'move_action_run', type: parentKind } as unknown as AnyNode
262
+ const child = {
263
+ id: 'move_action_module',
264
+ type: childKind,
265
+ parentId: parent.id,
266
+ } as unknown as AnyNode
267
+
268
+ expect(
269
+ resolveMoveActionNode(child, {
270
+ [parent.id]: parent,
271
+ [child.id]: child,
272
+ }),
273
+ ).toBe(child)
274
+ })
275
+ })
@@ -0,0 +1,118 @@
1
+ import {
2
+ type AnyNode,
3
+ type ArcResizeHandle,
4
+ createSceneApi,
5
+ DEFAULT_ANGLE_STEP,
6
+ type HandleDescriptor,
7
+ hasRegistry3DMoveTool,
8
+ isMovable,
9
+ nodeRegistry,
10
+ resolveSelectionProxyId,
11
+ type SceneApi,
12
+ useScene,
13
+ } from '@pascal-app/core'
14
+
15
+ function resolveHandles(node: AnyNode): HandleDescriptor<AnyNode>[] {
16
+ const handles = nodeRegistry.get(node.type)?.handles
17
+ if (!handles) return []
18
+ return (
19
+ typeof handles === 'function' ? handles(node as never) : handles
20
+ ) as HandleDescriptor<AnyNode>[]
21
+ }
22
+
23
+ export function getDirectRotateHandle(node: AnyNode): ArcResizeHandle<AnyNode> | null {
24
+ for (const handle of resolveHandles(node)) {
25
+ if (handle.kind === 'arc-resize' && handle.shape === 'rotate') {
26
+ return handle as ArcResizeHandle<AnyNode>
27
+ }
28
+ }
29
+ return null
30
+ }
31
+
32
+ export function canDirectRotateNode(node: AnyNode): boolean {
33
+ return (
34
+ getDirectRotateHandle(node) !== null ||
35
+ nodeRegistry.get(node.type)?.capabilities?.rotatable !== undefined
36
+ )
37
+ }
38
+
39
+ const BESPOKE_SELECTION_MOVE_KINDS = new Set([
40
+ 'duct-segment',
41
+ 'duct-fitting',
42
+ 'pipe-segment',
43
+ 'pipe-fitting',
44
+ 'lineset',
45
+ 'liquid-line',
46
+ ])
47
+
48
+ export function canDirectMoveNode(node: AnyNode): boolean {
49
+ // These MEP kinds own move through bespoke selection rigs (latch cubes,
50
+ // directional arrows, grid-driven previews). Sending body drags/clicks
51
+ // through the generic direct-move handoff conflicts with that path and can
52
+ // leave the editor appearing frozen while their mover waits for the wrong
53
+ // gesture stream.
54
+ if (BESPOKE_SELECTION_MOVE_KINDS.has(node.type)) return false
55
+ // 3D direct move (Ctrl/Meta-drag, the move-cross grip) needs a move tool that
56
+ // mounts in 3D — distinct from `isRegistryMovable`, which also accepts
57
+ // floorplan-only movers (zone) for the 2D plan.
58
+ if (!hasRegistry3DMoveTool(node.type)) return false
59
+ // Bespoke movers (`affordanceTools.move`) own their constraints and often
60
+ // deliberately omit `capabilities.movable` — only gate registry-movable
61
+ // kinds on `isMovable`, so a per-node `movable.override` (e.g. a cabinet
62
+ // run locked behind its selection proxy) can opt out of direct move.
63
+ if (nodeRegistry.get(node.type)?.affordanceTools?.move) return true
64
+ return isMovable(node)
65
+ }
66
+
67
+ export function resolveDirectManipulationNode(
68
+ node: AnyNode,
69
+ nodes: Readonly<Record<string, AnyNode | undefined>>,
70
+ ): AnyNode {
71
+ const target = nodes[resolveSelectionProxyId(node, nodes)] ?? node
72
+ const parentFrame = nodeRegistry.get(target.type)?.capabilities?.movable?.parentFrame
73
+ const parent = parentFrame?.resolveParent(target, nodes as Readonly<Record<string, AnyNode>>)
74
+ return parent && canDirectRotateNode(parent) ? parent : target
75
+ }
76
+
77
+ export function resolveMoveActionNode(
78
+ node: AnyNode,
79
+ nodes: Readonly<Record<string, AnyNode | undefined>>,
80
+ ): AnyNode {
81
+ const parentFrame = nodeRegistry.get(node.type)?.capabilities?.movable?.parentFrame
82
+ const parent = parentFrame?.resolveParent(node, nodes as Readonly<Record<string, AnyNode>>)
83
+ return parent?.type === node.type ? parent : node
84
+ }
85
+
86
+ export function snapDirectRotationDelta(delta: number, free: boolean): number {
87
+ return free ? delta : Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
88
+ }
89
+
90
+ export function resolveDirectRotationDragDelta(
91
+ startX: number,
92
+ clientX: number,
93
+ radiansPerPixel: number,
94
+ free: boolean,
95
+ ): number {
96
+ return snapDirectRotationDelta((startX - clientX) * radiansPerPixel, free)
97
+ }
98
+
99
+ export function resolveDirectRotationPatch(
100
+ node: AnyNode,
101
+ delta: number,
102
+ sceneApi: SceneApi = createSceneApi(useScene),
103
+ ): Partial<AnyNode> | null {
104
+ const rotateHandle = getDirectRotateHandle(node)
105
+ if (rotateHandle) {
106
+ return rotateHandle.apply(node, delta, sceneApi) as Partial<AnyNode>
107
+ }
108
+
109
+ const rotation = (node as { rotation?: unknown }).rotation
110
+ if (typeof rotation === 'number') {
111
+ return { rotation: rotation - delta } as Partial<AnyNode>
112
+ }
113
+ if (Array.isArray(rotation)) {
114
+ const [rx = 0, ry = 0, rz = 0] = rotation as [number?, number?, number?]
115
+ return { rotation: [rx, ry - delta, rz] } as Partial<AnyNode>
116
+ }
117
+ return null
118
+ }
@@ -15,7 +15,7 @@ type DoorOpenAnimationOptions = {
15
15
  persist?: boolean
16
16
  }
17
17
 
18
- function getDisplayedDoorValue(
18
+ export function getDisplayedDoorValue(
19
19
  doorId: AnyNodeId,
20
20
  field: keyof DoorInteractiveState,
21
21
  nodeValue: number | undefined,
@@ -1,33 +1,20 @@
1
- import type { AnyNode, EditorApi, FenceNode, WallNode } from '@pascal-app/core'
1
+ import type { AnyNode, EditorApi } from '@pascal-app/core'
2
2
  import useEditor from '../store/use-editor'
3
-
4
- type EditorState = ReturnType<typeof useEditor.getState>
5
- type EndpointEngager = (node: AnyNode, endpoint: 'start' | 'end', editor: EditorState) => void
3
+ import useInteractionScope from '../store/use-interaction-scope'
4
+ import {
5
+ controlPointReshapeScope,
6
+ endpointReshapeScope,
7
+ tangentReshapeScope,
8
+ } from './interaction/scope'
6
9
 
7
10
  /**
8
- * Per-kind endpoint-move engagement. Kinds whose 2D endpoint drag
9
- * needs its own store field (wall `movingWallEndpoint`, fence
10
- * `movingFenceEndpoint`) register their bridge here. The dispatcher
11
- * is a table lookup rather than an `if (type === 'wall')` chain so
12
- * adding a new endpoint-draggable kind is a one-line entry instead
13
- * of a new branch. Each entry casts the generic `AnyNode` to its
14
- * concrete kind — the lookup key already guarantees the type.
15
- */
16
- const endpointEngagers: Record<string, EndpointEngager> = {
17
- wall: (node, endpoint, editor) =>
18
- editor.setMovingWallEndpoint({ wall: node as WallNode, endpoint }),
19
- fence: (node, endpoint, editor) =>
20
- editor.setMovingFenceEndpoint({ fence: node as FenceNode, endpoint }),
21
- }
22
-
23
- /**
24
- * Concrete {@link EditorApi} backed by `useEditor`. Descriptors call into
25
- * editor state through this interface; the editor owns the actual setter
26
- * names so core stays decoupled.
11
+ * Concrete {@link EditorApi} backed by `useEditor` + the interaction scope.
12
+ * Descriptors call into editor state through this interface; the editor owns
13
+ * the actual store wiring so core stays decoupled.
27
14
  *
28
- * `engageMove` clears any in-progress endpoint drag or curve gesture so
29
- * the move tool takes over cleanly mirrors the legacy bookkeeping that
30
- * lived inside `WallMoveArrowHandle.activateWallMove` / `FenceMoveArrowHandle`.
15
+ * `engageMove` no longer clears any in-progress endpoint drag or curve gesture:
16
+ * `setMovingNode` begins the `moving` scope, and the scope is single-owner, so
17
+ * it atomically replaces any prior reshape — there is no separate flag to reset.
31
18
  */
32
19
  export function createEditorApi(): EditorApi {
33
20
  return {
@@ -39,10 +26,6 @@ export function createEditorApi(): EditorApi {
39
26
  // cast lets registry-driven move kinds through without forcing a
40
27
  // schema-level type widening.
41
28
  editor.setMovingNode(node as Parameters<typeof editor.setMovingNode>[0])
42
- editor.setMovingWallEndpoint(null)
43
- editor.setMovingFenceEndpoint(null)
44
- editor.setCurvingWall(null)
45
- editor.setCurvingFence(null)
46
29
  },
47
30
  engageMoveDrag(node: AnyNode) {
48
31
  const editor = useEditor.getState()
@@ -50,13 +33,18 @@ export function createEditorApi(): EditorApi {
50
33
  // it at setup and wires its commit-on-release listener.
51
34
  editor.setPlacementDragMode(true)
52
35
  editor.setMovingNode(node as Parameters<typeof editor.setMovingNode>[0])
53
- editor.setMovingWallEndpoint(null)
54
- editor.setMovingFenceEndpoint(null)
55
- editor.setCurvingWall(null)
56
- editor.setCurvingFence(null)
57
36
  },
58
37
  engageEndpointMove(node: AnyNode, endpoint: 'start' | 'end') {
59
- endpointEngagers[node.type]?.(node, endpoint, useEditor.getState())
38
+ // Endpoint reshape is kind-agnostic: the scope carries the node id + which
39
+ // endpoint, and consumers recover the node from the scene. Adding a new
40
+ // endpoint-draggable kind needs no entry here.
41
+ useInteractionScope.getState().begin(endpointReshapeScope(node.id, endpoint))
42
+ },
43
+ engageControlPointMove(node: AnyNode, index: number) {
44
+ useInteractionScope.getState().begin(controlPointReshapeScope(node.id, index))
45
+ },
46
+ engageTangentMove(node: AnyNode, index: number, side: 'in' | 'out') {
47
+ useInteractionScope.getState().begin(tangentReshapeScope(node.id, index, side))
60
48
  },
61
49
  }
62
50
  }
@@ -0,0 +1,107 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { type AnyNode, CeilingNode, LevelNode, SlabNode } from '@pascal-app/core'
3
+ import useElevationGuides from '../store/use-elevation-guides'
4
+ import {
5
+ clearStructuralElevationGuide,
6
+ collectElevationSnapTargets,
7
+ publishStructuralElevationGuide,
8
+ resolveElevationSnapMatch,
9
+ resolveStructuralElevationSnap,
10
+ } from './elevation-guides'
11
+
12
+ function structuralScene() {
13
+ const rawLevel = LevelNode.parse({ level: 0, height: 3 })
14
+ const slab = SlabNode.parse({
15
+ parentId: rawLevel.id,
16
+ polygon: [
17
+ [2, -1],
18
+ [4, -1],
19
+ [4, 1],
20
+ [2, 1],
21
+ ],
22
+ elevation: 0.6,
23
+ thickness: 0.2,
24
+ })
25
+ const ceiling = CeilingNode.parse({
26
+ parentId: rawLevel.id,
27
+ polygon: [
28
+ [-4, -1],
29
+ [-2, -1],
30
+ [-2, 1],
31
+ [-4, 1],
32
+ ],
33
+ height: 2.4,
34
+ })
35
+ const level = { ...rawLevel, children: [slab.id, ceiling.id] }
36
+ const nodes: Record<string, AnyNode> = {
37
+ [level.id]: level,
38
+ [slab.id]: slab,
39
+ [ceiling.id]: ceiling,
40
+ }
41
+ return { ceiling, level, nodes, slab }
42
+ }
43
+
44
+ describe('elevation guides', () => {
45
+ test('collects the level, slab faces, and ceiling plane on the source level', () => {
46
+ const { level, nodes, slab } = structuralScene()
47
+ const targets = collectElevationSnapTargets(
48
+ { nodeId: 'wall_moving', levelId: level.id, anchor: [0, 0] },
49
+ nodes,
50
+ )
51
+
52
+ expect(targets.map((target) => [target.label, target.elevation])).toEqual([
53
+ ['Level', 0],
54
+ ['Slab top', 0.6],
55
+ ['Slab underside', 0.39999999999999997],
56
+ ['Ceiling', 2.4],
57
+ ])
58
+
59
+ const withoutSelf = collectElevationSnapTargets(
60
+ { nodeId: slab.id, levelId: level.id, anchor: [0, 0] },
61
+ nodes,
62
+ )
63
+ expect(withoutSelf.some((target) => target.id.startsWith(slab.id))).toBe(false)
64
+ })
65
+
66
+ test('snaps inside the Y tolerance and leaves values outside it alone', () => {
67
+ const { level, nodes } = structuralScene()
68
+ const source = { nodeId: 'wall_moving', levelId: level.id, anchor: [0, 0] } as const
69
+
70
+ expect(resolveStructuralElevationSnap(source, 0.54, nodes)).toBe(0.6)
71
+ expect(resolveStructuralElevationSnap(source, 0.49, nodes)).toBe(0.49)
72
+ expect(resolveStructuralElevationSnap(source, 2.34, nodes)).toBe(2.4)
73
+ })
74
+
75
+ test('uses plan distance to disambiguate datums at the same elevation', () => {
76
+ const match = resolveElevationSnapMatch(
77
+ 1,
78
+ [0, 0],
79
+ [
80
+ { id: 'far', elevation: 1, anchor: [10, 0], label: 'Far' },
81
+ { id: 'near', elevation: 1, anchor: [2, 0], label: 'Near' },
82
+ ],
83
+ )
84
+
85
+ expect(match?.target.id).toBe('near')
86
+ })
87
+
88
+ test('publishes one owner-scoped guide only while exactly aligned', () => {
89
+ const { level, nodes } = structuralScene()
90
+ const source = { nodeId: 'wall_moving', levelId: level.id, anchor: [0, 0] } as const
91
+ useElevationGuides.setState({ guide: null })
92
+
93
+ publishStructuralElevationGuide(source, 0.6, nodes)
94
+ expect(useElevationGuides.getState().guide).toMatchObject({
95
+ ownerId: 'wall_moving',
96
+ elevation: 0.6,
97
+ label: 'Slab top',
98
+ direction: [1, 0],
99
+ })
100
+
101
+ clearStructuralElevationGuide('someone_else')
102
+ expect(useElevationGuides.getState().guide).not.toBeNull()
103
+
104
+ publishStructuralElevationGuide(source, 0.7, nodes)
105
+ expect(useElevationGuides.getState().guide).toBeNull()
106
+ })
107
+ })