@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
@@ -10,7 +10,6 @@ import {
10
10
  useLiveTerrain,
11
11
  } from '@pascal-app/core'
12
12
  import { useViewer } from '@pascal-app/viewer'
13
- import { Html } from '@react-three/drei'
14
13
  import { useFrame, useThree } from '@react-three/fiber'
15
14
  import { type MutableRefObject, useEffect, useMemo, useRef } from 'react'
16
15
  import {
@@ -25,6 +24,7 @@ import { EDITOR_LAYER } from '../../../lib/constants'
25
24
  import { sculptFieldForSite, terrainPointInsideSite } from '../../../lib/terrain-sculpt'
26
25
  import { brushRingColor } from '../../../lib/terrain-verb-color'
27
26
  import useEditor from '../../../store/use-editor'
27
+ import { Html } from '../../editor/localized-html'
28
28
  import { formatMeasurement } from '../../editor/measurement-pill'
29
29
 
30
30
  /** Segments in the cursor ring. 64 reads as a circle at any usable brush size. */
@@ -41,7 +41,6 @@ import { FloorRegionControls3D } from '../editor/floor-region-controls'
41
41
  import { OpenWallEnds3DLayer } from '../editor/open-wall-ends-3d-layer'
42
42
  import { OpeningGuides3DLayer } from '../editor/opening-guides-3d-layer'
43
43
  import { WallSnapBeaconLayer } from '../editor/wall-snap-beacon-layer'
44
- import { ElevatorTool } from './elevator/elevator-tool'
45
44
  import { MoveTool } from './item/move-tool'
46
45
  import { WallPaintRegionTool } from './paint-region/wall-paint-region-tool'
47
46
  import { RegistryToolProvider } from './registry-tool-context'
@@ -305,21 +304,6 @@ export const ToolManager: React.FC = () => {
305
304
  const handlePlacedNodeSelected = (nodeId: AnyNodeId) => {
306
305
  setSelection({ selectedIds: [nodeId] })
307
306
  }
308
- const handlePlacedElevatorSelected = (
309
- nodeId: AnyNodeId,
310
- elevatorBuildingId: BuildingNode['id'],
311
- ) => {
312
- // Preserve the active level. `setSelection`'s hierarchy guard nulls
313
- // `levelId` whenever `buildingId` is passed without an explicit
314
- // `levelId` — which deselected the current floor plan the moment an
315
- // elevator was placed. Pass the current level through so the floor
316
- // plan stays selected.
317
- setSelection({
318
- buildingId: elevatorBuildingId,
319
- levelId: activeLevelId ?? null,
320
- selectedIds: [nodeId],
321
- })
322
- }
323
307
 
324
308
  return (
325
309
  <RegistryToolProvider value={registryToolContext}>
@@ -463,14 +447,7 @@ export const ToolManager: React.FC = () => {
463
447
  <RegistryToolComponent />
464
448
  </Suspense>
465
449
  )}
466
- {!movingNode && !useRegistryTool && showBuildTool && tool === 'elevator' && (
467
- <ElevatorTool
468
- buildingId={buildingId as BuildingNode['id'] | null}
469
- levelId={activeLevelId ?? null}
470
- onPlaced={handlePlacedElevatorSelected}
471
- />
472
- )}
473
- {!movingNode && BuildToolComponent && tool !== 'elevator' ? <BuildToolComponent /> : null}
450
+ {!movingNode && BuildToolComponent ? <BuildToolComponent /> : null}
474
451
  {/* Figma-style alignment guides published by the move / placement
475
452
  tools above. Lives inside the building-local group so the
476
453
  building-local guide coords render at the right world position. */}
@@ -9,6 +9,7 @@ import {
9
9
  createTerrainField,
10
10
  DoorNode as DoorSchema,
11
11
  encodeTerrainField,
12
+ findOpenWallEnds,
12
13
  flattenPatch,
13
14
  GROUND_SUPPORT_ID,
14
15
  getFloorPlacedElevation,
@@ -19,6 +20,7 @@ import {
19
20
  resolveTerrainWallConstructionOptions,
20
21
  runAsSingleSceneHistoryStep,
21
22
  SlabNode,
23
+ snapWorldXZToBuildingLocal,
22
24
  spatialGridManager,
23
25
  useScene,
24
26
  type WallNode,
@@ -30,11 +32,15 @@ import {
30
32
  migrateRoomZones,
31
33
  } from '@pascal-app/core/scene-migrations'
32
34
  import { useViewer } from '@pascal-app/viewer'
33
- import useEditor from '../../../store/use-editor'
35
+ import type { SnappingMode } from '../../../lib/snapping-mode'
36
+ import { snapBuildingLocalToWorldGrid } from '../../../lib/world-grid-snap'
37
+ import useEditor, { isAngleSnapActive, isMagneticSnapActive } from '../../../store/use-editor'
34
38
  import useInteractionScope from '../../../store/use-interaction-scope'
35
39
  import {
36
40
  createWallOnCurrentLevel,
41
+ getSegmentGridStep,
37
42
  resolveEndpointWallSplit,
43
+ snapWallDraftPoint,
38
44
  snapWallDraftPointDetailed,
39
45
  } from './wall-drafting'
40
46
  import type { WallPlanPoint } from './wall-snap-geometry'
@@ -758,27 +764,64 @@ describe('createWallOnCurrentLevel', () => {
758
764
  expect(levelWalls()).toHaveLength(2)
759
765
  })
760
766
 
761
- test('genuine mid-wall endpoint still splits the host (T junction)', () => {
762
- const created = createWallOnCurrentLevel([2, 2], [2, 0])
767
+ test.each(['grid', 'lines', 'angles', 'off'] as const)(
768
+ '%s drafting splits the host at a mid-wall junction',
769
+ (mode) => {
770
+ useEditor.getState().setSnappingMode('wall', mode)
771
+ const created = createWallOnCurrentLevel([2, 2], [2, 0])
772
+
773
+ expect(created?.end).toEqual([2, 0])
774
+ expect(useScene.getState().nodes['wall_a' as AnyNodeId]).toBeUndefined()
775
+ expect(
776
+ levelWalls()
777
+ .filter((wall) => wall.id !== created?.id)
778
+ .map(({ start, end }) => [start, end]),
779
+ ).toEqual([
780
+ [
781
+ [0, 0],
782
+ [2, 0],
783
+ ],
784
+ [
785
+ [2, 0],
786
+ [4, 0],
787
+ ],
788
+ ])
789
+ },
790
+ )
763
791
 
764
- expect(created?.end).toEqual([2, 0])
765
- expect(useScene.getState().nodes['wall_a' as AnyNodeId]).toBeUndefined()
766
- const walls = levelWalls()
767
- expect(walls).toHaveLength(3)
768
- expect(
769
- walls.some((wall) => wall.start[0] === 0 && wall.end[0] === 2 && wall.end[1] === 0),
770
- ).toBe(true)
771
- expect(
772
- walls.some((wall) => wall.start[0] === 2 && wall.start[1] === 0 && wall.end[0] === 4),
773
- ).toBe(true)
792
+ test('a wall drawn exactly over one built another way takes its place', () => {
793
+ // The seed wall is plain; the wall tool draws a thicker one.
794
+ const previousDefaults = useEditor.getState().toolDefaults
795
+ useEditor.setState({ toolDefaults: { ...previousDefaults, wall: { thickness: 0.3 } } } as never)
796
+ try {
797
+ const drawn = createWallOnCurrentLevel([0, 0], [4, 0])
798
+ expect(drawn).not.toBeNull()
799
+ expect(useScene.getState().nodes['wall_a' as AnyNodeId]).toBeUndefined()
800
+ expect(levelWalls().map((wall) => wall.id)).toEqual([drawn!.id])
801
+ } finally {
802
+ useEditor.setState({ toolDefaults: previousDefaults } as never)
803
+ }
774
804
  })
775
805
 
776
- test('exact duplicate segment is rejected', () => {
806
+ test('a wall drawn exactly over one built the same is refused', () => {
777
807
  expect(createWallOnCurrentLevel([0, 0], [4, 0])).toBeNull()
778
808
  expect(levelWalls()).toHaveLength(1)
779
809
  })
780
810
 
781
- test('grid mode: endpoint resolved onto a wall body still splits the host', () => {
811
+ test('a wall drawn on along part of another takes that stretch over', () => {
812
+ const drawn = createWallOnCurrentLevel([2, 0], [6, 0])
813
+ expect(drawn).not.toBeNull()
814
+ const walls = levelWalls()
815
+ // The seed wall keeps [0, 2]; nothing else runs along [2, 4].
816
+ expect(walls).toHaveLength(2)
817
+ const seed = walls.find((wall) => wall.id !== drawn!.id)!
818
+ expect([seed.start, seed.end]).toEqual([
819
+ [0, 0],
820
+ [2, 0],
821
+ ])
822
+ })
823
+
824
+ test('grid mode: endpoint resolved onto a wall body splits its host', () => {
782
825
  useEditor.getState().setSnappingMode('wall', 'grid')
783
826
 
784
827
  const created = createWallOnCurrentLevel([2, 2], [2, 0])
@@ -798,7 +841,7 @@ describe('createWallOnCurrentLevel', () => {
798
841
  expect(levelWalls()).toHaveLength(2)
799
842
  })
800
843
 
801
- test('mid-span split migrates the host attachments to the covering half', () => {
844
+ test('a T into a wall rehosts its door at the same position', () => {
802
845
  const door = DoorSchema.parse({
803
846
  position: [1, 1.05, 0],
804
847
  parentId: 'wall_a',
@@ -809,12 +852,12 @@ describe('createWallOnCurrentLevel', () => {
809
852
  const created = createWallOnCurrentLevel([2, 2], [2, 0])
810
853
 
811
854
  expect(created?.end).toEqual([2, 0])
812
- const walls = levelWalls()
813
- const firstHalf = walls.find((wall) => wall.start[0] === 0 && wall.end[0] === 2)
814
- expect(firstHalf).toBeDefined()
815
- const migratedDoor = useScene.getState().nodes[door.id as AnyNodeId]
816
- expect(migratedDoor?.parentId).toBe(firstHalf?.id)
817
- expect(firstHalf?.children).toContain(door.id)
855
+ const kept = useScene.getState().nodes[door.id as AnyNodeId] as DoorSchema
856
+ expect(kept.parentId).not.toBe('wall_a')
857
+ expect(kept.position[0]).toBe(1)
858
+ expect((useScene.getState().nodes[kept.parentId as AnyNodeId] as WallNode).children).toContain(
859
+ door.id,
860
+ )
818
861
  })
819
862
 
820
863
  test('a splitting commit lands as a single undo step', () => {
@@ -826,7 +869,7 @@ describe('createWallOnCurrentLevel', () => {
826
869
  expect(useScene.temporal.getState().pastStates.length - before).toBe(1)
827
870
  })
828
871
 
829
- test('a crossing splits both the host and the inserted wall in one undo step', () => {
872
+ test('a crossing splits both walls in one undo step', () => {
830
873
  const before = useScene.temporal.getState().pastStates.length
831
874
 
832
875
  const created = createWallOnCurrentLevel([2, -2], [2, 2])
@@ -1095,4 +1138,237 @@ describe('snapWallDraftPointDetailed', () => {
1095
1138
  expect(freed.point).toEqual([2, 0])
1096
1139
  expect(freed.snap).toBeNull()
1097
1140
  })
1141
+
1142
+ test('an end aimed into an L corner block joins the corner and reads as one', () => {
1143
+ const south = { ...makeWall([0, 0], [4, 0], 'wall_south'), thickness: 0.2 }
1144
+ const west = { ...makeWall([0, 4], [0, 0], 'wall_west'), thickness: 0.2 }
1145
+ const result = snapWallDraftPointDetailed({
1146
+ point: [0.08, 0.08],
1147
+ walls: [south, west],
1148
+ start: [2, 3],
1149
+ magnetic: false,
1150
+ step: 0,
1151
+ captureWallBodies: true,
1152
+ })
1153
+ expect(result.point).toEqual([0, 0])
1154
+ expect(result.snap).toBe('endpoint')
1155
+ expect(result.targetWallIds).toEqual(['wall_south', 'wall_west'])
1156
+ })
1157
+
1158
+ // The 3D tool snaps to the floor below as a reference but can't join it: an
1159
+ // end by a lower wall's face stays put, while its corner still snaps.
1160
+ test('walls left out of bodyWalls never capture an end, but still snap corners', () => {
1161
+ const lower = { ...makeWall([0, 0.4], [4, 0.4], 'wall_lower'), thickness: 0.2 }
1162
+ const snap = (point: WallPlanPoint, bodyWalls?: WallNode[]) =>
1163
+ snapWallDraftPointDetailed({
1164
+ point,
1165
+ walls: [lower],
1166
+ start: [1, 3],
1167
+ magnetic: false,
1168
+ step: 0,
1169
+ captureWallBodies: true,
1170
+ bodyWalls,
1171
+ })
1172
+
1173
+ const captured = snap([1, 0.5])
1174
+ expect(captured.snap).toBe('wall')
1175
+ expect(captured.point[1]).toBeCloseTo(0.4, 12)
1176
+
1177
+ const free = snap([1, 0.5], [])
1178
+ expect(free.point).toEqual([1, 0.5])
1179
+ expect(free.snap).toBeNull()
1180
+
1181
+ const corner = snap([0.02, 0.42], [])
1182
+ expect(corner.point).toEqual([0, 0.4])
1183
+ expect(corner.snap).toBe('endpoint')
1184
+ })
1098
1185
  })
1186
+
1187
+ // The site planner sets a house on its lot turned and offset. Its walls are
1188
+ // drawn square to the house, in building-local metres. An interior wall drawn
1189
+ // across it has to meet both exterior walls exactly and stay square to the
1190
+ // house however the building is turned, in 3D and on the plan alike.
1191
+ const HOUSE_BUILDING_ID = 'building_lot'
1192
+ // A 30' × 24' house of 8" walls, 2' × 1' off the building origin, so no wall
1193
+ // line or face lies on the 0.5 m lattice.
1194
+ const HOUSE_MIN: WallPlanPoint = [0.6096, 0.3048]
1195
+ const HOUSE_MAX: WallPlanPoint = [9.7536, 7.62]
1196
+ const EXTERIOR_THICKNESS = 0.2032
1197
+ // Where a builder aims an interior wall's end: just inside the exterior
1198
+ // wall's interior face (1.18" off it).
1199
+ const FACE_AIM = EXTERIOR_THICKNESS / 2 + 0.03
1200
+
1201
+ const BUILDING_POSES: Array<{
1202
+ label: string
1203
+ yaw: number
1204
+ position: [number, number, number]
1205
+ }> = [
1206
+ { label: 'an unturned building at the origin', yaw: 0, position: [0, 0, 0] },
1207
+ {
1208
+ label: 'a building turned 17° and offset on its lot',
1209
+ yaw: (17 * Math.PI) / 180,
1210
+ position: [12.37, 0, -8.91],
1211
+ },
1212
+ ]
1213
+
1214
+ function seedHouse({ yaw, position }: { yaw: number; position: [number, number, number] }) {
1215
+ const [x0, z0] = HOUSE_MIN
1216
+ const [x1, z1] = HOUSE_MAX
1217
+ const walls = [
1218
+ makeWall([x0, z0], [x1, z0], 'wall_south'),
1219
+ makeWall([x1, z0], [x1, z1], 'wall_east'),
1220
+ makeWall([x1, z1], [x0, z1], 'wall_north'),
1221
+ makeWall([x0, z1], [x0, z0], 'wall_west'),
1222
+ ].map((wall) => ({ ...wall, thickness: EXTERIOR_THICKNESS }))
1223
+ const building = BuildingNode.parse({
1224
+ id: HOUSE_BUILDING_ID,
1225
+ position,
1226
+ rotation: [0, yaw, 0],
1227
+ children: [LEVEL_ID],
1228
+ })
1229
+ const level = LevelSchema.parse({ id: LEVEL_ID, parentId: building.id, level: 0, height: 2.5 })
1230
+ useScene.setState({
1231
+ nodes: Object.fromEntries([
1232
+ [building.id, building],
1233
+ [level.id, { ...level, children: walls.map((wall) => wall.id) }],
1234
+ ...walls.map((wall) => [wall.id, wall] as const),
1235
+ ]),
1236
+ rootNodeIds: [building.id],
1237
+ dirtyNodes: new Set(),
1238
+ collections: {},
1239
+ } as never)
1240
+ useViewer.setState({
1241
+ selection: {
1242
+ buildingId: HOUSE_BUILDING_ID,
1243
+ levelId: LEVEL_ID,
1244
+ zoneId: null,
1245
+ selectedIds: [],
1246
+ },
1247
+ } as never)
1248
+ }
1249
+
1250
+ for (const pose of BUILDING_POSES) {
1251
+ describe(`an interior wall drawn across a house in ${pose.label}`, () => {
1252
+ const [, z0] = HOUSE_MIN
1253
+ const [, z1] = HOUSE_MAX
1254
+
1255
+ beforeEach(() => {
1256
+ useEditor.getState().setGridSnapStep(0.5)
1257
+ // A reshaping-endpoint scope resolves to the 'wall' snap context
1258
+ // without the node registry, as in `createWallOnCurrentLevel` above.
1259
+ useInteractionScope
1260
+ .getState()
1261
+ .begin({ kind: 'reshaping', nodeId: 'wall_south', reshape: 'endpoint', driver: 'tool' })
1262
+ seedHouse(pose)
1263
+ useScene.temporal.getState().clear()
1264
+ useScene.temporal.getState().resume()
1265
+ })
1266
+
1267
+ afterEach(() => {
1268
+ useEditor.getState().setSnappingMode('wall', 'grid')
1269
+ })
1270
+
1271
+ const setMode = (mode: SnappingMode) => useEditor.getState().setSnappingMode('wall', mode)
1272
+
1273
+ // The cursor as a grid event hands it over: a hit on the world plane,
1274
+ // taken into the building's frame.
1275
+ const cursorAt = (local: WallPlanPoint): WallPlanPoint => {
1276
+ const cos = Math.cos(pose.yaw)
1277
+ const sin = Math.sin(pose.yaw)
1278
+ const worldX = pose.position[0] + local[0] * cos + local[1] * sin
1279
+ const worldZ = pose.position[2] - local[0] * sin + local[1] * cos
1280
+ return snapWorldXZToBuildingLocal(worldX, worldZ, pose.position, pose.yaw, 0).local
1281
+ }
1282
+
1283
+ // The 3D wall tool's snap call.
1284
+ const snapIn3d = (point: WallPlanPoint, start?: WallPlanPoint, captureWallBodies = true) =>
1285
+ snapWallDraftPointDetailed({
1286
+ point,
1287
+ walls: levelWalls(),
1288
+ start,
1289
+ angleSnap: start !== undefined && isAngleSnapActive(),
1290
+ magnetic: isMagneticSnapActive(),
1291
+ captureWallBodies,
1292
+ })
1293
+
1294
+ // The plan's click: the same cursor through the plan's grid callback.
1295
+ const snapOnPlan = (point: WallPlanPoint, start?: WallPlanPoint) =>
1296
+ snapWallDraftPoint({
1297
+ point,
1298
+ walls: levelWalls(),
1299
+ start,
1300
+ angleSnap: start !== undefined && isAngleSnapActive(),
1301
+ magnetic: isMagneticSnapActive(),
1302
+ gridSnap: (p) => snapBuildingLocalToWorldGrid(p, getSegmentGridStep()),
1303
+ captureWallBodies: true,
1304
+ })
1305
+
1306
+ const partitionOpenEnds = (partition: WallNode) =>
1307
+ findOpenWallEnds(useScene.getState().nodes, LEVEL_ID).filter(
1308
+ (end) => end.wallId === partition.id,
1309
+ )
1310
+
1311
+ test('grid: both ends land on the exterior walls, square to the house, with no gap', () => {
1312
+ setMode('grid')
1313
+ const startAim = cursorAt([4.2, z0 + FACE_AIM])
1314
+ const start = snapIn3d(startAim)
1315
+ expect(start.snap).toBe('wall')
1316
+ expect(start.targetWallIds).toEqual(['wall_south'])
1317
+ expect(start.point[0]).toBeCloseTo(4, 9)
1318
+ expect(start.point[1]).toBeCloseTo(z0, 9)
1319
+
1320
+ // The cursor drifts a little sideways on its way across the house.
1321
+ const endAim = cursorAt([4.1, z1 - FACE_AIM])
1322
+ const end = snapIn3d(endAim, start.point)
1323
+ expect(end.snap).toBe('wall')
1324
+ expect(end.targetWallIds).toEqual(['wall_north'])
1325
+ expect(end.point[0]).toBeCloseTo(start.point[0], 9)
1326
+ expect(end.point[1]).toBeCloseTo(z1, 9)
1327
+
1328
+ // The plan snaps the same cursor to the same points.
1329
+ expect(snapOnPlan(startAim)).toEqual(start.point)
1330
+ expect(snapOnPlan(endAim, start.point)).toEqual(end.point)
1331
+
1332
+ const partition = createWallOnCurrentLevel(start.point, end.point)
1333
+ expect(partition).not.toBeNull()
1334
+ expect(partitionOpenEnds(partition!)).toEqual([])
1335
+ })
1336
+
1337
+ test('ortho holds the wall square to the house and lands it on the far wall', () => {
1338
+ setMode('angles')
1339
+ const startAim = cursorAt([4.2, z0 + FACE_AIM])
1340
+ const start = snapIn3d(startAim)
1341
+ expect(start.point[0]).toBeCloseTo(4.2, 9)
1342
+ expect(start.point[1]).toBeCloseTo(z0, 9)
1343
+
1344
+ // 4° off square: the 15° lock squares it to the house.
1345
+ const endAim = cursorAt([3.7, z1 - FACE_AIM])
1346
+ const end = snapIn3d(endAim, start.point)
1347
+ expect(end.snap).toBe('wall')
1348
+ expect(end.point[0]).toBeCloseTo(start.point[0], 9)
1349
+ expect(end.point[1]).toBeCloseTo(z1, 9)
1350
+
1351
+ const onPlan = snapOnPlan(endAim, start.point)
1352
+ expect(onPlan[0]).toBeCloseTo(end.point[0], 9)
1353
+ expect(onPlan[1]).toBeCloseTo(end.point[1], 9)
1354
+
1355
+ const partition = createWallOnCurrentLevel(start.point, end.point)
1356
+ expect(partition).not.toBeNull()
1357
+ expect(partitionOpenEnds(partition!)).toEqual([])
1358
+ })
1359
+
1360
+ test('without the body snap the lattice stops the end short of the face: an open gap', () => {
1361
+ setMode('grid')
1362
+ const start = snapIn3d(cursorAt([4.2, z0 + FACE_AIM]))
1363
+ const end = snapIn3d(cursorAt([4.1, z1 - FACE_AIM]), start.point, false)
1364
+ // The lattice line under the cursor, 0.72" short of the north wall's face.
1365
+ expect(end.snap).toBeNull()
1366
+ expect(end.point[1]).toBeCloseTo(7.5, 9)
1367
+
1368
+ const partition = createWallOnCurrentLevel(start.point, end.point)
1369
+ const [open] = partitionOpenEnds(partition!)
1370
+ expect(open?.reason).toBe('gap')
1371
+ expect(open?.gap).toBeCloseTo(z1 - EXTERIOR_THICKNESS / 2 - 7.5, 3)
1372
+ })
1373
+ })
1374
+ }
@@ -1,21 +1,30 @@
1
1
  import {
2
2
  type AnyNodeId,
3
3
  DEFAULT_ANGLE_STEP,
4
+ finishWallEdit,
5
+ generateId,
6
+ planSeparatorInsertion,
4
7
  planWallInsertion,
5
8
  planWallSplitAtPoint,
9
+ railingWallDefaults,
6
10
  resolveWallConstruction,
7
11
  runAsSingleSceneHistoryStep,
12
+ type SeparatorNode,
13
+ sameWallConstruction,
8
14
  snapPointAlongAngleRay,
9
15
  useScene,
10
16
  type WallConstructionOptions,
11
17
  type WallNode,
18
+ wallsRunAlong,
12
19
  } from '@pascal-app/core'
13
20
  import { useViewer } from '@pascal-app/viewer'
14
21
  import { sfxEmitter } from '../../../lib/sfx-bus'
15
22
  import { resolveSnapFlags } from '../../../lib/snapping-mode'
23
+ import { getWallLineKind } from '../../../lib/wall-draw-variant'
16
24
  import useEditor, { getActiveSnappingMode, isMagneticSnapActive } from '../../../store/use-editor'
17
25
  import {
18
26
  distanceSquared,
27
+ findWallBodySnapTarget,
19
28
  findWallSnapTarget,
20
29
  findWallSpecialPointSnap,
21
30
  WALL_CONNECT_SNAP_RADIUS,
@@ -109,14 +118,30 @@ type SnapWallDraftArgs = {
109
118
  */
110
119
  magnetic?: boolean
111
120
  /**
112
- * Optional grid-snap override. Lets the caller route grid snapping
113
- * through a world-XZ aligned snap (so a rotated building's draft
114
- * lands on the visible grid). When omitted, falls back to the
115
- * local-axis grid at `step`.
121
+ * Optional grid-snap override, in the caller's plan frame. When omitted,
122
+ * falls back to the building-local grid at `step` — the lattice the wall
123
+ * tools draft on, square to the house however the building is turned.
116
124
  */
117
125
  gridSnap?: (point: WallPlanPoint) => WallPlanPoint
118
126
  /** Optional magnetic snap radii. Omitted means wall tools keep their defaults. */
119
127
  snapRadii?: WallSnapRadii
128
+ /**
129
+ * The point is a wall end (drawn or dragged): in the non-magnetic modes an
130
+ * end aimed into another wall's drawn body, or within the connect radius of
131
+ * either face, lands on that wall's reference line — where the wall
132
+ * topology T-joins it — ahead of the grid / angle placement, keeping the
133
+ * segment's direction from `start` (see `findWallBodySnapTarget`). Without
134
+ * it the grid quantises an end aimed at a face to the nearest lattice point,
135
+ * a fraction short of the face, and the end stays open with a gap.
136
+ */
137
+ captureWallBodies?: boolean
138
+ /**
139
+ * The walls `captureWallBodies` may join an end into; `walls` when omitted.
140
+ * A body capture T-joins the wall, so a caller that adds reference walls it
141
+ * can't join (the floor below) keeps them out of this list while they still
142
+ * serve the corner and edge snaps.
143
+ */
144
+ bodyWalls?: WallNode[]
120
145
  }
121
146
 
122
147
  export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSnapResult {
@@ -131,6 +156,8 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn
131
156
  magnetic = true,
132
157
  gridSnap,
133
158
  snapRadii,
159
+ captureWallBodies = false,
160
+ bodyWalls = walls,
134
161
  } = args
135
162
 
136
163
  if (bypassSnap) return { point, snap: null, targetWallIds: [] }
@@ -144,9 +171,8 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn
144
171
  }
145
172
 
146
173
  const step = overrideStep ?? getSegmentGridStep()
147
- // The angle path snaps the distance ALONG the 15° ray — a scalar, the
148
- // same in world and local frames — so the `gridSnap` world-grid override
149
- // only applies when the angle lock is off.
174
+ // The angle path snaps the distance ALONG the 15° ray from `start`, so the
175
+ // `gridSnap` override only applies when the angle lock is off.
150
176
  const basePoint: WallPlanPoint =
151
177
  start && angleSnap
152
178
  ? [...snapPointAlongAngleRay(start, point, DEFAULT_ANGLE_STEP, step)]
@@ -182,6 +208,30 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn
182
208
  }
183
209
  const connectSpecial = findWallSpecialPointSnap(basePoint, walls, ignoreWallIds, connectRadii)
184
210
  if (connectSpecial) return connectSpecial
211
+ // A wall end aimed into a wall's body joins it, whichever lattice point or
212
+ // ray station the mode picked near its face.
213
+ const bodySnap = captureWallBodies
214
+ ? findWallBodySnapTarget(point, bodyWalls, {
215
+ ignoreWallIds,
216
+ placed: basePoint,
217
+ start,
218
+ margin: WALL_CONNECT_SNAP_RADIUS,
219
+ })
220
+ : null
221
+ if (bodySnap) {
222
+ // An end aimed into a corner block joins the corner, and reads as one.
223
+ const atCorner = bodyWalls.some(
224
+ (wall) =>
225
+ !ignoreWallIds?.includes(wall.id) &&
226
+ (distanceSquared(bodySnap, wall.start) <= 1e-12 ||
227
+ distanceSquared(bodySnap, wall.end) <= 1e-12),
228
+ )
229
+ return {
230
+ point: bodySnap,
231
+ snap: atCorner ? 'endpoint' : 'wall',
232
+ targetWallIds: wallIdsAtSnapPoint(bodySnap, walls, ignoreWallIds),
233
+ }
234
+ }
185
235
  const connectWall = findWallSnapTarget(basePoint, walls, {
186
236
  ignoreWallIds,
187
237
  radius: WALL_CONNECT_SNAP_RADIUS,
@@ -205,6 +255,36 @@ export function isSegmentLongEnough(start: WallPlanPoint, end: WallPlanPoint): b
205
255
  return distanceSquared(start, end) >= WALL_MIN_LENGTH * WALL_MIN_LENGTH
206
256
  }
207
257
 
258
+ /**
259
+ * An invisible wall drawn `start` → `end` on the current level (the Walls variant's Invisible, a room
260
+ * divider): room separators, each end joined to a wall it lands on and broken
261
+ * either side of a wall it crosses (`planSeparatorInsertion`), as one undo step. Rooms close on
262
+ * it; a room it closes off outside the house becomes a porch. The last piece, for the run to go
263
+ * on from its end; null when it would be too short.
264
+ */
265
+ export function createInvisibleWallOnCurrentLevel(
266
+ start: WallPlanPoint,
267
+ end: WallPlanPoint,
268
+ ): SeparatorNode | null {
269
+ const currentLevelId = useViewer.getState().selection.levelId
270
+ const { nodes, applyNodeChanges } = useScene.getState()
271
+ if (!(currentLevelId && isSegmentLongEnough(start, end))) return null
272
+ const joinRadius = isMagneticSnapActive() ? WALL_JOIN_SNAP_RADIUS : WALL_CONNECT_SNAP_RADIUS
273
+ return runAsSingleSceneHistoryStep(useScene, () => {
274
+ const plan = planSeparatorInsertion(nodes, {
275
+ levelId: currentLevelId as AnyNodeId,
276
+ start,
277
+ end,
278
+ joinRadius,
279
+ mintId: () => generateId('separator'),
280
+ })
281
+ if (!plan) return null
282
+ applyNodeChanges(plan.changes)
283
+ sfxEmitter.emit('sfx:structure-build')
284
+ return plan.separators.at(-1) ?? null
285
+ })
286
+ }
287
+
208
288
  export function createWallOnCurrentLevel(
209
289
  start: WallPlanPoint,
210
290
  end: WallPlanPoint,
@@ -220,13 +300,33 @@ export function createWallOnCurrentLevel(
220
300
  const joinRadius = isMagneticSnapActive() ? WALL_JOIN_SNAP_RADIUS : WALL_CONNECT_SNAP_RADIUS
221
301
 
222
302
  return runAsSingleSceneHistoryStep(useScene, () => {
223
- const result = planWallInsertion(nodes, {
303
+ // The Walls variant's Railing draws railings: a guard of their own.
304
+ const insertion = {
224
305
  levelId: currentLevelId as AnyNodeId,
225
306
  start,
226
307
  end,
227
308
  joinRadius,
228
- wallDefaults: useEditor.getState().toolDefaults.wall ?? {},
229
- })
309
+ wallDefaults:
310
+ getWallLineKind() === 'railing'
311
+ ? railingWallDefaults()
312
+ : (useEditor.getState().toolDefaults.wall ?? {}),
313
+ }
314
+ let result = planWallInsertion(nodes, insertion)
315
+ if (!result.ok && result.reason === 'covered-existing-wall') {
316
+ // Drawn over walls built another way (an outside wall over an inside
317
+ // jog), the new wall takes their stretch over; over walls built the
318
+ // same, it would change nothing, so it stays refused.
319
+ const replacing = planWallInsertion(nodes, { ...insertion, replaceCovered: true })
320
+ if (
321
+ replacing.ok &&
322
+ replacing.plan.insertedWalls.some((wall) =>
323
+ wallsRunAlong(nodes, { ...wall, parentId: currentLevelId } as WallNode).some(
324
+ (covered) => !sameWallConstruction(covered, wall),
325
+ ),
326
+ )
327
+ )
328
+ result = replacing
329
+ }
230
330
  if (!result.ok) return null
231
331
  const { plan } = result
232
332
 
@@ -237,20 +337,28 @@ export function createWallOnCurrentLevel(
237
337
  const sourceAlreadyUpdated = sourceUpdate
238
338
  ? plan.changes.update.some((operation) => operation.id === sourceUpdate.id)
239
339
  : false
240
- applyNodeChanges({
241
- ...plan.changes,
242
- update: plan.changes.update
243
- .map((operation) =>
244
- sourceUpdate?.id === operation.id
245
- ? { ...operation, data: { ...operation.data, ...sourceUpdate.data } }
246
- : operation,
247
- )
248
- .concat(sourceUpdate && !sourceAlreadyUpdated ? [sourceUpdate] : []),
249
- create: plan.changes.create.map((operation) => ({
250
- ...operation,
251
- node: finalizedWallsById.get(operation.node.id as WallNode['id']) ?? operation.node,
252
- })),
253
- })
340
+ // A wall drawn along part of a parallel wall takes that stretch over, so
341
+ // drawing on from a wall never doubles the one it runs along.
342
+ applyNodeChanges(
343
+ finishWallEdit(
344
+ nodes,
345
+ {
346
+ ...plan.changes,
347
+ update: plan.changes.update
348
+ .map((operation) =>
349
+ sourceUpdate?.id === operation.id
350
+ ? { ...operation, data: { ...operation.data, ...sourceUpdate.data } }
351
+ : operation,
352
+ )
353
+ .concat(sourceUpdate && !sourceAlreadyUpdated ? [sourceUpdate] : []),
354
+ create: plan.changes.create.map((operation) => ({
355
+ ...operation,
356
+ node: finalizedWallsById.get(operation.node.id as WallNode['id']) ?? operation.node,
357
+ })),
358
+ },
359
+ { winnerIds: finalizedWalls.map((wall) => wall.id), mintId: () => generateId('wall') },
360
+ ),
361
+ )
254
362
  sfxEmitter.emit('sfx:structure-build')
255
363
 
256
364
  const terminalWall = finalizedWalls.at(-1)!