@pascal-app/editor 1.0.6 → 1.1.0

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 (382) hide show
  1. package/package.json +18 -15
  2. package/src/components/editor/action-menu-button.tsx +4 -1
  3. package/src/components/editor/alignment-3d-guide-layer.tsx +102 -11
  4. package/src/components/editor/bake-thumbnail.tsx +3 -2
  5. package/src/components/editor/custom-camera-controls.tsx +110 -42
  6. package/src/components/editor/duplicate-node.ts +2 -1
  7. package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
  8. package/src/components/editor/first-person-controls.tsx +77 -68
  9. package/src/components/editor/fit-scene-framing.test.ts +318 -0
  10. package/src/components/editor/fit-scene-framing.ts +142 -0
  11. package/src/components/editor/floating-action-menu.tsx +31 -11
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floor-openings-overlay.tsx +1 -1
  14. package/src/components/editor/floor-region-controls.tsx +2 -2
  15. package/src/components/editor/floorplan-panel.test.ts +220 -0
  16. package/src/components/editor/floorplan-panel.tsx +458 -140
  17. package/src/components/editor/floorplan-wall-typing-keys.test.tsx +152 -0
  18. package/src/components/editor/floorplan-wall-typing-keys.ts +79 -0
  19. package/src/components/editor/footprint-height-handle.tsx +2 -2
  20. package/src/components/editor/grid.tsx +24 -15
  21. package/src/components/editor/group-actions.ts +19 -1
  22. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  23. package/src/components/editor/group-move-3d.ts +27 -2
  24. package/src/components/editor/group-selection-box-3d.tsx +4 -1
  25. package/src/components/editor/group-transform-shared.ts +23 -1
  26. package/src/components/editor/index.tsx +150 -20
  27. package/src/components/editor/inspector-extension-actions.tsx +36 -0
  28. package/src/components/editor/localized-html.tsx +22 -0
  29. package/src/components/editor/measurement-pill.tsx +1 -1
  30. package/src/components/editor/node-action-menu.tsx +46 -12
  31. package/src/components/editor/node-arrow-handles.tsx +2 -3
  32. package/src/components/editor/open-wall-ends-3d-layer.tsx +2 -3
  33. package/src/components/editor/open-wall-ends-hint.tsx +1 -1
  34. package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
  35. package/src/components/editor/quick-measurement-card.tsx +1 -1
  36. package/src/components/editor/registry-action-contributions.test.tsx +67 -6
  37. package/src/components/editor/registry-action-contributions.tsx +115 -15
  38. package/src/components/editor/riser-diagram-panel.tsx +59 -9
  39. package/src/components/editor/room-controls.tsx +4 -3
  40. package/src/components/editor/room-handles-2d.tsx +158 -7
  41. package/src/components/editor/room-handles.tsx +244 -54
  42. package/src/components/editor/room-highlight.tsx +2 -0
  43. package/src/components/editor/room-labels.tsx +33 -17
  44. package/src/components/editor/selection-manager.tsx +15 -102
  45. package/src/components/editor/separator-end-grip.tsx +74 -0
  46. package/src/components/editor/site-edge-labels.tsx +7 -3
  47. package/src/components/editor/thumbnail-generator.test.ts +15 -6
  48. package/src/components/editor/thumbnail-generator.tsx +26 -1
  49. package/src/components/editor/use-floorplan-background-placement.ts +55 -52
  50. package/src/components/editor/views/view-bar.tsx +111 -26
  51. package/src/components/editor/wall-measurement-label.tsx +31 -15
  52. package/src/components/editor/wall-move-side-handles.tsx +15 -7
  53. package/src/components/editor/wall-pull-grip.tsx +193 -0
  54. package/src/components/editor/wall-region-handles.tsx +2 -2
  55. package/src/components/editor/wall-snap-beacon-layer.tsx +134 -33
  56. package/src/components/editor-2d/floorplan-adjacent-walls-layer.test.ts +76 -0
  57. package/src/components/editor-2d/floorplan-adjacent-walls-layer.tsx +186 -0
  58. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +63 -3
  59. package/src/components/editor-2d/floorplan-group-move.tsx +32 -4
  60. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +6 -0
  61. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +33 -24
  62. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +12 -5
  63. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +2 -1
  64. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +29 -5
  65. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +96 -4
  66. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +52 -12
  67. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +7 -1
  68. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +13 -1
  69. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.test.tsx +35 -0
  70. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.tsx +65 -0
  71. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +34 -1
  72. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +211 -39
  73. package/src/components/procedural-items/inspector.tsx +59 -12
  74. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +2 -0
  75. package/src/components/systems/ceiling/ceiling-system.test.ts +141 -0
  76. package/src/components/systems/ceiling/ceiling-system.tsx +53 -18
  77. package/src/components/systems/roof/roof-edit-system.test.tsx +15 -1
  78. package/src/components/systems/roof/roof-edit-system.tsx +30 -19
  79. package/src/components/tools/distribution/distribution-run-tool.test.tsx +371 -3
  80. package/src/components/tools/distribution/distribution-run-tool.tsx +200 -58
  81. package/src/components/tools/item/move-tool.tsx +1 -6
  82. package/src/components/tools/item/placement-history.test.tsx +1 -1
  83. package/src/components/tools/item/placement-strategies.test.ts +64 -1
  84. package/src/components/tools/item/placement-strategies.ts +5 -9
  85. package/src/components/tools/item/use-placement-coordinator.tsx +31 -35
  86. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +1 -1
  87. package/src/components/tools/registry/item-surface-move.test.ts +11 -0
  88. package/src/components/tools/registry/move-registry-node-tool.test.ts +42 -1
  89. package/src/components/tools/registry/move-registry-node-tool.tsx +38 -14
  90. package/src/components/tools/select/select-candidates.test.ts +45 -0
  91. package/src/components/tools/select/select-candidates.ts +13 -2
  92. package/src/components/tools/shared/cursor-sphere.tsx +10 -3
  93. package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
  94. package/src/components/tools/shared/placement-box.tsx +2 -6
  95. package/src/components/tools/shared/placement-coordinate-input.tsx +94 -0
  96. package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
  97. package/src/components/tools/shared/segment-angle.ts +0 -4
  98. package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
  99. package/src/components/tools/tool-manager.tsx +1 -24
  100. package/src/components/tools/wall/wall-drafting.test.ts +299 -23
  101. package/src/components/tools/wall/wall-drafting.ts +132 -24
  102. package/src/components/tools/wall/wall-pull-snap.test.ts +274 -0
  103. package/src/components/tools/wall/wall-pull-snap.ts +563 -0
  104. package/src/components/tools/wall/wall-snap-geometry.test.ts +113 -1
  105. package/src/components/tools/wall/wall-snap-geometry.ts +137 -0
  106. package/src/components/tools/wall/wall-typed-entry.test.ts +99 -0
  107. package/src/components/tools/wall/wall-typed-entry.ts +70 -0
  108. package/src/components/tools/zone/zone-tool.tsx +3 -3
  109. package/src/components/ui/action-menu/action-button.tsx +2 -0
  110. package/src/components/ui/action-menu/camera-actions.tsx +8 -7
  111. package/src/components/ui/action-menu/control-modes.tsx +9 -5
  112. package/src/components/ui/action-menu/follow-pascal-pill.tsx +3 -1
  113. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +7 -1
  114. package/src/components/ui/action-menu/furnish-tools.tsx +7 -6
  115. package/src/components/ui/action-menu/index.tsx +1 -0
  116. package/src/components/ui/action-menu/measurement-control.tsx +35 -29
  117. package/src/components/ui/action-menu/point-ask-button.tsx +4 -3
  118. package/src/components/ui/action-menu/structure-tools.tsx +25 -23
  119. package/src/components/ui/action-menu/view-toggles.test.ts +18 -0
  120. package/src/components/ui/action-menu/view-toggles.tsx +256 -51
  121. package/src/components/ui/build-panel/build-panel.tsx +163 -25
  122. package/src/components/ui/command-palette/editor-commands.tsx +38 -11
  123. package/src/components/ui/controls/finish-swatch.tsx +45 -19
  124. package/src/components/ui/controls/material-picker.tsx +24 -17
  125. package/src/components/ui/controls/metric-control.test.ts +52 -0
  126. package/src/components/ui/controls/metric-control.tsx +62 -24
  127. package/src/components/ui/controls/segmented-control.tsx +5 -2
  128. package/src/components/ui/controls/slider-control.test.tsx +23 -0
  129. package/src/components/ui/controls/slider-control.tsx +47 -19
  130. package/src/components/ui/controls/tool-options-panel.tsx +74 -44
  131. package/src/components/ui/floating-level-selector.tsx +100 -48
  132. package/src/components/ui/helpers/building-helper.tsx +5 -3
  133. package/src/components/ui/helpers/contextual-helper-panel.tsx +97 -37
  134. package/src/components/ui/helpers/helper-manager.test.tsx +106 -3
  135. package/src/components/ui/helpers/helper-manager.tsx +136 -57
  136. package/src/components/ui/helpers/item-helper.tsx +6 -4
  137. package/src/components/ui/helpers/registered-tool-helper.tsx +3 -1
  138. package/src/components/ui/level-duplicate-dialog.tsx +32 -23
  139. package/src/components/ui/level-wall-copy-prompt.tsx +124 -0
  140. package/src/components/ui/panels/collections/collections-popover.tsx +11 -11
  141. package/src/components/ui/panels/deck-boards-fields.tsx +136 -0
  142. package/src/components/ui/panels/floor-foundation-panel.tsx +114 -39
  143. package/src/components/ui/panels/floor-opening-panel.tsx +74 -26
  144. package/src/components/ui/panels/inspector-extensions.tsx +66 -0
  145. package/src/components/ui/panels/mobile-panel-sheet.tsx +3 -1
  146. package/src/components/ui/panels/mobile-selection-bar.tsx +34 -28
  147. package/src/components/ui/panels/multi-field-value.test.ts +27 -0
  148. package/src/components/ui/panels/multi-field-value.ts +20 -1
  149. package/src/components/ui/panels/multi-height-mode.tsx +11 -10
  150. package/src/components/ui/panels/multi-parametric-inspector.tsx +122 -34
  151. package/src/components/ui/panels/multi-selection-panel.tsx +76 -36
  152. package/src/components/ui/panels/node-display.ts +35 -24
  153. package/src/components/ui/panels/panel-manager.tsx +9 -5
  154. package/src/components/ui/panels/panel-wrapper.tsx +63 -127
  155. package/src/components/ui/panels/parametric-field-control.tsx +13 -10
  156. package/src/components/ui/panels/parametric-inspector.tsx +18 -12
  157. package/src/components/ui/panels/reference-panel.tsx +51 -35
  158. package/src/components/ui/panels/room-construction-rows.tsx +465 -66
  159. package/src/components/ui/panels/room-name-options.test.ts +36 -3
  160. package/src/components/ui/panels/room-name-options.ts +110 -11
  161. package/src/components/ui/panels/room-openings-field.tsx +22 -13
  162. package/src/components/ui/panels/room-panel.tsx +207 -100
  163. package/src/components/ui/panels/shape-choice.tsx +7 -5
  164. package/src/components/ui/panels/wall-region-list.tsx +7 -7
  165. package/src/components/ui/primitives/shortcut-token.tsx +10 -6
  166. package/src/components/ui/right-stack.test.ts +13 -6
  167. package/src/components/ui/right-stack.tsx +21 -10
  168. package/src/components/ui/sidebar/icon-rail.tsx +11 -9
  169. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +10 -10
  170. package/src/components/ui/sidebar/panels/items-panel/index.tsx +10 -9
  171. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +3 -0
  172. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +102 -32
  173. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +1 -5
  174. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +4 -3
  175. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -3
  176. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +4 -2
  177. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +7 -5
  178. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +4 -2
  179. package/src/components/ui/sidebar/panels/site-panel/default-names.ts +75 -0
  180. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +4 -2
  181. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +4 -2
  182. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +4 -2
  183. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +4 -2
  184. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +4 -2
  185. package/src/components/ui/sidebar/panels/site-panel/index.tsx +118 -72
  186. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +36 -3
  187. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +4 -2
  188. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -4
  189. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +8 -2
  190. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +8 -5
  191. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +4 -2
  192. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -4
  193. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +4 -2
  194. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +4 -2
  195. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +8 -7
  196. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +7 -6
  197. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +16 -1
  198. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +15 -8
  199. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +4 -2
  200. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +4 -2
  201. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +3 -1
  202. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +19 -9
  203. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +25 -14
  204. package/src/components/ui/sidebar/tab-bar.tsx +4 -1
  205. package/src/components/ui/sidebar/use-plugin-panels.tsx +19 -10
  206. package/src/components/ui/snap-target-badge.tsx +7 -6
  207. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +721 -0
  208. package/src/components/ui/wall-type/wall-type-menu.tsx +167 -0
  209. package/src/components/viewer/floorplan-compass-button.tsx +12 -3
  210. package/src/components/viewer/floorplan-preview.tsx +27 -19
  211. package/src/components/viewer/render-settings-menu.tsx +294 -0
  212. package/src/components/viewer/sky-control.tsx +710 -0
  213. package/src/components/viewer/use-viewer-ui-sounds.test.ts +15 -0
  214. package/src/components/viewer/use-viewer-ui-sounds.ts +46 -0
  215. package/src/components/viewer/viewer-controls-bar.tsx +278 -260
  216. package/src/components/viewer/viewer-hover-label.tsx +12 -4
  217. package/src/components/viewer/viewer-rooms.tsx +62 -5
  218. package/src/components/viewer/viewer-scene-header.tsx +28 -17
  219. package/src/components/viewer/viewer-stage-switcher.tsx +4 -2
  220. package/src/components/viewer-overlay.tsx +8 -0
  221. package/src/components/walkthrough-hud.tsx +75 -2
  222. package/src/hooks/use-keyboard.test.ts +85 -4
  223. package/src/hooks/use-keyboard.ts +65 -7
  224. package/src/hooks/use-selected-room.ts +12 -1
  225. package/src/i18n/floor-footprint-name.ts +30 -0
  226. package/src/i18n/level-name.ts +39 -0
  227. package/src/i18n/locales/en/messages.po +15849 -0
  228. package/src/i18n/locales/en/messages.ts +1 -0
  229. package/src/i18n/locales/en-XA/messages.ts +1 -0
  230. package/src/i18n/locales/es/messages.po +39331 -0
  231. package/src/i18n/locales/es/messages.ts +1 -0
  232. package/src/i18n/locales/fr/messages.po +39331 -0
  233. package/src/i18n/locales/fr/messages.ts +1 -0
  234. package/src/i18n/locales/ja/messages.po +39331 -0
  235. package/src/i18n/locales/ja/messages.ts +1 -0
  236. package/src/i18n/locales/zh-CN/messages.po +39331 -0
  237. package/src/i18n/locales/zh-CN/messages.ts +1 -0
  238. package/src/i18n/locales.test.ts +63 -0
  239. package/src/i18n/locales.ts +32 -0
  240. package/src/i18n/message-slot.tsx +25 -0
  241. package/src/i18n/node-messages.ts +169 -0
  242. package/src/i18n/pseudo-locale.ts +17 -0
  243. package/src/i18n/published-entry.test.ts +17 -0
  244. package/src/i18n/scene-theme-name.ts +16 -0
  245. package/src/index.tsx +224 -45
  246. package/src/lib/add-storey.test.ts +3 -3
  247. package/src/lib/contextual-help.test.ts +20 -1
  248. package/src/lib/contextual-help.ts +101 -19
  249. package/src/lib/continuation.ts +38 -11
  250. package/src/lib/elevation-guides.ts +21 -6
  251. package/src/lib/elevator-support.ts +1 -72
  252. package/src/lib/floor-footprints.test.ts +3 -2
  253. package/src/lib/floor-footprints.ts +1 -9
  254. package/src/lib/floor-foundation-support.test.ts +15 -6
  255. package/src/lib/floor-opening-draft.ts +1 -1
  256. package/src/lib/floor-region-session.ts +1 -1
  257. package/src/lib/floorplan/dimension-drive.test.ts +36 -2
  258. package/src/lib/floorplan/dimension-drive.ts +68 -3
  259. package/src/lib/floorplan/floorplan-export.test.ts +16 -5
  260. package/src/lib/floorplan/floorplan-export.tsx +13 -16
  261. package/src/lib/floorplan/floorplan-extension.test.ts +11 -12
  262. package/src/lib/floorplan/floorplan-extension.ts +37 -18
  263. package/src/lib/floorplan/floorplan-mode.test.ts +13 -6
  264. package/src/lib/floorplan/floorplan-mode.ts +14 -12
  265. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +1 -1
  266. package/src/lib/floorplan/floorplan-readonly.ts +3 -3
  267. package/src/lib/floorplan/open-wall-ends.ts +2 -1
  268. package/src/lib/floorplan/schedules.ts +10 -6
  269. package/src/lib/floorplan/site-plan/site-annotations.test.ts +46 -1
  270. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -0
  271. package/src/lib/floorplan/site-plan/site-parts.ts +6 -3
  272. package/src/lib/geometry-script/author.test.ts +114 -0
  273. package/src/lib/geometry-script/author.ts +55 -23
  274. package/src/lib/geometry-script/client.ts +3 -0
  275. package/src/lib/geometry-script/finish-scripts.ts +7 -7
  276. package/src/lib/gesture-lifecycle.ts +3 -1
  277. package/src/lib/glb-export.test.ts +327 -0
  278. package/src/lib/glb-export.ts +70 -2
  279. package/src/lib/grid-frame.test.ts +56 -3
  280. package/src/lib/grid-frame.ts +28 -1
  281. package/src/lib/history.test.ts +2 -1
  282. package/src/lib/hud-title.ts +48 -24
  283. package/src/lib/ifc-export.test.ts +41 -1
  284. package/src/lib/inspector-card-mode.test.ts +3 -31
  285. package/src/lib/inspector-card-mode.ts +5 -10
  286. package/src/lib/inspector-expanded.test.ts +13 -0
  287. package/src/lib/inspector-expanded.ts +13 -1
  288. package/src/lib/interaction/scope.ts +1 -0
  289. package/src/lib/keyboard-pan.test.ts +7 -0
  290. package/src/lib/keyboard-pan.ts +9 -2
  291. package/src/lib/layer-thickness.test.ts +22 -0
  292. package/src/lib/layer-thickness.ts +79 -0
  293. package/src/lib/level-selection.test.ts +18 -2
  294. package/src/lib/level-selection.ts +31 -8
  295. package/src/lib/linear-display.ts +1 -1
  296. package/src/lib/live-gesture-round-trip.test.ts +1 -1
  297. package/src/lib/lot/drop-in.test.ts +59 -1
  298. package/src/lib/lot/drop-in.ts +42 -50
  299. package/src/lib/material-swatch-style.ts +24 -0
  300. package/src/lib/measurement-label.test.ts +1 -1
  301. package/src/lib/measurement-parser.ts +7 -3
  302. package/src/lib/measurements.test.ts +24 -6
  303. package/src/lib/measurements.ts +1 -203
  304. package/src/lib/mezzanine-selection.test.ts +3 -1
  305. package/src/lib/node-action-movement.test.tsx +115 -0
  306. package/src/lib/node-action-movement.ts +2 -0
  307. package/src/lib/orbit-near-plane.test.ts +12 -0
  308. package/src/lib/orbit-near-plane.ts +13 -0
  309. package/src/lib/paint-regions.test.ts +8 -5
  310. package/src/lib/paint-scope.test.ts +53 -0
  311. package/src/lib/paint-scope.ts +18 -4
  312. package/src/lib/panel-rows.ts +17 -8
  313. package/src/lib/placement-metadata.ts +4 -0
  314. package/src/lib/point-ask/describe-target.ts +6 -8
  315. package/src/lib/point-ask/region.ts +1 -1
  316. package/src/lib/print-shell-compiler.ts +12 -1
  317. package/src/lib/print-wall-solids.test.ts +11 -0
  318. package/src/lib/print-wall-solids.ts +9 -0
  319. package/src/lib/reference-scale.ts +83 -0
  320. package/src/lib/room-assembly-overlay.ts +1 -1
  321. package/src/lib/room-built-on.test.ts +28 -17
  322. package/src/lib/room-built-on.ts +21 -17
  323. package/src/lib/room-construction-commands.ts +3 -2
  324. package/src/lib/room-construction.test.ts +28 -1
  325. package/src/lib/room-construction.ts +14 -0
  326. package/src/lib/room-controls-phase6.test.ts +141 -1
  327. package/src/lib/room-handle-drag.ts +554 -58
  328. package/src/lib/room-name-catalog.test.ts +8 -0
  329. package/src/lib/room-name-catalog.ts +8 -1
  330. package/src/lib/room-name-messages.test.ts +132 -0
  331. package/src/lib/room-name-messages.ts +147 -0
  332. package/src/lib/room-name-storage.test.ts +334 -0
  333. package/src/lib/room-push-dimensions.test.ts +38 -1
  334. package/src/lib/room-push-dimensions.ts +226 -2
  335. package/src/lib/room-selection.test.ts +23 -6
  336. package/src/lib/scene-bounds.test.ts +144 -0
  337. package/src/lib/scene-bounds.ts +79 -15
  338. package/src/lib/scene-clipboard.test.ts +70 -49
  339. package/src/lib/scene-clipboard.ts +11 -5
  340. package/src/lib/selection-cycle.ts +63 -0
  341. package/src/lib/separator-edit.test.ts +431 -0
  342. package/src/lib/separator-edit.ts +179 -0
  343. package/src/lib/separator-lines.ts +38 -0
  344. package/src/lib/sfx-bus.ts +27 -1
  345. package/src/lib/sfx-player.ts +16 -0
  346. package/src/lib/snapping-mode.test.ts +23 -1
  347. package/src/lib/tool-cursor-icon.ts +5 -1
  348. package/src/lib/tool-shortcuts.ts +130 -0
  349. package/src/lib/viewer-selection.test.ts +28 -24
  350. package/src/lib/viewer-selection.ts +21 -6
  351. package/src/lib/wall-draw-variant.test.ts +7 -6
  352. package/src/lib/wall-draw-variant.ts +107 -6
  353. package/src/lib/wall-nudge.test.ts +44 -0
  354. package/src/lib/wall-nudge.ts +131 -0
  355. package/src/lib/wall-parallel-snap.test.ts +83 -0
  356. package/src/lib/wall-parallel-snap.ts +176 -0
  357. package/src/lib/wall-placement-pointer.test.ts +23 -0
  358. package/src/lib/wall-placement-pointer.ts +18 -0
  359. package/src/lib/wall-pull.test.ts +245 -0
  360. package/src/lib/wall-pull.ts +209 -0
  361. package/src/lib/wall-push-line-up.test.ts +132 -0
  362. package/src/lib/wall-region-list.ts +17 -6
  363. package/src/lib/wall-run-snap.test.ts +188 -0
  364. package/src/lib/wall-run-snap.ts +83 -0
  365. package/src/lib/wall-type.test.ts +125 -0
  366. package/src/lib/wall-type.ts +119 -0
  367. package/src/lib/window-interaction.test.ts +44 -0
  368. package/src/lib/world-grid-snap.test.ts +139 -2
  369. package/src/lib/world-grid-snap.ts +102 -105
  370. package/src/store/use-alignment-guides.ts +27 -1
  371. package/src/store/use-editor.tsx +53 -0
  372. package/src/store/use-floorplan-annotation-visibility.test.ts +64 -0
  373. package/src/store/use-floorplan-annotation-visibility.ts +47 -22
  374. package/src/store/use-placement-typing.test.ts +165 -0
  375. package/src/store/use-placement-typing.ts +186 -0
  376. package/src/store/use-wall-snap-indicator.ts +15 -2
  377. package/src/components/tools/elevator/elevator-defaults.ts +0 -8
  378. package/src/components/tools/elevator/elevator-tool.tsx +0 -372
  379. package/src/components/tools/elevator/move-elevator-tool.tsx +0 -262
  380. package/src/lib/door-interaction.ts +0 -102
  381. package/src/lib/measurement-label.ts +0 -68
  382. package/src/lib/window-interaction.ts +0 -100
