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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (366) hide show
  1. package/package.json +14 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +49 -0
  4. package/src/components/editor/bake-thumbnail.tsx +95 -0
  5. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  6. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  7. package/src/components/editor/custom-camera-controls.tsx +332 -199
  8. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  9. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  10. package/src/components/editor/editor-layout-v2.tsx +18 -0
  11. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  12. package/src/components/editor/export-manager.tsx +71 -63
  13. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  14. package/src/components/editor/first-person/build-collider-world.test.ts +102 -5
  15. package/src/components/editor/first-person/build-collider-world.ts +52 -5
  16. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  17. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  18. package/src/components/editor/first-person-controls.tsx +410 -136
  19. package/src/components/editor/floating-action-menu.tsx +435 -64
  20. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  21. package/src/components/editor/floorplan-background-selection.test.ts +72 -0
  22. package/src/components/editor/floorplan-background-selection.ts +7 -20
  23. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  24. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  25. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  26. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  27. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  28. package/src/components/editor/floorplan-panel.tsx +2517 -1492
  29. package/src/components/editor/grid.tsx +166 -39
  30. package/src/components/editor/group-actions.ts +597 -0
  31. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  32. package/src/components/editor/group-move-3d.ts +397 -0
  33. package/src/components/editor/group-rotate-handle.tsx +102 -64
  34. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  35. package/src/components/editor/group-transform-shared.test.ts +192 -1
  36. package/src/components/editor/group-transform-shared.ts +240 -7
  37. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  38. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  39. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  40. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  41. package/src/components/editor/handles/preview-overrides.ts +13 -0
  42. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  43. package/src/components/editor/handles/resize-snap.ts +23 -0
  44. package/src/components/editor/handles/use-handle-drag.ts +49 -6
  45. package/src/components/editor/index.tsx +289 -35
  46. package/src/components/editor/measurement-pill.tsx +68 -23
  47. package/src/components/editor/node-action-menu.tsx +14 -1
  48. package/src/components/editor/node-arrow-handles.tsx +409 -180
  49. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  50. package/src/components/editor/quick-measurement-card.tsx +76 -0
  51. package/src/components/editor/quick-measurement-hud.tsx +34 -0
  52. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  53. package/src/components/editor/selection-manager.tsx +814 -466
  54. package/src/components/editor/site-edge-labels.tsx +28 -6
  55. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  56. package/src/components/editor/snapshot-capture-overlay.tsx +258 -115
  57. package/src/components/editor/three-context-bridge.ts +22 -0
  58. package/src/components/editor/thumbnail-generator.tsx +118 -253
  59. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  60. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  61. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  62. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  63. package/src/components/editor/use-mesh-settle-epoch.ts +27 -0
  64. package/src/components/editor/wall-measurement-label.tsx +10 -28
  65. package/src/components/editor/wall-move-side-handles.tsx +229 -39
  66. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  67. package/src/components/editor/wall-snap-beacon-layer.tsx +162 -6
  68. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  69. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  70. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +40 -9
  71. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  72. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  73. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  74. package/src/components/editor-2d/floorplan-group-move.tsx +744 -0
  75. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  76. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1672 -0
  77. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  78. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  79. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +219 -20
  80. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +327 -59
  81. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  82. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  83. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  84. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  85. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  86. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  87. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  88. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  89. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  90. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +89 -0
  91. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +106 -0
  92. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +94 -26
  93. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +587 -0
  94. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +2280 -578
  95. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  96. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  97. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +425 -61
  98. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  99. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  100. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  101. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  102. package/src/components/systems/zone/zone-system.tsx +42 -13
  103. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  104. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  105. package/src/components/tools/fence/fence-drafting.ts +107 -8
  106. package/src/components/tools/item/move-tool.tsx +17 -13
  107. package/src/components/tools/item/placement-math.test.ts +13 -1
  108. package/src/components/tools/item/placement-math.ts +28 -2
  109. package/src/components/tools/item/placement-strategies.ts +246 -5
  110. package/src/components/tools/item/placement-types.ts +13 -1
  111. package/src/components/tools/item/use-draft-node.ts +146 -57
  112. package/src/components/tools/item/use-placement-coordinator.tsx +813 -125
  113. package/src/components/tools/registry/move-registry-node-tool.tsx +636 -103
  114. package/src/components/tools/roof/roof-tool.tsx +336 -94
  115. package/src/components/tools/select/box-select-state.ts +18 -7
  116. package/src/components/tools/select/box-select-tool.tsx +176 -40
  117. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  118. package/src/components/tools/select/marquee-geometry.ts +155 -0
  119. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  120. package/src/components/tools/select/select-candidates.ts +1 -1
  121. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  122. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  123. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  124. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  125. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  126. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  127. package/src/components/tools/shared/placement-box.tsx +183 -1
  128. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  129. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  130. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  131. package/src/components/tools/site/site-boundary-editor.tsx +101 -42
  132. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  133. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  134. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  135. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  136. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  137. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  138. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  139. package/src/components/tools/stair/stair-defaults.ts +1 -0
  140. package/src/components/tools/stair/stair-tool.tsx +164 -42
  141. package/src/components/tools/tool-manager.tsx +149 -30
  142. package/src/components/tools/wall/wall-drafting.test.ts +447 -0
  143. package/src/components/tools/wall/wall-drafting.ts +304 -108
  144. package/src/components/tools/wall/wall-snap-geometry.test.ts +52 -0
  145. package/src/components/tools/wall/wall-snap-geometry.ts +137 -9
  146. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  147. package/src/components/tools/zone/zone-tool.tsx +98 -88
  148. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  149. package/src/components/ui/action-menu/control-modes.tsx +44 -94
  150. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  151. package/src/components/ui/action-menu/index.tsx +2 -4
  152. package/src/components/ui/action-menu/measurement-control.tsx +267 -0
  153. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  154. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  155. package/src/components/ui/command-palette/editor-commands.tsx +16 -3
  156. package/src/components/ui/command-palette/index.tsx +4 -3
  157. package/src/components/ui/controls/material-paint-panel.tsx +93 -18
  158. package/src/components/ui/controls/material-picker.tsx +162 -152
  159. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  160. package/src/components/ui/controls/metric-control.tsx +118 -44
  161. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  162. package/src/components/ui/controls/slider-control.tsx +88 -38
  163. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  164. package/src/components/ui/floating-level-selector.tsx +74 -20
  165. package/src/components/ui/helpers/building-helper.tsx +10 -23
  166. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  167. package/src/components/ui/helpers/helper-manager.tsx +273 -14
  168. package/src/components/ui/helpers/item-helper.tsx +28 -32
  169. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  170. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  171. package/src/components/ui/icon-ref.tsx +47 -0
  172. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  173. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  174. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  175. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  176. package/src/components/ui/panels/node-display.ts +17 -17
  177. package/src/components/ui/panels/panel-manager.tsx +36 -14
  178. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  179. package/src/components/ui/panels/parametric-inspector.tsx +19 -4
  180. package/src/components/ui/panels/reference-panel.tsx +54 -19
  181. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  182. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  183. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  184. package/src/components/ui/primitives/sidebar.tsx +1 -0
  185. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  186. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  187. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  188. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +82 -41
  189. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +76 -6
  190. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  191. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  192. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  193. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  194. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  195. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  196. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  197. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  198. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  199. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  200. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -17
  201. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  202. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  203. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  204. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  205. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  206. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  207. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  208. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  209. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  210. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  211. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  212. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  213. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  214. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  215. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  216. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  217. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  218. package/src/components/ui/snap-target-badge.tsx +88 -0
  219. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  220. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  221. package/src/components/viewer-overlay.tsx +36 -596
  222. package/src/components/viewer-zone-system.tsx +6 -1
  223. package/src/components/walkthrough-hud.tsx +111 -0
  224. package/src/hooks/use-auto-save.test.ts +14 -0
  225. package/src/hooks/use-auto-save.ts +54 -7
  226. package/src/hooks/use-ceiling-events.ts +3 -2
  227. package/src/hooks/use-drag-action.ts +4 -1
  228. package/src/hooks/use-grid-events.ts +24 -0
  229. package/src/hooks/use-keyboard.ts +438 -70
  230. package/src/index.tsx +355 -9
  231. package/src/lib/active-placement-surface.test.ts +24 -0
  232. package/src/lib/active-placement-surface.ts +42 -0
  233. package/src/lib/camera-pose.test.ts +237 -0
  234. package/src/lib/camera-pose.ts +172 -0
  235. package/src/lib/ceiling-plan-snap.ts +24 -0
  236. package/src/lib/contextual-help.test.ts +112 -0
  237. package/src/lib/contextual-help.ts +144 -0
  238. package/src/lib/continuation.ts +64 -0
  239. package/src/lib/direct-manipulation.test.ts +275 -0
  240. package/src/lib/direct-manipulation.ts +118 -0
  241. package/src/lib/door-interaction.ts +1 -1
  242. package/src/lib/editor-api.ts +23 -35
  243. package/src/lib/elevation-guides.test.ts +107 -0
  244. package/src/lib/elevation-guides.ts +236 -0
  245. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  246. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  247. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  248. package/src/lib/floorplan/apply-alignment.ts +18 -12
  249. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  250. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  251. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  252. package/src/lib/floorplan/floorplan-export.tsx +978 -0
  253. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  254. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  255. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  256. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  257. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  258. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  259. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  260. package/src/lib/floorplan/geometry.ts +52 -0
  261. package/src/lib/floorplan/index.ts +3 -0
  262. package/src/lib/floorplan/items.ts +5 -2
  263. package/src/lib/floorplan/plan-coords.ts +21 -0
  264. package/src/lib/fresh-planar-placement.test.ts +283 -3
  265. package/src/lib/fresh-planar-placement.ts +85 -1
  266. package/src/lib/glb-export.test.ts +556 -0
  267. package/src/lib/glb-export.ts +1071 -0
  268. package/src/lib/ground-surface.ts +95 -0
  269. package/src/lib/history.test.ts +96 -0
  270. package/src/lib/history.ts +31 -0
  271. package/src/lib/interaction/hot-set.test.ts +133 -0
  272. package/src/lib/interaction/hot-set.ts +67 -0
  273. package/src/lib/interaction/overlay-policy.test.ts +52 -0
  274. package/src/lib/interaction/overlay-policy.ts +59 -0
  275. package/src/lib/interaction/scope.ts +243 -0
  276. package/src/lib/level-duplication.test.ts +2 -1
  277. package/src/lib/level-selection.ts +2 -2
  278. package/src/lib/material-paint.ts +36 -49
  279. package/src/lib/measurement-kind.test.ts +30 -0
  280. package/src/lib/measurement-kind.ts +19 -0
  281. package/src/lib/measurement-label.test.ts +47 -0
  282. package/src/lib/measurement-label.ts +68 -0
  283. package/src/lib/measurement-parser.ts +116 -0
  284. package/src/lib/measurements.test.ts +188 -0
  285. package/src/lib/measurements.ts +210 -0
  286. package/src/lib/paint-scope.test.ts +454 -0
  287. package/src/lib/paint-scope.ts +461 -0
  288. package/src/lib/placement-drag-release.ts +23 -0
  289. package/src/lib/planar-cursor-placement.test.ts +57 -0
  290. package/src/lib/plugin-panels.test.ts +19 -0
  291. package/src/lib/plugin-panels.ts +91 -0
  292. package/src/lib/quick-action-feedback.ts +33 -0
  293. package/src/lib/quick-action-nodes.test.ts +94 -0
  294. package/src/lib/quick-action-nodes.ts +47 -0
  295. package/src/lib/quick-measurement.test.ts +77 -0
  296. package/src/lib/quick-measurement.ts +109 -0
  297. package/src/lib/roof-duplication.ts +6 -6
  298. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  299. package/src/lib/roof-wall-hit.ts +164 -0
  300. package/src/lib/scene-clipboard.test.ts +325 -0
  301. package/src/lib/scene-clipboard.ts +310 -21
  302. package/src/lib/scene.ts +28 -5
  303. package/src/lib/selection-routing.test.ts +313 -0
  304. package/src/lib/selection-routing.ts +180 -0
  305. package/src/lib/sfx/index.ts +1 -0
  306. package/src/lib/sfx/movement-tick.test.ts +65 -0
  307. package/src/lib/sfx/movement-tick.ts +18 -0
  308. package/src/lib/sfx-bus.ts +82 -14
  309. package/src/lib/sfx-player.test.ts +266 -0
  310. package/src/lib/sfx-player.ts +262 -69
  311. package/src/lib/site-boundary.test.ts +53 -0
  312. package/src/lib/site-boundary.ts +27 -0
  313. package/src/lib/slab-plan-snap.test.ts +93 -0
  314. package/src/lib/slab-plan-snap.ts +108 -0
  315. package/src/lib/snapping-mode.test.ts +165 -0
  316. package/src/lib/snapping-mode.ts +191 -0
  317. package/src/lib/stair-duplication.test.ts +103 -0
  318. package/src/lib/stair-duplication.ts +58 -86
  319. package/src/lib/stair-levels.ts +2 -0
  320. package/src/lib/surface-plan-snap.test.ts +71 -0
  321. package/src/lib/surface-plan-snap.ts +256 -0
  322. package/src/lib/terrain-sculpt.test.ts +429 -0
  323. package/src/lib/terrain-sculpt.ts +301 -0
  324. package/src/lib/terrain-verb-color.test.ts +51 -0
  325. package/src/lib/terrain-verb-color.ts +58 -0
  326. package/src/lib/touch-gesture-priority.test.ts +45 -0
  327. package/src/lib/touch-gesture-priority.ts +30 -0
  328. package/src/lib/use-linear-display.ts +40 -0
  329. package/src/lib/window-interaction.ts +1 -1
  330. package/src/lib/world-grid-snap.ts +37 -5
  331. package/src/store/camera-pose-store.test.ts +29 -0
  332. package/src/store/camera-pose-store.ts +27 -0
  333. package/src/store/live-draft-preview-stores.test.ts +136 -0
  334. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  335. package/src/store/navigation-sync-pose-store.ts +27 -0
  336. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  337. package/src/store/use-delete-confirmation.ts +27 -0
  338. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  339. package/src/store/use-drawing-view.test.ts +23 -0
  340. package/src/store/use-drawing-view.ts +84 -0
  341. package/src/store/use-editor.tsx +665 -126
  342. package/src/store/use-elevation-guides.ts +40 -0
  343. package/src/store/use-facing-pose.ts +44 -0
  344. package/src/store/use-fence-curve-draft.ts +51 -0
  345. package/src/store/use-first-person-hud.ts +40 -0
  346. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  347. package/src/store/use-floorplan-draft-preview.ts +151 -0
  348. package/src/store/use-floorplan-marquee.ts +50 -0
  349. package/src/store/use-floorplan-mode.ts +110 -0
  350. package/src/store/use-interaction-scope.test.ts +206 -0
  351. package/src/store/use-interaction-scope.ts +140 -0
  352. package/src/store/use-measurement-draft.test.ts +559 -0
  353. package/src/store/use-measurement-draft.ts +570 -0
  354. package/src/store/use-opening-guides.ts +41 -0
  355. package/src/store/use-path-draft-preview.ts +90 -0
  356. package/src/store/use-placement-preview.ts +11 -3
  357. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  358. package/src/store/use-quick-measurement-hud.ts +77 -0
  359. package/src/store/use-segment-draft-chain.ts +28 -0
  360. package/src/store/use-stair-build-preview.ts +53 -0
  361. package/src/store/use-wall-snap-indicator.ts +2 -0
  362. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  363. package/src/components/editor/group-move-handle.tsx +0 -316
  364. package/src/components/ui/panels/paint-panel.tsx +0 -163
  365. package/src/lib/floorplan/selection-tool.ts +0 -271
  366. package/src/lib/level-name.ts +0 -11
