@pascal-app/editor 1.0.5 → 1.0.7

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 (317) hide show
  1. package/package.json +20 -17
  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 +20 -8
  6. package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
  7. package/src/components/editor/first-person-controls.tsx +72 -61
  8. package/src/components/editor/floating-action-menu.tsx +27 -9
  9. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  10. package/src/components/editor/floorplan-panel.test.ts +220 -0
  11. package/src/components/editor/floorplan-panel.tsx +285 -132
  12. package/src/components/editor/footprint-height-handle.tsx +1 -1
  13. package/src/components/editor/grid.tsx +24 -15
  14. package/src/components/editor/group-actions.ts +19 -1
  15. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  16. package/src/components/editor/group-move-3d.ts +27 -2
  17. package/src/components/editor/group-selection-box-3d.tsx +4 -1
  18. package/src/components/editor/group-transform-shared.ts +20 -0
  19. package/src/components/editor/index.tsx +130 -20
  20. package/src/components/editor/inspector-extension-actions.tsx +36 -0
  21. package/src/components/editor/localized-html.tsx +22 -0
  22. package/src/components/editor/node-action-menu.tsx +46 -12
  23. package/src/components/editor/node-arrow-handles.tsx +1 -2
  24. package/src/components/editor/open-wall-ends-3d-layer.tsx +1 -1
  25. package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
  26. package/src/components/editor/registry-action-contributions.test.tsx +67 -6
  27. package/src/components/editor/registry-action-contributions.tsx +115 -15
  28. package/src/components/editor/riser-diagram-panel.tsx +59 -9
  29. package/src/components/editor/room-controls.tsx +1 -1
  30. package/src/components/editor/room-handles-2d.tsx +156 -6
  31. package/src/components/editor/room-handles.tsx +241 -51
  32. package/src/components/editor/room-highlight.tsx +2 -0
  33. package/src/components/editor/room-labels.tsx +29 -15
  34. package/src/components/editor/selection-manager.tsx +15 -102
  35. package/src/components/editor/separator-end-grip.tsx +74 -0
  36. package/src/components/editor/site-edge-labels.tsx +1 -1
  37. package/src/components/editor/thumbnail-generator.test.ts +15 -6
  38. package/src/components/editor/thumbnail-generator.tsx +26 -1
  39. package/src/components/editor/use-floorplan-background-placement.ts +55 -52
  40. package/src/components/editor/views/view-bar.tsx +111 -26
  41. package/src/components/editor/wall-measurement-label.tsx +1 -1
  42. package/src/components/editor/wall-move-side-handles.tsx +5 -1
  43. package/src/components/editor/wall-pull-grip.tsx +193 -0
  44. package/src/components/editor/wall-snap-beacon-layer.tsx +81 -1
  45. package/src/components/editor-2d/floorplan-adjacent-walls-layer.test.ts +76 -0
  46. package/src/components/editor-2d/floorplan-adjacent-walls-layer.tsx +186 -0
  47. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +63 -3
  48. package/src/components/editor-2d/floorplan-group-move.tsx +31 -3
  49. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +6 -0
  50. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +22 -13
  51. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +11 -3
  52. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +7 -1
  53. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +96 -4
  54. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +52 -12
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +12 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +197 -37
  57. package/src/components/procedural-items/inspector.tsx +59 -12
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +2 -0
  59. package/src/components/systems/ceiling/ceiling-system.test.ts +141 -0
  60. package/src/components/systems/ceiling/ceiling-system.tsx +53 -18
  61. package/src/components/tools/distribution/distribution-run-tool.tsx +50 -26
  62. package/src/components/tools/item/placement-history.test.tsx +1 -1
  63. package/src/components/tools/item/placement-strategies.test.ts +64 -1
  64. package/src/components/tools/item/placement-strategies.ts +5 -9
  65. package/src/components/tools/item/use-placement-coordinator.tsx +30 -34
  66. package/src/components/tools/registry/item-surface-move.test.ts +11 -0
  67. package/src/components/tools/select/select-candidates.test.ts +45 -0
  68. package/src/components/tools/select/select-candidates.ts +13 -2
  69. package/src/components/tools/shared/cursor-sphere.tsx +10 -3
  70. package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
  71. package/src/components/tools/shared/placement-box.tsx +1 -1
  72. package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
  73. package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
  74. package/src/components/tools/wall/wall-drafting.test.ts +299 -23
  75. package/src/components/tools/wall/wall-drafting.ts +132 -24
  76. package/src/components/tools/wall/wall-pull-snap.test.ts +274 -0
  77. package/src/components/tools/wall/wall-pull-snap.ts +563 -0
  78. package/src/components/tools/wall/wall-snap-geometry.test.ts +113 -1
  79. package/src/components/tools/wall/wall-snap-geometry.ts +137 -0
  80. package/src/components/tools/zone/zone-tool.tsx +3 -3
  81. package/src/components/ui/action-menu/action-button.tsx +2 -0
  82. package/src/components/ui/action-menu/camera-actions.tsx +8 -7
  83. package/src/components/ui/action-menu/control-modes.tsx +9 -5
  84. package/src/components/ui/action-menu/follow-pascal-pill.tsx +3 -1
  85. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +7 -1
  86. package/src/components/ui/action-menu/furnish-tools.tsx +7 -6
  87. package/src/components/ui/action-menu/index.tsx +1 -0
  88. package/src/components/ui/action-menu/measurement-control.tsx +35 -29
  89. package/src/components/ui/action-menu/point-ask-button.tsx +4 -3
  90. package/src/components/ui/action-menu/structure-tools.tsx +25 -23
  91. package/src/components/ui/action-menu/view-toggles.test.ts +18 -0
  92. package/src/components/ui/action-menu/view-toggles.tsx +256 -51
  93. package/src/components/ui/build-panel/build-panel.tsx +163 -25
  94. package/src/components/ui/command-palette/editor-commands.tsx +38 -11
  95. package/src/components/ui/controls/finish-swatch.tsx +45 -19
  96. package/src/components/ui/controls/material-picker.tsx +24 -17
  97. package/src/components/ui/controls/metric-control.test.ts +52 -0
  98. package/src/components/ui/controls/metric-control.tsx +62 -24
  99. package/src/components/ui/controls/segmented-control.tsx +5 -2
  100. package/src/components/ui/controls/slider-control.test.tsx +23 -0
  101. package/src/components/ui/controls/slider-control.tsx +30 -14
  102. package/src/components/ui/floating-level-selector.tsx +100 -48
  103. package/src/components/ui/helpers/building-helper.tsx +5 -3
  104. package/src/components/ui/helpers/contextual-helper-panel.tsx +97 -37
  105. package/src/components/ui/helpers/helper-manager.test.tsx +53 -3
  106. package/src/components/ui/helpers/helper-manager.tsx +82 -57
  107. package/src/components/ui/helpers/item-helper.tsx +6 -4
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +3 -1
  109. package/src/components/ui/level-duplicate-dialog.tsx +32 -23
  110. package/src/components/ui/level-wall-copy-prompt.tsx +124 -0
  111. package/src/components/ui/panels/collections/collections-popover.tsx +11 -11
  112. package/src/components/ui/panels/deck-boards-fields.tsx +136 -0
  113. package/src/components/ui/panels/floor-foundation-panel.tsx +113 -38
  114. package/src/components/ui/panels/floor-opening-panel.tsx +73 -25
  115. package/src/components/ui/panels/inspector-extensions.tsx +66 -0
  116. package/src/components/ui/panels/mobile-panel-sheet.tsx +3 -1
  117. package/src/components/ui/panels/mobile-selection-bar.tsx +8 -6
  118. package/src/components/ui/panels/multi-field-value.test.ts +27 -0
  119. package/src/components/ui/panels/multi-field-value.ts +20 -1
  120. package/src/components/ui/panels/multi-height-mode.tsx +8 -7
  121. package/src/components/ui/panels/multi-parametric-inspector.tsx +122 -34
  122. package/src/components/ui/panels/multi-selection-panel.tsx +75 -35
  123. package/src/components/ui/panels/node-display.ts +35 -24
  124. package/src/components/ui/panels/panel-manager.tsx +5 -3
  125. package/src/components/ui/panels/panel-wrapper.tsx +22 -109
  126. package/src/components/ui/panels/parametric-field-control.tsx +13 -10
  127. package/src/components/ui/panels/parametric-inspector.tsx +18 -12
  128. package/src/components/ui/panels/reference-panel.tsx +51 -35
  129. package/src/components/ui/panels/room-construction-rows.tsx +464 -65
  130. package/src/components/ui/panels/room-name-options.test.ts +36 -3
  131. package/src/components/ui/panels/room-name-options.ts +110 -11
  132. package/src/components/ui/panels/room-openings-field.tsx +21 -12
  133. package/src/components/ui/panels/room-panel.tsx +206 -99
  134. package/src/components/ui/panels/shape-choice.tsx +7 -5
  135. package/src/components/ui/panels/wall-region-list.tsx +7 -7
  136. package/src/components/ui/primitives/shortcut-token.tsx +10 -6
  137. package/src/components/ui/sidebar/icon-rail.tsx +11 -9
  138. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +10 -10
  139. package/src/components/ui/sidebar/panels/items-panel/index.tsx +10 -9
  140. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +3 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +102 -32
  142. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -2
  144. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +4 -2
  145. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +7 -5
  146. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +4 -2
  147. package/src/components/ui/sidebar/panels/site-panel/default-names.ts +75 -0
  148. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +4 -2
  149. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +4 -2
  150. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +4 -2
  151. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +4 -2
  152. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +4 -2
  153. package/src/components/ui/sidebar/panels/site-panel/index.tsx +117 -64
  154. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +36 -3
  155. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +4 -2
  156. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -4
  157. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +8 -2
  158. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +8 -5
  159. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +4 -2
  160. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +8 -3
  161. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +4 -2
  162. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +4 -2
  163. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +8 -7
  164. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +7 -6
  165. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +16 -1
  166. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +15 -8
  167. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +4 -2
  168. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +4 -2
  169. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +3 -1
  170. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +18 -8
  171. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +25 -14
  172. package/src/components/ui/sidebar/tab-bar.tsx +4 -1
  173. package/src/components/ui/sidebar/use-plugin-panels.tsx +19 -10
  174. package/src/components/ui/snap-target-badge.tsx +7 -6
  175. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +721 -0
  176. package/src/components/ui/wall-type/wall-type-menu.tsx +167 -0
  177. package/src/components/viewer/floorplan-compass-button.tsx +12 -3
  178. package/src/components/viewer/floorplan-preview.tsx +27 -19
  179. package/src/components/viewer/render-settings-menu.tsx +294 -0
  180. package/src/components/viewer/sky-control.tsx +710 -0
  181. package/src/components/viewer/use-viewer-ui-sounds.test.ts +15 -0
  182. package/src/components/viewer/use-viewer-ui-sounds.ts +46 -0
  183. package/src/components/viewer/viewer-controls-bar.tsx +278 -260
  184. package/src/components/viewer/viewer-hover-label.tsx +12 -4
  185. package/src/components/viewer/viewer-rooms.tsx +61 -4
  186. package/src/components/viewer/viewer-scene-header.tsx +28 -17
  187. package/src/components/viewer/viewer-stage-switcher.tsx +4 -2
  188. package/src/components/viewer-overlay.tsx +8 -0
  189. package/src/components/walkthrough-hud.tsx +75 -2
  190. package/src/hooks/use-keyboard.test.ts +85 -4
  191. package/src/hooks/use-keyboard.ts +65 -7
  192. package/src/hooks/use-selected-room.ts +12 -1
  193. package/src/i18n/floor-footprint-name.ts +30 -0
  194. package/src/i18n/level-name.ts +39 -0
  195. package/src/i18n/locales/en/messages.po +15117 -0
  196. package/src/i18n/locales/en/messages.ts +1 -0
  197. package/src/i18n/locales/en-XA/messages.ts +1 -0
  198. package/src/i18n/locales/es/messages.po +37501 -0
  199. package/src/i18n/locales/es/messages.ts +1 -0
  200. package/src/i18n/locales/fr/messages.po +37501 -0
  201. package/src/i18n/locales/fr/messages.ts +1 -0
  202. package/src/i18n/locales/ja/messages.po +37501 -0
  203. package/src/i18n/locales/ja/messages.ts +1 -0
  204. package/src/i18n/locales/zh-CN/messages.po +37501 -0
  205. package/src/i18n/locales/zh-CN/messages.ts +1 -0
  206. package/src/i18n/locales.test.ts +63 -0
  207. package/src/i18n/locales.ts +32 -0
  208. package/src/i18n/message-slot.tsx +25 -0
  209. package/src/i18n/node-messages.ts +169 -0
  210. package/src/i18n/pseudo-locale.ts +17 -0
  211. package/src/i18n/published-entry.test.ts +17 -0
  212. package/src/i18n/scene-theme-name.ts +16 -0
  213. package/src/index.tsx +156 -4
  214. package/src/lib/add-storey.test.ts +3 -3
  215. package/src/lib/contextual-help.test.ts +20 -1
  216. package/src/lib/contextual-help.ts +95 -19
  217. package/src/lib/continuation.ts +38 -11
  218. package/src/lib/floor-footprints.test.ts +2 -1
  219. package/src/lib/floor-foundation-support.test.ts +7 -3
  220. package/src/lib/floorplan/dimension-drive.test.ts +36 -2
  221. package/src/lib/floorplan/dimension-drive.ts +68 -3
  222. package/src/lib/floorplan/floorplan-export.test.ts +16 -5
  223. package/src/lib/floorplan/floorplan-export.tsx +13 -16
  224. package/src/lib/floorplan/floorplan-extension.test.ts +11 -12
  225. package/src/lib/floorplan/floorplan-extension.ts +37 -18
  226. package/src/lib/floorplan/floorplan-mode.test.ts +13 -6
  227. package/src/lib/floorplan/floorplan-mode.ts +14 -12
  228. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +1 -1
  229. package/src/lib/floorplan/floorplan-readonly.ts +3 -3
  230. package/src/lib/floorplan/schedules.ts +10 -6
  231. package/src/lib/floorplan/site-plan/site-annotations.test.ts +46 -1
  232. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -0
  233. package/src/lib/floorplan/site-plan/site-parts.ts +6 -3
  234. package/src/lib/geometry-script/author.test.ts +114 -0
  235. package/src/lib/geometry-script/author.ts +55 -23
  236. package/src/lib/geometry-script/client.ts +3 -0
  237. package/src/lib/geometry-script/finish-scripts.ts +7 -7
  238. package/src/lib/glb-export.test.ts +327 -0
  239. package/src/lib/glb-export.ts +70 -2
  240. package/src/lib/grid-frame.test.ts +56 -3
  241. package/src/lib/grid-frame.ts +28 -1
  242. package/src/lib/history.test.ts +2 -1
  243. package/src/lib/hud-title.ts +48 -24
  244. package/src/lib/ifc-export.test.ts +41 -1
  245. package/src/lib/inspector-card-mode.test.ts +3 -31
  246. package/src/lib/inspector-card-mode.ts +5 -10
  247. package/src/lib/inspector-expanded.test.ts +13 -0
  248. package/src/lib/inspector-expanded.ts +13 -1
  249. package/src/lib/keyboard-pan.test.ts +7 -0
  250. package/src/lib/keyboard-pan.ts +9 -2
  251. package/src/lib/layer-thickness.test.ts +22 -0
  252. package/src/lib/layer-thickness.ts +79 -0
  253. package/src/lib/lot/drop-in.test.ts +59 -1
  254. package/src/lib/lot/drop-in.ts +42 -50
  255. package/src/lib/material-swatch-style.ts +24 -0
  256. package/src/lib/measurement-parser.ts +7 -3
  257. package/src/lib/measurements.test.ts +17 -0
  258. package/src/lib/measurements.ts +20 -5
  259. package/src/lib/mezzanine-selection.test.ts +3 -1
  260. package/src/lib/orbit-near-plane.test.ts +12 -0
  261. package/src/lib/orbit-near-plane.ts +13 -0
  262. package/src/lib/paint-regions.test.ts +8 -5
  263. package/src/lib/paint-scope.test.ts +53 -0
  264. package/src/lib/paint-scope.ts +18 -4
  265. package/src/lib/panel-rows.ts +17 -8
  266. package/src/lib/reference-scale.ts +83 -0
  267. package/src/lib/room-built-on.test.ts +26 -12
  268. package/src/lib/room-built-on.ts +20 -2
  269. package/src/lib/room-construction.test.ts +28 -1
  270. package/src/lib/room-construction.ts +14 -0
  271. package/src/lib/room-controls-phase6.test.ts +140 -0
  272. package/src/lib/room-handle-drag.ts +557 -42
  273. package/src/lib/room-name-catalog.test.ts +8 -0
  274. package/src/lib/room-name-catalog.ts +8 -1
  275. package/src/lib/room-name-messages.test.ts +132 -0
  276. package/src/lib/room-name-messages.ts +147 -0
  277. package/src/lib/room-name-storage.test.ts +334 -0
  278. package/src/lib/room-push-dimensions.test.ts +37 -0
  279. package/src/lib/room-push-dimensions.ts +226 -2
  280. package/src/lib/room-selection.test.ts +23 -6
  281. package/src/lib/scene-clipboard.test.ts +70 -49
  282. package/src/lib/scene-clipboard.ts +9 -4
  283. package/src/lib/selection-cycle.ts +63 -0
  284. package/src/lib/separator-edit.test.ts +431 -0
  285. package/src/lib/separator-edit.ts +179 -0
  286. package/src/lib/separator-lines.ts +38 -0
  287. package/src/lib/sfx-bus.ts +27 -1
  288. package/src/lib/sfx-player.ts +16 -0
  289. package/src/lib/tool-cursor-icon.ts +5 -1
  290. package/src/lib/tool-shortcuts.ts +130 -0
  291. package/src/lib/viewer-selection.test.ts +28 -24
  292. package/src/lib/viewer-selection.ts +20 -5
  293. package/src/lib/wall-draw-variant.test.ts +7 -6
  294. package/src/lib/wall-draw-variant.ts +107 -6
  295. package/src/lib/wall-nudge.test.ts +44 -0
  296. package/src/lib/wall-nudge.ts +131 -0
  297. package/src/lib/wall-parallel-snap.test.ts +83 -0
  298. package/src/lib/wall-parallel-snap.ts +176 -0
  299. package/src/lib/wall-placement-pointer.test.ts +23 -0
  300. package/src/lib/wall-placement-pointer.ts +18 -0
  301. package/src/lib/wall-pull.test.ts +245 -0
  302. package/src/lib/wall-pull.ts +209 -0
  303. package/src/lib/wall-push-line-up.test.ts +132 -0
  304. package/src/lib/wall-region-list.ts +14 -5
  305. package/src/lib/wall-run-snap.test.ts +188 -0
  306. package/src/lib/wall-run-snap.ts +83 -0
  307. package/src/lib/wall-type.test.ts +125 -0
  308. package/src/lib/wall-type.ts +119 -0
  309. package/src/lib/window-interaction.test.ts +42 -0
  310. package/src/lib/window-interaction.ts +47 -5
  311. package/src/lib/world-grid-snap.test.ts +139 -2
  312. package/src/lib/world-grid-snap.ts +102 -105
  313. package/src/store/use-alignment-guides.ts +27 -1
  314. package/src/store/use-editor.tsx +48 -0
  315. package/src/store/use-floorplan-annotation-visibility.test.ts +64 -0
  316. package/src/store/use-floorplan-annotation-visibility.ts +47 -22
  317. package/src/store/use-wall-snap-indicator.ts +15 -2