@@ -0,0 +1,563 @@
1
+ // Pure snap geometry for a wall pulled from a selected wall's end (the "pull to
2
+ // continue" grip) and for the run it continues — no store / viewer / React
3
+ // deps, so it's unit-testable in isolation. Coordinates are XZ plan points in
4
+ // the building's own frame, the frame both views draft in: the building's axes
5
+ // are the plan's X and Z, so a turned house keeps its square.
6
+
7
+ import { isCurvedWall, type WallNode } from '@pascal-app/core'
8
+ import {
9
+ distanceSquared,
10
+ findWallBodySnapTarget,
11
+ findWallSpecialPointSnap,
12
+ WALL_CONNECT_SNAP_RADIUS,
13
+ type WallPlanPoint,
14
+ wallIdsAtSnapPoint,
15
+ } from './wall-snap-geometry'
16
+
17
+ /**
18
+ * What a pulled wall's end caught, as the snap marker names it: an existing
19
+ * wall's end or middle, a crossing (two walls, or two guides), a point on a
20
+ * wall (`perpendicular` when the new wall meets it square), a guide it lines up
21
+ * with, or a plain grid length.
22
+ */
23
+ export type WallPullSnapKind =
24
+ | 'endpoint'
25
+ | 'midpoint'
26
+ | 'intersection'
27
+ | 'wall'
28
+ | 'perpendicular'
29
+ | 'alignment'
30
+ | 'grid'
31
+
32
+ /** The words the snap marker shows for each kind. */
33
+ export const WALL_PULL_SNAP_LABELS: Record<WallPullSnapKind, string> = {
34
+ endpoint: 'Endpoint',
35
+ midpoint: 'Midpoint',
36
+ intersection: 'Intersection',
37
+ wall: 'On wall',
38
+ perpendicular: 'Perpendicular',
39
+ alignment: 'Aligned',
40
+ grid: 'Grid',
41
+ }
42
+
43
+ /**
44
+ * How the wall's direction was held: straight on from the wall it comes from,
45
+ * square to it, along one of the house's axes, on a 15° step, or not at all.
46
+ */
47
+ export type WallPullDirectionLock = 'straight' | 'square' | 'axis' | 'angle' | 'free'
48
+
49
+ /**
50
+ * A dashed guide: the extension of the wall the new one comes from, a line
51
+ * through another wall's end along one of the house's axes, or the same through
52
+ * the run's start (so the run can close square back to it).
53
+ */
54
+ export type WallPullGuideKind = 'extension' | 'alignment' | 'run-start'
55
+
56
+ export type WallPullGuide = {
57
+ kind: WallPullGuideKind
58
+ from: WallPlanPoint
59
+ to: WallPlanPoint
60
+ }
61
+
62
+ export type WallPullSnapResult = {
63
+ point: WallPlanPoint
64
+ snap: WallPullSnapKind | null
65
+ /** `WALL_PULL_SNAP_LABELS[snap]`, or "In line with start" for the run's start guide. */
66
+ label: string | null
67
+ lock: WallPullDirectionLock
68
+ /** Walls whose geometry the end landed on, for the marker's wall highlight. */
69
+ targetWallIds: string[]
70
+ guides: WallPullGuide[]
71
+ /** Where two guides cross near the cursor: markers, and snap targets. */
72
+ intersections: WallPlanPoint[]
73
+ }
74
+
75
+ /** Snap radii (m) measured from the cursor, or along the held direction for `wall` / `alignment`. */
76
+ export type WallPullSnapRadii = {
77
+ endpoint: number
78
+ /** A wall's middle, or where two walls cross. */
79
+ special: number
80
+ /** Where two guides cross. */
81
+ intersection: number
82
+ /** The held direction landing on a wall's line, or the square foot on it. */
83
+ wall: number
84
+ /** The held direction landing on a guide. */
85
+ alignment: number
86
+ /** How near a guide line passes the cursor to be considered at all. */
87
+ guide: number
88
+ }
89
+
90
+ export const WALL_PULL_SNAP_RADII: WallPullSnapRadii = {
91
+ endpoint: 0.3,
92
+ special: 0.2,
93
+ intersection: 0.2,
94
+ wall: 0.25,
95
+ alignment: 0.15,
96
+ guide: 0.6,
97
+ }
98
+
99
+ /** The direction holds straight on / square / on a house axis within this angle. */
100
+ export const WALL_PULL_LOCK_TOLERANCE = (7 * Math.PI) / 180
101
+ /** Past the lock the direction snaps to steps of this angle, on the house's axes. */
102
+ export const WALL_PULL_ANGLE_STEP = Math.PI / 12
103
+
104
+ const PERPENDICULAR_SINE = Math.sin((1 * Math.PI) / 180)
105
+ const ON_LINE = 1e-6
106
+ const INTERSECTION_SHOW_RADIUS = 0.75
107
+ const EXTENSION_MIN_LENGTH = 1.5
108
+ const EXTENSION_OVERSHOOT = 1
109
+ const GUIDE_OVERSHOOT = 0.5
110
+
111
+ export type WallPullSnapInput = {
112
+ /** The raw cursor. */
113
+ point: WallPlanPoint
114
+ /** Where the wall being drawn starts. */
115
+ start: WallPlanPoint
116
+ /** Where the run started: the pulled wall end. */
117
+ runStart: WallPlanPoint
118
+ /**
119
+ * Unit direction of the wall the new one comes from, pointing out of its end
120
+ * (the pulled wall's, then each placed wall's). Null draws on the house's
121
+ * axes and 15° steps only.
122
+ */
123
+ reference: WallPlanPoint | null
124
+ /** The level's walls: snap targets and guide anchors. */
125
+ walls: readonly WallNode[]
126
+ /** Length step along the held direction; 0 leaves lengths free. */
127
+ gridStep: number
128
+ /** Alt, or snapping off: the end follows the cursor. */
129
+ free?: boolean
130
+ radii?: Partial<WallPullSnapRadii>
131
+ }
132
+
133
+ type GuideLine = {
134
+ kind: WallPullGuideKind
135
+ /** A point on the line: the anchor it is drawn from. */
136
+ anchor: WallPlanPoint
137
+ /** Unit direction. */
138
+ direction: WallPlanPoint
139
+ }
140
+
141
+ function unit(vector: WallPlanPoint): WallPlanPoint | null {
142
+ const length = Math.hypot(vector[0], vector[1])
143
+ return length > 1e-9 ? [vector[0] / length, vector[1] / length] : null
144
+ }
145
+
146
+ function angleBetween(a: WallPlanPoint, b: WallPlanPoint): number {
147
+ return Math.abs(Math.atan2(a[0] * b[1] - a[1] * b[0], a[0] * b[0] + a[1] * b[1]))
148
+ }
149
+
150
+ function samePoint(a: WallPlanPoint, b: WallPlanPoint, tolerance = 1e-6): boolean {
151
+ return distanceSquared(a, b) <= tolerance * tolerance
152
+ }
153
+
154
+ /**
155
+ * The direction a pull from `start` toward `vector` holds: straight on from
156
+ * `reference` or square to it, or along a house axis, whichever is nearest
157
+ * within `WALL_PULL_LOCK_TOLERANCE`; otherwise the nearest 15° step on the
158
+ * house's axes. Folding back over the wall it comes from is not offered.
159
+ */
160
+ export function lockWallPullDirection(
161
+ vector: WallPlanPoint,
162
+ reference: WallPlanPoint | null,
163
+ ): { direction: WallPlanPoint; lock: WallPullDirectionLock } {
164
+ const along = unit(vector)
165
+ if (!along) return { direction: reference ?? [1, 0], lock: 'free' }
166
+ const candidates: Array<{ direction: WallPlanPoint; lock: WallPullDirectionLock }> = []
167
+ const ref = reference ? unit(reference) : null
168
+ if (ref) {
169
+ candidates.push(
170
+ { direction: ref, lock: 'straight' },
171
+ { direction: [-ref[1], ref[0]], lock: 'square' },
172
+ { direction: [ref[1], -ref[0]], lock: 'square' },
173
+ )
174
+ }
175
+ for (const axis of [
176
+ [1, 0],
177
+ [-1, 0],
178
+ [0, 1],
179
+ [0, -1],
180
+ ] as WallPlanPoint[]) {
181
+ candidates.push({ direction: axis, lock: 'axis' })
182
+ }
183
+ let best: { direction: WallPlanPoint; lock: WallPullDirectionLock } | null = null
184
+ let bestAngle = Number.POSITIVE_INFINITY
185
+ for (const candidate of candidates) {
186
+ const angle = angleBetween(along, candidate.direction)
187
+ if (angle < bestAngle - 1e-9) {
188
+ best = candidate
189
+ bestAngle = angle
190
+ }
191
+ }
192
+ if (best && bestAngle <= WALL_PULL_LOCK_TOLERANCE) return best
193
+ const stepped =
194
+ Math.round(Math.atan2(along[1], along[0]) / WALL_PULL_ANGLE_STEP) * WALL_PULL_ANGLE_STEP
195
+ return { direction: [Math.cos(stepped), Math.sin(stepped)], lock: 'angle' }
196
+ }
197
+
198
+ /**
199
+ * The points guides run through: every wall end on the level and the run's
200
+ * start, once each, leaving out the start of the wall being drawn (a guide
201
+ * through it is the direction lock itself).
202
+ */
203
+ function guideAnchors(
204
+ walls: readonly WallNode[],
205
+ runStart: WallPlanPoint,
206
+ start: WallPlanPoint,
207
+ ): Array<{ point: WallPlanPoint; runStart: boolean }> {
208
+ const anchors: Array<{ point: WallPlanPoint; runStart: boolean }> = []
209
+ const add = (point: WallPlanPoint, isRunStart: boolean) => {
210
+ if (samePoint(point, start, 1e-4)) return
211
+ const existing = anchors.find((anchor) => samePoint(anchor.point, point, 1e-4))
212
+ if (existing) {
213
+ existing.runStart ||= isRunStart
214
+ return
215
+ }
216
+ anchors.push({ point: [point[0], point[1]], runStart: isRunStart })
217
+ }
218
+ add(runStart, true)
219
+ for (const wall of walls) {
220
+ add(wall.start, false)
221
+ add(wall.end, false)
222
+ }
223
+ return anchors
224
+ }
225
+
226
+ /**
227
+ * The guide lines near the cursor: a vertical (constant X) line through each
228
+ * anchor whose X is within `radius` of the cursor's, and a horizontal one
229
+ * likewise. Several anchors on one line give one line, drawn from the anchor
230
+ * nearest the cursor; the run's start keeps its own lines.
231
+ */
232
+ function nearbyGuideLines(
233
+ anchors: Array<{ point: WallPlanPoint; runStart: boolean }>,
234
+ cursor: WallPlanPoint,
235
+ radius: number,
236
+ ): GuideLine[] {
237
+ const lines: GuideLine[] = []
238
+ for (const axis of [0, 1] as const) {
239
+ // axis 0: vertical lines (constant X); axis 1: horizontal lines (constant Z).
240
+ const across = axis === 0 ? 1 : 0
241
+ const byCoord = new Map<string, { point: WallPlanPoint; runStart: boolean }>()
242
+ for (const anchor of anchors) {
243
+ if (Math.abs(anchor.point[axis] - cursor[axis]) > radius) continue
244
+ const key = `${anchor.runStart ? 's' : 'a'}:${anchor.point[axis].toFixed(4)}`
245
+ const held = byCoord.get(key)
246
+ if (
247
+ !held ||
248
+ Math.abs(anchor.point[across] - cursor[across]) <
249
+ Math.abs(held.point[across] - cursor[across])
250
+ ) {
251
+ byCoord.set(key, anchor)
252
+ }
253
+ }
254
+ for (const anchor of byCoord.values()) {
255
+ lines.push({
256
+ kind: anchor.runStart ? 'run-start' : 'alignment',
257
+ anchor: anchor.point,
258
+ direction: axis === 0 ? [0, 1] : [1, 0],
259
+ })
260
+ }
261
+ }
262
+ return lines
263
+ }
264
+
265
+ /** Where two lines cross, or null when they run parallel. */
266
+ function crossLines(a: GuideLine, b: GuideLine): WallPlanPoint | null {
267
+ const denom = a.direction[0] * b.direction[1] - a.direction[1] * b.direction[0]
268
+ if (Math.abs(denom) < 1e-9) return null
269
+ const dx = b.anchor[0] - a.anchor[0]
270
+ const dz = b.anchor[1] - a.anchor[1]
271
+ const t = (dx * b.direction[1] - dz * b.direction[0]) / denom
272
+ return [a.anchor[0] + a.direction[0] * t, a.anchor[1] + a.direction[1] * t]
273
+ }
274
+
275
+ /** Station along the ray `start + direction · t` where it crosses `line`, or null. */
276
+ function rayStationOnLine(
277
+ start: WallPlanPoint,
278
+ direction: WallPlanPoint,
279
+ line: GuideLine,
280
+ ): number | null {
281
+ const denom = direction[0] * line.direction[1] - direction[1] * line.direction[0]
282
+ if (Math.abs(denom) < 1e-9) return null
283
+ const dx = line.anchor[0] - start[0]
284
+ const dz = line.anchor[1] - start[1]
285
+ const t = (dx * line.direction[1] - dz * line.direction[0]) / denom
286
+ return t > 1e-6 ? t : null
287
+ }
288
+
289
+ function passesThrough(line: GuideLine, point: WallPlanPoint): boolean {
290
+ const dx = point[0] - line.anchor[0]
291
+ const dz = point[1] - line.anchor[1]
292
+ return Math.abs(dx * line.direction[1] - dz * line.direction[0]) <= 1e-4
293
+ }
294
+
295
+ /** A guide drawn from its anchor through `to` and a little past it. */
296
+ function guideThrough(line: GuideLine, to: WallPlanPoint): WallPullGuide {
297
+ const dx = to[0] - line.anchor[0]
298
+ const dz = to[1] - line.anchor[1]
299
+ const along = dx * line.direction[0] + dz * line.direction[1]
300
+ const sign = along >= 0 ? 1 : -1
301
+ return {
302
+ kind: line.kind,
303
+ from: [line.anchor[0], line.anchor[1]],
304
+ to: [
305
+ to[0] + line.direction[0] * sign * GUIDE_OVERSHOOT,
306
+ to[1] + line.direction[1] * sign * GUIDE_OVERSHOOT,
307
+ ],
308
+ }
309
+ }
310
+
311
+ function extensionGuide(
312
+ start: WallPlanPoint,
313
+ reference: WallPlanPoint,
314
+ cursor: WallPlanPoint,
315
+ ): WallPullGuide {
316
+ const along = (cursor[0] - start[0]) * reference[0] + (cursor[1] - start[1]) * reference[1]
317
+ const length = Math.max(EXTENSION_MIN_LENGTH, along + EXTENSION_OVERSHOOT)
318
+ return {
319
+ kind: 'extension',
320
+ from: [start[0], start[1]],
321
+ to: [start[0] + reference[0] * length, start[1] + reference[1] * length],
322
+ }
323
+ }
324
+
325
+ type WallLine = { wall: WallNode; ux: number; uz: number; length: number }
326
+
327
+ function straightWallLines(walls: readonly WallNode[]): WallLine[] {
328
+ return walls.flatMap((wall) => {
329
+ if (isCurvedWall(wall)) return []
330
+ const dx = wall.end[0] - wall.start[0]
331
+ const dz = wall.end[1] - wall.start[1]
332
+ const length = Math.hypot(dx, dz)
333
+ return length > 1e-9 ? [{ wall, ux: dx / length, uz: dz / length, length }] : []
334
+ })
335
+ }
336
+
337
+ /** True when `segment` meets `wall` square (within 1°). */
338
+ function meetsSquare(segment: WallPlanPoint, line: WallLine): boolean {
339
+ const along = unit(segment)
340
+ if (!along) return false
341
+ return Math.abs(along[0] * line.ux + along[1] * line.uz) <= PERPENDICULAR_SINE
342
+ }
343
+
344
+ /**
345
+ * Snap the end of a pulled wall (or of a wall continuing the run).
346
+ *
347
+ * The direction holds straight on from the wall it comes from, square to it, or
348
+ * along a house axis within about 7°, and on 15° steps past that; the length
349
+ * steps by `gridStep` along it. Existing walls win over the held direction so a
350
+ * loop closes exactly: a wall end (the run's start among them) first, then a
351
+ * wall's middle or a crossing of two walls, then a crossing of two guides; then
352
+ * a wall the held direction runs into (or the square foot on a wall near the
353
+ * cursor); then a guide the held direction runs into, so the run lines up with
354
+ * its start or another wall's end; then the grid length. `free` (Alt, or
355
+ * snapping off) leaves the end at the cursor.
356
+ */
357
+ export function snapWallPullPoint(input: WallPullSnapInput): WallPullSnapResult {
358
+ const { point: cursor, start, runStart, walls, gridStep } = input
359
+ const radii = { ...WALL_PULL_SNAP_RADII, ...input.radii }
360
+ const reference = input.reference ? unit(input.reference) : null
361
+ const extension = reference ? extensionGuide(start, reference, cursor) : null
362
+ const extensionLine: GuideLine | null = reference
363
+ ? { kind: 'extension', anchor: [start[0], start[1]], direction: reference }
364
+ : null
365
+ const baseGuides = extension ? [extension] : []
366
+ const result = (
367
+ point: WallPlanPoint,
368
+ snap: WallPullSnapKind | null,
369
+ lock: WallPullDirectionLock,
370
+ extra: Partial<Pick<WallPullSnapResult, 'label' | 'targetWallIds' | 'guides'>> = {},
371
+ intersections: WallPlanPoint[] = [],
372
+ ): WallPullSnapResult => ({
373
+ point: [point[0], point[1]],
374
+ snap,
375
+ label: extra.label ?? (snap ? WALL_PULL_SNAP_LABELS[snap] : null),
376
+ lock,
377
+ targetWallIds: extra.targetWallIds ?? [],
378
+ guides: [...baseGuides, ...(extra.guides ?? [])],
379
+ intersections,
380
+ })
381
+
382
+ const vector: WallPlanPoint = [cursor[0] - start[0], cursor[1] - start[1]]
383
+ if (input.free) return result(cursor, null, 'free')
384
+ if (Math.hypot(vector[0], vector[1]) < 1e-6) return result(start, null, 'free')
385
+
386
+ // The held direction, and the length along it.
387
+ const { direction, lock } = lockWallPullDirection(vector, reference)
388
+ const along = vector[0] * direction[0] + vector[1] * direction[1]
389
+ const length = gridStep > 0 ? Math.round(along / gridStep) * gridStep : along
390
+ const held: WallPlanPoint = [start[0] + direction[0] * length, start[1] + direction[1] * length]
391
+
392
+ const anchors = guideAnchors(walls, runStart, start)
393
+ const lines = nearbyGuideLines(anchors, cursor, radii.guide)
394
+ const allLines = extensionLine ? [extensionLine, ...lines] : lines
395
+ // Guide crossings near the cursor, each with the two lines through it.
396
+ const crossings: Array<{ point: WallPlanPoint; a: GuideLine; b: GuideLine }> = []
397
+ for (let i = 0; i < allLines.length; i += 1) {
398
+ for (let j = i + 1; j < allLines.length; j += 1) {
399
+ const a = allLines[i]!
400
+ const b = allLines[j]!
401
+ const point = crossLines(a, b)
402
+ if (!point || samePoint(point, start, 1e-4)) continue
403
+ if (distanceSquared(point, cursor) > INTERSECTION_SHOW_RADIUS ** 2) continue
404
+ if (crossings.some((crossing) => samePoint(crossing.point, point, 1e-4))) continue
405
+ crossings.push({ point, a, b })
406
+ }
407
+ }
408
+ const intersections = crossings.map((crossing) => crossing.point)
409
+ // The guides the final point sits on, then those through each crossing
410
+ // shown, drawn from their anchors — each line once.
411
+ const guidesThrough = (point: WallPlanPoint) => {
412
+ const drawn = new Set<GuideLine>()
413
+ const guides: WallPullGuide[] = []
414
+ for (const line of lines) {
415
+ if (!passesThrough(line, point) || samePoint(line.anchor, point, 1e-4)) continue
416
+ drawn.add(line)
417
+ guides.push(guideThrough(line, point))
418
+ }
419
+ for (const crossing of crossings) {
420
+ for (const line of [crossing.a, crossing.b]) {
421
+ if (line.kind === 'extension' || drawn.has(line)) continue
422
+ drawn.add(line)
423
+ guides.push(guideThrough(line, crossing.point))
424
+ }
425
+ }
426
+ return guides
427
+ }
428
+ const settle = (
429
+ point: WallPlanPoint,
430
+ snap: WallPullSnapKind,
431
+ pointLock: WallPullDirectionLock,
432
+ extra: Partial<Pick<WallPullSnapResult, 'label' | 'targetWallIds' | 'guides'>> = {},
433
+ ) =>
434
+ result(
435
+ point,
436
+ snap,
437
+ pointLock,
438
+ { ...extra, guides: extra.guides ?? guidesThrough(point) },
439
+ intersections,
440
+ )
441
+
442
+ // 1. A wall end, the run's start among them.
443
+ let endpoint: WallPlanPoint | null = null
444
+ let endpointDistance = radii.endpoint ** 2
445
+ for (const anchor of anchors) {
446
+ const distance = distanceSquared(anchor.point, cursor)
447
+ if (distance <= endpointDistance) {
448
+ endpoint = anchor.point
449
+ endpointDistance = distance
450
+ }
451
+ }
452
+ if (endpoint) {
453
+ return settle(endpoint, 'endpoint', 'free', {
454
+ targetWallIds: wallIdsAtSnapPoint(endpoint, [...walls]),
455
+ })
456
+ }
457
+
458
+ // 2. A wall's middle, or where two walls cross.
459
+ const special = findWallSpecialPointSnap(cursor, [...walls], undefined, {
460
+ endpoint: 0,
461
+ midpoint: radii.special,
462
+ intersection: radii.special,
463
+ })
464
+ if (special && special.snap !== 'endpoint' && !samePoint(special.point, start, 1e-4)) {
465
+ return settle(special.point, special.snap ?? 'midpoint', 'free', {
466
+ targetWallIds: special.targetWallIds,
467
+ })
468
+ }
469
+
470
+ // 3. Where two guides cross.
471
+ let crossing: (typeof crossings)[number] | null = null
472
+ let crossingDistance = radii.intersection ** 2
473
+ for (const candidate of crossings) {
474
+ const distance = distanceSquared(candidate.point, cursor)
475
+ if (distance <= crossingDistance) {
476
+ crossing = candidate
477
+ crossingDistance = distance
478
+ }
479
+ }
480
+ if (crossing) return settle(crossing.point, 'intersection', 'free')
481
+
482
+ // 4. A wall the held direction runs into: its body, its line near the held
483
+ // length, or the square foot on a wall line near the cursor.
484
+ const wallLines = straightWallLines(walls)
485
+ const onWall = (point: WallPlanPoint, pointLock: WallPullDirectionLock) => {
486
+ const targetWallIds = wallIdsAtSnapPoint(point, [...walls])
487
+ const atEnd = walls.some(
488
+ (wall) => samePoint(point, wall.start, 1e-6) || samePoint(point, wall.end, 1e-6),
489
+ )
490
+ const segment: WallPlanPoint = [point[0] - start[0], point[1] - start[1]]
491
+ const square = wallLines.some(
492
+ (line) => targetWallIds.includes(line.wall.id) && meetsSquare(segment, line),
493
+ )
494
+ return settle(point, atEnd ? 'endpoint' : square ? 'perpendicular' : 'wall', pointLock, {
495
+ targetWallIds,
496
+ })
497
+ }
498
+ if (length > ON_LINE) {
499
+ const body = findWallBodySnapTarget(cursor, [...walls], {
500
+ placed: held,
501
+ start,
502
+ margin: WALL_CONNECT_SNAP_RADIUS,
503
+ })
504
+ if (body && !samePoint(body, start, 1e-4)) return onWall(body, lock)
505
+ }
506
+ let rayHit: WallPlanPoint | null = null
507
+ let rayHitGap = radii.wall
508
+ for (const line of wallLines) {
509
+ const denom = direction[0] * line.uz - direction[1] * line.ux
510
+ if (Math.abs(denom) < 1e-9) continue
511
+ const dx = line.wall.start[0] - start[0]
512
+ const dz = line.wall.start[1] - start[1]
513
+ const t = (dx * line.uz - dz * line.ux) / denom
514
+ const s = (dx * direction[1] - dz * direction[0]) / denom
515
+ if (!(t > 1e-4) || s <= 1e-6 || s >= line.length - 1e-6) continue
516
+ const gap = Math.abs(t - along)
517
+ if (gap <= rayHitGap) {
518
+ rayHit = [start[0] + direction[0] * t, start[1] + direction[1] * t]
519
+ rayHitGap = gap
520
+ }
521
+ }
522
+ if (rayHit) return onWall(rayHit, lock)
523
+ let foot: WallPlanPoint | null = null
524
+ let footDistance = radii.wall ** 2
525
+ for (const line of wallLines) {
526
+ const station =
527
+ (start[0] - line.wall.start[0]) * line.ux + (start[1] - line.wall.start[1]) * line.uz
528
+ if (station <= 1e-6 || station >= line.length - 1e-6) continue
529
+ const point: WallPlanPoint = [
530
+ line.wall.start[0] + line.ux * station,
531
+ line.wall.start[1] + line.uz * station,
532
+ ]
533
+ if (samePoint(point, start, 1e-4)) continue
534
+ const distance = distanceSquared(point, cursor)
535
+ if (distance <= footDistance) {
536
+ foot = point
537
+ footDistance = distance
538
+ }
539
+ }
540
+ if (foot) return onWall(foot, 'free')
541
+
542
+ // 5. A guide the held direction runs into.
543
+ let aligned: { point: WallPlanPoint; line: GuideLine } | null = null
544
+ let alignedGap = radii.alignment
545
+ for (const line of lines) {
546
+ const t = rayStationOnLine(start, direction, line)
547
+ if (t === null) continue
548
+ const gap = Math.abs(t - along)
549
+ if (gap <= alignedGap) {
550
+ aligned = { point: [start[0] + direction[0] * t, start[1] + direction[1] * t], line }
551
+ alignedGap = gap
552
+ }
553
+ }
554
+ if (aligned) {
555
+ return settle(aligned.point, 'alignment', lock, {
556
+ label: aligned.line.kind === 'run-start' ? 'In line with start' : undefined,
557
+ })
558
+ }
559
+
560
+ // 6. The grid length along the held direction.
561
+ if (gridStep > 0) return settle(held, 'grid', lock)
562
+ return result(held, null, lock, { guides: guidesThrough(held) }, intersections)
563
+ }
@@ -2,12 +2,18 @@ import { describe, expect, test } from 'bun:test'
2
2
  import type { WallNode } from '@pascal-app/core'