@@ -1,9 +1,17 @@
1
1
  import {
2
2
  type AnyNode,
3
3
  type AnyNodeId,
4
+ DEFAULT_ANGLE_STEP,
5
+ DEFAULT_LEVEL_HEIGHT,
4
6
  type DoorNode,
7
+ GROUND_SUPPORT_ID,
5
8
  getScaledDimensions,
6
9
  type ItemNode,
10
+ resolveWallSupportSlabPatch,
11
+ runAsSingleSceneHistoryStep,
12
+ snapPointAlongAngleRay,
13
+ spatialGridManager,
14
+ terrainSupportLift,
7
15
  useScene,
8
16
  type WallNode,
9
17
  WallNode as WallSchema,
@@ -11,51 +19,58 @@ import {
11
19
  } from '@pascal-app/core'
12
20
  import { useViewer } from '@pascal-app/viewer'
13
21
  import { sfxEmitter } from '../../../lib/sfx-bus'
14
- import useEditor from '../../../store/use-editor'
22
+ import { resolveSnapFlags } from '../../../lib/snapping-mode'
23
+ import useEditor, { getActiveSnappingMode, isMagneticSnapActive } from '../../../store/use-editor'
15
24
  import {
16
25
  distanceSquared,
17
26
  findWallSnapTarget,
18
27
  findWallSpecialPointSnap,
19
28
  projectPointOntoWall,
29
+ WALL_CONNECT_SNAP_RADIUS,
20
30
  WALL_JOIN_SNAP_RADIUS,
21
31
  type WallDraftSnapResult,
22
32
  type WallPlanPoint,
33
+ type WallSnapRadii,
34
+ wallIdsAtSnapPoint,
23
35
  } from './wall-snap-geometry'
24
36
 
25
37
  // The pure snap geometry lives in `./wall-snap-geometry`; re-exported here so
26
38
  // existing importers (fence drafting, the editor barrel) keep their paths.
27
39
  export {
40
+ chainEndJoinsExistingWall,
28
41
  findWallSnapTarget,
42
+ WALL_CONNECT_SNAP_RADIUS,
29
43
  WALL_JOIN_SNAP_RADIUS,
30
44
  type WallDraftSnapKind,
31
45
  type WallDraftSnapResult,
32
46
  type WallPlanPoint,
47
+ type WallSnapRadii,
33
48
  } from './wall-snap-geometry'
34
49
 
35
50
  export const WALL_GRID_STEP = 0.5
36
- // Smallest available grid snap. Used as a precision-mode step (Shift +
37
- // drag) so a drag can land on values the regular grid skips.
38
- export const WALL_FINE_GRID_STEP = 0.05
39
51
  export const WALL_MIN_LENGTH = 0.01
40
- const DEFAULT_WALL_ANGLE_SNAP_STEP = Math.PI / 4
41
-
42
- const WALL_ANGLE_SNAP_BY_GRID_STEP: Record<number, number> = {
43
- 0.5: Math.PI / 4,
44
- 0.25: Math.PI / 8,
45
- 0.1: Math.PI / 12,
46
- 0.05: Math.PI / 36,
47
- }
52
+ // An endpoint projecting within this distance of an existing wall's corner
53
+ // resolves to the corner without splitting — splitting there would mint a
54
+ // sliver segment a hair longer than `WALL_MIN_LENGTH` that no snap radius
55
+ // can ever target again.
56
+ const WALL_SPLIT_ENDPOINT_EPSILON = 0.02
48
57
 
49
58
  type WallSplitIntersection = {
50
- wallId: WallNode['id']
59
+ /** `null` = snap-only outcome: resolve to `point` but split no wall. */
60
+ wallId: WallNode['id'] | null
51
61
  point: WallPlanPoint
52
62
  }
53
63
 
54
64
  export function getSegmentGridStep(): number {
55
- return useEditor.getState().gridSnapStep
65
+ // A 0 step means "no grid lattice" — every grid-snap consumer guards on
66
+ // `step <= 0` and returns the raw value, so disabling grid here suppresses
67
+ // the lattice for walls, fences, and every node move/affordance that reads
68
+ // this choke point, without retuning their snap math.
69
+ return resolveSnapFlags(getActiveSnappingMode()).grid ? useEditor.getState().gridSnapStep : 0
56
70
  }
57
71
 
58
72
  export function snapScalarToGrid(value: number, step = WALL_GRID_STEP): number {
73
+ if (step <= 0) return value
59
74
  return Math.round(value / step) * step
60
75
  }
61
76
 
@@ -63,36 +78,11 @@ export function snapPointToGrid(point: WallPlanPoint, step = WALL_GRID_STEP): Wa
63
78
  return [snapScalarToGrid(point[0], step), snapScalarToGrid(point[1], step)]
64
79
  }
