@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
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  type AnyNode,
3
3
  type AnyNodeId,
4
+ DEFAULT_ANGLE_STEP,
4
5
  type DoorNode,
5
6
  getScaledDimensions,
6
- getWallCurveFrameAt,
7
- getWallCurveLength,
8
7
  type ItemNode,
9
- isCurvedWall,
8
+ runAsSingleSceneHistoryStep,
9
+ snapPointAlongAngleRay,
10
10
  useScene,
11
11
  type WallNode,
12
12
  WallNode as WallSchema,
@@ -14,38 +14,57 @@ import {
14
14
  } from '@pascal-app/core'
15
15
  import { useViewer } from '@pascal-app/viewer'
16
16
  import { sfxEmitter } from '../../../lib/sfx-bus'
17
- import useEditor from '../../../store/use-editor'
18
-
19
- export type WallPlanPoint = [number, number]
17
+ import { resolveSnapFlags } from '../../../lib/snapping-mode'
18
+ import useEditor, { getActiveSnappingMode, isMagneticSnapActive } from '../../../store/use-editor'
19
+ import {
20
+ distanceSquared,
21
+ findWallSnapTarget,
22
+ findWallSpecialPointSnap,
23
+ projectPointOntoWall,
24
+ WALL_CONNECT_SNAP_RADIUS,
25
+ WALL_JOIN_SNAP_RADIUS,
26
+ type WallDraftSnapResult,
27
+ type WallPlanPoint,
28
+ type WallSnapRadii,
29
+ } from './wall-snap-geometry'
30
+
31
+ // The pure snap geometry lives in `./wall-snap-geometry`; re-exported here so
32
+ // existing importers (fence drafting, the editor barrel) keep their paths.
33
+ export {
34
+ chainEndJoinsExistingWall,
35
+ findWallSnapTarget,
36
+ WALL_CONNECT_SNAP_RADIUS,
37
+ WALL_JOIN_SNAP_RADIUS,
38
+ type WallDraftSnapKind,
39
+ type WallDraftSnapResult,
40
+ type WallPlanPoint,
41
+ type WallSnapRadii,
42
+ } from './wall-snap-geometry'
20
43
 
21
44
  export const WALL_GRID_STEP = 0.5
22
- export const WALL_JOIN_SNAP_RADIUS = 0.35
23
45
  export const WALL_MIN_LENGTH = 0.01
24
- const DEFAULT_WALL_ANGLE_SNAP_STEP = Math.PI / 4
25
-
26
- const WALL_ANGLE_SNAP_BY_GRID_STEP: Record<number, number> = {
27
- 0.5: Math.PI / 4,
28
- 0.25: Math.PI / 8,
29
- 0.1: Math.PI / 12,
30
- 0.05: Math.PI / 36,
31
- }
46
+ // An endpoint projecting within this distance of an existing wall's corner
47
+ // resolves to the corner without splitting — splitting there would mint a
48
+ // sliver segment a hair longer than `WALL_MIN_LENGTH` that no snap radius
49
+ // can ever target again.
50
+ const WALL_SPLIT_ENDPOINT_EPSILON = 0.02
32
51
 
33
52
  type WallSplitIntersection = {
34
- wallId: WallNode['id']
53
+ /** `null` = snap-only outcome: resolve to `point` but split no wall. */
54
+ wallId: WallNode['id'] | null
35
55
  point: WallPlanPoint
36
56
  }
37
57
 
38
- function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number {
39
- const dx = a[0] - b[0]
40
- const dz = a[1] - b[1]
41
- return dx * dx + dz * dz
42
- }
43
-
44
- export function getWallGridStep(): number {
45
- return useEditor.getState().gridSnapStep
58
+ export function getSegmentGridStep(): number {
59
+ // A 0 step means "no grid lattice" — every grid-snap consumer guards on
60
+ // `step <= 0` and returns the raw value, so disabling grid here suppresses
61
+ // the lattice for walls, fences, and every node move/affordance that reads
62
+ // this choke point, without retuning their snap math.
63
+ return resolveSnapFlags(getActiveSnappingMode()).grid ? useEditor.getState().gridSnapStep : 0
46
64
  }
47
65
 
48
66
  export function snapScalarToGrid(value: number, step = WALL_GRID_STEP): number {
67
+ if (step <= 0) return value
49
68
  return Math.round(value / step) * step
50
69
  }
51
70
 
@@ -53,46 +72,6 @@ export function snapPointToGrid(point: WallPlanPoint, step = WALL_GRID_STEP): Wa
53
72
  return [snapScalarToGrid(point[0], step), snapScalarToGrid(point[1], step)]
54
73
  }
