@pascal-app/editor 0.8.0 → 0.9.2

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 (350) hide show
  1. package/package.json +15 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +168 -0
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +795 -28
  5. package/src/components/editor/editor-layout-mobile.tsx +8 -3
  6. package/src/components/editor/editor-layout-v2.tsx +76 -51
  7. package/src/components/editor/export-manager.tsx +69 -32
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +182 -0
  10. package/src/components/editor/first-person/build-collider-world.ts +228 -83
  11. package/src/components/editor/first-person-controls.tsx +943 -27
  12. package/src/components/editor/floating-action-menu.tsx +676 -208
  13. package/src/components/editor/floating-building-action-menu.tsx +2 -2
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +6916 -12137
  17. package/src/components/editor/grid.tsx +197 -36
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +420 -0
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +396 -0
  24. package/src/components/editor/group-transform-shared.ts +489 -0
  25. package/src/components/editor/handles/handle-arrow.tsx +581 -0
  26. package/src/components/editor/handles/use-handle-drag.ts +246 -0
  27. package/src/components/editor/index.tsx +412 -162
  28. package/src/components/editor/measurement-pill.tsx +115 -0
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +1646 -0
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +1106 -483
  36. package/src/components/editor/site-edge-labels.tsx +45 -21
  37. package/src/components/editor/slab-hole-highlights.tsx +474 -0
  38. package/src/components/editor/snapshot-capture-overlay.tsx +369 -155
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +140 -41
  41. package/src/components/editor/use-floorplan-background-placement.ts +190 -35
  42. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  43. package/src/components/editor/use-floorplan-scene-data.ts +19 -7
  44. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  45. package/src/components/editor/wall-measurement-label.tsx +14 -19
  46. package/src/components/editor/wall-move-side-handles.tsx +870 -0
  47. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  48. package/src/components/editor/wall-snap-beacon-layer.tsx +304 -0
  49. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +15 -6
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  52. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  53. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  54. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  56. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +392 -0
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +857 -0
  59. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  60. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +77 -0
  61. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  62. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  63. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  64. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  65. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  66. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +62 -0
  67. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  68. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +3325 -0
  69. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  70. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  71. package/src/components/editor-2d/svg-paths.ts +10 -2
  72. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +439 -57
  73. package/src/components/systems/ceiling/ceiling-system.tsx +109 -12
  74. package/src/components/systems/roof/roof-edit-system.tsx +1851 -22
  75. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  76. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  77. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  78. package/src/components/systems/zone/zone-system.tsx +42 -13
  79. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  80. package/src/components/tools/elevator/elevator-tool.tsx +375 -0
  81. package/src/components/tools/elevator/move-elevator-tool.tsx +263 -0
  82. package/src/components/tools/fence/fence-drafting.ts +141 -27
  83. package/src/components/tools/item/move-tool.tsx +45 -105
  84. package/src/components/tools/item/placement-math.test.ts +22 -0
  85. package/src/components/tools/item/placement-math.ts +59 -7
  86. package/src/components/tools/item/placement-strategies.ts +432 -16
  87. package/src/components/tools/item/placement-types.ts +22 -1
  88. package/src/components/tools/item/use-draft-node.ts +51 -2
  89. package/src/components/tools/item/use-placement-coordinator.tsx +1399 -256
  90. package/src/components/tools/registry/move-registry-node-tool.tsx +1064 -0
  91. package/src/components/tools/roof/roof-tool.tsx +350 -28
  92. package/src/components/tools/select/box-select-state.ts +88 -0
  93. package/src/components/tools/select/box-select-tool.tsx +442 -520
  94. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  95. package/src/components/tools/select/marquee-geometry.ts +155 -0
  96. package/src/components/tools/select/plane-box-select-tool.tsx +570 -0
  97. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  98. package/src/components/tools/select/select-candidates.test.ts +109 -0
  99. package/src/components/tools/select/select-candidates.ts +85 -0
  100. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  101. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  102. package/src/components/tools/shared/drag-bounding-box.tsx +195 -0
  103. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  104. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  105. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  106. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  107. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  108. package/src/components/tools/shared/placement-box.tsx +305 -0
  109. package/src/components/tools/shared/polygon-editor.tsx +866 -175
  110. package/src/components/tools/shared/segment-angle.ts +56 -0
  111. package/src/components/tools/site/site-boundary-editor.tsx +520 -16
  112. package/src/components/tools/site/site-flag-model.ts +18 -0
  113. package/src/components/tools/stair/stair-defaults.ts +2 -1
  114. package/src/components/tools/stair/stair-tool.tsx +342 -37
  115. package/src/components/tools/tool-manager.tsx +239 -62
  116. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  117. package/src/components/tools/wall/wall-drafting.ts +248 -171
  118. package/src/components/tools/wall/wall-snap-geometry.test.ts +123 -0
  119. package/src/components/tools/wall/wall-snap-geometry.ts +331 -0
  120. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  121. package/src/components/tools/zone/zone-tool.tsx +86 -82
  122. package/src/components/ui/action-menu/action-button.tsx +21 -1
  123. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  124. package/src/components/ui/action-menu/control-modes.tsx +40 -170
  125. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  126. package/src/components/ui/action-menu/index.tsx +3 -117
  127. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  128. package/src/components/ui/action-menu/structure-tools.tsx +27 -93
  129. package/src/components/ui/action-menu/view-toggles.tsx +279 -90
  130. package/src/components/ui/command-palette/editor-commands.tsx +20 -15
  131. package/src/components/ui/command-palette/index.tsx +5 -3
  132. package/src/components/ui/controls/material-paint-panel.tsx +152 -0
  133. package/src/components/ui/controls/material-picker.tsx +87 -177
  134. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  135. package/src/components/ui/controls/metric-control.tsx +142 -46
  136. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  137. package/src/components/ui/controls/slider-control.tsx +88 -38
  138. package/src/components/ui/floating-level-selector.tsx +47 -10
  139. package/src/components/ui/helpers/building-helper.tsx +10 -23
  140. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  141. package/src/components/ui/helpers/helper-manager.tsx +235 -23
  142. package/src/components/ui/helpers/item-helper.tsx +28 -32
  143. package/src/components/ui/helpers/registered-tool-helper.tsx +59 -0
  144. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  145. package/src/components/ui/icon-ref.tsx +47 -0
  146. package/src/components/ui/item-catalog/catalog-items.tsx +40 -1
  147. package/src/components/ui/item-catalog/item-catalog.tsx +16 -36
  148. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  149. package/src/components/ui/panels/node-display.ts +17 -16
  150. package/src/components/ui/panels/panel-manager.tsx +55 -58
  151. package/src/components/ui/panels/panel-wrapper.tsx +238 -12
  152. package/src/components/ui/panels/parametric-inspector.tsx +482 -0
  153. package/src/components/ui/panels/reference-panel.tsx +54 -19
  154. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  155. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  156. package/src/components/ui/primitives/sidebar.tsx +1 -0
  157. package/src/components/ui/scene-loader.tsx +1 -3
  158. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  159. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  160. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  161. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  162. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  163. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +122 -41
  164. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +66 -7
  165. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +267 -0
  166. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  167. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +9 -5
  168. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +86 -0
  169. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  170. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  171. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +92 -0
  172. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  173. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +3 -3
  174. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +86 -0
  175. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -21
  176. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  177. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  178. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +140 -0
  179. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +48 -8
  180. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +132 -0
  181. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -5
  182. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +86 -0
  183. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  184. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  185. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +112 -39
  186. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  187. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  188. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  189. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  190. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  191. package/src/components/ui/sidebar/tab-bar.tsx +75 -1
  192. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  193. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  194. package/src/components/ui/snap-target-badge.tsx +88 -0
  195. package/src/components/viewer-overlay.tsx +253 -61
  196. package/src/components/viewer-zone-system.tsx +7 -2
  197. package/src/hooks/use-auto-save.test.ts +14 -0
  198. package/src/hooks/use-auto-save.ts +64 -10
  199. package/src/hooks/use-ceiling-events.ts +176 -0
  200. package/src/hooks/use-drag-action.ts +127 -0
  201. package/src/hooks/use-keyboard.ts +493 -95
  202. package/src/hooks/use-selection.ts +64 -0
  203. package/src/index.tsx +451 -6
  204. package/src/lib/active-placement-surface.ts +35 -0
  205. package/src/lib/ceiling-plan-snap.ts +24 -0
  206. package/src/lib/constants.ts +6 -2
  207. package/src/lib/contextual-help.test.ts +112 -0
  208. package/src/lib/contextual-help.ts +144 -0
  209. package/src/lib/continuation.ts +64 -0
  210. package/src/lib/direct-manipulation.test.ts +192 -0
  211. package/src/lib/direct-manipulation.ts +109 -0
  212. package/src/lib/editor-api.ts +50 -0
  213. package/src/lib/elevator-support.ts +96 -0
  214. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  215. package/src/lib/floorplan/apply-alignment.ts +117 -0
  216. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  217. package/src/lib/floorplan/geometry.ts +53 -0
  218. package/src/lib/floorplan/index.ts +10 -0
  219. package/src/lib/floorplan/items.ts +5 -2
  220. package/src/lib/floorplan/plan-coords.ts +21 -0
  221. package/src/lib/floorplan/selection-tool.ts +40 -0
  222. package/src/lib/floorplan/stairs.ts +12 -1
  223. package/src/lib/fresh-planar-placement.test.ts +339 -0
  224. package/src/lib/fresh-planar-placement.ts +130 -0
  225. package/src/lib/glb-export.test.ts +441 -0
  226. package/src/lib/glb-export.ts +874 -0
  227. package/src/lib/history.ts +11 -1
  228. package/src/lib/interaction/hot-set.test.ts +133 -0
  229. package/src/lib/interaction/hot-set.ts +67 -0
  230. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  231. package/src/lib/interaction/overlay-policy.ts +59 -0
  232. package/src/lib/interaction/scope.ts +211 -0
  233. package/src/lib/level-duplication.ts +54 -42
  234. package/src/lib/level-selection.ts +2 -2
  235. package/src/lib/material-paint.ts +227 -48
  236. package/src/lib/measurement-kind.test.ts +30 -0
  237. package/src/lib/measurement-kind.ts +19 -0
  238. package/src/lib/measurement-label.test.ts +47 -0
  239. package/src/lib/measurement-label.ts +68 -0
  240. package/src/lib/measurement-parser.ts +116 -0
  241. package/src/lib/measurements.test.ts +183 -0
  242. package/src/lib/measurements.ts +199 -0
  243. package/src/lib/paint-scope.test.ts +454 -0
  244. package/src/lib/paint-scope.ts +461 -0
  245. package/src/lib/placement-drag-release.ts +23 -0
  246. package/src/lib/placement-metadata.ts +29 -0
  247. package/src/lib/planar-cursor-placement.test.ts +100 -0
  248. package/src/lib/planar-cursor-placement.ts +42 -0
  249. package/src/lib/plugin-panels.test.ts +19 -0
  250. package/src/lib/plugin-panels.ts +91 -0
  251. package/src/lib/quick-measurement.test.ts +77 -0
  252. package/src/lib/quick-measurement.ts +109 -0
  253. package/src/lib/roof-duplication.ts +7 -7
  254. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  255. package/src/lib/roof-wall-hit.ts +164 -0
  256. package/src/lib/scene-clipboard.test.ts +196 -0
  257. package/src/lib/scene-clipboard.ts +332 -0
  258. package/src/lib/scene.ts +40 -8
  259. package/src/lib/selection-routing.test.ts +298 -0
  260. package/src/lib/selection-routing.ts +175 -0
  261. package/src/lib/sfx/index.ts +1 -0
  262. package/src/lib/sfx/movement-tick.test.ts +65 -0
  263. package/src/lib/sfx/movement-tick.ts +18 -0
  264. package/src/lib/sfx-bus.ts +69 -10
  265. package/src/lib/sfx-player.test.ts +124 -0
  266. package/src/lib/sfx-player.ts +148 -55
  267. package/src/lib/site-boundary.ts +1 -0
  268. package/src/lib/slab-plan-snap.test.ts +93 -0
  269. package/src/lib/slab-plan-snap.ts +108 -0
  270. package/src/lib/snapping-mode.test.ts +138 -0
  271. package/src/lib/snapping-mode.ts +179 -0
  272. package/src/lib/stair-duplication.ts +4 -4
  273. package/src/lib/stair-levels.test.ts +146 -0
  274. package/src/lib/stair-levels.ts +177 -0
  275. package/src/lib/surface-plan-snap.test.ts +71 -0
  276. package/src/lib/surface-plan-snap.ts +256 -0
  277. package/src/lib/use-linear-display.ts +40 -0
  278. package/src/lib/world-grid-snap.ts +231 -0
  279. package/src/lib/zone-content.ts +130 -0
  280. package/src/store/use-alignment-guides.ts +21 -0
  281. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  282. package/src/store/use-editor.tsx +767 -114
  283. package/src/store/use-facing-pose.ts +44 -0
  284. package/src/store/use-fence-curve-draft.ts +21 -0
  285. package/src/store/use-floorplan-draft-preview.ts +101 -0
  286. package/src/store/use-floorplan-marquee.ts +50 -0
  287. package/src/store/use-interaction-scope.test.ts +186 -0
  288. package/src/store/use-interaction-scope.ts +132 -0
  289. package/src/store/use-measurement-draft.test.ts +530 -0
  290. package/src/store/use-measurement-draft.ts +543 -0
  291. package/src/store/use-opening-guides.ts +41 -0
  292. package/src/store/use-placement-preview.ts +39 -0
  293. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  294. package/src/store/use-quick-measurement-hud.ts +77 -0
  295. package/src/store/use-segment-draft-chain.ts +28 -0
  296. package/src/store/use-stair-build-preview.ts +43 -0
  297. package/src/store/use-wall-move-ghosts.ts +36 -0
  298. package/src/store/use-wall-snap-indicator.ts +34 -0
  299. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  300. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  301. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  302. package/src/components/tools/building/move-building-tool.tsx +0 -157
  303. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  304. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  305. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  306. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  307. package/src/components/tools/column/column-tool.tsx +0 -97
  308. package/src/components/tools/column/move-column-tool.tsx +0 -105
  309. package/src/components/tools/door/door-math.ts +0 -110
  310. package/src/components/tools/door/door-tool.tsx +0 -324
  311. package/src/components/tools/door/move-door-tool.tsx +0 -412
  312. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  313. package/src/components/tools/fence/fence-tool.tsx +0 -346
  314. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  315. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  316. package/src/components/tools/item/item-tool.tsx +0 -26
  317. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  318. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  319. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  320. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  321. package/src/components/tools/slab/slab-tool.tsx +0 -322
  322. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  323. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  324. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  325. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  326. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  327. package/src/components/tools/wall/wall-tool.tsx +0 -332
  328. package/src/components/tools/window/move-window-tool.tsx +0 -447
  329. package/src/components/tools/window/window-math.ts +0 -117
  330. package/src/components/tools/window/window-tool.tsx +0 -332
  331. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  332. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  333. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  334. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  335. package/src/components/ui/panels/column-panel.tsx +0 -759
  336. package/src/components/ui/panels/door-panel.tsx +0 -1299
  337. package/src/components/ui/panels/fence-panel.tsx +0 -222
  338. package/src/components/ui/panels/item-panel.tsx +0 -306
  339. package/src/components/ui/panels/paint-panel.tsx +0 -163
  340. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  341. package/src/components/ui/panels/roof-panel.tsx +0 -280
  342. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  343. package/src/components/ui/panels/slab-panel.tsx +0 -250
  344. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  345. package/src/components/ui/panels/stair-panel.tsx +0 -577
  346. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  347. package/src/components/ui/panels/wall-panel.tsx +0 -273
  348. package/src/components/ui/panels/window-panel.tsx +0 -968
  349. package/src/contexts/presets-context.tsx +0 -121
  350. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -0,0 +1,112 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { resolveSelectModeHelpHints } from './contextual-help'