65
80
 
66
- export function snapPointTo45Degrees(
67
- start: WallPlanPoint,
68
- cursor: WallPlanPoint,
69
- step = WALL_GRID_STEP,
70
- angleStep = DEFAULT_WALL_ANGLE_SNAP_STEP,
71
- /**
72
- * Optional grid-snap callback. Lets the caller route the final
73
- * snap through a world-XZ grid (or any other axis system) instead
74
- * of the local-axis grid `snapPointToGrid` uses. When omitted,
75
- * falls back to the local-axis snap at `step`.
76
- */
77
- gridSnap?: (point: WallPlanPoint) => WallPlanPoint,
78
- ): WallPlanPoint {
79
- const dx = cursor[0] - start[0]
80
- const dz = cursor[1] - start[1]
81
- const angle = Math.atan2(dz, dx)
82
- const snappedAngle = Math.round(angle / angleStep) * angleStep
83
- const distance = Math.sqrt(dx * dx + dz * dz)
84
- const point: WallPlanPoint = [
85
- start[0] + Math.cos(snappedAngle) * distance,
86
- start[1] + Math.sin(snappedAngle) * distance,
87
- ]
88
- return gridSnap ? gridSnap(point) : snapPointToGrid(point, step)
89
- }
90
-
91
- export function getWallAngleSnapStep(step = getSegmentGridStep()): number {
92
- return WALL_ANGLE_SNAP_BY_GRID_STEP[step] ?? DEFAULT_WALL_ANGLE_SNAP_STEP
93
- }
94
-
95
- function splitWallAtPoint(wall: WallNode, splitPoint: WallPlanPoint): [WallNode, WallNode] {
81
+ function splitWallAtPoint(
82
+ wall: WallNode,
83
+ splitPoint: WallPlanPoint,
84
+ nodes: ReturnType<typeof useScene.getState>['nodes'],
85
+ ): [WallNode, WallNode] {
96
86
  const { id: _id, parentId: _parentId, children, ...rest } = wall
97
87
 
98
88
  const first = WallSchema.parse({
@@ -108,7 +98,24 @@ function splitWallAtPoint(wall: WallNode, splitPoint: WallPlanPoint): [WallNode,
108
98
  children: [],
109
99
  })
110
100
 
111
- return [first, second]
101
+ if (wall.supportSlabId !== GROUND_SUPPORT_ID || !wall.parentId) {
102
+ return [first, second]
103
+ }
104
+
105
+ const levelId = wall.parentId
106
+ const originalElevation =
107
+ (terrainSupportLift(nodes, levelId, wall.start[0], wall.start[1]) ?? 0) +
108
+ (wall.supportOffset ?? 0)
109
+ const rebase = (segment: WallNode): WallNode => {
110
+ const terrainElevation =
111
+ terrainSupportLift(nodes, levelId, segment.start[0], segment.start[1]) ?? 0
112
+ const supportOffset = originalElevation - terrainElevation
113
+ return {
114
+ ...segment,
115
+ supportOffset: Math.abs(supportOffset) > 1e-6 ? supportOffset : undefined,
116
+ }
117
+ }
118
+ return [rebase(first), rebase(second)]
112
119
  }
113
120
 
114
121
  function pointsEqual(a: WallPlanPoint, b: WallPlanPoint, tolerance = 1e-6): boolean {
@@ -118,6 +125,7 @@ function pointsEqual(a: WallPlanPoint, b: WallPlanPoint, tolerance = 1e-6): bool
118
125
  function findWallIntersection(
119
126
  point: WallPlanPoint,
120
127
  walls: WallNode[],
128
+ radius: number,
121
129
  ignoreWallIds?: string[],
122
130
  ): WallSplitIntersection | null {
123
131
  const ignore = new Set(ignoreWallIds ?? [])
@@ -132,13 +140,20 @@ function findWallIntersection(
132
140
 
133
141
  const candidateDistanceSquared = distanceSquared(point, projected)
134
142
  if (
135
- candidateDistanceSquared > WALL_JOIN_SNAP_RADIUS * WALL_JOIN_SNAP_RADIUS ||
143
+ candidateDistanceSquared > radius * radius ||
136
144
  candidateDistanceSquared >= bestDistanceSquared
137
145
  ) {
138
146
  continue
139
147
  }
140
148
 
141
- best = { wallId: wall.id, point: projected }
149
+ const nearCorner = ([wall.start, wall.end] as WallPlanPoint[]).find(
150
+ (corner) =>
151
+ distanceSquared(projected, corner) <=
152
+ WALL_SPLIT_ENDPOINT_EPSILON * WALL_SPLIT_ENDPOINT_EPSILON,
153
+ )
154
+ best = nearCorner
155
+ ? { wallId: null, point: [nearCorner[0], nearCorner[1]] }
156
+ : { wallId: wall.id, point: projected }
142
157
  bestDistanceSquared = candidateDistanceSquared
143
158
  }
144
159
 
@@ -290,12 +305,16 @@ function splitWallIfNeeded(
290
305
  ): { walls: WallNode[]; point: WallPlanPoint } | null {
291
306
  if (!intersection) return null
292
307
 
308
+ if (!intersection.wallId) {
309
+ return { walls, point: intersection.point }
310
+ }
311
+
293
312
  const wallToSplit = walls.find((wall) => wall.id === intersection.wallId)
294
313
  if (!wallToSplit) {
295
314
  return { walls, point: intersection.point }
296
315
  }
297
316
 
298
- const [first, second] = splitWallAtPoint(wallToSplit, intersection.point)
317
+ const [first, second] = splitWallAtPoint(wallToSplit, intersection.point, nodes)
299
318
  const attachmentUpdates = buildAttachmentMigrationPlan(
300
319
  wallToSplit,
301
320
  intersection.point,
@@ -323,13 +342,51 @@ function splitWallIfNeeded(
323
342
  }
324
343
  }
325
344
 
345
+ /**
346
+ * Commit-time split resolution for an endpoint MOVE — the sibling of the
347
+ * inline resolution in `createWallOnCurrentLevel`: when a moved endpoint is
348
+ * dropped on another wall's interior, split that host exactly like the draw
349
+ * path (duplicate props, migrate attachments by span, skip the split when an
350
+ * opening straddles the point). Mutates the scene store (create halves /
351
+ * migrate attachments / delete host), so callers MUST run it inside the same
352
+ * `runAsSingleSceneHistoryStep` as their endpoint write.
353
+ *
354
+ * Returns the resolved endpoint (projection onto the host, or a nearby corner
355
+ * when the drop is within `WALL_SPLIT_ENDPOINT_EPSILON` of one — corner joins
356
+ * are not splits), or `null` when the point lands on no wall.
357
+ */
358
+ export function resolveEndpointWallSplit(args: {
359
+ point: WallPlanPoint
360
+ /** Level the moved wall lives on — only its walls are split candidates. */
361
+ levelId: string | null
362
+ /** The moved wall + every wall receiving an endpoint update in the same commit. */
363
+ ignoreWallIds: string[]
364
+ /**
365
+ * Capture radius. The endpoint already snapped onto the wall body during
366
+ * the drag, so the tight connect radius (drop genuinely on the wall) is
367
+ * the default.
368
+ */
369
+ radius?: number
370
+ }): WallPlanPoint | null {
371
+ const { point, levelId, ignoreWallIds, radius = WALL_CONNECT_SNAP_RADIUS } = args
372
+ const { nodes, createNodes, updateNodes, deleteNode } = useScene.getState()
373
+ const walls = Object.values(nodes).filter(
374
+ (node): node is WallNode => node?.type === 'wall' && (node.parentId ?? null) === levelId,
375
+ )
376
+
377
+ const intersection = findWallIntersection(point, walls, radius, ignoreWallIds)
378
+ const split = splitWallIfNeeded(intersection, walls, nodes, createNodes, updateNodes, deleteNode)
379
+ return split ? split.point : null
380
+ }
381
+
326
382
  type SnapWallDraftArgs = {
327
383
  point: WallPlanPoint
328
384
  walls: WallNode[]
329
385
  start?: WallPlanPoint
330
386
  angleSnap?: boolean
331
387
  ignoreWallIds?: string[]
332
- /** Override the grid step (e.g. `WALL_FINE_GRID_STEP` for precision mode). */
388
+ bypassSnap?: boolean
389
+ /** Override the grid step. */
333
390
  step?: number
334
391
  /**
335
392
  * Magnetic snapping to existing wall geometry (corners, midpoints,
@@ -345,6 +402,8 @@ type SnapWallDraftArgs = {
345
402
  * local-axis grid at `step`.
346
403
  */
347
404
  gridSnap?: (point: WallPlanPoint) => WallPlanPoint
405
+ /** Optional magnetic snap radii. Omitted means wall tools keep their defaults. */
406
+ snapRadii?: WallSnapRadii
348
407
  }
349
408
 
350
409
  export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSnapResult {
@@ -354,34 +413,75 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn
354
413
  start,
355
414
  angleSnap = false,
356
415
  ignoreWallIds,
416
+ bypassSnap = false,
357
417
  step: overrideStep,
358
418
  magnetic = true,
359
419
  gridSnap,
420
+ snapRadii,
360
421
  } = args
361
422
 
423
+ if (bypassSnap) return { point, snap: null, targetWallIds: [] }
424
+
362
425
  // Discrete special points (corner / midpoint / crossing) are taken from the
363
426
  // raw cursor so an interim grid snap can't mask them. A corner always wins,
364
427
  // then the nearer of midpoint / crossing — see `findWallSpecialPointSnap`.
365
428
  if (magnetic) {
366
- const special = findWallSpecialPointSnap(point, walls, ignoreWallIds)
429
+ const special = findWallSpecialPointSnap(point, walls, ignoreWallIds, snapRadii)
367
430
  if (special) return special
368
431
  }
369
432
 
370
433
  const step = overrideStep ?? getSegmentGridStep()
371
- const angleStep = getWallAngleSnapStep(step)
372
- const basePoint =
434
+ // The angle path snaps the distance ALONG the 15° ray — a scalar, the
435
+ // same in world and local frames — so the `gridSnap` world-grid override
436
+ // only applies when the angle lock is off.
437
+ const basePoint: WallPlanPoint =
373
438
  start && angleSnap
374
- ? snapPointTo45Degrees(start, point, step, angleStep, gridSnap)
439
+ ? [...snapPointAlongAngleRay(start, point, DEFAULT_ANGLE_STEP, step)]
375
440
  : gridSnap
376
441
  ? gridSnap(point)
377
442
  : snapPointToGrid(point, step)
378
443
 
379
444
  if (magnetic) {
380
- const wallSnap = findWallSnapTarget(basePoint, walls, { ignoreWallIds })
381
- if (wallSnap) return { point: wallSnap, snap: 'wall' }
445
+ const wallSnap = findWallSnapTarget(basePoint, walls, {
446
+ ignoreWallIds,
447
+ radius: snapRadii?.wall,
448
+ })
449
+ if (wallSnap) {
450
+ return {
451
+ point: wallSnap,
452
+ snap: 'wall',
453
+ targetWallIds: wallIdsAtSnapPoint(wallSnap, walls, ignoreWallIds),
454
+ }
455
+ }
456
+ return { point: basePoint, snap: null, targetWallIds: [] }
382
457
  }
383
458
 
384
- return { point: basePoint, snap: null }
459
+ // Non-magnetic modes (grid / off / angles): connectivity still sticks so a
460
+ // room can close, but only within a tight radius — placement elsewhere is left
461
+ // to the mode (grid quantise / angle lock / free). Snap from the already
462
+ // positioned `basePoint` so the mode's placement is respected right up to the
463
+ // wall, then the last few cm stick onto it (and the beacon shows).
464
+ const connectRadii: WallSnapRadii = {
465
+ endpoint: WALL_CONNECT_SNAP_RADIUS,
466
+ midpoint: WALL_CONNECT_SNAP_RADIUS,
467
+ intersection: WALL_CONNECT_SNAP_RADIUS,
468
+ wall: WALL_CONNECT_SNAP_RADIUS,
469
+ }
470
+ const connectSpecial = findWallSpecialPointSnap(basePoint, walls, ignoreWallIds, connectRadii)
471
+ if (connectSpecial) return connectSpecial
472
+ const connectWall = findWallSnapTarget(basePoint, walls, {
473
+ ignoreWallIds,
474
+ radius: WALL_CONNECT_SNAP_RADIUS,
475
+ })
476
+ if (connectWall) {
477
+ return {
478
+ point: connectWall,
479
+ snap: 'wall',
480
+ targetWallIds: wallIdsAtSnapPoint(connectWall, walls, ignoreWallIds),
481
+ }
482
+ }
483
+
484
+ return { point: basePoint, snap: null, targetWallIds: [] }
385
485
  }
386
486
 
387
487
  export function snapWallDraftPoint(args: SnapWallDraftArgs): WallPlanPoint {
@@ -392,9 +492,45 @@ export function isSegmentLongEnough(start: WallPlanPoint, end: WallPlanPoint): b
392
492
  return distanceSquared(start, end) >= WALL_MIN_LENGTH * WALL_MIN_LENGTH
393
493
  }
394
494
 
495
+ export type WallConstructionOptions = {
496
+ /** Pointer-decided maximum support elevation in level-local metres. */
497
+ supportCap?: number | null
498
+ /** Support source selected by the first click or inherited from a snapped wall. */
499
+ preferredSupportSlabId?: string | null
500
+ /** Frozen level-local Y shown by the draft ghost. */
501
+ constructionElevation?: number | null
502
+ /** Height shown by the draft ghost. */
503
+ constructionHeight?: number | null
504
+ }
505
+
506
+ export function resolveTerrainWallConstructionOptions(
507
+ nodes: Record<string, AnyNode>,
508
+ levelId: string,
509
+ point: WallPlanPoint,
510
+ defaults?: Record<string, unknown>,
511
+ ): WallConstructionOptions | undefined {
512
+ const constructionElevation = terrainSupportLift(nodes, levelId, point[0], point[1])
513
+ if (constructionElevation == null) return undefined
514
+
515
+ const level = nodes[levelId]
516
+ const constructionHeight =
517
+ typeof defaults?.height === 'number'
518
+ ? defaults.height
519
+ : level?.type === 'level'
520
+ ? (level.height ?? DEFAULT_LEVEL_HEIGHT)
521
+ : DEFAULT_LEVEL_HEIGHT
522
+
523
+ return {
524
+ constructionElevation,
525
+ constructionHeight,
526
+ supportCap: constructionElevation,
527
+ }
528
+ }
529
+
395
530
  export function createWallOnCurrentLevel(
396
531
  start: WallPlanPoint,
397
532
  end: WallPlanPoint,
533
+ options?: WallConstructionOptions,
398
534
  ): WallNode | null {
399
535
  const currentLevelId = useViewer.getState().selection.levelId
400
536
  const { createNode, createNodes, deleteNode, nodes } = useScene.getState()
@@ -411,61 +547,121 @@ export function createWallOnCurrentLevel(
411
547
  let resolvedStart = start
412
548
  let resolvedEnd = end
413
549
 
414
- const endIntersection = findWallIntersection(resolvedEnd, workingWalls)
415
- const splitEnd = splitWallIfNeeded(
416
- endIntersection,
417
- workingWalls,
418
- nodes,
419
- createNodes,
420
- updateNodes,
421
- deleteNode,
422
- )
423
- if (splitEnd) {
424
- workingWalls = splitEnd.walls
425
- resolvedEnd = splitEnd.point
426
- }
427
-
428
- const startIntersection = findWallIntersection(resolvedStart, workingWalls)
429
- const splitStart = splitWallIfNeeded(
430
- startIntersection,
431
- workingWalls,
432
- nodes,
433
- createNodes,
434
- updateNodes,
435
- deleteNode,
436
- )
437
- if (splitStart) {
438
- workingWalls = splitStart.walls
439
- resolvedStart = splitStart.point
440
- }
550
+ // The corner-join / wall-split resolution follows the snapping mode like the
551
+ // draft preview does: magnetic ('lines') keeps the generous join radius,
552
+ // every other mode uses the same tight connect radius the draft path already
553
+ // sticks endpoints with. So an endpoint the user saw connect to a wall body
554
+ // actually splits that wall (and redistributes its attachments) in every
555
+ // mode, while `'off'` / `'angles'` gain no residual long-range snap.
556
+ const joinRadius = isMagneticSnapActive() ? WALL_JOIN_SNAP_RADIUS : WALL_CONNECT_SNAP_RADIUS
557
+
558
+ // One undo step for the whole commit: the split ops (create halves, migrate
559
+ // attachments, delete host) plus the new wall each push their own history
560
+ // entry, and a single Ctrl-Z must not strand a half-split wall network.
561
+ return runAsSingleSceneHistoryStep(useScene, () => {
562
+ const endIntersection = findWallIntersection(resolvedEnd, workingWalls, joinRadius)
563
+ const splitEnd = splitWallIfNeeded(
564
+ endIntersection,
565
+ workingWalls,
566
+ nodes,
567
+ createNodes,
568
+ updateNodes,
569
+ deleteNode,
570
+ )
571
+ if (splitEnd) {
572
+ workingWalls = splitEnd.walls
573
+ resolvedEnd = splitEnd.point
574
+ }
441
575
 
442
- if (!isSegmentLongEnough(resolvedStart, resolvedEnd) || pointsEqual(resolvedStart, resolvedEnd)) {
443
- return null
444
- }
576
+ const startIntersection = findWallIntersection(resolvedStart, workingWalls, joinRadius)
577
+ const splitStart = splitWallIfNeeded(
578
+ startIntersection,
579
+ workingWalls,
580
+ nodes,
581
+ createNodes,
582
+ updateNodes,
583
+ deleteNode,
584
+ )
585
+ if (splitStart) {
586
+ workingWalls = splitStart.walls
587
+ resolvedStart = splitStart.point
588
+ }
445
589
 
446
- const duplicateWall = workingWalls.some(
447
- (wall) =>
448
- (pointsEqual(wall.start, resolvedStart) && pointsEqual(wall.end, resolvedEnd)) ||
449
- (pointsEqual(wall.start, resolvedEnd) && pointsEqual(wall.end, resolvedStart)),
450
- )
451
- if (duplicateWall) {
452
- return null
453
- }
590
+ if (
591
+ !isSegmentLongEnough(resolvedStart, resolvedEnd) ||
592
+ pointsEqual(resolvedStart, resolvedEnd)
593
+ ) {
594
+ return null
595
+ }
454
596
 
455
- const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length
456
- // A placed wall preset seeds `toolDefaults.wall` (thickness, height,
457
- // materials, sides) before the tool activates; merge those first so the
458
- // drawn wall reproduces the preset. Identity + endpoints always win.
459
- const defaults = useEditor.getState().toolDefaults.wall ?? {}
460
- const wall = WallSchema.parse({
461
- ...defaults,
462
- name: `Wall ${wallCount + 1}`,
463
- start: resolvedStart,
464
- end: resolvedEnd,
465
- })
597
+ const duplicateWall = workingWalls.some(
598
+ (wall) =>
599
+ (pointsEqual(wall.start, resolvedStart) && pointsEqual(wall.end, resolvedEnd)) ||
600
+ (pointsEqual(wall.start, resolvedEnd) && pointsEqual(wall.end, resolvedStart)),
601
+ )
602
+ if (duplicateWall) {
603
+ return null
604
+ }
466
605
 
467
- createNode(wall, currentLevelId)
468
- sfxEmitter.emit('sfx:structure-build')
606
+ const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length
607
+ // A placed wall preset seeds `toolDefaults.wall` (thickness, height,
608
+ // materials, sides) before the tool activates; merge those first so the
609
+ // drawn wall reproduces the preset. Identity + endpoints always win.
610
+ const defaults = useEditor.getState().toolDefaults.wall ?? {}
611
+ const wall = WallSchema.parse({
612
+ ...defaults,
613
+ name: `Wall ${wallCount + 1}`,
614
+ start: resolvedStart,
615
+ end: resolvedEnd,
616
+ })
617
+
618
+ createNode(wall, currentLevelId)
619
+ const createdWall = useScene.getState().nodes[wall.id]
620
+ if (createdWall?.type === 'wall') {
621
+ const terrainBase = terrainSupportLift(
622
+ useScene.getState().nodes,
623
+ currentLevelId,
624
+ createdWall.start[0],
625
+ createdWall.start[1],
626
+ )
627
+ const preferredSupportSlabId =
628
+ options?.preferredSupportSlabId ??
629
+ (options?.constructionElevation != null && terrainBase != null ? GROUND_SUPPORT_ID : null)
630
+ const supportPatch = resolveWallSupportSlabPatch(createdWall, useScene.getState().nodes, {
631
+ maxElevation: options?.supportCap ?? null,
632
+ preferredSlabId: preferredSupportSlabId,
633
+ })
634
+ const supportSlabId = supportPatch.supportSlabId
635
+ const sourceSupport = spatialGridManager.getSlabSupportForWall(
636
+ currentLevelId,
637
+ createdWall.start,
638
+ createdWall.end,
639
+ createdWall.curveOffset,
640
+ createdWall.thickness,
641
+ supportSlabId,
642
+ options?.supportCap ?? null,
643
+ )
644
+ const supportOffset =
645
+ options?.constructionElevation == null
646
+ ? undefined
647
+ : options.constructionElevation - sourceSupport.elevation
648
+ const preserveDraftHeight =
649
+ createdWall.height == null &&
650
+ options?.constructionHeight != null &&
651
+ options.constructionElevation != null &&
652
+ (terrainBase != null || Math.abs(options.constructionElevation) > 1e-6)
653
+ useScene.getState().updateNode(createdWall.id, {
654
+ ...supportPatch,
655
+ height: preserveDraftHeight
656
+ ? (options?.constructionHeight ?? createdWall.height)
657
+ : createdWall.height,
658
+ supportOffset:
659
+ supportOffset != null && Math.abs(supportOffset) > 1e-6 ? supportOffset : undefined,
660
+ })
661
+ }
662
+ sfxEmitter.emit('sfx:structure-build')
469
663
 
470
- return wall
664
+ const committedWall = useScene.getState().nodes[wall.id]
665
+ return committedWall?.type === 'wall' ? committedWall : wall
666
+ })
471
667
  }
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
  import type { WallNode } from '@pascal-app/core'
3
3
  import {
4
+ chainEndJoinsExistingWall,
4
5
  findWallSnapTarget,
5
6
  findWallSpecialPointSnap,
6
7
  type WallPlanPoint,
@@ -47,6 +48,14 @@ describe('findWallSpecialPointSnap', () => {
47
48
  expect(result?.snap).toBe('intersection')
48
49
  expect(result?.point[0]).toBeCloseTo(1, 6)
49
50
  expect(result?.point[1]).toBeCloseTo(0, 6)
51
+ expect(result?.targetWallIds).toEqual(['a', 'b'])
52
+ })
53
+
54
+ test('keeps every wall that shares a snapped endpoint as provenance', () => {
55
+ const walls = [makeWall([0, 0], [4, 0], 'a'), makeWall([0, 0], [0, 4], 'b')]
56
+ const result = findWallSpecialPointSnap([0.1, 0.1], walls)
57
+ expect(result?.point).toEqual([0, 0])
58
+ expect(result?.targetWallIds).toEqual(['a', 'b'])
50
59
  })
51
60
 
52
61
  test('corner wins over a midpoint when both are in range', () => {
@@ -62,6 +71,43 @@ describe('findWallSpecialPointSnap', () => {
62
71
  // handled separately by findWallSnapTarget, not a special point.
63
72
  expect(findWallSpecialPointSnap([1.2, 0.1], walls)).toBeNull()
64
73
  })
74
+
75
+ test('honors tighter per-call radii without changing defaults', () => {
76
+ const walls = [makeWall([0, 0], [4, 0])]
77
+
78
+ expect(findWallSpecialPointSnap([0.34, 0], walls)?.snap).toBe('endpoint')
79
+ expect(findWallSpecialPointSnap([0.34, 0], walls, undefined, { endpoint: 0.3 })).toBeNull()
80
+ })
81
+ })
82
+
83
+ describe('chainEndJoinsExistingWall (chain termination)', () => {
84
+ test('true when the end lies on a non-chain wall interior (T junction)', () => {
85
+ const walls = [makeWall([0, 0], [4, 0], 'host'), makeWall([2, 2], [2, 0], 'chain_1')]
86
+ expect(chainEndJoinsExistingWall([2, 0], walls, ['chain_1'])).toBe(true)
87
+ })
88
+
89
+ test('true when the end lands on a non-chain wall endpoint', () => {
90
+ const walls = [makeWall([0, 0], [4, 0], 'host'), makeWall([6, 2], [4, 0], 'chain_1')]
91
+ expect(chainEndJoinsExistingWall([4, 0], walls, ['chain_1'])).toBe(true)
92
+ })
93
+
94
+ test('false when the end only touches the chain walls themselves', () => {
95
+ // Second segment doubles back onto the first one's midpoint — own-chain
96
+ // geometry is excluded, so the chain keeps going.
97
+ const walls = [makeWall([0, 0], [4, 0], 'chain_1'), makeWall([4, 0], [2, 0], 'chain_2')]
98
+ expect(chainEndJoinsExistingWall([2, 0], walls, ['chain_1', 'chain_2'])).toBe(false)
99
+ })
100
+
101
+ test('false for a dead end in free space', () => {
102
+ const walls = [makeWall([0, 0], [4, 0], 'host'), makeWall([0, 2], [2, 2], 'chain_1')]
103
+ expect(chainEndJoinsExistingWall([2, 2], walls, ['chain_1'])).toBe(false)
104
+ })
105
+
106
+ test('a near miss beyond the tolerance is not a join', () => {
107
+ const walls = [makeWall([0, 0], [4, 0], 'host')]
108
+ expect(chainEndJoinsExistingWall([2, 0.01], walls, [])).toBe(false)
109
+ expect(chainEndJoinsExistingWall([2, 0.0005], walls, [])).toBe(true)
110
+ })
65
111
  })
66
112
 
67
113
  describe('findWallSnapTarget (edge / along-wall)', () => {
@@ -76,4 +122,10 @@ describe('findWallSnapTarget (edge / along-wall)', () => {
76
122
  const walls = [makeWall([0, 0], [4, 0])]
77
123
  expect(findWallSnapTarget([1.2, 2], walls)).toBeNull()
78
124
  })
125
+
126
+ test('honors a tighter wall-body radius', () => {
127
+ const walls = [makeWall([0, 0], [4, 0])]
128
+
129
+ expect(findWallSnapTarget([1.2, 0.1], walls, { radius: 0.08 })).toBeNull()
130
+ })
79
131
  })