3
3
  import {
4
4
  chainEndJoinsExistingWall,
5
+ findWallBodySnapTarget,
5
6
  findWallSnapTarget,
6
7
  findWallSpecialPointSnap,
7
8
  type WallPlanPoint,
8
9
  } from './wall-snap-geometry'
9
10
 
10
- function makeWall(start: WallPlanPoint, end: WallPlanPoint, id?: string): WallNode {
11
+ function makeWall(
12
+ start: WallPlanPoint,
13
+ end: WallPlanPoint,
14
+ id?: string,
15
+ overrides: Partial<WallNode> = {},
16
+ ): WallNode {
11
17
  return {
12
18
  object: 'node',
13
19
  id: (id ?? `wall_${start.join('_')}_${end.join('_')}`) as WallNode['id'],
@@ -22,6 +28,7 @@ function makeWall(start: WallPlanPoint, end: WallPlanPoint, id?: string): WallNo
22
28
  thickness: 0.1,
23
29
  frontSide: 'unknown',
24
30
  backSide: 'unknown',
31
+ ...overrides,
25
32
  } as unknown as WallNode
26
33
  }
27
34
 
@@ -129,3 +136,108 @@ describe('findWallSnapTarget (edge / along-wall)', () => {
129
136
  expect(findWallSnapTarget([1.2, 0.1], walls, { radius: 0.08 })).toBeNull()
130
137
  })
131
138
  })