55
74
 
56
- export function snapPointTo45Degrees(
57
- start: WallPlanPoint,
58
- cursor: WallPlanPoint,
59
- step = WALL_GRID_STEP,
60
- angleStep = DEFAULT_WALL_ANGLE_SNAP_STEP,
61
- ): WallPlanPoint {
62
- const dx = cursor[0] - start[0]
63
- const dz = cursor[1] - start[1]
64
- const angle = Math.atan2(dz, dx)
65
- const snappedAngle = Math.round(angle / angleStep) * angleStep
66
- const distance = Math.sqrt(dx * dx + dz * dz)
67
-
68
- return snapPointToGrid(
69
- [start[0] + Math.cos(snappedAngle) * distance, start[1] + Math.sin(snappedAngle) * distance],
70
- step,
71
- )
72
- }
73
-
74
- export function getWallAngleSnapStep(step = getWallGridStep()): number {
75
- return WALL_ANGLE_SNAP_BY_GRID_STEP[step] ?? DEFAULT_WALL_ANGLE_SNAP_STEP
76
- }
77
-
78
- function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoint | null {
79
- const [x1, z1] = wall.start
80
- const [x2, z2] = wall.end
81
- const dx = x2 - x1
82
- const dz = z2 - z1
83
- const lengthSquared = dx * dx + dz * dz
84
- if (lengthSquared < 1e-9) {
85
- return null
86
- }
87
-
88
- const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared
89
- if (t <= 0 || t >= 1) {
90
- return null
91
- }
92
-
93
- return [x1 + dx * t, z1 + dz * t]
94
- }
95
-
96
75
  function splitWallAtPoint(wall: WallNode, splitPoint: WallPlanPoint): [WallNode, WallNode] {
97
76
  const { id: _id, parentId: _parentId, children, ...rest } = wall
98
77
 
@@ -119,6 +98,7 @@ function pointsEqual(a: WallPlanPoint, b: WallPlanPoint, tolerance = 1e-6): bool
119
98
  function findWallIntersection(
120
99
  point: WallPlanPoint,
121
100
  walls: WallNode[],
101
+ radius: number,
122
102
  ignoreWallIds?: string[],
123
103
  ): WallSplitIntersection | null {
124
104
  const ignore = new Set(ignoreWallIds ?? [])
@@ -133,13 +113,20 @@ function findWallIntersection(
133
113
 
134
114
  const candidateDistanceSquared = distanceSquared(point, projected)
135
115
  if (
136
- candidateDistanceSquared > WALL_JOIN_SNAP_RADIUS * WALL_JOIN_SNAP_RADIUS ||
116
+ candidateDistanceSquared > radius * radius ||
137
117
  candidateDistanceSquared >= bestDistanceSquared
138
118
  ) {
139
119
  continue
140
120
  }
141
121
 
142
- best = { wallId: wall.id, point: projected }
122
+ const nearCorner = ([wall.start, wall.end] as WallPlanPoint[]).find(
123
+ (corner) =>
124
+ distanceSquared(projected, corner) <=
125
+ WALL_SPLIT_ENDPOINT_EPSILON * WALL_SPLIT_ENDPOINT_EPSILON,
126
+ )
127
+ best = nearCorner
128
+ ? { wallId: null, point: [nearCorner[0], nearCorner[1]] }
129
+ : { wallId: wall.id, point: projected }
143
130
  bestDistanceSquared = candidateDistanceSquared
144
131
  }
145
132
 
@@ -291,6 +278,10 @@ function splitWallIfNeeded(
291
278
  ): { walls: WallNode[]; point: WallPlanPoint } | null {
292
279
  if (!intersection) return null
293
280
 
281
+ if (!intersection.wallId) {
282
+ return { walls, point: intersection.point }
283
+ }
284
+
294
285
  const wallToSplit = walls.find((wall) => wall.id === intersection.wallId)
295
286
  if (!wallToSplit) {
296
287
  return { walls, point: intersection.point }
@@ -324,76 +315,141 @@ function splitWallIfNeeded(
324
315
  }
325
316
  }
326
317
 
327
- export function findWallSnapTarget(
328
- point: WallPlanPoint,
329
- walls: WallNode[],
330
- options?: { ignoreWallIds?: string[]; radius?: number },
331
- ): WallPlanPoint | null {
332
- const ignoreWallIds = new Set(options?.ignoreWallIds ?? [])
333
- const radiusSquared = (options?.radius ?? WALL_JOIN_SNAP_RADIUS) ** 2
334
- let bestTarget: WallPlanPoint | null = null
335
- let bestDistanceSquared = Number.POSITIVE_INFINITY
336
-
337
- for (const wall of walls) {
338
- if (ignoreWallIds.has(wall.id)) {
339
- continue
340
- }
341
-
342
- const candidates: Array<WallPlanPoint | null> = [wall.start, wall.end]
343
-
344
- if (isCurvedWall(wall)) {
345
- const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(wall) / 0.3))
346
- for (let index = 0; index <= sampleCount; index += 1) {
347
- const frame = getWallCurveFrameAt(wall, index / sampleCount)
348
- candidates.push([frame.point.x, frame.point.y])
349
- }
350
- } else {
351
- candidates.push(projectPointOntoWall(point, wall))
352
- }
353
- for (const candidate of candidates) {
354
- if (!candidate) {
355
- continue
356
- }
357
-
358
- const candidateDistanceSquared = distanceSquared(point, candidate)
359
- if (
360
- candidateDistanceSquared > radiusSquared ||
361
- candidateDistanceSquared >= bestDistanceSquared
362
- ) {
363
- continue
364
- }
365
-
366
- bestTarget = candidate
367
- bestDistanceSquared = candidateDistanceSquared
368
- }
369
- }
318
+ /**
319
+ * Commit-time split resolution for an endpoint MOVE — the sibling of the
320
+ * inline resolution in `createWallOnCurrentLevel`: when a moved endpoint is
321
+ * dropped on another wall's interior, split that host exactly like the draw
322
+ * path (duplicate props, migrate attachments by span, skip the split when an
323
+ * opening straddles the point). Mutates the scene store (create halves /
324
+ * migrate attachments / delete host), so callers MUST run it inside the same
325
+ * `runAsSingleSceneHistoryStep` as their endpoint write.
326
+ *
327
+ * Returns the resolved endpoint (projection onto the host, or a nearby corner
328
+ * when the drop is within `WALL_SPLIT_ENDPOINT_EPSILON` of one — corner joins
329
+ * are not splits), or `null` when the point lands on no wall.
330
+ */
331
+ export function resolveEndpointWallSplit(args: {
332
+ point: WallPlanPoint
333
+ /** Level the moved wall lives on — only its walls are split candidates. */
334
+ levelId: string | null
335
+ /** The moved wall + every wall receiving an endpoint update in the same commit. */
336
+ ignoreWallIds: string[]
337
+ /**
338
+ * Capture radius. The endpoint already snapped onto the wall body during
339
+ * the drag, so the tight connect radius (drop genuinely on the wall) is
340
+ * the default.
341
+ */
342
+ radius?: number
343
+ }): WallPlanPoint | null {
344
+ const { point, levelId, ignoreWallIds, radius = WALL_CONNECT_SNAP_RADIUS } = args
345
+ const { nodes, createNodes, updateNodes, deleteNode } = useScene.getState()
346
+ const walls = Object.values(nodes).filter(
347
+ (node): node is WallNode => node?.type === 'wall' && (node.parentId ?? null) === levelId,
348
+ )
370
349
 
371
- return bestTarget
350
+ const intersection = findWallIntersection(point, walls, radius, ignoreWallIds)
351
+ const split = splitWallIfNeeded(intersection, walls, nodes, createNodes, updateNodes, deleteNode)
352
+ return split ? split.point : null
372
353
  }
373
354
 
374
- export function snapWallDraftPoint(args: {
355
+ type SnapWallDraftArgs = {
375
356
  point: WallPlanPoint
376
357
  walls: WallNode[]
377
358
  start?: WallPlanPoint
378
359
  angleSnap?: boolean
379
360
  ignoreWallIds?: string[]
380
- }): WallPlanPoint {
381
- const { point, walls, start, angleSnap = false, ignoreWallIds } = args
382
- const step = getWallGridStep()
383
- const angleStep = getWallAngleSnapStep(step)
384
- const basePoint =
361
+ bypassSnap?: boolean
362
+ /** Override the grid step. */
363
+ step?: number
364
+ /**
365
+ * Magnetic snapping to existing wall geometry (corners, midpoints,
366
+ * crossings, wall bodies). When `false`, only grid/angle snap applies and
367
+ * `snap` is always `null`. Defaults to `true` so callers that don't care
368
+ * keep the prior behaviour.
369
+ */
370
+ magnetic?: boolean
371
+ /**
372
+ * Optional grid-snap override. Lets the caller route grid snapping
373
+ * through a world-XZ aligned snap (so a rotated building's draft
374
+ * lands on the visible grid). When omitted, falls back to the
375
+ * local-axis grid at `step`.
376
+ */
377
+ gridSnap?: (point: WallPlanPoint) => WallPlanPoint
378
+ /** Optional magnetic snap radii. Omitted means wall tools keep their defaults. */
379
+ snapRadii?: WallSnapRadii
380
+ }
381
+
382
+ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSnapResult {
383
+ const {
384
+ point,
385
+ walls,
386
+ start,
387
+ angleSnap = false,
388
+ ignoreWallIds,
389
+ bypassSnap = false,
390
+ step: overrideStep,
391
+ magnetic = true,
392
+ gridSnap,
393
+ snapRadii,
394
+ } = args
395
+
396
+ if (bypassSnap) return { point, snap: null }
397
+
398
+ // Discrete special points (corner / midpoint / crossing) are taken from the
399
+ // raw cursor so an interim grid snap can't mask them. A corner always wins,
400
+ // then the nearer of midpoint / crossing — see `findWallSpecialPointSnap`.
401
+ if (magnetic) {
402
+ const special = findWallSpecialPointSnap(point, walls, ignoreWallIds, snapRadii)
403
+ if (special) return special
404
+ }
405
+
406
+ const step = overrideStep ?? getSegmentGridStep()
407
+ // The angle path snaps the distance ALONG the 15° ray — a scalar, the
408
+ // same in world and local frames — so the `gridSnap` world-grid override
409
+ // only applies when the angle lock is off.
410
+ const basePoint: WallPlanPoint =
385
411
  start && angleSnap
386
- ? snapPointTo45Degrees(start, point, step, angleStep)
387
- : snapPointToGrid(point, step)
412
+ ? [...snapPointAlongAngleRay(start, point, DEFAULT_ANGLE_STEP, step)]
413
+ : gridSnap
414
+ ? gridSnap(point)
415
+ : snapPointToGrid(point, step)
388
416
 
389
- return (
390
- findWallSnapTarget(basePoint, walls, {
417
+ if (magnetic) {
418
+ const wallSnap = findWallSnapTarget(basePoint, walls, {
391
419
  ignoreWallIds,
392
- }) ?? basePoint
393
- )
420
+ radius: snapRadii?.wall,
421
+ })
422
+ if (wallSnap) return { point: wallSnap, snap: 'wall' }
423
+ return { point: basePoint, snap: null }
424
+ }
425
+
426
+ // Non-magnetic modes (grid / off / angles): connectivity still sticks so a
427
+ // room can close, but only within a tight radius — placement elsewhere is left
428
+ // to the mode (grid quantise / angle lock / free). Snap from the already
429
+ // positioned `basePoint` so the mode's placement is respected right up to the
430
+ // wall, then the last few cm stick onto it (and the beacon shows).
431
+ const connectRadii: WallSnapRadii = {
432
+ endpoint: WALL_CONNECT_SNAP_RADIUS,
433
+ midpoint: WALL_CONNECT_SNAP_RADIUS,
434
+ intersection: WALL_CONNECT_SNAP_RADIUS,
435
+ wall: WALL_CONNECT_SNAP_RADIUS,
436
+ }
437
+ const connectSpecial = findWallSpecialPointSnap(basePoint, walls, ignoreWallIds, connectRadii)
438
+ if (connectSpecial) return connectSpecial
439
+ const connectWall = findWallSnapTarget(basePoint, walls, {
440
+ ignoreWallIds,
441
+ radius: WALL_CONNECT_SNAP_RADIUS,
442
+ })
443
+ if (connectWall) return { point: connectWall, snap: 'wall' }
444
+
445
+ return { point: basePoint, snap: null }
446
+ }
447
+
448
+ export function snapWallDraftPoint(args: SnapWallDraftArgs): WallPlanPoint {
449
+ return snapWallDraftPointDetailed(args).point
394
450
  }
395
451
 
396
- export function isWallLongEnough(start: WallPlanPoint, end: WallPlanPoint): boolean {
452
+ export function isSegmentLongEnough(start: WallPlanPoint, end: WallPlanPoint): boolean {
397
453
  return distanceSquared(start, end) >= WALL_MIN_LENGTH * WALL_MIN_LENGTH
398
454
  }
399
455
 
@@ -405,7 +461,7 @@ export function createWallOnCurrentLevel(
405
461
  const { createNode, createNodes, deleteNode, nodes } = useScene.getState()
406
462
  const { updateNodes } = useScene.getState()
407
463
 
408
- if (!(currentLevelId && isWallLongEnough(start, end))) {
464
+ if (!(currentLevelId && isSegmentLongEnough(start, end))) {
409
465
  return null
410
466
  }
411
467
 
@@ -416,56 +472,77 @@ export function createWallOnCurrentLevel(
416
472
  let resolvedStart = start
417
473
  let resolvedEnd = end
418
474
 
419
- const endIntersection = findWallIntersection(resolvedEnd, workingWalls)
420
- const splitEnd = splitWallIfNeeded(
421
- endIntersection,
422
- workingWalls,
423
- nodes,
424
- createNodes,
425
- updateNodes,
426
- deleteNode,
427
- )
428
- if (splitEnd) {
429
- workingWalls = splitEnd.walls
430
- resolvedEnd = splitEnd.point
431
- }
475
+ // The corner-join / wall-split resolution follows the snapping mode like the
476
+ // draft preview does: magnetic ('lines') keeps the generous join radius,
477
+ // every other mode uses the same tight connect radius the draft path already
478
+ // sticks endpoints with. So an endpoint the user saw connect to a wall body
479
+ // actually splits that wall (and redistributes its attachments) in every
480
+ // mode, while `'off'` / `'angles'` gain no residual long-range snap.
481
+ const joinRadius = isMagneticSnapActive() ? WALL_JOIN_SNAP_RADIUS : WALL_CONNECT_SNAP_RADIUS
482
+
483
+ // One undo step for the whole commit: the split ops (create halves, migrate
484
+ // attachments, delete host) plus the new wall each push their own history
485
+ // entry, and a single Ctrl-Z must not strand a half-split wall network.
486
+ return runAsSingleSceneHistoryStep(useScene, () => {
487
+ const endIntersection = findWallIntersection(resolvedEnd, workingWalls, joinRadius)
488
+ const splitEnd = splitWallIfNeeded(
489
+ endIntersection,
490
+ workingWalls,
491
+ nodes,
492
+ createNodes,
493
+ updateNodes,
494
+ deleteNode,
495
+ )
496
+ if (splitEnd) {
497
+ workingWalls = splitEnd.walls
498
+ resolvedEnd = splitEnd.point
499
+ }
432
500
 
433
- const startIntersection = findWallIntersection(resolvedStart, workingWalls)
434
- const splitStart = splitWallIfNeeded(
435
- startIntersection,
436
- workingWalls,
437
- nodes,
438
- createNodes,
439
- updateNodes,
440
- deleteNode,
441
- )
442
- if (splitStart) {
443
- workingWalls = splitStart.walls
444
- resolvedStart = splitStart.point
445
- }
501
+ const startIntersection = findWallIntersection(resolvedStart, workingWalls, joinRadius)
502
+ const splitStart = splitWallIfNeeded(
503
+ startIntersection,
504
+ workingWalls,
505
+ nodes,
506
+ createNodes,
507
+ updateNodes,
508
+ deleteNode,
509
+ )
510
+ if (splitStart) {
511
+ workingWalls = splitStart.walls
512
+ resolvedStart = splitStart.point
513
+ }
446
514
 
447
- if (!isWallLongEnough(resolvedStart, resolvedEnd) || pointsEqual(resolvedStart, resolvedEnd)) {
448
- return null
449
- }
515
+ if (
516
+ !isSegmentLongEnough(resolvedStart, resolvedEnd) ||
517
+ pointsEqual(resolvedStart, resolvedEnd)
518
+ ) {
519
+ return null
520
+ }
450
521
 
451
- const duplicateWall = workingWalls.some(
452
- (wall) =>
453
- (pointsEqual(wall.start, resolvedStart) && pointsEqual(wall.end, resolvedEnd)) ||
454
- (pointsEqual(wall.start, resolvedEnd) && pointsEqual(wall.end, resolvedStart)),
455
- )
456
- if (duplicateWall) {
457
- return null
458
- }
522
+ const duplicateWall = workingWalls.some(
523
+ (wall) =>
524
+ (pointsEqual(wall.start, resolvedStart) && pointsEqual(wall.end, resolvedEnd)) ||
525
+ (pointsEqual(wall.start, resolvedEnd) && pointsEqual(wall.end, resolvedStart)),
526
+ )
527
+ if (duplicateWall) {
528
+ return null
529
+ }
459
530
 
460
- const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length
461
- const wall = WallSchema.parse({
462
- name: `Wall ${wallCount + 1}`,
463
- start: resolvedStart,
464
- end: resolvedEnd,
531
+ const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length
532
+ // A placed wall preset seeds `toolDefaults.wall` (thickness, height,
533
+ // materials, sides) before the tool activates; merge those first so the
534
+ // drawn wall reproduces the preset. Identity + endpoints always win.
535
+ const defaults = useEditor.getState().toolDefaults.wall ?? {}
536
+ const wall = WallSchema.parse({
537
+ ...defaults,
538
+ name: `Wall ${wallCount + 1}`,
539
+ start: resolvedStart,
540
+ end: resolvedEnd,
541
+ })
542
+
543
+ createNode(wall, currentLevelId)
544
+ sfxEmitter.emit('sfx:structure-build')
545
+
546
+ return wall
465
547
  })
466
-
467
- createNode(wall, currentLevelId)
468
- sfxEmitter.emit('sfx:structure-build')
469
-
470
- return wall
471
548
  }
@@ -0,0 +1,123 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { WallNode } from '@pascal-app/core'
3
+ import {
4
+ chainEndJoinsExistingWall,
5
+ findWallSnapTarget,
6
+ findWallSpecialPointSnap,
7
+ type WallPlanPoint,
8
+ } from './wall-snap-geometry'
9
+
10
+ function makeWall(start: WallPlanPoint, end: WallPlanPoint, id?: string): WallNode {
11
+ return {
12
+ object: 'node',
13
+ id: (id ?? `wall_${start.join('_')}_${end.join('_')}`) as WallNode['id'],
14
+ type: 'wall',
15
+ name: 'Wall',
16
+ parentId: null,
17
+ visible: true,
18
+ metadata: {},
19
+ children: [],
20
+ start,
21
+ end,
22
+ thickness: 0.1,
23
+ frontSide: 'unknown',
24
+ backSide: 'unknown',
25
+ } as unknown as WallNode
26
+ }
27
+
28
+ describe('findWallSpecialPointSnap', () => {
29
+ test('snaps to a wall corner (endpoint) when near it', () => {
30
+ const walls = [makeWall([0, 0], [4, 0])]
31
+ const result = findWallSpecialPointSnap([0.1, 0.1], walls)
32
+ expect(result?.snap).toBe('endpoint')
33
+ expect(result?.point).toEqual([0, 0])
34
+ })
35
+
36
+ test('snaps to a wall midpoint when near it (not a corner)', () => {
37
+ const walls = [makeWall([0, 0], [4, 0])]
38
+ const result = findWallSpecialPointSnap([2.1, 0.2], walls)
39
+ expect(result?.snap).toBe('midpoint')
40
+ expect(result?.point).toEqual([2, 0])
41
+ })
42
+
43
+ test('snaps to the crossing of two walls (intersection)', () => {
44
+ // A runs along z=0; B crosses it at x=1. B's own midpoint is [1,1], far
45
+ // from the crossing, so the snap is the intersection, not a midpoint.
46
+ const walls = [makeWall([0, 0], [4, 0], 'a'), makeWall([1, -1], [1, 3], 'b')]
47
+ const result = findWallSpecialPointSnap([1.1, 0.1], walls)
48
+ expect(result?.snap).toBe('intersection')
49
+ expect(result?.point[0]).toBeCloseTo(1, 6)
50
+ expect(result?.point[1]).toBeCloseTo(0, 6)
51
+ })
52
+
53
+ test('corner wins over a midpoint when both are in range', () => {
54
+ const walls = [makeWall([0, 0], [1, 0])]
55
+ const result = findWallSpecialPointSnap([0.9, 0.1], walls)
56
+ expect(result?.snap).toBe('endpoint')
57
+ expect(result?.point).toEqual([1, 0])
58
+ })
59
+
60
+ test('returns null when no special point is in range', () => {
61
+ const walls = [makeWall([0, 0], [4, 0])]
62
+ // Near the wall body but far from corner/midpoint — that's an edge snap,
63
+ // handled separately by findWallSnapTarget, not a special point.
64
+ expect(findWallSpecialPointSnap([1.2, 0.1], walls)).toBeNull()
65
+ })
66
+
67
+ test('honors tighter per-call radii without changing defaults', () => {
68
+ const walls = [makeWall([0, 0], [4, 0])]
69
+
70
+ expect(findWallSpecialPointSnap([0.34, 0], walls)?.snap).toBe('endpoint')
71
+ expect(findWallSpecialPointSnap([0.34, 0], walls, undefined, { endpoint: 0.3 })).toBeNull()
72
+ })
73
+ })
74
+
75
+ describe('chainEndJoinsExistingWall (chain termination)', () => {
76
+ test('true when the end lies on a non-chain wall interior (T junction)', () => {
77
+ const walls = [makeWall([0, 0], [4, 0], 'host'), makeWall([2, 2], [2, 0], 'chain_1')]
78
+ expect(chainEndJoinsExistingWall([2, 0], walls, ['chain_1'])).toBe(true)
79
+ })
80
+
81
+ test('true when the end lands on a non-chain wall endpoint', () => {
82
+ const walls = [makeWall([0, 0], [4, 0], 'host'), makeWall([6, 2], [4, 0], 'chain_1')]
83
+ expect(chainEndJoinsExistingWall([4, 0], walls, ['chain_1'])).toBe(true)
84
+ })
85
+
86
+ test('false when the end only touches the chain walls themselves', () => {
87
+ // Second segment doubles back onto the first one's midpoint — own-chain
88
+ // geometry is excluded, so the chain keeps going.
89
+ const walls = [makeWall([0, 0], [4, 0], 'chain_1'), makeWall([4, 0], [2, 0], 'chain_2')]
90
+ expect(chainEndJoinsExistingWall([2, 0], walls, ['chain_1', 'chain_2'])).toBe(false)
91
+ })
92
+
93
+ test('false for a dead end in free space', () => {
94
+ const walls = [makeWall([0, 0], [4, 0], 'host'), makeWall([0, 2], [2, 2], 'chain_1')]
95
+ expect(chainEndJoinsExistingWall([2, 2], walls, ['chain_1'])).toBe(false)
96
+ })
97
+
98
+ test('a near miss beyond the tolerance is not a join', () => {
99
+ const walls = [makeWall([0, 0], [4, 0], 'host')]
100
+ expect(chainEndJoinsExistingWall([2, 0.01], walls, [])).toBe(false)
101
+ expect(chainEndJoinsExistingWall([2, 0.0005], walls, [])).toBe(true)
102
+ })
103
+ })
104
+
105
+ describe('findWallSnapTarget (edge / along-wall)', () => {
106
+ test('projects onto a wall body within range', () => {
107
+ const walls = [makeWall([0, 0], [4, 0])]
108
+ const result = findWallSnapTarget([1.2, 0.1], walls)
109
+ expect(result?.[0]).toBeCloseTo(1.2, 6)
110
+ expect(result?.[1]).toBeCloseTo(0, 6)
111
+ })
112
+
113
+ test('returns null when too far from any wall', () => {
114
+ const walls = [makeWall([0, 0], [4, 0])]
115
+ expect(findWallSnapTarget([1.2, 2], walls)).toBeNull()
116
+ })
117
+
118
+ test('honors a tighter wall-body radius', () => {
119
+ const walls = [makeWall([0, 0], [4, 0])]
120
+
121
+ expect(findWallSnapTarget([1.2, 0.1], walls, { radius: 0.08 })).toBeNull()
122
+ })
123
+ })