@@ -139,4 +139,49 @@ describe('selectable candidates', () => {
139
139
  })
140
140
  expect(collectSelectableCandidateIds().sort()).toEqual(['item_pick', 'wall_pick'])
141
141
  })
142
+
143
+ test("a box leaves a room's automatic floor plate and ceiling to the room, keeping what hangs from it", () => {
144
+ const restore = nodeRegistry._snapshot()
145
+ try {
146
+ for (const kind of ['slab', 'ceiling', 'box-select-test-light'] as const)
147
+ if (!nodeRegistry.has(kind))
148
+ registerNode({
149
+ kind,
150
+ schemaVersion: 1,
151
+ schema: z.object({ type: z.literal(kind) }) as never,
152
+ category: 'structure',
153
+ defaults: () => ({}),
154
+ capabilities: { selectable: {} },
155
+ } as AnyNodeDefinition)
156
+ useScene.setState({
157
+ nodes: {
158
+ level_test: {
159
+ id: 'level_test',
160
+ type: 'level',
161
+ parentId: 'building_test',
162
+ children: ['slab_shared', 'slab_drawn', 'ceiling_auto'],
163
+ },
164
+ slab_shared: {
165
+ id: 'slab_shared',
166
+ type: 'slab',
167
+ parentId: 'level_test',
168
+ boundary: 'auto',
169
+ plateRole: 'base',
170
+ },
171
+ slab_drawn: { id: 'slab_drawn', type: 'slab', parentId: 'level_test' },
172
+ ceiling_auto: {
173
+ id: 'ceiling_auto',
174
+ type: 'ceiling',
175
+ parentId: 'level_test',
176
+ boundary: 'auto',
177
+ children: ['light_hung'],
178
+ },
179
+ light_hung: { id: 'light_hung', type: 'box-select-test-light', parentId: 'ceiling_auto' },
180
+ } as unknown as Record<string, AnyNode>,
181
+ })
182
+ expect(collectSelectableCandidateIds().sort()).toEqual(['light_hung', 'slab_drawn'])
183
+ } finally {
184
+ restore()
185
+ }
186
+ })
142
187
  })
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  type AnyNode,
3
3
  type AnyNodeId,
