@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,124 @@
1
+ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test'
2
+
3
+ type FakeContext = { id: string }
4
+
5
+ let activeContext: FakeContext = { id: 'first' }
6
+ let throwOnPlay = false
7
+ const instances: FakeHowl[] = []
8
+
9
+ class FakeHowl {
10
+ stateValue: 'loaded' | 'unloaded' = 'loaded'
11
+ unloadCount = 0
12
+ playCount = 0
13
+
14
+ constructor(_options: unknown) {
15
+ instances.push(this)
16
+ }
17
+
18
+ play() {
19
+ this.playCount++
20
+ if (throwOnPlay) throw new DOMException('stale graph', 'InvalidAccessError')
21
+ return 1
22
+ }
23
+
24
+ volume() {
25
+ return this
26
+ }
27
+
28
+ rate() {
29
+ return this
30
+ }
31
+
32
+ state() {
33
+ return this.stateValue
34
+ }
35
+
36
+ unload() {
37
+ this.unloadCount++
38
+ this.stateValue = 'unloaded'
39
+ return null
40
+ }
41
+ }
42
+
43
+ const fakeHowler = {
44
+ get ctx() {
45
+ return activeContext
46
+ },
47
+ }
48
+
49
+ mock.module('howler', () => ({ Howl: FakeHowl, Howler: fakeHowler }))
50
+ mock.module('../store/use-audio', () => ({
51
+ default: {
52
+ getState: () => ({ masterVolume: 100, muted: false, sfxVolume: 100 }),
53
+ },
54
+ }))
55
+
56
+ const { disposeSFX, playSFX, preloadSFX } = await import('./sfx-player')
57
+ const { disposeSFXBus, initSFXBus, triggerSFX } = await import('./sfx-bus')
58
+
59
+ beforeEach(() => {
60
+ disposeSFXBus()
61
+ activeContext = { id: 'first' }
62
+ throwOnPlay = false
63
+ instances.length = 0
64
+ })
65
+
66
+ afterAll(() => {
67
+ disposeSFXBus()
68
+ mock.restore()
69
+ })
70
+
71
+ describe('SFX audio context lifecycle', () => {
72
+ test('reuses the preloaded cache while the Howler context is unchanged', () => {
73
+ preloadSFX()
74
+ const initialCount = instances.length
75
+
76
+ playSFX('itemDelete')
77
+
78
+ expect(instances).toHaveLength(initialCount)
79
+ })
80
+
81
+ test('rebuilds every cached Howl before playing against a new context', () => {
82
+ preloadSFX()
83
+ const oldSounds = [...instances]
84
+ const initialCount = instances.length
85
+ activeContext = { id: 'second' }
86
+
87
+ playSFX('itemDelete')
88
+
89
+ expect(oldSounds.every((sound) => sound.unloadCount === 1)).toBe(true)
90
+ expect(instances.length).toBe(initialCount * 2)
91
+ expect(instances.slice(initialCount).some((sound) => sound.playCount === 1)).toBe(true)
92
+ })
93
+
94
+ test('contains a stale graph failure and backs off instead of rebuilding per cue', () => {
95
+ preloadSFX()
96
+ const initialCount = instances.length
97
+ throwOnPlay = true
98
+
99
+ expect(() => playSFX('menuHover')).not.toThrow()
100
+ expect(instances.slice(0, initialCount).every((sound) => sound.unloadCount === 1)).toBe(true)
101
+
102
+ throwOnPlay = false
103
+ playSFX('menuHover')
104
+ expect(instances.length).toBe(initialCount)
105
+ })
106
+
107
+ test('disposes idempotently and recreates sounds after remount', () => {
108
+ preloadSFX()
109
+ const initialCount = instances.length
110
+
111
+ disposeSFX()
112
+ disposeSFX()
113
+ playSFX('itemDelete')
114
+
115
+ expect(instances.length).toBe(initialCount * 2)
116
+ })
117
+
118
+ test('does not let an emitted SFX failure escape an editor callback', () => {
119
+ initSFXBus()
120
+ throwOnPlay = true
121
+
122
+ expect(() => triggerSFX('sfx:item-delete')).not.toThrow()
123
+ })
124
+ })
@@ -1,11 +1,12 @@
1
- import { Howl } from 'howler'
1
+ import { Howl, Howler } from 'howler'
2
2
  import useAudio from '../store/use-audio'
