@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,108 @@
1
+ import { type AnyNode, snapSlabEdgeToWallBand, useScene } from '@pascal-app/core'
2
+ import { WALL_CONNECT_SNAP_RADIUS } from '../components/tools/wall/wall-drafting'
3
+ import useAlignmentGuides from '../store/use-alignment-guides'
4
+ import { isMagneticSnapActive } from '../store/use-editor'
5
+ import useWallSnapIndicator from '../store/use-wall-snap-indicator'
6
+ import {
7
+ clearSurfacePlanSnapFeedback,
8
+ getLevelWalls,
9
+ resolveSurfacePlanPointSnap,
10
+ SURFACE_ALIGNMENT_THRESHOLD_M,
11
+ type SurfacePlanSnapInput,
12
+ type SurfacePlanSnapResult,
13
+ } from './surface-plan-snap'
14
+
15
+ const SLAB_SNAP_MOVING_ID = '__slab_snap__'
16
+
17
+ export const SLAB_ALIGNMENT_THRESHOLD_M = SURFACE_ALIGNMENT_THRESHOLD_M
18
+ export type SlabPlanSnapInput = SurfacePlanSnapInput
19
+ export type SlabPlanSnapResult = SurfacePlanSnapResult
20
+
21
+ export function clearSlabSnapFeedback() {
22
+ clearSurfacePlanSnapFeedback()
23
+ }
24
+
25
+ export function resolveSlabPlanPointSnap(input: SlabPlanSnapInput): SlabPlanSnapResult {
26
+ return resolveSurfacePlanPointSnap({
27
+ ...input,
28
+ highlightWalls: input.highlightWalls ?? false,
29
+ movingId: input.movingId ?? SLAB_SNAP_MOVING_ID,
30
+ })
31
+ }
32
+
33
+ export type SlabEdgeBandSnapInput = {
34
+ /** Candidate edge endpoints after the raw/grid perpendicular translation. */
35
+ edge: [[number, number], [number, number]]
36
+ levelId?: string | null
37
+ nodes?: Readonly<Record<string, AnyNode>>
38
+ /**
39
+ * Plan point (typically the cursor) the snap beacon should hug along
40
+ * the wall. Falls back to the snapped edge's midpoint.
41
+ */
42
+ referencePoint?: [number, number]
43
+ /** Override the mode-driven magnetic gate (tests). */
44
+ magnetic?: boolean
45
+ }
46
+
47
+ export type SlabEdgeBandSnapResult = {
48
+ /** The candidate edge translated onto the wall centerline. */
49
+ edge: [[number, number], [number, number]]
50
+ wallId: string
51
+ }
52
+
53
+ /**
54
+ * Edge-level slab reshape snap against wall footprint bands. Unlike the
55
+ * cursor-based `resolveSlabPlanPointSnap`, this tests the DRAGGED EDGE
56
+ * itself (band adoption, span overlap — same permissiveness as the
57
+ * render rule) and sticks it onto the wall CENTERLINE, the canonical
58
+ * stored position; the render rule then places it flush with the face.
59
+ * The beacon, the live preview and the committed polygon therefore all
60
+ * agree. In non-magnetic modes only a tight connect-radius stick
61
+ * remains, mirroring the sanctioned wall connect snap. Publishes /
62
+ * clears the wall-snap beacon as a side effect.
63
+ */
64
+ export function resolveSlabEdgeBandSnap(
65
+ input: SlabEdgeBandSnapInput,
66
+ ): SlabEdgeBandSnapResult | null {
67
+ const nodes = input.nodes ?? useScene.getState().nodes
68
+ const walls = getLevelWalls(nodes, input.levelId)
69
+ const magnetic = input.magnetic ?? isMagneticSnapActive()
70
+
71
+ const snap = snapSlabEdgeToWallBand(
72
+ input.edge[0],
73
+ input.edge[1],
74
+ walls,
75
+ magnetic ? undefined : { maxLateral: WALL_CONNECT_SNAP_RADIUS },
76
+ )
77
+ if (!snap) {
78
+ useWallSnapIndicator.getState().clear()
79
+ useAlignmentGuides.getState().clear()
80
+ return null
81
+ }
82
+
83
+ const [a, b] = snap.edge
84
+ const dx = b[0] - a[0]
85
+ const dz = b[1] - a[1]
86
+ const lengthSquared = dx * dx + dz * dz
87
+ let beacon: [number, number] = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]
88
+ if (input.referencePoint && lengthSquared > 1e-12) {
89
+ const t = Math.max(
90
+ 0,
91
+ Math.min(
92
+ 1,
93
+ ((input.referencePoint[0] - a[0]) * dx + (input.referencePoint[1] - a[1]) * dz) /
94
+ lengthSquared,
95
+ ),
96
+ )
97
+ beacon = [a[0] + dx * t, a[1] + dz * t]
98
+ }
99
+ useWallSnapIndicator.getState().set({
100
+ x: beacon[0],
101
+ z: beacon[1],
102
+ kind: 'wall',
103
+ wallIds: [snap.wallId],
104
+ })
105
+ useAlignmentGuides.getState().clear()
106
+
107
+ return { edge: snap.edge, wallId: snap.wallId }
108
+ }
@@ -0,0 +1,165 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { ROTATE_HANDLE_DRAG_LABEL } from './contextual-help'
3
+ import {
4
+ cycleSnappingModeIn,
5
+ DEFAULT_SNAPPING_MODE,
6
+ defaultSnappingModeFor,
7
+ nextSnappingMode,
8
+ resolveSnapFlags,
9
+ SNAPPING_MODES,
10
+ snapContextOf,
11
+ snappingModesFor,
12
+ } from './snapping-mode'
13
+
14
+ describe('resolveSnapFlags', () => {
15
+ it('default mode is grid', () => {
16
+ expect(DEFAULT_SNAPPING_MODE).toBe('grid')
17
+ })
18
+
19
+ it("modes are exclusive: 'grid' snaps to the lattice only", () => {
20
+ expect(resolveSnapFlags('grid')).toEqual({ grid: true, magnetic: false, angles: false })
21
+ })
22
+
23
+ it("'off' disables grid, magnetic, and angles", () => {
24
+ expect(resolveSnapFlags('off')).toEqual({ grid: false, magnetic: false, angles: false })
25
+ })
26
+
27
+ it("'lines' keeps magnetic but drops the grid lattice and angle lock", () => {
28
+ expect(resolveSnapFlags('lines')).toEqual({ grid: false, magnetic: true, angles: false })
29
+ })
30
+
31
+ it("'angles' keeps the angle lock but drops grid and magnetic", () => {
32
+ expect(resolveSnapFlags('angles')).toEqual({ grid: false, magnetic: false, angles: true })
33
+ })
34
+
35
+ it("'lines' and 'angles' are distinct", () => {
36
+ expect(resolveSnapFlags('lines')).not.toEqual(resolveSnapFlags('angles'))
37
+ })
38
+
39
+ it('cycles through every mode and wraps', () => {
40
+ const seen = [DEFAULT_SNAPPING_MODE]
41
+ let mode = DEFAULT_SNAPPING_MODE
42
+ for (let i = 0; i < SNAPPING_MODES.length - 1; i += 1) {
43
+ mode = nextSnappingMode(mode)
44
+ seen.push(mode)
45
+ }
46
+ expect(seen).toEqual(SNAPPING_MODES)
47
+ expect(nextSnappingMode(mode)).toBe(DEFAULT_SNAPPING_MODE)
48
+ })
49
+ })
50
+
51
+ describe('per-context snapping', () => {
52
+ it('items default to grid with no angle lock', () => {
53
+ expect(defaultSnappingModeFor('item')).toBe('grid')
54
+ expect(snappingModesFor('item')).toEqual(['lines', 'grid', 'off'])
55
+ expect(snappingModesFor('item')).not.toContain('angles')
56
+ })
57
+
58
+ it('walls default to grid and expose the angle lock; polygons do NOT', () => {
59
+ expect(defaultSnappingModeFor('wall')).toBe('grid')
60
+ expect(defaultSnappingModeFor('polygon')).toBe('grid')
61
+ expect(snappingModesFor('wall')).toContain('angles')
62
+ // Angle lock is wall/fence-only — slabs, curves and translates never get it.
63
+ expect(snappingModesFor('polygon')).not.toContain('angles')
64
+ expect(snappingModesFor('polygon')).toEqual(['grid', 'lines', 'off'])
65
+ })
66
+
67
+ it('cycles within the context set and clamps a foreign value', () => {
68
+ expect(cycleSnappingModeIn('item', 'lines')).toBe('grid')
69
+ expect(cycleSnappingModeIn('item', 'off')).toBe('lines')
70
+ // 'angles' isn't an item mode → restart at the first entry
71
+ expect(cycleSnappingModeIn('item', 'angles')).toBe('lines')
72
+ })
73
+ })
74
+
75
+ describe('snapContextOf (profile-driven, node-declared)', () => {
76
+ // Stands in for the registry's declared `def.snapProfile` (the only per-kind
77
+ // data) — the resolver itself has no kind switch.
78
+ const declared: Record<string, 'item' | 'structural'> = {
79
+ wall: 'structural',
80
+ fence: 'structural',
81
+ item: 'item',
82
+ slab: 'structural',
83
+ ceiling: 'structural',
84
+ roof: 'structural',
85
+ zone: 'structural',
86
+ }
87
+ const profileOf = (t: string) => declared[t]
88
+ const profileOfNode = (id: string) =>
89
+ id === 'cabinet-module_1' ? declared.item : id === 'wall_1' ? declared.wall : undefined
90
+ const ctx = (
91
+ scope: {
92
+ kind: string
93
+ nodeType?: string
94
+ reshape?: string
95
+ nodeId?: string
96
+ tool?: string
97
+ handle?: string
98
+ },
99
+ mode = 'select',
100
+ tool: string | null = null,
101
+ ) => snapContextOf({ scope, mode, tool, profileOf, profileOfNode })
102
+
103
+ it('translating a whole structural node has no angle (polygon, not wall)', () => {
104
+ expect(ctx({ kind: 'moving', nodeType: 'wall' })).toBe('polygon')
105
+ expect(ctx({ kind: 'moving', nodeType: 'slab' })).toBe('polygon')
106
+ expect(ctx({ kind: 'placing', nodeType: 'item' }, 'build', 'item')).toBe('item')
107
+ })
108
+
109
+ it('resolves handle drags from the target node profile', () => {
110
+ expect(ctx({ kind: 'handle-drag', nodeId: 'cabinet-module_1' })).toBe('item')
111
+ expect(ctx({ kind: 'handle-drag', nodeId: 'wall_1' })).toBe('polygon')
112
+ expect(ctx({ kind: 'handle-drag', nodeId: 'unknown_1' })).toBeNull()
113
+ expect(
114
+ ctx({ kind: 'handle-drag', nodeId: 'cabinet-module_1', handle: ROTATE_HANDLE_DRAG_LABEL }),
115
+ ).toBeNull()
116
+ })
117
+
118
+ it('endpoint reshape is angle-bearing (wall); curve + polygon vertex edits are not', () => {
119
+ expect(ctx({ kind: 'reshaping', reshape: 'endpoint' })).toBe('wall')
120
+ expect(ctx({ kind: 'reshaping', reshape: 'curve' })).toBe('polygon')
121
+ expect(ctx({ kind: 'reshaping', reshape: 'boundary' })).toBe('polygon')
122
+ expect(ctx({ kind: 'reshaping', reshape: 'hole' })).toBe('polygon')
123
+ })
124
+
125
+ it('drafting a structural kind (wall OR slab) is angle-bearing (wall)', () => {
126
+ expect(ctx({ kind: 'idle' }, 'build', 'wall')).toBe('wall')
127
+ expect(ctx({ kind: 'idle' }, 'build', 'slab')).toBe('wall')
128
+ expect(ctx({ kind: 'idle' }, 'build', 'item')).toBe('item')
129
+ expect(ctx({ kind: 'idle' }, 'select', null)).toBeNull()
130
+ })
131
+
132
+ it('an undeclared kind (no snapProfile) gets no snap context', () => {
133
+ expect(ctx({ kind: 'moving', nodeType: 'door' })).toBeNull()
134
+ expect(ctx({ kind: 'idle' }, 'build', 'shelf')).toBeNull()
135
+ })
136
+
137
+ it('the room-preset stamp tool (not a node kind) resolves to polygon', () => {
138
+ // The host app's room stamp drives placement with `tool='room'`, which has
139
+ // no registry entry — the tool map must still give it the no-angle set so
140
+ // Shift cycling and the HUD chip work during preset placement.
141
+ expect(ctx({ kind: 'idle' }, 'build', 'room')).toBe('polygon')
142
+ expect(ctx({ kind: 'idle' }, 'select', 'room')).toBeNull()
143
+ })
144
+
145
+ it('drafting a non-directional structural kind is angle-less (polygon, not wall)', () => {
146
+ // Roof / stair / elevator are placed as footprints, not directional draws →
147
+ // declared `snapDraftDirectional: false`, so their draft context drops the
148
+ // angle-lock mode. Directional structural kinds (no flag) stay `wall`.
149
+ const draftDirectionalOf = (t: string) => t !== 'roof'
150
+ const draftCtx = (tool: string) =>
151
+ snapContextOf({ scope: { kind: 'idle' }, mode: 'build', tool, profileOf, draftDirectionalOf })
152
+ expect(draftCtx('roof')).toBe('polygon')
153
+ expect(draftCtx('wall')).toBe('wall')
154
+ // Also via the explicit `drafting` scope path.
155
+ expect(
156
+ snapContextOf({
157
+ scope: { kind: 'drafting', tool: 'roof' },
158
+ mode: 'build',
159
+ tool: 'roof',
160
+ profileOf,
161
+ draftDirectionalOf,
162
+ }),
163
+ ).toBe('polygon')
164
+ })
165
+ })
@@ -0,0 +1,191 @@
1
+ import type { SnapProfile } from '@pascal-app/core'
2
+ import { GROUP_MOVE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from './contextual-help'
3
+
4
+ /**
5
+ * Snapping mode is a single global, user-cyclable control that maps onto the
6
+ * two pre-existing snap knobs (`gridSnapStep` grid snap + `magneticSnap`).
7
+ * The default `'grid'` resolves to the exact pair the editor shipped with
8
+ * before this control existed (grid on, magnetic on), so the default path is
9
+ * behaviourally unchanged — only when a user opts into `'lines'` or `'off'`
10
+ * does any snap math get suppressed.
11
+ */
12
+ export type SnappingMode = 'grid' | 'lines' | 'angles' | 'off'
13
+
14
+ export const SNAPPING_MODES: SnappingMode[] = ['grid', 'lines', 'angles', 'off']
15
+
16
+ export const DEFAULT_SNAPPING_MODE: SnappingMode = 'grid'
17
+
18
+ export type SnapFlags = {
19
+ grid: boolean
20
+ magnetic: boolean
21
+ angles: boolean
22
+ }
23
+
24
+ /**
25
+ * Pure mapping from the mode enum onto the individual snap knobs. Modes are
26
+ * EXCLUSIVE — each does exactly what its chip label says, one guide at a time,
27
+ * so the HUD is honest:
28
+ *
29
+ * - `grid` → grid lattice only.
30
+ * - `lines` → magnetic only: alignment axes + wall corner-join (connectivity
31
+ * is part of the "lines" magnetic snap, not a separate always-on behaviour).
32
+ * - `angles` → angle lock only (15°/45° rays).
33
+ * - `off` → nothing snaps (raw cursor).
34
+ */
35
+ export function resolveSnapFlags(mode: SnappingMode): SnapFlags {
36
+ switch (mode) {
37
+ case 'grid':
38
+ return { grid: true, magnetic: false, angles: false }
39
+ case 'lines':
40
+ return { grid: false, magnetic: true, angles: false }
41
+ case 'angles':
42
+ return { grid: false, magnetic: false, angles: true }
43
+ case 'off':
44
+ return { grid: false, magnetic: false, angles: false }
45
+ }
46
+ }
47
+
48
+ const SNAPPING_MODE_LABELS: Record<SnappingMode, string> = {
49
+ grid: 'Grid',
50
+ lines: 'Lines',
51
+ angles: 'Angles',
52
+ off: 'Off',
53
+ }
54
+
55
+ export function getSnappingModeLabel(mode: SnappingMode): string {
56
+ return SNAPPING_MODE_LABELS[mode]
57
+ }
58
+
59
+ export function nextSnappingMode(mode: SnappingMode): SnappingMode {
60
+ const index = SNAPPING_MODES.indexOf(mode)
61
+ return SNAPPING_MODES[(index + 1) % SNAPPING_MODES.length] ?? DEFAULT_SNAPPING_MODE
62
+ }
63
+
64
+ // ── Per-context snapping ──────────────────────────────────────────────────────
65
+ //
66
+ // Snapping is no longer one global value: each *activity* has its own mode set
67
+ // and default, because they want different behaviour (drawing a wall wants a
68
+ // grid + angle lock; nudging an item wants free movement that only catches on
69
+ // alignment lines). The mode is remembered per context and shown live, so it's
70
+ // never a silent surprise — it just matches what you're doing.
71
+
72
+ export type SnapContext = 'wall' | 'item' | 'polygon'
73
+
74
+ // The cyclable mode-set for a context (distinct from the node's `SnapProfile`).
75
+ type SnapModeSet = { modes: SnappingMode[]; default: SnappingMode }
76
+
77
+ // `modes[0]` is the cycle's first entry; `default` is what a context starts at.
78
+ // The 'wall' set is the ONLY one with an angle lock — it applies solely when
79
+ // you're setting a segment's DIRECTION (wall/fence drafting + endpoint drag).
80
+ // Translating a whole wall, curving it, or drawing/moving a slab can't change an
81
+ // angle, so those use the no-angle 'polygon' set.
82
+ const SNAP_PROFILES: Record<SnapContext, SnapModeSet> = {
83
+ // Wall / fence drafting + endpoint reshape: direction matters → angle lock.
84
+ wall: { modes: ['grid', 'lines', 'angles', 'off'], default: 'grid' },
85
+ // Item placement / move: grid by default; lines = magnetic alignment only (no
86
+ // grid lattice), no angle lock (meaningless for a footprint).
87
+ item: { modes: ['lines', 'grid', 'off'], default: 'grid' },
88
+ // Structural / surface, no direction to set: slab / ceiling / roof draft+move,
89
+ // whole wall/fence translate, curve reshape, polygon boundary edit. Grid by
90
+ // default, NO angle lock.
91
+ polygon: { modes: ['grid', 'lines', 'off'], default: 'grid' },
92
+ }
93
+
94
+ export const SNAP_CONTEXTS: SnapContext[] = ['wall', 'item', 'polygon']
95
+
96
+ export function snappingModesFor(context: SnapContext): SnappingMode[] {
97
+ return SNAP_PROFILES[context].modes
98
+ }
99
+
100
+ export function defaultSnappingModeFor(context: SnapContext): SnappingMode {
101
+ return SNAP_PROFILES[context].default
102
+ }
103
+
104
+ // Cycle within the context's own set (clamps a foreign value to the first entry).
105
+ export function cycleSnappingModeIn(context: SnapContext, mode: SnappingMode): SnappingMode {
106
+ const modes = SNAP_PROFILES[context].modes
107
+ const index = modes.indexOf(mode)
108
+ return modes[(index + 1) % modes.length] ?? modes[0] ?? DEFAULT_SNAPPING_MODE
109
+ }
110
+
111
+ // The kind's declared `snapProfile` (from the registry) → the active mode-set
112
+ // context. The only behaviour difference is the angle lock, which a `structural`
113
+ // kind gets while SETTING DIRECTION (drafting a run/polygon, dragging an endpoint
114
+ // or a polygon vertex) — never while translating or curving. A node with no
115
+ // declared profile has no snapping UI (chip) yet — returns null.
116
+ function contextForProfile(
117
+ profile: SnapProfile | undefined,
118
+ directionSetting: boolean,
119
+ ): SnapContext | null {
120
+ if (profile === 'item') return 'item'
121
+ if (profile === 'structural') return directionSetting ? 'wall' : 'polygon'
122
+ return null
123
+ }
124
+
125
+ // Tools that are not registered node kinds (no `snapProfile` to look up) but
126
+ // still place a whole footprint — the host app's room-preset stamp. A stamp is
127
+ // a whole-footprint translate: no direction to set → the no-angle 'polygon'
128
+ // set. Without an entry here the tool has no snap context at all, so Shift
129
+ // cycling and the HUD chip stay dead while it drives placement.
130
+ const TOOL_SNAP_CONTEXTS: Record<string, SnapContext> = { room: 'polygon' }
131
+
132
+ /**
133
+ * The active snapping context, derived from what the user is doing — fully
134
+ * node-declared: the kind's `snapProfile` (looked up via the injected
135
+ * `profileOf`) supplies the data, and this maps (profile × action) to the
136
+ * mode-set. No per-kind switch lives here. `profileOf` is injected so this stays
137
+ * pure + testable and `snapping-mode` need not import the registry.
138
+ *
139
+ * Prefers the authoritative interaction scope; falls back to the build tool
140
+ * because the `drafting` scope isn't wired yet (wall/slab draw runs idle).
141
+ * Returns null when nothing snappable is active → no chip, safe-default snap.
142
+ */
143
+ export function snapContextOf(args: {
144
+ scope: {
145
+ kind: string
146
+ nodeType?: string
147
+ reshape?: string
148
+ nodeId?: string
149
+ tool?: string
150
+ handle?: string
151
+ }
152
+ mode: string
153
+ tool: string | null
154
+ profileOf: (typeOrTool: string) => SnapProfile | undefined
155
+ profileOfNode?: (nodeId: string) => SnapProfile | undefined
156
+ // Whether drafting a kind sets a direction (angle-lock meaningful). Injected
157
+ // like `profileOf` so `snapping-mode` need not import the registry; defaults
158
+ // to `true` (the structural draw default) when not supplied.
159
+ draftDirectionalOf?: (typeOrTool: string) => boolean
160
+ }): SnapContext | null {
161
+ const { scope, mode, tool, profileOf, profileOfNode, draftDirectionalOf } = args
162
+ // The group-move gizmo translates the whole selection — same no-angle
163
+ // treatment as a single-node move, so Shift cycles the 'item' modes and the
164
+ // HUD shows the item snapping chips for the drag.
165
+ if (scope.kind === 'handle-drag' && scope.handle === GROUP_MOVE_DRAG_LABEL) {
166
+ return 'item'
167
+ }
168
+ switch (scope.kind) {
169
+ case 'handle-drag':
170
+ if (scope.handle === ROTATE_HANDLE_DRAG_LABEL) return null
171
+ return scope.nodeId ? contextForProfile(profileOfNode?.(scope.nodeId), false) : null
172
+ case 'placing':
173
+ case 'moving':
174
+ // A whole-node translate never sets direction → no angle.
175
+ return scope.nodeType ? contextForProfile(profileOf(scope.nodeType), false) : null
176
+ case 'reshaping':
177
+ // Dragging a wall ENDPOINT sets the segment's direction → angle-bearing
178
+ // 'wall'. Curving, and polygon vertex/edge edits (boundary / hole), don't
179
+ // — they use the no-angle 'polygon' set (grid / lines / off).
180
+ return scope.reshape === 'endpoint' ? 'wall' : 'polygon'
181
+ case 'drafting':
182
+ return scope.tool
183
+ ? contextForProfile(profileOf(scope.tool), draftDirectionalOf?.(scope.tool) ?? true)
184
+ : null
185
+ default:
186
+ return mode === 'build' && tool
187
+ ? (TOOL_SNAP_CONTEXTS[tool] ??
188
+ contextForProfile(profileOf(tool), draftDirectionalOf?.(tool) ?? true))
189
+ : null
190
+ }
191
+ }
@@ -0,0 +1,103 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeId,
5
+ LevelNode,
6
+ StairNode,
7
+ StairSegmentNode,
8
+ useScene,
9
+ } from '@pascal-app/core'
10
+ import { useViewer } from '@pascal-app/viewer'
11
+ import useInteractionScope, { getMovingNode } from '../store/use-interaction-scope'
12
+ import { duplicateStairSubtree } from './stair-duplication'
13
+
14
+ const LEVEL_ID = 'level_stair-duplicate' as AnyNodeId
15
+ const STAIR_ID = 'stair_original' as AnyNodeId
16
+ const SEGMENT_ID = 'sseg_original' as AnyNodeId
17
+
18
+ function seedStraightStair() {
19
+ const level = LevelNode.parse({
20
+ id: LEVEL_ID,
21
+ type: 'level',
22
+ children: [STAIR_ID],
23
+ })
24
+ const stair = StairNode.parse({
25
+ id: STAIR_ID,
26
+ type: 'stair',
27
+ parentId: LEVEL_ID,
28
+ children: [SEGMENT_ID],
29
+ position: [2, 0, 3],
30
+ })
31
+ const segment = StairSegmentNode.parse({
32
+ id: SEGMENT_ID,
33
+ type: 'stair-segment',
34
+ parentId: STAIR_ID,
35
+ })
36
+ useScene.setState({
37
+ nodes: {
38
+ [LEVEL_ID]: level as AnyNode,
39
+ [STAIR_ID]: stair as AnyNode,
40
+ [SEGMENT_ID]: segment as AnyNode,
41
+ },
42
+ rootNodeIds: [LEVEL_ID],
43
+ collections: {},
44
+ dirtyNodes: new Set(),
45
+ } as never)
46
+ }
47
+
48
+ describe('duplicateStairSubtree', () => {
49
+ beforeEach(() => {
50
+ useInteractionScope.getState().end()
51
+ useViewer.getState().setSelection({ selectedIds: [STAIR_ID] })
52
+ useScene.temporal.getState().clear()
53
+ useScene.temporal.getState().resume()
54
+ seedStraightStair()
55
+ })
56
+
57
+ afterEach(() => {
58
+ useInteractionScope.getState().end()
59
+ useScene.temporal.getState().resume()
60
+ useViewer.getState().setSelection({ selectedIds: [] })
61
+ })
62
+
63
+ test('moves the exact fresh scene subtree and clears the original selection', () => {
64
+ const result = duplicateStairSubtree(STAIR_ID, { mode: 'move' })
65
+ const nodes = useScene.getState().nodes
66
+ const draft = nodes[result.stair.id as AnyNodeId]
67
+
68
+ expect(result.stair.id).not.toBe(STAIR_ID)
69
+ expect(draft).toBe(result.stair)
70
+ expect(getMovingNode()?.id).toBe(result.stair.id)
71
+ expect(useViewer.getState().selection.selectedIds).toEqual([])
72
+ expect((result.stair.metadata as Record<string, unknown>)?.isNew).toBe(true)
73
+ expect(result.stair.position).toEqual([3, 0, 4])
74
+ expect(result.segmentIds).toHaveLength(1)
75
+ expect(result.segmentIds[0]).not.toBe(SEGMENT_ID)
76
+ expect(nodes[result.segmentIds[0] as AnyNodeId]?.parentId).toBe(result.stair.id)
77
+ expect(nodes[STAIR_ID]).toBeDefined()
78
+ expect(nodes[SEGMENT_ID]).toBeDefined()
79
+ expect(useScene.temporal.getState().isTracking).toBe(false)
80
+ })
81
+
82
+ test('keeps childless curved stairs on the same real draft path', () => {
83
+ const curved = StairNode.parse({
84
+ ...(useScene.getState().nodes[STAIR_ID] as AnyNode),
85
+ children: [],
86
+ stairType: 'curved',
87
+ })
88
+ useScene.setState((state) => ({
89
+ nodes: {
90
+ ...state.nodes,
91
+ [LEVEL_ID]: { ...state.nodes[LEVEL_ID], children: [STAIR_ID] } as AnyNode,
92
+ [STAIR_ID]: curved as AnyNode,
93
+ },
94
+ }))
95
+
96
+ const result = duplicateStairSubtree(STAIR_ID, { mode: 'move', offset: [0, 0, 0] })
97
+
98
+ expect(result.segmentIds).toEqual([])
99
+ expect(useScene.getState().nodes[result.stair.id as AnyNodeId]).toBe(result.stair)
100
+ expect(getMovingNode()?.id).toBe(result.stair.id)
101
+ expect((result.stair.metadata as Record<string, unknown>)?.isNew).toBe(true)
102
+ })
103
+ })