4
+ isDerivedNode,
4
5
  isRegistrySelectable,
5
6
  type LevelNode,
6
7
  nodeRegistry,
@@ -16,6 +17,14 @@ function isVisibleSelectableNode(node: AnyNode): boolean {
16
17
  return isRegistrySelectable(node.type)
17
18
  }
18
19
 
20
+ /**
21
+ * Derived construction (a room's automatic floor plate or ceiling) belongs to its rooms, so a box
22
+ * never takes it along: a box round a porch would otherwise catch the house's shared floor plate,
23
+ * and deleting the selection took the floor out from under every room on it. A click still selects
24
+ * one on its own.
25
+ */
26
+ const boxSelectable = (node: AnyNode) => !isDerivedNode(node)
27
+
19
28
  export function collectSelectableCandidateIds(): string[] {
20
29
  const { levelId } = useViewer.getState().selection
21
30
  const { nodes } = useScene.getState()
@@ -43,7 +52,8 @@ export function collectSelectableCandidateIds(): string[] {
43
52
  )
44
53
  return
45
54
 
46
- if (isRegistrySelectable(node.type)) {
55
+ // A ceiling's hung lights and fans are still the box's; the ceiling is not.
56
+ if (isRegistrySelectable(node.type) && boxSelectable(node)) {
47
57
  addNode(node)
48
58
  }
49
59
 
@@ -70,7 +80,8 @@ export function collectSelectableCandidateIds(): string[] {
70
80
 
71
81
  const buildingId = resolveBuildingForLevel(levelId as AnyNodeId, nodes)
72
82
  for (const node of Object.values(nodes)) {
73
- if (!node || node.type === 'level' || !isVisibleSelectableNode(node)) continue
83
+ if (!node || node.type === 'level' || !isVisibleSelectableNode(node) || !boxSelectable(node))
84
+ continue
74
85
 
75
86
  const def = nodeRegistry.get(node.type)
76
87
  const isBuildingScoped = def?.floorplanScope === 'building'
@@ -1,4 +1,4 @@
1
- import { Html } from '@react-three/drei'
1
+ import { type MessageDescriptor, usePascalI18n } from '@pascal-app/core'
2
2
  import type { ThreeElements } from '@react-three/fiber'
3
3
  import { forwardRef } from 'react'
4
4
  import { CircleGeometry, CylinderGeometry, type Group, SphereGeometry } from 'three'
@@ -8,6 +8,7 @@ import { EDITOR_LAYER } from '../../../lib/constants'
8
8
  import { wallCursorIcon } from '../../../lib/tool-cursor-icon'
9
9
  import useEditor from '../../../store/use-editor'
10
10
  import useWallSnapIndicator from '../../../store/use-wall-snap-indicator'
11
+ import { Html } from '../../editor/localized-html'
11
12
 
12
13
  interface CursorSphereProps extends Omit<ThreeElements['group'], 'ref'> {
13
14
  color?: string
@@ -51,6 +52,7 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
51
52
  },
52
53
  ref,
53
54
  ) {
55
+ const i18n = usePascalI18n()
54
56
  const tool = useEditor((s) => s.tool)
55
57
  const mode = useEditor((s) => s.mode)
56
58
  const catalogCategory = useEditor((s) => s.catalogCategory)
@@ -62,7 +64,8 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
62
64
  const wallMode = useEditor((s) => s.continuationByContext.wall)
63
65
 
64
66
  // Find the icon for the current tool — the wall shows its Rooms variant.
65
- let activeToolConfig: { label: string; iconSrc: string } | null | undefined = null
67
+ let activeToolConfig: { label: string | MessageDescriptor; iconSrc: string } | null | undefined =
68
+ null
66
69
  if (mode === 'build' && tool) {
67
70
  if (tool === 'wall') {
68
71
  activeToolConfig = wallCursorIcon(wallMode)
@@ -162,7 +165,11 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
162
165
  {tooltipContent || (
163
166
  // eslint-disable-next-line @next/next/no-img-element
164
167
  <img
165
- alt={activeToolConfig!.label}
168
+ alt={
169
+ typeof activeToolConfig!.label === 'string'
170
+ ? activeToolConfig!.label
171
+ : i18n._(activeToolConfig!.label)
172
+ }
166
173
  src={activeToolConfig!.iconSrc}
167
174
  style={{
168
175
  width: '100%',
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { Html } from '@react-three/drei'
3
+ import { Html } from '../../editor/localized-html'
4
4
 
5
5
  /** Lift of a draft's readout above the top of what it measures, in metres. */
6
6
  export const DRAFT_LABEL_Y_OFFSET = 0.22
@@ -2,7 +2,6 @@
2
2
 
3
3
  import '../../../three-types'
4
4
 
5
- import { Html } from '@react-three/drei'
6
5
  import { useEffect, useMemo } from 'react'
7
6
  import { PlaneGeometry } from 'three'
8
7
  import { distance, smoothstep, uv, vec2 } from 'three/tsl'
@@ -13,6 +12,7 @@ import {
13
12
  type LinearUnit,
14
13
  type MetricNotation,
15
14
  } from '../../../lib/measurements'
15
+ import { Html } from '../../editor/localized-html'
16
16
  import { createLineGeometry, getBoxEdgePoints } from './placement-box-geometry'
17
17
 
18
18
  const VALID_COLOR = 0x22_c5_5e // green-500
@@ -1,13 +1,13 @@
1
1
  'use client'
2
2
 
3
3
  import { useViewer } from '@pascal-app/viewer'
4
- import { Html } from '@react-three/drei'
5
4
  import { useLayoutEffect, useMemo } from 'react'
6
5
  import { BufferGeometry, Float32BufferAttribute, Line as ThreeLine } from 'three'
7
6
  import { LineBasicNodeMaterial } from 'three/webgpu'
8
7
  import { EDITOR_LAYER } from '../../../lib/constants'
9
8
  import type { PlacementPreviewDimension } from '../../../store/use-placement-preview'
10
9
  import usePlacementPreview from '../../../store/use-placement-preview'
10
+ import { Html } from '../../editor/localized-html'
11
11
  import { formatMeasurement } from '../../editor/measurement-pill'
12
12
 
13
13
  const DIMENSION_COLOR = 0x63_66_f1
@@ -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. */
@@ -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
+ }