139
+
140
+ describe('findWallBodySnapTarget (a wall end aimed into a wall)', () => {
141
+ // An 8" wall centred on z = 0: its faces sit at z = ±0.1016.
142
+ const host = makeWall([0, 0], [4, 0], 'host', { thickness: 0.2032 })
143
+
144
+ test('an end inside the body or within the margin of a face lands on the reference line', () => {
145
+ expect(findWallBodySnapTarget([1.2, 0.05], [host])).toEqual([1.2, 0])
146
+ expect(findWallBodySnapTarget([1.2, 0.14], [host])).toEqual([1.2, 0])
147
+ expect(findWallBodySnapTarget([1.2, -0.14], [host])).toEqual([1.2, 0])
148
+ expect(findWallBodySnapTarget([1.2, 0.16], [host])).toBeNull()
149
+ // Past the wall's ends the corner snap decides, not the body.
150
+ expect(findWallBodySnapTarget([4.05, 0.05], [host])).toBeNull()
151
+ })
152
+
153
+ test('the grid placement keeps its station along the wall, and captures on its own', () => {
154
+ // The cursor sits by the face; its lattice point is out of the band.
155
+ expect(findWallBodySnapTarget([1.23, 0.12], [host], { placed: [1.25, 0.25] })).toEqual([
156
+ 1.25, 0,
157
+ ])
158
+ // The cursor is out of the band; the lattice point fell into it.
159
+ expect(findWallBodySnapTarget([1.23, 0.3], [host], { placed: [1.25, 0.1] })).toEqual([1.25, 0])
160
+ })
161
+
162
+ test('with a start, the end slides along the drafted segment onto the line', () => {
163
+ const start: WallPlanPoint = [1, 3]
164
+ const placed: WallPlanPoint = [1.3, 0.12]
165
+ const target = findWallBodySnapTarget(placed, [host], { start, placed })
166
+ expect(target?.[1]).toBeCloseTo(0, 12)
167
+ // Still on the drafted line start → placed: the segment keeps its direction.
168
+ const cross =
169
+ (placed[0] - start[0]) * (target![1] - start[1]) -
170
+ (placed[1] - start[1]) * (target![0] - start[0])
171
+ expect(cross).toBeCloseTo(0, 12)
172
+ })
173
+
174
+ test('a square segment stays square', () => {
175
+ const target = findWallBodySnapTarget([2, 0.13], [host], { start: [2, 3] })
176
+ expect(target?.[0]).toBe(2)
177
+ expect(target?.[1]).toBeCloseTo(0, 12)
178
+ })
179
+
180
+ test('walls the segment runs alongside, or starts on, are left alone', () => {
181
+ expect(findWallBodySnapTarget([2, 0.06], [host], { start: [0.5, 0.05] })).toBeNull()
182
+ expect(findWallBodySnapTarget([1.1, 0.12], [host], { start: [1, 0] })).toBeNull()
183
+ })
184
+
185
+ test('a justified wall captures across its whole body, measured from its faces', () => {
186
+ // Justified to face a: the body runs from the line (z = 0) to z = 0.2032.
187
+ const justified = makeWall([0, 0], [4, 0], 'justified', {
188
+ thickness: 0.2032,
189
+ justification: 'a',
190
+ })
191
+ expect(findWallBodySnapTarget([1.2, 0.24], [justified])).toEqual([1.2, 0])
192
+ expect(findWallBodySnapTarget([1.2, -0.04], [justified])).toEqual([1.2, 0])
193
+ expect(findWallBodySnapTarget([1.2, -0.08], [justified])).toBeNull()
194
+ })
195
+
196
+ test('an end already on the line stays put', () => {
197
+ const onLine: WallPlanPoint = [1.234, 0]
198
+ expect(findWallBodySnapTarget(onLine, [host], { start: [1.5, 3], placed: [1.5, 0.5] })).toEqual(
199
+ onLine,
200
+ )
201
+ })
202
+
203
+ test('picks the wall whose body the end is closest to', () => {
204
+ // 0.05 off `near`'s face, 0.018 off the host's.
205
+ const near = makeWall([0, 0.22], [4, 0.22], 'near', { thickness: 0.1 })
206
+ expect(findWallBodySnapTarget([1, 0.12], [near, host])).toEqual([1, 0])
207
+ })
208
+
209
+ test('an end in the corner block of an L joins the corner, not an 8 cm stub', () => {
210
+ // Two 0.2 m walls meeting at the origin: the corner block is the 0.2 m
211
+ // square around it.
212
+ const l = [
213
+ makeWall([0, 0], [4, 0], 'south', { thickness: 0.2 }),
214
+ makeWall([0, 4], [0, 0], 'west', { thickness: 0.2 }),
215
+ ]
216
+ expect(findWallBodySnapTarget([0.08, 0.08], l)).toEqual([0, 0])
217
+ // The block's outer quadrant lies in one wall's body only, and joins too.
218
+ expect(findWallBodySnapTarget([0.05, -0.05], l)).toEqual([0, 0])
219
+ // Aimed into the block from across the room.
220
+ expect(findWallBodySnapTarget([0.08, 0.08], l, { start: [2, 3] })).toEqual([0, 0])
221
+ // Past the other wall's face, the end T-joins as before.
222
+ expect(findWallBodySnapTarget([0.3, 0.05], l)).toEqual([0.3, 0])
223
+ })
224
+
225
+ test('an end in the corner block of a T joins the junction', () => {
226
+ // The host split at the junction, as the topology leaves a T-join.
227
+ const t = [
228
+ makeWall([0, 0], [2, 0], 'left', { thickness: 0.2 }),
229
+ makeWall([2, 0], [4, 0], 'right', { thickness: 0.2 }),
230
+ makeWall([2, 0], [2, 4], 'stem', { thickness: 0.2 }),
231
+ ]
232
+ expect(findWallBodySnapTarget([2.08, 0.08], t)).toEqual([2, 0])
233
+ expect(findWallBodySnapTarget([1.92, 0.06], t)).toEqual([2, 0])
234
+ // A host left whole, the stem ending on its interior, from either wall's side.
235
+ const whole = makeWall([0, 0], [4, 0], 'whole', { thickness: 0.2 })
236
+ const stem = makeWall([2, 0], [2, 4], 'stem', { thickness: 0.2 })
237
+ expect(findWallBodySnapTarget([2.08, 0.08], [whole, stem])).toEqual([2, 0])
238
+ expect(findWallBodySnapTarget([2.08, 0.08], [stem, whole])).toEqual([2, 0])
239
+ // Clear of the corner block each wall T-joins as before.
240
+ expect(findWallBodySnapTarget([2.4, 0.08], [whole, stem])).toEqual([2.4, 0])
241
+ expect(findWallBodySnapTarget([2.06, 0.3], [whole, stem])).toEqual([2, 0.3])
242
+ })
243
+ })