3
3
 
4
4
  // Per-sound variation config. Playback rate also shifts pitch (one semitone ≈ 1.0595×),
5
5
  // so a rate range of ~0.88–1.12 reads as a subtle ±2 semitones — enough to kill the
6
6
  // machine-gun feeling when the same SFX fires in rapid succession.
7
7
  type SFXConfig = {
8
- src: string
8
+ // One file, or several pre-rendered variations cycled round-robin per play.
9
+ src: string | string[]
9
10
  // Random playback-rate range applied per play (1 = unchanged).
10
11
  rateRange?: [number, number]
11
12
  // Random volume multiplier range applied per play (1 = unchanged).
@@ -13,62 +14,96 @@ type SFXConfig = {
13
14
  // Minimum gap between two plays of this SFX. Triggers within this window
14
15
  // are silently dropped so bursty sequences don't phase-stack into noise.
15
16
  minIntervalMs?: number
16
- // Random stereo pan per play — max absolute offset (0 = center, 1 = hard
17
- // right). A small value like 0.15 keeps things centred but adds just enough
18
- // spread to stop repeats from stacking on the same point in the field.
19
- panJitter?: number
20
17
  }
21
18
 
22
19
  const DEFAULT_MIN_INTERVAL_MS = 30
20
+ const SFX_FAILURE_BACKOFF_MS = 5_000
23
21
 
24
22
  // SFX sound definitions
25
23
  export const SFX: Record<string, SFXConfig> = {
26
24
  gridSnap: {
27
- src: '/audios/sfx/grid_snap.mp3',
28
- rateRange: [0.94, 1.06],
29
- volumeRange: [0.92, 1.0],
30
- panJitter: 0.15,
25
+ src: [
26
+ '/audios/sfx/grid_snap_0.mp3',
27
+ '/audios/sfx/grid_snap_1.mp3',
28
+ '/audios/sfx/grid_snap_2.mp3',
29
+ ],
30
+ rateRange: [0.98, 1.02],
31
+ volumeRange: [0.5, 0.6],
32
+ minIntervalMs: 50,
31
33
  },
32
34
  itemDelete: {
33
35
  src: '/audios/sfx/item_delete.mp3',
34
36
  rateRange: [0.9, 1.1],
35
37
  volumeRange: [0.9, 1.0],
36
- panJitter: 0.15,
37
38
  },
38
39
  itemPick: {
39
40
  src: '/audios/sfx/item_pick.mp3',
40
- rateRange: [0.92, 1.08],
41
+ rateRange: [0.95, 1.05],
41
42
  volumeRange: [0.92, 1.0],
42
- panJitter: 0.15,
43
43
  },
44
44
  itemPlace: {
45
45
  src: '/audios/sfx/item_place.mp3',
46
- rateRange: [0.98, 1.06],
46
+ rateRange: [0.98, 1.02],
47
47
  volumeRange: [0.9, 1.0],
48
- panJitter: 0.15,
49
48
  },
50
49
  itemRotate: {
51
50
  src: '/audios/sfx/item_rotate.mp3',
52
51
  rateRange: [0.94, 1.06],
53
52
  volumeRange: [0.92, 1.0],
54
- panJitter: 0.15,
55
53
  },
56
- structureBuild: {
57
- src: '/audios/sfx/structure_build.mp3',
54
+ // Ticks as a resize handle is dragged across snap steps. Fires in rapid
55
+ // succession, so it mirrors gridSnap: three variations cycled round-robin
56
+ // with pitch jitter and a gap so the run reads as texture, not a tone.
57
+ resize: {
58
+ src: ['/audios/sfx/resize_0.mp3', '/audios/sfx/resize_1.mp3', '/audios/sfx/resize_2.mp3'],
59
+ rateRange: [0.98, 1.02],
60
+ volumeRange: [0.26, 0.34],
61
+ minIntervalMs: 80,
62
+ },
63
+ // Fired when a structure draft begins (first click of a wall/slab/etc).
64
+ structureBuildStart: {
65
+ src: '/audios/sfx/structure_build_start.mp3',
66
+ rateRange: [0.95, 1.05],
67
+ volumeRange: [0.88, 1.0],
68
+ },
69
+ // Fired when a structure is committed (segment placed / polygon closed).
70
+ structureBuildEnd: {
71
+ src: '/audios/sfx/structure_build_end.mp3',
58
72
  rateRange: [0.95, 1.05],
59
73
  volumeRange: [0.88, 1.0],
60
- panJitter: 0.15,
61
74
  },
62
75
  structureDelete: {
63
76
  src: '/audios/sfx/structure_delete.mp3',
64
77
  rateRange: [0.9, 1.1],
65
78
  volumeRange: [0.9, 1.0],
66
- panJitter: 0.15,
67
79
  },
68
80
  snapshotCapture: {
69
81
  // Shutter should sound consistent — no variation.
70
82
  src: '/audios/sfx/snapshot_capture.mp3',
71
83
  },
84
+ // Soft tick when hovering a main category in the Build / Items panels.
85
+ // Kept quiet and rate-locked so sweeping across the grid reads as texture,
86
+ // not a melody.
87
+ menuHover: {
88
+ src: '/audios/sfx/menu_hover.mp3',
89
+ rateRange: [0.98, 1.02],
90
+ volumeRange: [0.2, 0.3],
91
+ minIntervalMs: 0,
92
+ },
93
+ // Fired when a main category in the Build / Items panels is clicked.
94
+ menuClick: {
95
+ src: '/audios/sfx/menu_click.mp3',
96
+ rateRange: [0.98, 1.02],
97
+ volumeRange: [0.5, 0.6],
98
+ },
99
+ // Fired when a material is applied to a surface in paint mode. Painting can
100
+ // fire in quick succession across faces, so keep variation + a small gap.
101
+ paintApply: {
102
+ src: '/audios/sfx/paint_apply.mp3',
103
+ rateRange: [0.95, 1.05],
104
+ volumeRange: [0.85, 1.0],
105
+ minIntervalMs: 60,
106
+ },
72
107
  } as const
73
108
 
74
109
  export type SFXName = keyof typeof SFX
@@ -77,56 +112,105 @@ function randomInRange([min, max]: [number, number]): number {
77
112
  return min + Math.random() * (max - min)
78
113
  }
79
114
 
80
- // Preload all SFX sounds
81
- const sfxCache = new Map<SFXName, Howl>()
115
+ let sfxCache = new Map<SFXName, Howl[]>()
116
+ let sfxAudioContext: AudioContext | null = null
117
+ let sfxRetryAfter = 0
82
118
  const lastPlayedAt = new Map<SFXName, number>()
119
+ const lastVariation = new Map<SFXName, number>()
83
120
 
84
- // Initialize all sounds
85
- Object.entries(SFX).forEach(([name, config]) => {
86
- const sound = new Howl({
87
- src: [config.src],
88
- preload: true,
89
- volume: 0.5, // Will be adjusted by the bus
90
- })
91
- sfxCache.set(name as SFXName, sound)
92
- })
121
+ function unloadCachedSounds(resetPlaybackState: boolean) {
122
+ for (const sounds of sfxCache.values()) {
123
+ for (const sound of sounds) {
124
+ try {
125
+ sound.unload()
126
+ } catch {}
127
+ }
128
+ }
129
+ sfxCache.clear()
130
+ sfxAudioContext = null
131
+ if (resetPlaybackState) {
132
+ sfxRetryAfter = 0
133
+ lastPlayedAt.clear()
134
+ lastVariation.clear()
135
+ }
136
+ }
137
+
138
+ function cacheNeedsRebuild(): boolean {
139
+ if (sfxCache.size === 0) return true
140
+ if (sfxAudioContext !== Howler.ctx) return true
141
+ for (const sounds of sfxCache.values()) {
142
+ if (sounds.some((sound) => sound.state() === 'unloaded')) return true
143
+ }
144
+ return false
145
+ }
146
+
147
+ export function preloadSFX() {
148
+ if (!cacheNeedsRebuild()) return
149
+ unloadCachedSounds(false)
150
+
151
+ for (const [name, config] of Object.entries(SFX)) {
152
+ const sources = Array.isArray(config.src) ? config.src : [config.src]
153
+ sfxCache.set(
154
+ name as SFXName,
155
+ sources.map(
156
+ (src) =>
157
+ new Howl({
158
+ src: [src],
159
+ preload: true,
160
+ volume: 0.5,
161
+ }),
162
+ ),
163
+ )
164
+ }
165
+ sfxAudioContext = Howler.ctx ?? null
166
+ }
167
+
168
+ export function disposeSFX() {
169
+ unloadCachedSounds(true)
170
+ }
93
171
 
94
172
  /**
95
173
  * Play a sound effect with volume based on audio settings
96
174
  */
97
175
  export function playSFX(name: SFXName) {
98
- const sound = sfxCache.get(name)
99
- if (!sound) {
100
- console.warn(`SFX not found: ${name}`)
101
- return
102
- }
103
176
  const config = SFX[name]!
177
+ const { masterVolume, sfxVolume, muted } = useAudio.getState()
178
+
179
+ if (muted) return
104
180
 
105
181
  // Drop rapid repeats — two plays of the same SFX within minIntervalMs just
106
182
  // smear into noise, they don't add useful information.
107
183
  const now = performance.now()
184
+ if (now < sfxRetryAfter) return
108
185
  const minInterval = config.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS
109
186
  const last = lastPlayedAt.get(name)
110
187
  if (last !== undefined && now - last < minInterval) return
111
188
  lastPlayedAt.set(name, now)
112
189
 
113
- const { masterVolume, sfxVolume, muted } = useAudio.getState()
190
+ try {
191
+ preloadSFX()
192
+ const sounds = sfxCache.get(name)
193
+ if (!sounds || sounds.length === 0) return
114
194
 
115
- if (muted) return
116
-
117
- // Calculate final volume (masterVolume and sfxVolume are 0-100)
118
- const baseVolume = (masterVolume / 100) * (sfxVolume / 100)
119
- const volumeJitter = config.volumeRange ? randomInRange(config.volumeRange) : 1
120
- const rate = config.rateRange ? randomInRange(config.rateRange) : 1
121
-
122
- // Apply per-play variation using the returned sound id so overlapping plays
123
- // don't fight over shared properties on the Howl.
124
- const id = sound.play()
125
- sound.volume(baseVolume * volumeJitter, id)
126
- if (rate !== 1) sound.rate(rate, id)
127
- if (config.panJitter) {
128
- const pan = (Math.random() * 2 - 1) * config.panJitter
129
- sound.stereo(pan, id)
195
+ // Pick a random variation, avoiding an immediate repeat of the last one so
196
+ // consecutive plays don't land on the same file.
197
+ let index = Math.floor(Math.random() * sounds.length)
198
+ if (sounds.length > 1 && index === lastVariation.get(name)) {
199
+ index = (index + 1) % sounds.length
200
+ }
201
+ lastVariation.set(name, index)
202
+ const sound = sounds[index]!
203
+ const baseVolume = (masterVolume / 100) * (sfxVolume / 100)
204
+ const volumeJitter = config.volumeRange ? randomInRange(config.volumeRange) : 1
205
+ const rate = config.rateRange ? randomInRange(config.rateRange) : 1
206
+ const id = sound.play()
207
+ sound.volume(baseVolume * volumeJitter, id)
208
+ if (rate !== 1) sound.rate(rate, id)
209
+ } catch {
210
+ // Optional audio must never abort an editor input callback. Rebuild from
211
+ // the current Howler context after a backoff instead of retrying every pointer cue.
212
+ unloadCachedSounds(false)
213
+ sfxRetryAfter = now + SFX_FAILURE_BACKOFF_MS
130
214
  }
131
215
  }
132
216
 
@@ -137,7 +221,16 @@ export function updateSFXVolumes() {
137
221
  const { masterVolume, sfxVolume } = useAudio.getState()
138
222
  const finalVolume = (masterVolume / 100) * (sfxVolume / 100)
139
223
 
140
- sfxCache.forEach((sound) => {
141
- sound.volume(finalVolume)
142
- })
224
+ try {
225
+ if (performance.now() < sfxRetryAfter) return
226
+ preloadSFX()
227
+ sfxCache.forEach((sounds) => {
228
+ sounds.forEach((sound) => {
229
+ sound.volume(finalVolume)
230
+ })
231
+ })
232
+ } catch {
233
+ unloadCachedSounds(false)
234
+ sfxRetryAfter = performance.now() + SFX_FAILURE_BACKOFF_MS
235
+ }
143
236
  }
@@ -0,0 +1 @@
1
+ export const SITE_BOUNDARY_DRAG_LABEL = 'site-boundary'
@@ -0,0 +1,93 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { type AnyNode, WallNode } from '@pascal-app/core'
3
+ import useWallSnapIndicator from '../store/use-wall-snap-indicator'
4
+ import { resolveSlabEdgeBandSnap } from './slab-plan-snap'
5
+
6
+ function sceneWithWall(thickness = 0.1) {
7
+ const wall = WallNode.parse({ start: [0, 0], end: [4, 0], thickness })
8
+ const nodes: Record<string, AnyNode> = { [wall.id]: wall }
9
+ return { wall, nodes }
10
+ }
11
+
12
+ describe('resolveSlabEdgeBandSnap', () => {
13
+ test('magnetic: an edge inside the wall band sticks to the centerline and shows the beacon', () => {
14
+ const { wall, nodes } = sceneWithWall()
15
+ useWallSnapIndicator.getState().clear()
16
+
17
+ const snap = resolveSlabEdgeBandSnap({
18
+ edge: [
19
+ [0.5, 0.08],
20
+ [3.5, 0.08],
21
+ ],
22
+ nodes,
23
+ referencePoint: [1, 0.08],
24
+ magnetic: true,
25
+ })
26
+
27
+ expect(snap).not.toBeNull()
28
+ expect(snap!.wallId).toBe(wall.id)
29
+ expect(snap!.edge[0][1]).toBeCloseTo(0)
30
+ expect(snap!.edge[1][1]).toBeCloseTo(0)
31
+
32
+ const beacon = useWallSnapIndicator.getState().point
33
+ expect(beacon).not.toBeNull()
34
+ expect(beacon!.kind).toBe('wall')
35
+ expect(beacon!.wallIds).toEqual([wall.id])
36
+ // Beacon hugs the reference point projected onto the snapped edge.
37
+ expect(beacon!.x).toBeCloseTo(1)
38
+ expect(beacon!.z).toBeCloseTo(0)
39
+ })
40
+
41
+ test('non-magnetic: only the tight connect stick remains', () => {
42
+ const { nodes } = sceneWithWall()
43
+
44
+ // 8cm off the centerline: inside the band but beyond the connect radius.
45
+ expect(
46
+ resolveSlabEdgeBandSnap({
47
+ edge: [
48
+ [0.5, 0.08],
49
+ [3.5, 0.08],
50
+ ],
51
+ nodes,
52
+ magnetic: false,
53
+ }),
54
+ ).toBeNull()
55
+ expect(useWallSnapIndicator.getState().point).toBeNull()
56
+
57
+ // 4cm: genuinely dropped on the wall — still sticks.
58
+ expect(
59
+ resolveSlabEdgeBandSnap({
60
+ edge: [
61
+ [0.5, 0.04],
62
+ [3.5, 0.04],
63
+ ],
64
+ nodes,
65
+ magnetic: false,
66
+ }),
67
+ ).not.toBeNull()
68
+ })
69
+
70
+ test('clears the beacon when the edge leaves every band', () => {
71
+ const { nodes } = sceneWithWall()
72
+
73
+ resolveSlabEdgeBandSnap({
74
+ edge: [
75
+ [0.5, 0.05],
76
+ [3.5, 0.05],
77
+ ],
78
+ nodes,
79
+ magnetic: true,
80
+ })
81
+ expect(useWallSnapIndicator.getState().point).not.toBeNull()
82
+
83
+ resolveSlabEdgeBandSnap({
84
+ edge: [
85
+ [0.5, 1.5],
86
+ [3.5, 1.5],
87
+ ],
88
+ nodes,
89
+ magnetic: true,
90
+ })
91
+ expect(useWallSnapIndicator.getState().point).toBeNull()
92
+ })
93
+ })
@@ -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
+ }