@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
@@ -1,6 +1,7 @@
1
1
  import { afterEach, describe, expect, test } from 'bun:test'
2
2
  import { emitter } from '@pascal-app/core'
3
3
  import useEditor from '../store/use-editor'
4
+ import { CONTINUATION_PROFILES } from './continuation'
4
5
  import { toolHudTitle } from './hud-title'
5
6
  import {
6
7
  getWallDrawVariant,
@@ -23,9 +24,9 @@ describe('Rooms variants', () => {
23
24
  ])
24
25
  })
25
26
 
26
- test('default to the chain that closes into a room', () => {
27
- expect(getWallDrawVariant()).toBe('polygon')
28
- expect(wallDrawVariantOf('unknown')).toBe('polygon')
27
+ test('default to Rectangle', () => {
28
+ expect(wallDrawVariantOf(CONTINUATION_PROFILES.wall.default)).toBe('rectangle')
29
+ expect(wallDrawVariantOf('unknown')).toBe('rectangle')
29
30
  })
30
31
 
31
32
  test('picking one sets the wall tool drawing mode, and it sticks', () => {
@@ -61,11 +62,11 @@ describe('Rooms variants', () => {
61
62
 
62
63
  test('name the HUD after the variant in hand', () => {
63
64
  expect(toolHudTitle('wall', 'rectangle')).toEqual({
64
- label: 'Rectangle room',
65
+ label: expect.objectContaining({ message: 'Rectangle room' }),
65
66
  icon: { kind: 'url', src: '/icons/room.webp' },
66
67
  shortcut: 'B',
67
68
  })
68
- expect(toolHudTitle('wall', 'room')?.label).toBe('Polygon room')
69
- expect(toolHudTitle('wall', 'single')?.label).toBe('Walls')
69
+ expect(toolHudTitle('wall', 'room')?.label.message).toBe('Polygon room')
70
+ expect(toolHudTitle('wall', 'single')?.label.message).toBe('Walls')
70
71
  })
71
72
  })
@@ -1,6 +1,15 @@
1
- import { emitter } from '@pascal-app/core'
1
+ import {
2
+ emitter,
3
+ type MessageDescriptor,
4
+ railingWallDefaults,
5
+ useScene,
6
+ type WallNode,
7
+ } from '@pascal-app/core'
8
+ import { useViewer } from '@pascal-app/viewer'
9
+ import { create } from 'zustand'
2
10
  import useEditor from '../store/use-editor'
3
11
  import type { ContinuationMode } from './continuation'
12
+ import { levelSeparatorLines } from './separator-lines'
4
13
 
5
14
  /**
6
15
  * How the wall tool draws, picked from the Build panel's Rooms group before
@@ -16,7 +25,7 @@ export type WallDrawVariantInfo = {
16
25
  /** Tile label. */
17
26
  label: string
18
27
  /** The name the HUD shows while the variant is armed. */
19
- title: string
28
+ title: MessageDescriptor
20
29
  iconSrc: string
21
30
  /** Caption under the Rooms tiles: a bold lead, then the rest. */
22
31
  caption: { lead: string; rest: string }
@@ -27,7 +36,7 @@ export const WALL_DRAW_VARIANTS: readonly WallDrawVariantInfo[] = [
27
36
  id: 'rectangle',
28
37
  mode: 'rectangle',
29
38
  label: 'Rectangle',
30
- title: 'Rectangle room',
39
+ title: /* i18n */ { id: 'editor.lib.wallDrawVariant.rectangleRoom', message: 'Rectangle room' },
31
40
  iconSrc: '/icons/room.webp',
32
41
  caption: {
33
42
  lead: 'Click two corners',
@@ -38,7 +47,7 @@ export const WALL_DRAW_VARIANTS: readonly WallDrawVariantInfo[] = [
38
47
  id: 'polygon',
39
48
  mode: 'room',
40
49
  label: 'Polygon',
41
- title: 'Polygon room',
50
+ title: /* i18n */ { id: 'editor.lib.wallDrawVariant.polygonRoom', message: 'Polygon room' },
42
51
  iconSrc: '/icons/polygon-room.webp',
43
52
  caption: {
44
53
  lead: 'Click each corner,',
@@ -49,7 +58,7 @@ export const WALL_DRAW_VARIANTS: readonly WallDrawVariantInfo[] = [
49
58
  id: 'walls',
50
59
  mode: 'single',
51
60
  label: 'Walls',
52
- title: 'Walls',
61
+ title: /* i18n */ { id: 'editor.lib.wallDrawVariant.walls', message: 'Walls' },
53
62
  iconSrc: '/icons/wall.webp',
54
63
  caption: {
55
64
  lead: 'Draw one wall at a time.',
@@ -66,7 +75,7 @@ export function getWallDrawVariantInfo(variant: WallDrawVariant): WallDrawVarian
66
75
  }
67
76
 
68
77
  export function wallDrawVariantOf(mode: ContinuationMode): WallDrawVariant {
69
- return WALL_DRAW_VARIANTS.find((info) => info.mode === mode)?.id ?? 'polygon'
78
+ return WALL_DRAW_VARIANTS.find((info) => info.mode === mode)?.id ?? 'rectangle'
70
79
  }
71
80
 
72
81
  export function getWallDrawVariant(): WallDrawVariant {
@@ -89,3 +98,95 @@ export function selectWallDrawVariant(variant: WallDrawVariant): void {
89
98
  if (editor.mode === 'build' && editor.tool === 'wall') emitter.emit('tool:cancel')
90
99
  editor.setContinuation('wall', mode)
91
100
  }
101
+
102
+ /**
103
+ * What the wall tool draws in place of plain walls: invisible walls (room separators no wall
104
+ * stands on) or railings.
105
+ */
106
+ export type WallLineKind = 'invisible' | 'railing'
107
+
108
+ /**
109
+ * The Walls variant's kinds, its sub-row in the Build panel: a plain wall (room detection
110
+ * sides it exterior or interior), an invisible wall, or a railing.
111
+ */
112
+ export type WallKind = 'standard' | WallLineKind
113
+
114
+ export const WALL_KINDS: ReadonlyArray<{ id: WallKind; label: MessageDescriptor }> = [
115
+ {
116
+ id: 'standard',
117
+ label: /* i18n */ { id: 'editor.lib.wallDrawVariant.kind.standard', message: 'Standard' },
118
+ },
119
+ {
120
+ id: 'invisible',
121
+ label: /* i18n */ { id: 'editor.lib.wallDrawVariant.kind.invisible', message: 'Invisible' },
122
+ },
123
+ {
124
+ id: 'railing',
125
+ label: /* i18n */ { id: 'editor.lib.wallDrawVariant.kind.railing', message: 'Railing' },
126
+ },
127
+ ]
128
+
129
+ /** The line an invisible wall's draft is drawn at, metres. */
130
+ export const INVISIBLE_WALL_DRAFT_THICKNESS = 0.01
131
+
132
+ // Session-only, so it lives beside the persisted editor store, not in it.
133
+ const useWallLineKindStore = create<{ lineKind: WallLineKind | null }>(() => ({
134
+ lineKind: null,
135
+ }))
136
+
137
+ export function getWallLineKind(): WallLineKind | null {
138
+ return useWallLineKindStore.getState().lineKind
139
+ }
140
+
141
+ /** The kind the Walls variant draws, for the Build panel's sub-row. */
142
+ export function useWallKind(): WallKind {
143
+ return useWallLineKindStore((state) => state.lineKind ?? 'standard')
144
+ }
145
+
146
+ /**
147
+ * Pick what the Walls variant draws, for this session: it lasts while the wall tool draws
148
+ * Walls (`subscribeWallKindReset`). Switching while the tool is armed drops the in-flight
149
+ * draft, so a half-drawn wall never lands as another kind.
150
+ */
151
+ export function selectWallKind(kind: WallKind): void {
152
+ const lineKind = kind === 'standard' ? null : kind
153
+ if (getWallLineKind() === lineKind) return
154
+ const editor = useEditor.getState()
155
+ if (editor.mode === 'build' && editor.tool === 'wall') emitter.emit('tool:cancel')
156
+ useWallLineKindStore.setState({ lineKind })
157
+ }
158
+
159
+ /** Back to plain walls once the wall tool is left or draws another variant. */
160
+ export function subscribeWallKindReset(): () => void {
161
+ return useEditor.subscribe((state) => {
162
+ if (
163
+ state.mode === 'build' &&
164
+ state.tool === 'wall' &&
165
+ state.getContinuation('wall') === 'single'
166
+ )
167
+ return
168
+ if (getWallLineKind() !== null) useWallLineKindStore.setState({ lineKind: null })
169
+ })
170
+ }
171
+
172
+ /**
173
+ * What an invisible-wall draft snaps to besides walls: the current level's
174
+ * invisible walls, as lines (`separatorWallLine`), so a new one closes onto
175
+ * their ends and bodies — its old corner. Nothing while walls or railings draw.
176
+ */
177
+ export function invisibleWallDraftSnapLines(): WallNode[] {
178
+ if (getWallLineKind() !== 'invisible') return []
179
+ return levelSeparatorLines(useScene.getState().nodes, useViewer.getState().selection.levelId)
180
+ }
181
+
182
+ /**
183
+ * The thickness the wall tool's drafts draw at: an invisible wall's line, a railing's, a
184
+ * placed preset's (`toolDefaults.wall`), else `fallback` (a plain wall).
185
+ */
186
+ export function useWallDraftThickness(fallback: number): number {
187
+ const lineKind = useWallLineKindStore((state) => state.lineKind)
188
+ const seeded = useEditor((state) => state.toolDefaults.wall?.thickness)
189
+ if (lineKind === 'invisible') return INVISIBLE_WALL_DRAFT_THICKNESS
190
+ if (lineKind === 'railing') return railingWallDefaults().thickness
191
+ return typeof seeded === 'number' ? seeded : fallback
192
+ }
@@ -0,0 +1,44 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { WallNode } from '@pascal-app/core'
3
+ import { isWallNudgeKey, WALL_NUDGE_IMPERIAL, wallNudgeDistance } from './wall-nudge'
4
+
5
+ const outside = { frontSide: 'exterior', backSide: 'interior' } as const
6
+ const wall = (id: string, start: [number, number], end: [number, number]) =>
7
+ WallNode.parse({ id: `wall_${id}`, start, end, thickness: 0.15, ...outside })
8
+
9
+ describe('wallNudgeDistance', () => {
10
+ const moving = wall('moving', [5, 0.3], [8, 0.3])
11
+
12
+ test('an arrow across the wall moves it one inch that way', () => {
13
+ const down = wallNudgeDistance(moving, [0, 1], [moving], WALL_NUDGE_IMPERIAL)
14
+ const up = wallNudgeDistance(moving, [0, -1], [moving], WALL_NUDGE_IMPERIAL)
15
+ expect(down).not.toBeNull()
16
+ expect(up).not.toBeNull()
17
+ // Signed along the move axis: both land 1 in from where the wall was, opposite ways.
18
+ const move = (nudge: NonNullable<typeof down>) => nudge.axis[1] * nudge.distance
19
+ expect(move(down!)).toBeCloseTo(0.0254, 10)
20
+ expect(move(up!)).toBeCloseTo(-0.0254, 10)
21
+ })
22
+
23
+ test('an arrow along the wall does nothing', () => {
24
+ expect(wallNudgeDistance(moving, [1, 0], [moving], WALL_NUDGE_IMPERIAL)).toBeNull()
25
+ expect(wallNudgeDistance(moving, [-1, 0], [moving], WALL_NUDGE_IMPERIAL)).toBeNull()
26
+ })
27
+
28
+ test('stops flush with a parallel outside wall it would pass', () => {
29
+ const top = wall('top', [0, 0.29], [4, 0.29])
30
+ const nudge = wallNudgeDistance(moving, [0, -1], [moving, top], WALL_NUDGE_IMPERIAL)!
31
+ expect(nudge.axis[1] * nudge.distance).toBeCloseTo(-0.01, 10)
32
+ })
33
+
34
+ test('a turned plan still picks the arrow nearest across the wall', () => {
35
+ const turned = [Math.SQRT1_2, Math.SQRT1_2] as [number, number]
36
+ const nudge = wallNudgeDistance(moving, turned, [moving], WALL_NUDGE_IMPERIAL)!
37
+ expect(nudge.axis[1] * nudge.distance).toBeCloseTo(0.0254, 10)
38
+ })
39
+ })
40
+
41
+ test('only the four bare arrows nudge', () => {
42
+ expect(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].every(isWallNudgeKey)).toBe(true)
43
+ expect(isWallNudgeKey('w')).toBe(false)
44
+ })
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Arrow keys nudge the selected wall square to itself in the floor plan: one
3
+ * inch a press (a centimetre in metric), or less when a parallel wall it lines
4
+ * up with comes first (`wall-parallel-snap`), so it stops flush. The arrow's
5
+ * direction on screen picks the way; an arrow along the wall does nothing.
6
+ * The nudge is the side arrows' push (`commitRoomPush`), so joined walls
7
+ * follow, jogs and overlaps come out as when the wall is pushed, and each
8
+ * press is one undo step.
9
+ */
10
+
11
+ import {
12
+ type AnyNodeId,
13
+ getPerpendicularWallMoveAxis,
14
+ useScene,
15
+ type WallNode,
16
+ type WallPlanPoint,
17
+ } from '@pascal-app/core'
18
+ import { useViewer } from '@pascal-app/viewer'
19
+ import { commitRoomPush } from './room-handle-drag'
20
+ import { levelSeparatorLines, separatorWallLine } from './separator-lines'
21
+ import { nudgeWallPosition, wallParallelTargets } from './wall-parallel-snap'
22
+
23
+ /** One nudge in feet and inches, in metres. */
24
+ export const WALL_NUDGE_IMPERIAL = 0.0254
25
+ /** One nudge in metric, in metres. */
26
+ export const WALL_NUDGE_METRIC = 0.01
27
+
28
+ const ARROW_SCREEN_DIRECTIONS: Record<string, [number, number]> = {
29
+ ArrowUp: [0, -1],
30
+ ArrowDown: [0, 1],
31
+ ArrowLeft: [-1, 0],
32
+ ArrowRight: [1, 0],
33
+ }
34
+
35
+ // An arrow within about 67° of the wall's own direction runs along it.
36
+ const MIN_ACROSS = 0.4
37
+
38
+ /** True for the four bare arrow keys. */
39
+ export function isWallNudgeKey(key: string): boolean {
40
+ return key in ARROW_SCREEN_DIRECTIONS
41
+ }
42
+
43
+ /**
44
+ * The plan direction an arrow points on screen: the shown floor plan's scene
45
+ * transform, turned or not, maps it back. Null when no floor plan is shown.
46
+ */
47
+ export function floorplanArrowDirection(key: string): WallPlanPoint | null {
48
+ const screen = ARROW_SCREEN_DIRECTIONS[key]
49
+ if (!screen || typeof document === 'undefined') return null
50
+ const scenes = document.querySelectorAll<SVGGraphicsElement>('[data-floorplan-scene]')
51
+ for (const scene of scenes) {
52
+ const rect = scene.ownerSVGElement?.getBoundingClientRect()
53
+ const ctm = scene.getScreenCTM()
54
+ if (!ctm || !rect || rect.width === 0 || rect.height === 0) continue
55
+ const inverse = ctm.inverse()
56
+ const origin = new DOMPoint(0, 0).matrixTransform(inverse)
57
+ const tip = new DOMPoint(screen[0], screen[1]).matrixTransform(inverse)
58
+ const dx = tip.x - origin.x
59
+ const dz = tip.y - origin.y
60
+ const length = Math.hypot(dx, dz)
61
+ if (length > 1e-12) return [dx / length, dz / length]
62
+ }
63
+ return null
64
+ }
65
+
66
+ /**
67
+ * How far (signed, along the wall's move axis) one nudge of `wall` in plan
68
+ * direction `direction` goes; null when the direction runs along the wall.
69
+ */
70
+ export function wallNudgeDistance(
71
+ wall: WallNode,
72
+ direction: WallPlanPoint,
73
+ walls: readonly WallNode[],
74
+ step: number,
75
+ ): { axis: WallPlanPoint; distance: number } | null {
76
+ const axis = getPerpendicularWallMoveAxis(wall.start, wall.end)
77
+ if (!axis) return null
78
+ const across = direction[0] * axis[0] + direction[1] * axis[1]
79
+ if (Math.abs(across) < MIN_ACROSS) return null
80
+ const position = wall.start[0] * axis[0] + wall.start[1] * axis[1]
81
+ const end = nudgeWallPosition(
82
+ wallParallelTargets(wall, axis, walls),
83
+ position,
84
+ Math.sign(across) * step,
85
+ )
86
+ return { axis, distance: end - position }
87
+ }
88
+
89
+ /**
90
+ * Nudge the one selected wall for an arrow key while the floor plan is shown.
91
+ * An invisible wall nudges the same way, as a line: it stops flush with the
92
+ * walls and the other invisible walls, and those joined to it follow.
93
+ * Returns true when the key was used.
94
+ */
95
+ export function nudgeSelectedWall(key: string): boolean {
96
+ const { selectedIds } = useViewer.getState().selection
97
+ if (selectedIds.length !== 1) return false
98
+ const nodes = useScene.getState().nodes
99
+ const selected = nodes[selectedIds[0] as AnyNodeId]
100
+ if (selected?.type !== 'wall' && selected?.type !== 'separator') return false
101
+ const direction = floorplanArrowDirection(key)
102
+ if (!direction) return false
103
+
104
+ const walls = Object.values(nodes).filter(
105
+ (entry): entry is WallNode => entry?.type === 'wall' && entry.parentId === selected.parentId,
106
+ )
107
+ const step = useViewer.getState().unit === 'imperial' ? WALL_NUDGE_IMPERIAL : WALL_NUDGE_METRIC
108
+ const nudge =
109
+ selected.type === 'wall'
110
+ ? wallNudgeDistance(selected, direction, walls, step)
111
+ : wallNudgeDistance(
112
+ separatorWallLine(selected),
113
+ direction,
114
+ [...walls, ...levelSeparatorLines(nodes, selected.parentId, new Set([selected.id]))],
115
+ step,
116
+ )
117
+ if (!nudge) return false
118
+ commitRoomPush(
119
+ selected.type === 'wall'
120
+ ? { wallId: selected.id, t0: 0, t1: 1 }
121
+ : {
122
+ wallId: selected.id,
123
+ t0: 0,
124
+ t1: 1,
125
+ separators: [{ separatorId: selected.id, t0: 0, t1: 1 }],
126
+ },
127
+ nudge.axis,
128
+ nudge.distance,
129
+ )
130
+ return true
131
+ }
@@ -0,0 +1,83 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { WallNode } from '@pascal-app/core'
3
+ import {
4
+ nearestWallParallelTarget,
5
+ nudgeWallPosition,
6
+ WALL_PARALLEL_SNAP_RADIUS,
7
+ wallParallelTargets,
8
+ } from './wall-parallel-snap'
9
+
10
+ const wall = (id: string, start: [number, number], end: [number, number], extra = {}) =>
11
+ WallNode.parse({ id: `wall_${id}`, start, end, thickness: 0.15, ...extra })
12
+ const outside = { frontSide: 'exterior', backSide: 'interior' } as const
13
+ const DOWN: [number, number] = [0, 1]
14
+
15
+ describe('wallParallelTargets', () => {
16
+ test('an outside wall lines up with a parallel outside wall, not a partition', () => {
17
+ const moving = wall('moving', [5, 0.3], [8, 0.3], outside)
18
+ const top = wall('top', [0, 0.07], [4, 0.07], outside)
19
+ const partition = wall('partition', [1, 0.2], [3, 0.2])
20
+ const side = wall('side', [4, 0.07], [4, 4], outside)
21
+
22
+ const targets = wallParallelTargets(moving, DOWN, [moving, top, partition, side])
23
+
24
+ expect(targets.map((target) => target.wallId)).toEqual(['wall_top'])
25
+ expect(targets).toHaveLength(1)
26
+ expect(targets[0]!.position).toBeCloseTo(0.07)
27
+ // The guide runs along both walls, on the top wall's outdoor face: frontSide
28
+ // is the left-normal side, +z for a wall running +x.
29
+ const [from, to] = [targets[0]!.from, targets[0]!.to]
30
+ expect([from[0], to[0]]).toEqual([0, 8])
31
+ expect(from[1]).toBeCloseTo(0.07 + 0.075)
32
+ expect(to[1]).toBeCloseTo(0.07 + 0.075)
33
+ })
34
+
35
+ test('a partition lines up with every parallel wall', () => {
36
+ const moving = wall('moving', [1, 2], [3, 2])
37
+ const top = wall('top', [0, 0], [4, 0], outside)
38
+ const other = wall('other', [1, 3], [3, 3])
39
+ const ids = wallParallelTargets(moving, DOWN, [moving, top, other]).map((t) => t.wallId)
40
+ expect(new Set(ids)).toEqual(new Set(['wall_top', 'wall_other']))
41
+ })
42
+
43
+ test('walls of different thickness line up face to face and centre to centre', () => {
44
+ const moving = wall('moving', [5, 1], [8, 1], { ...outside, thickness: 0.2 })
45
+ const top = wall('top', [0, 0], [4, 0], { ...outside, thickness: 0.1 })
46
+ const positions = wallParallelTargets(moving, DOWN, [top]).map((t) => t.position)
47
+ // Faces at ±0.05 against ±0.1, and the centres.
48
+ expect(positions.map((p) => Number(p.toFixed(3))).sort()).toEqual([-0.05, 0, 0.05])
49
+ })
50
+
51
+ test('a reversed or justified wall lines up by its faces', () => {
52
+ const moving = wall('moving', [8, 1], [5, 1], { ...outside, justification: 'a' })
53
+ const top = wall('top', [0, 0], [4, 0], outside)
54
+ const positions = wallParallelTargets(moving, DOWN, [top]).map((t) => t.position)
55
+ // Justified 'a' on a wall running -x puts its body on the -z side of its
56
+ // line, so its faces and centre all line up with the top wall's when that
57
+ // line sits half a thickness below it.
58
+ expect(positions.map((p) => Number(p.toFixed(3)))).toEqual([0.075])
59
+ })
60
+ })
61
+
62
+ describe('nearestWallParallelTarget', () => {
63
+ test('snaps within the radius and lets go past it', () => {
64
+ const moving = wall('moving', [5, 0.3], [8, 0.3], outside)
65
+ const targets = wallParallelTargets(moving, DOWN, [wall('top', [0, 0], [4, 0], outside)])
66
+ expect(nearestWallParallelTarget(targets, 0.1)?.position).toBeCloseTo(0)
67
+ expect(nearestWallParallelTarget(targets, WALL_PARALLEL_SNAP_RADIUS + 0.01)).toBeNull()
68
+ })
69
+ })
70
+
71
+ describe('nudgeWallPosition', () => {
72
+ const moving = wall('moving', [5, 0.3], [8, 0.3], outside)
73
+ const targets = wallParallelTargets(moving, DOWN, [wall('top', [0, 0.29], [4, 0.29], outside)])
74
+
75
+ test('stops on a line-up it would pass', () => {
76
+ expect(nudgeWallPosition(targets, 0.3, -0.0254)).toBeCloseTo(0.29)
77
+ })
78
+
79
+ test('otherwise moves the whole step', () => {
80
+ expect(nudgeWallPosition(targets, 0.3, 0.0254)).toBeCloseTo(0.3254)
81
+ expect(nudgeWallPosition(targets, 0.29, -0.0254)).toBeCloseTo(0.2646)
82
+ })
83
+ })
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Lines a wall that is moving sideways up with the parallel walls around it:
3
+ * a face onto the face on the same side of another wall, or its body centre
4
+ * onto theirs. An outside wall broken up by partitions moves one piece at a
5
+ * time, so each piece can be pulled back flush with the rest of the outside.
6
+ *
7
+ * A wall with an outdoor face lines up only with other outside walls; any
8
+ * other wall lines up with every wall on its level. The 2D and 3D wall moves
9
+ * snap through here, and the arrow-key nudge stops on these positions.
10
+ */
11
+
12
+ import {
13
+ getWallFaceOffsets,
14
+ isCurvedWall,
15
+ type WallNode,
16
+ type WallPlanPoint,
17
+ } from '@pascal-app/core'
18
+ import useAlignmentGuides from '../store/use-alignment-guides'
19
+
20
+ /** How near (metres) a moving wall comes to lining up before it snaps there. */
21
+ export const WALL_PARALLEL_SNAP_RADIUS = 0.15
22
+
23
+ // Walls within about 1° of each other count as parallel.
24
+ const PARALLEL_TOLERANCE = Math.sin(Math.PI / 180)
25
+ const SAME_POSITION = 1e-6
26
+
27
+ type MovingWall = Pick<
28
+ WallNode,
29
+ 'id' | 'start' | 'end' | 'thickness' | 'justification' | 'frontSide' | 'backSide'
30
+ > &
31
+ Partial<Pick<WallNode, 'curveOffset'>>
32
+
33
+ /**
34
+ * A position where the moving wall lines up with another: `position` is where
35
+ * its reference line sits along the move axis, `from` → `to` the shared face
36
+ * or centre line, drawn along both walls.
37
+ */
38
+ export type WallParallelTarget = {
39
+ position: number
40
+ wallId: string
41
+ from: WallPlanPoint
42
+ to: WallPlanPoint
43
+ }
44
+
45
+ function isOutsideWall(wall: Pick<WallNode, 'frontSide' | 'backSide'>): boolean {
46
+ return wall.frontSide === 'exterior' || wall.backSide === 'exterior'
47
+ }
48
+
49
+ function direction(wall: Pick<WallNode, 'start' | 'end'>): WallPlanPoint | null {
50
+ const dx = wall.end[0] - wall.start[0]
51
+ const dz = wall.end[1] - wall.start[1]
52
+ const length = Math.hypot(dx, dz)
53
+ return length > 1e-9 ? [dx / length, dz / length] : null
54
+ }
55
+
56
+ const dot = (a: readonly [number, number], b: readonly [number, number]) =>
57
+ a[0] * b[0] + a[1] * b[1]
58
+
59
+ // Which way the wall's left normal (-dy, dx) runs along `axis`.
60
+ function normalSign(dir: WallPlanPoint, axis: WallPlanPoint): number {
61
+ return Math.sign(dot([-dir[1], dir[0]], axis)) || 1
62
+ }
63
+
64
+ /**
65
+ * The wall's faces and body centre along `axis`, relative to its reference
66
+ * line: the face on the axis's positive side, the other face, the centre.
67
+ */
68
+ function sidePlanes(wall: MovingWall, dir: WallPlanPoint, axis: WallPlanPoint) {
69
+ const sign = normalSign(dir, axis)
70
+ const { a, b } = getWallFaceOffsets(wall)
71
+ const high = Math.max(a * sign, b * sign)
72
+ const low = Math.min(a * sign, b * sign)
73
+ return [high, low, (high + low) / 2] as const
74
+ }
75
+
76
+ /**
77
+ * The order to try a wall's planes in, so the guide of planes that coincide
78
+ * is drawn on its outdoor face (frontSide is the left-normal side), else on
79
+ * its centre.
80
+ */
81
+ function planeOrder(wall: MovingWall, dir: WallPlanPoint, axis: WallPlanPoint): number[] {
82
+ const outdoor = wall.frontSide === 'exterior' ? 1 : wall.backSide === 'exterior' ? -1 : 0
83
+ const outward = outdoor * normalSign(dir, axis)
84
+ return outward > 0 ? [0, 1, 2] : outward < 0 ? [1, 0, 2] : [2, 0, 1]
85
+ }
86
+
87
+ /**
88
+ * Every position along `axis` (a unit vector square to the wall) where `wall`
89
+ * lines up with a parallel wall in `walls`.
90
+ */
91
+ export function wallParallelTargets(
92
+ wall: MovingWall,
93
+ axis: WallPlanPoint,
94
+ walls: readonly WallNode[],
95
+ ): WallParallelTarget[] {
96
+ const dir = direction(wall)
97
+ if (!dir || isCurvedWall(wall)) return []
98
+ const own = sidePlanes(wall, dir, axis)
99
+ const outsideOnly = isOutsideWall(wall)
100
+ const ownSpan = [dot(wall.start, dir), dot(wall.end, dir)]
101
+ const targets: WallParallelTarget[] = []
102
+
103
+ for (const other of walls) {
104
+ if (other.id === wall.id || isCurvedWall(other)) continue
105
+ if (outsideOnly && !isOutsideWall(other)) continue
106
+ const otherDir = direction(other)
107
+ if (!otherDir || Math.abs(otherDir[0] * dir[1] - otherDir[1] * dir[0]) > PARALLEL_TOLERANCE) {
108
+ continue
109
+ }
110
+ const line = dot(other.start, axis)
111
+ const planes = sidePlanes(other, otherDir, axis)
112
+ const along = [...ownSpan, dot(other.start, dir), dot(other.end, dir)]
113
+ const lo = Math.min(...along)
114
+ const hi = Math.max(...along)
115
+ const seen: number[] = []
116
+ for (const index of planeOrder(other, otherDir, axis)) {
117
+ const shared = line + planes[index]!
118
+ const position = shared - own[index]!
119
+ if (seen.some((value) => Math.abs(value - position) < SAME_POSITION)) continue
120
+ seen.push(position)
121
+ targets.push({
122
+ position,
123
+ wallId: other.id,
124
+ from: [dir[0] * lo + axis[0] * shared, dir[1] * lo + axis[1] * shared],
125
+ to: [dir[0] * hi + axis[0] * shared, dir[1] * hi + axis[1] * shared],
126
+ })
127
+ }
128
+ }
129
+ return targets
130
+ }
131
+
132
+ /** The target nearest `position`, if one is within `radius`. */
133
+ export function nearestWallParallelTarget(
134
+ targets: readonly WallParallelTarget[],
135
+ position: number,
136
+ radius = WALL_PARALLEL_SNAP_RADIUS,
137
+ ): WallParallelTarget | null {
138
+ let best: WallParallelTarget | null = null
139
+ for (const target of targets) {
140
+ const gap = Math.abs(target.position - position)
141
+ if (gap > radius) continue
142
+ if (!best || gap < Math.abs(best.position - position)) best = target
143
+ }
144
+ return best
145
+ }
146
+
147
+ /**
148
+ * Where a nudge of `step` (signed, along the axis) from `position` ends: the
149
+ * full step, or the first line-up it passes on the way.
150
+ */
151
+ export function nudgeWallPosition(
152
+ targets: readonly WallParallelTarget[],
153
+ position: number,
154
+ step: number,
155
+ ): number {
156
+ let end = position + step
157
+ for (const target of targets) {
158
+ const travel = (target.position - position) * Math.sign(step)
159
+ if (travel > SAME_POSITION && travel < Math.abs(step) - SAME_POSITION) {
160
+ if (Math.abs(target.position - position) < Math.abs(end - position)) end = target.position
161
+ }
162
+ }
163
+ return end
164
+ }
165
+
166
+ /** Show the line a moving wall has snapped onto (null clears it). */
167
+ export function publishWallParallelGuide(target: WallParallelTarget | null): void {
168
+ if (!target) {
169
+ useAlignmentGuides.getState().clear()
170
+ return
171
+ }
172
+ useAlignmentGuides.getState().setDraft({
173
+ lines: [{ kind: 'alignment', from: target.from, to: target.to }],
174
+ intersections: [],
175
+ })
176
+ }
@@ -4,6 +4,7 @@ import {
4
4
  createWallPointerTracker,
5
5
  wallPointerInParent,
6
6
  wallSideAtPointer,
7
+ wallSideOfPlanPoint,
7
8
  } from './wall-placement-pointer'
8
9
 
9
10
  test('keeps the grab offset across a pointer gap on the original face', () => {
@@ -40,3 +41,25 @@ test('reads the pointer in the rotated wall parent and identifies its face', ()
40
41
  expect(local[1]).toBeCloseTo(3)
41
42
  expect(wallSideAtPointer(local)).toBe('back')
42
43
  })
44
+
45
+ test('a plan point reads the wall face it is on, as a 3D hover does', () => {
46
+ // Along +X the frame's +Z (front) is the plan's +Z side.
47
+ const wall = {
48
+ start: [0, 0] as [number, number],
49
+ end: [4, 0] as [number, number],
50
+ thickness: 0.2,
51
+ }
52
+ expect(wallSideOfPlanPoint(wall, [2, 0.3])).toBe('front')
53
+ expect(wallSideOfPlanPoint(wall, [2, -0.3])).toBe('back')
54
+ // Drawn the other way, the same point is on its back.
55
+ expect(
56
+ wallSideOfPlanPoint(
57
+ { ...wall, start: [4, 0] as [number, number], end: [0, 0] as [number, number] },
58
+ [2, 0.3],
59
+ ),
60
+ ).toBe('back')
61
+ // A justified wall splits at its body's middle, not its reference line.
62
+ const justified = { ...wall, justification: 'a' as const }
63
+ expect(wallSideOfPlanPoint(justified, [2, 0.05])).toBe('back')
64
+ expect(wallSideOfPlanPoint(justified, [2, 0.15])).toBe('front')
65
+ })