3
+
4
+ describe('resolveSelectModeHelpHints', () => {
5
+ test('stays hidden in idle select mode with no selection', () => {
6
+ expect(
7
+ resolveSelectModeHelpHints({
8
+ selectedCount: 0,
9
+ hasMovableSelection: false,
10
+ hasRotatableSelection: false,
11
+ commandPressed: false,
12
+ shiftPressed: false,
13
+ }),
14
+ ).toEqual([])
15
+ })
16
+
17
+ test('shows multi-select guidance when a modifier is held without selection', () => {
18
+ expect(
19
+ resolveSelectModeHelpHints({
20
+ selectedCount: 0,
21
+ hasMovableSelection: false,
22
+ hasRotatableSelection: false,
23
+ commandPressed: true,
24
+ shiftPressed: false,
25
+ }),
26
+ ).toEqual([
27
+ {
28
+ keys: [['Cmd/Ctrl', 'Shift'], 'Left click'],
29
+ label: 'Add or remove objects from the selection',
30
+ active: true,
31
+ },
32
+ ])
33
+ })
34
+
35
+ test('shows direct manipulation tips for selected movable and rotatable nodes', () => {
36
+ const hints = resolveSelectModeHelpHints({
37
+ selectedCount: 1,
38
+ hasMovableSelection: true,
39
+ hasRotatableSelection: true,
40
+ commandPressed: false,
41
+ shiftPressed: false,
42
+ })
43
+
44
+ expect(hints).toContainEqual({
45
+ keys: ['Left click'],
46
+ label: 'Drag selected movable object',
47
+ })
48
+ expect(hints).toContainEqual({
49
+ keys: ['Cmd/Ctrl', 'Right click'],
50
+ label: 'Drag left or right to rotate selected object',
51
+ })
52
+ // Cmd/Ctrl and Shift click both toggle selection membership (3D selection
53
+ // manager and 2D floorplan alike) — advertised as a single or-group row.
54
+ expect(hints).toContainEqual({
55
+ keys: [['Cmd/Ctrl', 'Shift'], 'Left click'],
56
+ label: 'Add or remove objects from the selection',
57
+ active: false,
58
+ })
59
+ })
60
+
61
+ test('multi-selection advertises the group move + rotate gestures', () => {
62
+ const hints = resolveSelectModeHelpHints({
63
+ selectedCount: 3,
64
+ hasMovableSelection: true,
65
+ hasRotatableSelection: true,
66
+ commandPressed: false,
67
+ shiftPressed: false,
68
+ })
69
+
70
+ expect(hints).toEqual([
71
+ {
72
+ keys: ['Left click'],
73
+ label: 'Click or drag the selection to move it as one',
74
+ },
75
+ { keys: ['R / T'], label: 'Rotate the selection ±45°' },
76
+ {
77
+ keys: [['Cmd/Ctrl', 'Shift'], 'Left click'],
78
+ label: 'Add or remove objects from the selection',
79
+ active: false,
80
+ },
81
+ { keys: ['Esc'], label: 'Clear the selection (or click outside)' },
82
+ ])
83
+ })
84
+
85
+ test('holding a modifier keeps the same rows and only lights the selection one', () => {
86
+ // Guides/snapping are governed by the snapping mode (Shift toggles it),
87
+ // so no modifier-specific "freely / with guides / bypass" variants exist.
88
+ const hints = resolveSelectModeHelpHints({
89
+ selectedCount: 1,
90
+ hasMovableSelection: true,
91
+ hasRotatableSelection: true,
92
+ commandPressed: true,
93
+ shiftPressed: true,
94
+ })
95
+
96
+ expect(hints).toEqual([
97
+ {
98
+ keys: ['Left click'],
99
+ label: 'Drag selected movable object',
100
+ },
101
+ {
102
+ keys: ['Cmd/Ctrl', 'Right click'],
103
+ label: 'Drag left or right to rotate selected object',
104
+ },
105
+ {
106
+ keys: [['Cmd/Ctrl', 'Shift'], 'Left click'],
107
+ label: 'Add or remove objects from the selection',
108
+ active: true,
109
+ },
110
+ ])
111
+ })
112
+ })
@@ -0,0 +1,144 @@
1
+ export type ContextualShortcutHint = {
2
+ // A combo of keys pressed together (rendered joined by "+"). An entry may
3
+ // itself be an array of alternatives (rendered joined by "/"), e.g.
4
+ // [['Cmd/Ctrl', 'Shift'], 'Left click'] → "⌘ / ⇧ + click".
5
+ keys: Array<string | string[]>
6
+ label: string
7
+ // Optional secondary line under the label for a terser qualifier
8
+ // (e.g. "disable 15° snap"). The HUD wraps both lines rather than truncating.
9
+ subtitle?: string
10
+ active?: boolean
11
+ }
12
+
13
+ // `activeHandleDrag.label` value a rotate gizmo sets while dragging, so the
14
+ // contextual HUD can surface the Shift = free-rotation toggle for the duration
15
+ // (mirrors how wall drafting advertises Shift). Distinct from resize handles,
16
+ // which route their own measurement label here.
17
+ export const ROTATE_HANDLE_DRAG_LABEL = 'rotate-handle'
18
+
19
+ // `activeHandleDrag.label` a plain resize / radial-resize arrow sets while
20
+ // dragging (when it carries no dimension `measureLabel`). It exists only so the
21
+ // interaction scope is non-idle during a resize, which keeps the idle
22
+ // select-mode hints off-screen — a resize is its own action, not a selection.
23
+ export const RESIZE_HANDLE_DRAG_LABEL = 'resize-handle'
24
+
25
+ // `activeHandleDrag.label`s for the multi-selection group drags: the body
26
+ // drag sessions (2D floorplan + 3D ground plane) and the rotate gizmo. Kept
27
+ // here (not in the session/gizmo modules) so `snapping-mode.ts` can resolve
28
+ // the group move to the 'item' snap context without importing component code.
29
+ export const GROUP_MOVE_DRAG_LABEL = 'group-move-handle'
30
+ export const GROUP_ROTATE_DRAG_LABEL = 'group-rotate-handle'
31
+
32
+ // Hints shown while a rotate gizmo is mid-drag: Shift bypasses the angle step
33
+ // (free rotation), the same toggle wall drafting exposes. `active` lights the
34
+ // pill while Shift is held.
35
+ export function resolveRotateHandleHelpHints(shiftPressed: boolean): ContextualShortcutHint[] {
36
+ return [
37
+ {
38
+ keys: [SHIFT_KEY],
39
+ label: shiftPressed ? 'Rotating freely (no angle step)' : 'Hold to rotate freely',
40
+ active: shiftPressed,
41
+ },
42
+ ]
43
+ }
44
+
45
+ export type SelectModeHelpContext = {
46
+ selectedCount: number
47
+ hasMovableSelection: boolean
48
+ hasRotatableSelection: boolean
49
+ commandPressed: boolean
50
+ shiftPressed: boolean
51
+ // When a single MEP node is selected its in-world handle rig (click a dot to
52
+ // reveal move arrows) is the real editing path, so the panel leads with the
53
+ // handle-specific hints instead of just the generic Cmd-drag tips.
54
+ mepSelection?: 'run' | 'fitting' | null
55
+ }
56
+
57
+ const COMMAND_KEY = 'Cmd/Ctrl'
58
+ const LEFT_CLICK = 'Left click'
59
+ const RIGHT_CLICK = 'Right click'
60
+ const SHIFT_KEY = 'Shift'
61
+ const CLICK = 'Click'
62
+ const ALT_KEY = 'Alt'
63
+ const ROTATE_KEYS = 'R / T'
64
+ const ESC_KEY = 'Esc'
65
+
66
+ export function resolveSelectModeHelpHints({
67
+ selectedCount,
68
+ hasMovableSelection,
69
+ hasRotatableSelection,
70
+ commandPressed,
71
+ shiftPressed,
72
+ mepSelection = null,
73
+ }: SelectModeHelpContext): ContextualShortcutHint[] {
74
+ const hints: ContextualShortcutHint[] = []
75
+
76
+ if (selectedCount === 0) {
77
+ if (!commandPressed && !shiftPressed) return hints
78
+
79
+ hints.push({
80
+ keys: [[COMMAND_KEY, SHIFT_KEY], LEFT_CLICK],
81
+ label: 'Add or remove objects from the selection',
82
+ active: true,
83
+ })
84
+ return hints
85
+ }
86
+
87
+ // Multi-selection — advertise the group gestures: drag any member to slide
88
+ // the whole selection (2D floor plan body drag + the 3D move gizmo), R / T
89
+ // to step-rotate the group around its bounding-box center.
90
+ if (selectedCount > 1) {
91
+ hints.push({
92
+ keys: [LEFT_CLICK],
93
+ label: 'Click or drag the selection to move it as one',
94
+ })
95
+ hints.push({ keys: [ROTATE_KEYS], label: 'Rotate the selection ±45°' })
96
+ hints.push({
97
+ keys: [[COMMAND_KEY, SHIFT_KEY], LEFT_CLICK],
98
+ label: 'Add or remove objects from the selection',
99
+ active: commandPressed || shiftPressed,
100
+ })
101
+ hints.push({ keys: [ESC_KEY], label: 'Clear the selection (or click outside)' })
102
+ return hints
103
+ }
104
+
105
+ // MEP handle workflow — duct/pipe runs and fittings are edited through the
106
+ // in-world arrow rig that a click on the handle dot reveals, so surface those
107
+ // hints first. A run endpoint's side / up-down arrows swing the run and Alt
108
+ // detaches the joint mid-drag; a fitting's cluster adds rotate arcs, with
109
+ // R / T (and Alt to switch axis) for keyboard rotation.
110
+ if (mepSelection === 'run') {
111
+ hints.push({ keys: [CLICK], label: 'Click a handle dot to show move arrows' })
112
+ hints.push({ keys: [ALT_KEY], label: 'Detach the joint while dragging an arrow' })
113
+ } else if (mepSelection === 'fitting') {
114
+ hints.push({ keys: [CLICK], label: 'Click the handle dot to show move + rotate handles' })
115
+ hints.push({ keys: [ROTATE_KEYS], label: 'Rotate ±45°' })
116
+ hints.push({ keys: [ALT_KEY], label: 'Switch the rotation axis (Y → X → Z)' })
117
+ }
118
+
119
+ // The rows are the same whatever modifier is held — guides/snapping are
120
+ // governed by the snapping mode (Shift toggles it), not by the modifier of
121
+ // this gesture, so there are no modifier-specific variants to advertise.
122
+ // Holding Cmd/Ctrl or Shift just lights the selection row.
123
+ if (hasMovableSelection) {
124
+ hints.push({
125
+ keys: [LEFT_CLICK],
126
+ label: 'Drag selected movable object',
127
+ })
128
+ }
129
+
130
+ if (hasRotatableSelection) {
131
+ hints.push({
132
+ keys: [COMMAND_KEY, RIGHT_CLICK],
133
+ label: 'Drag left or right to rotate selected object',
134
+ })
135
+ }
136
+
137
+ hints.push({
138
+ keys: [[COMMAND_KEY, SHIFT_KEY], LEFT_CLICK],
139
+ label: 'Add or remove objects from the selection',
140
+ active: commandPressed || shiftPressed,
141
+ })
142
+
143
+ return hints
144
+ }
@@ -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,192 @@
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
+ snapDirectRotationDelta,
15
+ } from './direct-manipulation'
16
+
17
+ function registerTestDefinition(kind: string, overrides: Partial<AnyNodeDefinition>) {
18
+ if (nodeRegistry.has(kind)) return
19
+ registerNode({
20
+ kind,
21
+ schemaVersion: 1,
22
+ schema: z.object({ type: z.literal(kind) }) as never,
23
+ category: 'structure',
24
+ defaults: () => ({ type: kind }) as never,
25
+ capabilities: {},
26
+ renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
27
+ ...overrides,
28
+ } as AnyNodeDefinition)
29
+ }
30
+
31
+ describe('snapDirectRotationDelta', () => {
32
+ test('snaps rotation deltas to the default angle increment', () => {
33
+ expect(snapDirectRotationDelta(DEFAULT_ANGLE_STEP * 0.49, false)).toBe(0)
34
+ expect(snapDirectRotationDelta(DEFAULT_ANGLE_STEP * 0.51, false)).toBeCloseTo(
35
+ DEFAULT_ANGLE_STEP,
36
+ )
37
+ expect(snapDirectRotationDelta(DEFAULT_ANGLE_STEP * -1.49, false)).toBeCloseTo(
38
+ -DEFAULT_ANGLE_STEP,
39
+ )
40
+ })
41
+
42
+ test('keeps the raw rotation delta while free-rotating', () => {
43
+ const rawDelta = DEFAULT_ANGLE_STEP * 0.42
44
+ expect(snapDirectRotationDelta(rawDelta, true)).toBe(rawDelta)
45
+ })
46
+ })
47
+
48
+ describe('resolveDirectRotationDragDelta', () => {
49
+ test('maps horizontal pointer motion to the direct rotation delta direction', () => {
50
+ const radiansPerPixel = DEFAULT_ANGLE_STEP / 12
51
+
52
+ expect(resolveDirectRotationDragDelta(100, 112, radiansPerPixel, false)).toBeCloseTo(
53
+ -DEFAULT_ANGLE_STEP,
54
+ )
55
+ expect(resolveDirectRotationDragDelta(100, 88, radiansPerPixel, false)).toBeCloseTo(
56
+ DEFAULT_ANGLE_STEP,
57
+ )
58
+ })
59
+
60
+ test('keeps unsnapped drag deltas while free-rotating', () => {
61
+ expect(resolveDirectRotationDragDelta(100, 103, 0.1, true)).toBeCloseTo(-0.3)
62
+ })
63
+ })
64
+
65
+ describe('canDirectMoveNode', () => {
66
+ // Accepts kinds with a 3D-mountable move tool (`movable` or
67
+ // `affordanceTools.move`); floorplan-only movers (zone) are excluded.
68
+ test('rejects floorplan-only move targets (no 3D tool mounts)', () => {
69
+ const kind = 'direct-move-floorplan-only-test'
70
+ registerTestDefinition(kind, { floorplanMoveTarget: {} as never })
71
+
72
+ expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
73
+ })
74
+
75
+ test('rejects MEP kinds that own move through bespoke selection affordances', () => {
76
+ for (const kind of [
77
+ 'duct-segment',
78
+ 'duct-fitting',
79
+ 'pipe-segment',
80
+ 'pipe-fitting',
81
+ 'lineset',
82
+ 'liquid-line',
83
+ ]) {
84
+ expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
85
+ }
86
+ })
87
+
88
+ test('accepts kinds with a bespoke move tool', () => {
89
+ const kind = 'direct-move-bespoke-tool-test'
90
+ registerTestDefinition(kind, {
91
+ affordanceTools: {
92
+ move: async () => ({ default: () => null }),
93
+ } as never,
94
+ })
95
+
96
+ expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(true)
97
+ })
98
+
99
+ test('accepts nodes with the generic movable capability', () => {
100
+ const kind = 'direct-move-movable-test'
101
+ registerTestDefinition(kind, {
102
+ capabilities: {
103
+ movable: { axes: ['x', 'z'], gridSnap: true },
104
+ },
105
+ } as Partial<AnyNodeDefinition>)
106
+
107
+ expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(true)
108
+ })
109
+
110
+ test('rejects kinds with no registered move path', () => {
111
+ const kind = 'direct-move-none-test'
112
+ registerTestDefinition(kind, {})
113
+
114
+ expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
115
+ })
116
+ })
117
+
118
+ describe('resolveDirectManipulationNode', () => {
119
+ test('routes proxied members to their assembly for direct transforms', () => {
120
+ const group = {
121
+ id: 'direct_manipulation_group',
122
+ type: 'direct-manipulation-group-test',
123
+ } as unknown as AnyNode
124
+ const member = {
125
+ id: 'direct_manipulation_member',
126
+ type: 'direct-manipulation-member-test',
127
+ metadata: { nodeSelectionProxyId: group.id },
128
+ } as unknown as AnyNode
129
+
130
+ expect(
131
+ resolveDirectManipulationNode(member, {
132
+ [group.id]: group,
133
+ [member.id]: member,
134
+ }),
135
+ ).toBe(group)
136
+ })
137
+
138
+ test('falls back to the selected node when the proxy target is missing', () => {
139
+ const member = {
140
+ id: 'direct_manipulation_orphan_member',
141
+ type: 'direct-manipulation-member-test',
142
+ metadata: { nodeSelectionProxyId: 'missing_group' },
143
+ } as unknown as AnyNode
144
+
145
+ expect(resolveDirectManipulationNode(member, { [member.id]: member })).toBe(member)
146
+ })
147
+
148
+ test('routes parent-frame children to their rotatable parent', () => {
149
+ const parentKind = 'direct-manipulation-parent-frame-parent-test'
150
+ const childKind = 'direct-manipulation-parent-frame-child-test'
151
+ registerTestDefinition(parentKind, {
152
+ capabilities: { rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] } },
153
+ })
154
+ registerTestDefinition(childKind, {
155
+ capabilities: {
156
+ movable: {
157
+ axes: ['x', 'z'],
158
+ gridSnap: true,
159
+ parentFrame: {
160
+ resolveParent: (node: AnyNode, nodes: Readonly<Record<string, AnyNode>>) =>
161
+ (node.parentId ? nodes[node.parentId] : null) ?? null,
162
+ parentRotationY: () => 0,
163
+ localToPlan: (_parent: AnyNode, local: readonly [number, number, number]) => [
164
+ local[0],
165
+ local[1],
166
+ local[2],
167
+ ],
168
+ planToLocal: (_parent: AnyNode, planX: number, localY: number, planZ: number) => [
169
+ planX,
170
+ localY,
171
+ planZ,
172
+ ],
173
+ },
174
+ },
175
+ },
176
+ })
177
+
178
+ const parent = { id: 'direct_manipulation_parent', type: parentKind } as unknown as AnyNode
179
+ const child = {
180
+ id: 'direct_manipulation_child',
181
+ type: childKind,
182
+ parentId: parent.id,
183
+ } as unknown as AnyNode
184
+
185
+ expect(
186
+ resolveDirectManipulationNode(child, {
187
+ [parent.id]: parent,
188
+ [child.id]: child,
189
+ }),
190
+ ).toBe(parent)
191
+ })
192
+ })
@@ -0,0 +1,109 @@
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 snapDirectRotationDelta(delta: number, free: boolean): number {
78
+ return free ? delta : Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
79
+ }
80
+
81
+ export function resolveDirectRotationDragDelta(
82
+ startX: number,
83
+ clientX: number,
84
+ radiansPerPixel: number,
85
+ free: boolean,
86
+ ): number {
87
+ return snapDirectRotationDelta((startX - clientX) * radiansPerPixel, free)
88
+ }
89
+
90
+ export function resolveDirectRotationPatch(
91
+ node: AnyNode,
92
+ delta: number,
93
+ sceneApi: SceneApi = createSceneApi(useScene),
94
+ ): Partial<AnyNode> | null {
95
+ const rotateHandle = getDirectRotateHandle(node)
96
+ if (rotateHandle) {
97
+ return rotateHandle.apply(node, delta, sceneApi) as Partial<AnyNode>
98
+ }
99
+
100
+ const rotation = (node as { rotation?: unknown }).rotation
101
+ if (typeof rotation === 'number') {
102
+ return { rotation: rotation - delta } as Partial<AnyNode>
103
+ }
104
+ if (Array.isArray(rotation)) {
105
+ const [rx = 0, ry = 0, rz = 0] = rotation as [number?, number?, number?]
106
+ return { rotation: [rx, ry - delta, rz] } as Partial<AnyNode>
107
+ }
108
+ return null
109
+ }
@@ -0,0 +1,50 @@
1
+ import type { AnyNode, EditorApi } from '@pascal-app/core'
2
+ import useEditor from '../store/use-editor'
3
+ import useInteractionScope from '../store/use-interaction-scope'
4
+ import {
5
+ controlPointReshapeScope,
6
+ endpointReshapeScope,
7
+ tangentReshapeScope,
8
+ } from './interaction/scope'
9
+
10
+ /**
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.
14
+ *
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.
18
+ */
19
+ export function createEditorApi(): EditorApi {
20
+ return {
21
+ engageMove(node: AnyNode) {
22
+ const editor = useEditor.getState()
23
+ editor.setPlacementDragMode(false)
24
+ // `setMovingNode` is typed against a narrower union than `AnyNode`
25
+ // (every concrete kind enumerated). Descriptors pass any node; the
26
+ // cast lets registry-driven move kinds through without forcing a
27
+ // schema-level type widening.
28
+ editor.setMovingNode(node as Parameters<typeof editor.setMovingNode>[0])
29
+ },
30
+ engageMoveDrag(node: AnyNode) {
31
+ const editor = useEditor.getState()
32
+ // Flag drag mode BEFORE mounting the move tool so the coordinator reads
33
+ // it at setup and wires its commit-on-release listener.
34
+ editor.setPlacementDragMode(true)
35
+ editor.setMovingNode(node as Parameters<typeof editor.setMovingNode>[0])
36
+ },
37
+ engageEndpointMove(node: AnyNode, endpoint: 'start' | 'end') {
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))
48
+ },
49
+ }
50
+ }