@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
@@ -0,0 +1,76 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { type AnyNode, BuildingNode, LevelNode, WallNode } from '@pascal-app/core'
3
+ import { normalizePersistedEditorLayoutState } from '../../store/use-editor'
4
+ import { adjacentWallLevels, adjacentWallOutlines } from './floorplan-adjacent-walls-layer'
5
+
6
+ function house() {
7
+ const building = BuildingNode.parse({ id: 'building_adj' })
8
+ const levels = ['ground', 'upper', 'roof'].map((name, level) =>
9
+ LevelNode.parse({
10
+ id: `level_adj_${name}`,
11
+ parentId: building.id,
12
+ level,
13
+ height: 3,
14
+ ...(name === 'roof' ? { metadata: { role: 'roof' } } : {}),
15
+ }),
16
+ )
17
+ building.children = levels.map((level) => level.id)
18
+ const walls = [
19
+ WallNode.parse({
20
+ id: 'wall_adj_a',
21
+ parentId: levels[1]!.id,
22
+ start: [0, 0],
23
+ end: [6, 0],
24
+ thickness: 0.2,
25
+ }),
26
+ WallNode.parse({
27
+ id: 'wall_adj_b',
28
+ parentId: levels[1]!.id,
29
+ start: [6, 0],
30
+ end: [6, 4],
31
+ thickness: 0.2,
32
+ }),
33
+ WallNode.parse({ id: 'wall_adj_c', parentId: levels[0]!.id, start: [0, 0], end: [6, 0] }),
34
+ ]
35
+ return Object.fromEntries(
36
+ [building, ...levels, ...walls].map((node) => [node.id, node]),
37
+ ) as Record<string, AnyNode>
38
+ }
39
+
40
+ test('the roof plan traces the top storey’s walls below, both faces, joined at the corner', () => {
41
+ const nodes = house()
42
+ expect(adjacentWallLevels(nodes, 'level_adj_roof', { below: true, above: true })).toEqual({
43
+ below: 'level_adj_upper',
44
+ above: null,
45
+ })
46
+ expect(adjacentWallLevels(nodes, 'level_adj_upper', { below: true, above: false })).toEqual({
47
+ below: 'level_adj_ground',
48
+ above: null,
49
+ })
50
+ const outlines = adjacentWallOutlines(nodes, 'level_adj_upper')
51
+ expect(outlines.map((outline) => outline.id).sort()).toEqual(['wall_adj_a', 'wall_adj_b'])
52
+ const points = outlines
53
+ .find((outline) => outline.id === 'wall_adj_a')!
54
+ .points.split(' ')
55
+ .map((pair) => pair.split(',').map(Number) as [number, number])
56
+ const zs = points.map(([, z]) => z)
57
+ // Both faces of the 0.2 m wall, and mitred into the corner at x = 6 (past the centreline).
58
+ expect(Math.max(...zs) - Math.min(...zs)).toBeCloseTo(0.2)
59
+ expect(Math.max(...points.map(([x]) => x))).toBeCloseTo(6.1)
60
+ })
61
+
62
+ test('walls below start on for the roof plan and off for the floor plans, each kept as set', () => {
63
+ const fresh = normalizePersistedEditorLayoutState(undefined)
64
+ expect(fresh.showRoofWallsBelow).toBe(true)
65
+ expect(fresh.showWallsBelow).toBe(false)
66
+ // Saved before the roof plan had its own setting: the roof plan still starts on.
67
+ expect(normalizePersistedEditorLayoutState({ showWallsBelow: false }).showRoofWallsBelow).toBe(
68
+ true,
69
+ )
70
+ const turnedOff = normalizePersistedEditorLayoutState({
71
+ showRoofWallsBelow: false,
72
+ showWallsBelow: true,
73
+ })
74
+ expect(turnedOff.showRoofWallsBelow).toBe(false)
75
+ expect(turnedOff.showWallsBelow).toBe(true)
76
+ })
@@ -0,0 +1,186 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ calculateLevelMiters,
7
+ getLevelAbove,
8
+ getLevelBelow,
9
+ getWallPlanFootprint,
10
+ type LevelNode,
11
+ type Point2D,
12
+ servesAsRoofLevel,
13
+ useScene,
14
+ type WallNode,
15
+ } from '@pascal-app/core'
16
+ import { memo, useMemo } from 'react'
17
+ import { useShallow } from 'zustand/react/shallow'
18
+ import useEditor from '../../store/use-editor'
19
+ import { useLevelWallCopyOffer } from '../ui/level-wall-copy-prompt'
20
+
21
+ /** Walls below read blue, walls above magenta: told apart at a glance and from the active walls. */
22
+ export const ADJACENT_WALL_COLORS = { below: '#2563eb', above: '#c026d3' } as const
23
+
24
+ export type AdjacentWallOutline = { id: string; points: string }
25
+
26
+ /**
27
+ * Each visible wall of `levelId` as the outline of its full thickness (both faces, joined at the
28
+ * corners as the level draws them), in plan metres.
29
+ */
30
+ export function adjacentWallOutlines(
31
+ nodes: Record<string, AnyNode>,
32
+ levelId: string | null | undefined,
33
+ ): AdjacentWallOutline[] {
34
+ if (!levelId) return []
35
+ const walls = Object.values(nodes).filter(
36
+ (node): node is WallNode =>
37
+ node.type === 'wall' && node.parentId === levelId && node.visible !== false,
38
+ )
39
+ if (!walls.length) return []
40
+ const miters = calculateLevelMiters(walls)
41
+ return walls.flatMap((wall) => {
42
+ const polygon: Point2D[] = getWallPlanFootprint(wall, miters)
43
+ return polygon.length >= 3
44
+ ? [{ id: wall.id, points: polygon.map((point) => `${point.x},${point.y}`).join(' ') }]
45
+ : []
46
+ })
47
+ }
48
+
49
+ /**
50
+ * The levels directly below and above `levelId` in its building's stack (by ordinal, as the
51
+ * storey stack orders them): on a roof level, "below" is the top storey, the walls the roof bears
52
+ * on. Reads only the building's levels, so a plan selector can call it on every scene change.
53
+ */
54
+ export function adjacentLevelIds(
55
+ nodes: Record<string, AnyNode>,
56
+ levelId: string | null | undefined,
57
+ ): { below: string | null; above: string | null } {
58
+ const level = levelId ? nodes[levelId] : undefined
59
+ if (level?.type !== 'level') return { below: null, above: null }
60
+ const building = level.parentId ? nodes[level.parentId] : undefined
61
+ if (building?.type !== 'building') {
62
+ const graph = nodes as Record<AnyNodeId, AnyNode>
63
+ return {
64
+ below: getLevelBelow(level.id, graph)?.id ?? null,
65
+ above: getLevelAbove(level.id, graph)?.id ?? null,
66
+ }
67
+ }
68
+ let below: LevelNode | null = null
69
+ let above: LevelNode | null = null
70
+ for (const id of building.children) {
71
+ const other = nodes[id]
72
+ if (other?.type !== 'level' || other.id === level.id) continue
73
+ if (other.level < level.level && (!below || other.level > below.level)) below = other
74
+ if (other.level > level.level && (!above || other.level < above.level)) above = other
75
+ }
76
+ return { below: below?.id ?? null, above: above?.id ?? null }
77
+ }
78
+
79
+ /** The adjacent levels whose walls the plan traces: each only while it is shown. */
80
+ export function adjacentWallLevels(
81
+ nodes: Record<string, AnyNode>,
82
+ levelId: string | null | undefined,
83
+ show: { below: boolean; above: boolean },
84
+ ): { below: string | null; above: string | null } {
85
+ const levels = adjacentLevelIds(nodes, levelId)
86
+ return { below: show.below ? levels.below : null, above: show.above ? levels.above : null }
87
+ }
88
+
89
+ /**
90
+ * The "Walls below" setting that applies on `levelId`: the roof plan's, on until turned off, on a
91
+ * roof level; the floor plans', off until turned on, on a storey.
92
+ */
93
+ export function useWallsBelowSetting(levelId: string | null | undefined): {
94
+ onRoofLevel: boolean
95
+ shown: boolean
96
+ setShown: (show: boolean) => void
97
+ } {
98
+ const onRoofLevel = useScene((state) => {
99
+ const level = levelId ? state.nodes[levelId as AnyNodeId] : undefined
100
+ return level?.type === 'level' && servesAsRoofLevel(state.nodes, level)
101
+ })
102
+ const shown = useEditor((state) =>
103
+ onRoofLevel ? state.showRoofWallsBelow : state.showWallsBelow,
104
+ )
105
+ const setRoof = useEditor((state) => state.setShowRoofWallsBelow)
106
+ const setFloor = useEditor((state) => state.setShowWallsBelow)
107
+ return { onRoofLevel, shown, setShown: onRoofLevel ? setRoof : setFloor }
108
+ }
109
+
110
+ /** The level's own walls, read through its children rather than the whole scene. */
111
+ function useLevelWalls(levelId: string | null) {
112
+ return useScene(
113
+ useShallow((state) => {
114
+ const level = levelId ? state.nodes[levelId as AnyNodeId] : undefined
115
+ if (level?.type !== 'level') return [] as WallNode[]
116
+ return level.children.flatMap((id) => {
117
+ const node = state.nodes[id as AnyNodeId]
118
+ return node?.type === 'wall' ? [node] : []
119
+ })
120
+ }),
121
+ )
122
+ }
123
+
124
+ function Outlines({ color, outlines }: { color: string; outlines: AdjacentWallOutline[] }) {
125
+ return (
126
+ <>
127
+ {outlines.map((outline) => (
128
+ <polygon
129
+ fill={color}
130
+ fillOpacity={0.05}
131
+ key={outline.id}
132
+ points={outline.points}
133
+ stroke={color}
134
+ strokeDasharray="7 5"
135
+ strokeLinejoin="miter"
136
+ strokeOpacity={0.9}
137
+ strokeWidth={1.4}
138
+ vectorEffect="non-scaling-stroke"
139
+ />
140
+ ))}
141
+ </>
142
+ )
143
+ }
144
+
145
+ /**
146
+ * "Walls below / above": dashed outlines of the walls on the neighbouring levels, drawn over the
147
+ * active level's plan so second-storey walls line up with the ones under them and a roof outline
148
+ * can be checked against the walls it covers — while its toggle is on, or while a new level's
149
+ * "Copy exterior walls?" offer is open. Display only: never hit-tested, never snapped to.
150
+ */
151
+ export const FloorplanAdjacentWallsLayer = memo(function FloorplanAdjacentWallsLayer({
152
+ levelId,
153
+ }: {
154
+ levelId: string | null | undefined
155
+ }) {
156
+ const offer = useLevelWallCopyOffer((state) =>
157
+ state.offer && state.offer.levelId === levelId ? state.offer.direction : null,
158
+ )
159
+ const showBelow = useWallsBelowSetting(levelId).shown || offer === 'below'
160
+ const showAbove = useEditor((state) => state.showWallsAbove) || offer === 'above'
161
+ const levels = useScene(
162
+ useShallow((state) =>
163
+ adjacentWallLevels(state.nodes as Record<string, AnyNode>, levelId, {
164
+ below: showBelow,
165
+ above: showAbove,
166
+ }),
167
+ ),
168
+ )
169
+ const belowWalls = useLevelWalls(levels.below)
170
+ const aboveWalls = useLevelWalls(levels.above)
171
+ const below = useMemo(
172
+ () => adjacentWallOutlines(Object.fromEntries(belowWalls.map((w) => [w.id, w])), levels.below),
173
+ [belowWalls, levels.below],
174
+ )
175
+ const above = useMemo(
176
+ () => adjacentWallOutlines(Object.fromEntries(aboveWalls.map((w) => [w.id, w])), levels.above),
177
+ [aboveWalls, levels.above],
178
+ )
179
+ if (!(below.length || above.length)) return null
180
+ return (
181
+ <g data-adjacent-walls pointerEvents="none">
182
+ <Outlines color={ADJACENT_WALL_COLORS.below} outlines={below} />
183
+ <Outlines color={ADJACENT_WALL_COLORS.above} outlines={above} />
184
+ </g>
185
+ )
186
+ })
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useViewer } from '@pascal-app/viewer'
4
4
  import { memo } from 'react'
5
- import useAlignmentGuides from '../../store/use-alignment-guides'
5
+ import useAlignmentGuides, { type DraftGuideLine } from '../../store/use-alignment-guides'
6
6
  import { formatMeasurement } from '../editor/measurement-pill'
7
7
  import { useFloorplanRender } from './floorplan-render-context'
8
8
 
@@ -24,16 +24,29 @@ import { useFloorplanRender } from './floorplan-render-context'
24
24
  *
25
25
  * Stroke widths and handle radii are scaled by `unitsPerPixel` so they
26
26
  * stay a constant size on screen no matter the zoom.
27
+ *
28
+ * A wall pulled from a wall end (and the run it continues) publishes its
29
+ * projection guides beside them (`useAlignmentGuides.draft`): dashed lines —
30
+ * the extension of the wall it comes from in amber, lines through other walls'
31
+ * ends in indigo, lines through the run's start in emerald — and a ringed ✕
32
+ * where two of them cross, matching the 3D layer.
27
33
  */
28
- export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuideLayer() {
34
+ export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuideLayer({
35
+ unitsPerPixel,
36
+ }: {
37
+ /** The plan's live scale, which keeps a wall draft's guides screen-sized. */
38
+ unitsPerPixel?: number
39
+ }) {
29
40
  const guides = useAlignmentGuides((s) => s.guides)
41
+ const draft = useAlignmentGuides((s) => s.draft)
30
42
  const unit = useViewer((s) => s.unit)
31
43
  const metricNotation = useViewer((s) => s.metricNotation)
32
44
  const ctx = useFloorplanRender()
33
45
 
34
- if (guides.length === 0) return null
46
+ if (guides.length === 0 && !draft) return null
35
47
 
36
48
  const upp = ctx?.unitsPerPixel ?? 0.01
49
+ const draftUpp = unitsPerPixel ?? upp
37
50
  const sceneRot = ctx?.sceneRotationDeg ?? 0
38
51
 
39
52
  // Pixel-budgeted sizes converted to world meters so visuals stay
@@ -50,6 +63,47 @@ export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuid
50
63
 
51
64
  return (
52
65
  <g pointerEvents="none">
66
+ {draft?.lines.map((line, i) => (
67
+ <line
68
+ key={`draft-${i}`}
69
+ stroke={DRAFT_GUIDE_COLORS[line.kind]}
70
+ strokeDasharray={`${6 * draftUpp} ${4 * draftUpp}`}
71
+ strokeLinecap="round"
72
+ strokeWidth={1.5 * draftUpp}
73
+ x1={line.from[0]}
74
+ x2={line.to[0]}
75
+ y1={line.from[1]}
76
+ y2={line.to[1]}
77
+ />
78
+ ))}
79
+ {draft?.intersections.map((point, i) => (
80
+ <g
81
+ key={`draft-x-${i}`}
82
+ stroke={DRAFT_GUIDE_COLORS.alignment}
83
+ strokeLinecap="round"
84
+ strokeWidth={1.5 * draftUpp}
85
+ >
86
+ <circle
87
+ cx={point[0]}
88
+ cy={point[1]}
89
+ fill="#ffffff"
90
+ fillOpacity={0.85}
91
+ r={5.5 * draftUpp}
92
+ />
93
+ <line
94
+ x1={point[0] - 3 * draftUpp}
95
+ x2={point[0] + 3 * draftUpp}
96
+ y1={point[1] - 3 * draftUpp}
97
+ y2={point[1] + 3 * draftUpp}
98
+ />
99
+ <line
100
+ x1={point[0] - 3 * draftUpp}
101
+ x2={point[0] + 3 * draftUpp}
102
+ y1={point[1] + 3 * draftUpp}
103
+ y2={point[1] - 3 * draftUpp}
104
+ />
105
+ </g>
106
+ ))}
53
107
  {guides.map((guide, i) => {
54
108
  const { from, to, axis } = guide
55
109
  const midX = (from.x + to.x) / 2
@@ -107,6 +161,12 @@ export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuid
107
161
  )
108
162
  })
109
163
 
164
+ const DRAFT_GUIDE_COLORS: Record<DraftGuideLine['kind'], string> = {
165
+ extension: '#f59e0b', // amber-500
166
+ alignment: '#6366f1', // indigo-500 — the snap marker's colour
167
+ 'run-start': '#10b981', // emerald-500 — the pull grip's colour
168
+ }
169
+
110
170
  function XCap({
111
171
  color,
112
172
  size,
@@ -24,6 +24,8 @@ import { applyFloorplanAlignment } from '../../lib/floorplan/apply-alignment'
24
24
  import { clientToPlan } from '../../lib/floorplan/plan-coords'
25
25
  import { isHistoryShortcut } from '../../lib/history'
26
26
  import { formatLinearMeasurement } from '../../lib/measurements'
27
+ import type { WallGroupPush } from '../../lib/room-handle-drag'
28
+ import { useRoomHandleDrag } from '../../lib/room-handle-drag'
27
29
  import { sfxEmitter } from '../../lib/sfx-bus'
28
30
  import { isTypingTarget } from '../../lib/typing-target'
29
31
  import useAlignmentGuides from '../../store/use-alignment-guides'
@@ -36,6 +38,7 @@ import useEditor, {
36
38
  import useFloorplanMode from '../../store/use-floorplan-mode'
37
39
  import useInteractionScope, { useMovingNode } from '../../store/use-interaction-scope'
38
40
  import {
41
+ beginGroupWallPush,
39
42
  classifyParticipant,
40
43
  collectParticipants,
41
44
  computeGroupPlanBox,
@@ -120,6 +123,8 @@ export function startFloorplanGroupMove(
120
123
  rotation: number
121
124
  restCenter: Vec2
122
125
  lastDelta: Vec2 | null
126
+ /** Walls alone: each moves as its push arrow does instead of the rigid slide. */
127
+ push: WallGroupPush | null
123
128
  }
124
129
  let session: Session | null = null
125
130
 
@@ -185,6 +190,7 @@ export function startFloorplanGroupMove(
185
190
  rotation: 0,
186
191
  restCenter,
187
192
  lastDelta: null,
193
+ push: beginGroupWallPush(starts, levelId),
188
194
  }
189
195
  }
190
196
 
@@ -201,8 +207,14 @@ export function startFloorplanGroupMove(
201
207
  let dz = snap ? Math.round((plan[1] - startPlan[1]) / step) * step : plan[1] - startPlan[1]
202
208
 
203
209
  // Figma-style alignment layered on top: guides display in every mode
204
- // except Off; the magnetic pull applies only in 'lines' mode.
205
- if (isAlignmentGuideActive() && s.candidates.length > 0 && s.restAnchors.length > 0) {
210
+ // except Off; the magnetic pull applies only in 'lines' mode. Pushed walls
211
+ // don't move as one footprint, so they have no box to align.
212
+ if (
213
+ !s.push &&
214
+ isAlignmentGuideActive() &&
215
+ s.candidates.length > 0 &&
216
+ s.restAnchors.length > 0
217
+ ) {
206
218
  const proposed: [number, number] = [startPlan[0] + dx, startPlan[1] + dz]
207
219
  const aligned = applyFloorplanAlignment(
208
220
  proposed,
@@ -225,6 +237,10 @@ export function startFloorplanGroupMove(
225
237
  sfxEmitter.emit('sfx:grid-snap')
226
238
  s.lastDelta = [dx, dz]
227
239
  }
240
+ if (s.push) {
241
+ s.push.preview([dx, dz])
242
+ return
243
+ }
228
244
 
229
245
  const entries = translateGroupPatches(s.starts, s.links, dx, dz)
230
246
  const patchById = new Map(entries)
@@ -252,6 +268,8 @@ export function startFloorplanGroupMove(
252
268
  // current delta — the carried group turns exactly like the idle keyboard
253
269
  // rotate, and the commit stays a single updateNodes.
254
270
  const rotateSession = (s: Session, direction: 1 | -1) => {
271
+ // Pushed walls keep their bearing: there is nothing to turn.
272
+ if (s.push) return
255
273
  const pivot = { x: s.restCenter[0], z: s.restCenter[1] }
256
274
  const delta = -direction * (Math.PI / 4)
257
275
  const rotated = rotateGroupSnapshots(s.starts, s.links, pivot, delta)
@@ -271,6 +289,7 @@ export function startFloorplanGroupMove(
271
289
  }
272
290
 
273
291
  const clearLivePreviews = (s: Session) => {
292
+ s.push?.cancel()
274
293
  const overrides = useLiveNodeOverrides.getState()
275
294
  const liveTransforms = useLiveTransforms.getState()
276
295
  for (const id of s.affectedIds) {
@@ -330,6 +349,13 @@ export function startFloorplanGroupMove(
330
349
  // doesn't clear the multi-selection the drag is preserving.
331
350
  swallowNextClick()
332
351
  sfxEmitter.emit('sfx:item-place')
352
+ if (session.push) {
353
+ resumeSceneHistory(useScene)
354
+ session.push.commit()
355
+ session = null
356
+ teardown()
357
+ return
358
+ }
333
359
  const overrides = useLiveNodeOverrides.getState()
334
360
  const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
335
361
  for (const id of session.affectedIds) {
@@ -632,6 +658,8 @@ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionB
632
658
  const delta = useFloorplanGroupDrag((s) => s.delta)
633
659
  const liveRotation = useFloorplanGroupDrag((s) => s.rotation)
634
660
  const movingNode = useMovingNode()
661
+ // Pushed walls don't move as one box: the room widths they show take its place.
662
+ const wallPush = useRoomHandleDrag((s) => s.drag?.kind === 'wall-group-push')
635
663
  const mode = useEditor((s) => s.mode)
636
664
  const floorplanMode = useFloorplanMode((s) => s.mode)
637
665
  const sceneRotationDeg = useFloorplanSceneRotation()
@@ -673,7 +701,7 @@ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionB
673
701
  }
674
702
  }, [selectedIds, levelId, nodes, meshEpoch])
675
703
 
676
- if (!box || movingNode || mode === 'delete') return null
704
+ if (!box || movingNode || wallPush || mode === 'delete') return null
677
705
 
678
706
  const pad = 6 * unitsPerPixel
679
707
  const stroke = palette?.selectedStroke ?? '#3b82f6'
@@ -1,10 +1,16 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
  import {
3
+ formatMeasurementInputValue,
3
4
  isProjectedFloorplanAxisPointVerified,
4
5
  resolveFloorplanMeasurementAxisSnap,
5
6
  resolveProjectedFloorplanSnap,
6
7
  } from './floorplan-measurement-tool-layer'
7
8
 
9
+ test('extrusion inputs write values back with the scoped decimal separator', () => {
10
+ expect(formatMeasurementInputValue(2.75, 'fr')).toBe('2,75')
11
+ expect(formatMeasurementInputValue(2.75, 'en')).toBe('2.75')
12
+ })
13
+
8
14
  describe('resolveFloorplanMeasurementAxisSnap', () => {
9
15
  test('snaps to the closest X or Z axis inside the screen-space threshold', () => {
10
16
  expect(resolveFloorplanMeasurementAxisSnap([3, 0, 2], [1, 0, 1], 4, 9)).toEqual({
@@ -6,6 +6,7 @@ import {
6
6
  closestMeasurementFeatureBinding,
7
7
  collectAlignmentAnchors,
8
8
  emitter,
9
+ formatNumber,
9
10
  type GeometryContext,
10
11
  type MeasurementFeatureAnchor,
11
12
  type MeasurementSnapKind,
@@ -16,6 +17,7 @@ import {
16
17
  measurementPerimeter,
17
18
  measurementPrismVolume,
18
19
  nodeRegistry,
20
+ useLocale,
19
21
  useScene,
20
22
  } from '@pascal-app/core'
21
23
  import { useViewer } from '@pascal-app/viewer'
@@ -27,9 +29,9 @@ import {
27
29
  formatLinearMeasurement,
28
30
  formatVolumeLabel,
29
31
  getLinearUnitLabel,
30
- linearUnitToMeters,
31
32
  MEASUREMENT_ACTIVE_COLOR,
32
33
  metersToLinearUnit,
34
+ parseLinearMeasurementInput,
33
35
  } from '../../lib/measurements'
34
36
  import {
35
37
  clearSurfacePlanSnapFeedback,
@@ -77,6 +79,10 @@ export type ProjectedFloorplanSnap = {
77
79
  const MAX_REGISTERED_SNAP_ELEMENTS = 512
78
80
  const MAX_REGISTERED_SNAP_SEGMENTS = 4096
79
81
  const MAX_SAMPLED_PATH_SEGMENTS = 48
82
+
83
+ export function formatMeasurementInputValue(value: number, locale: string): string {
84
+ return formatNumber(value, { locale, max: 3 })
85
+ }
80
86
  const VERTEX_SNAP_DISTANCE_PX = 16
81
87
  const EDGE_SNAP_DISTANCE_PX = 10
82
88
  const SEMANTIC_FEATURE_SNAP_DISTANCE = 0.2
@@ -636,17 +642,22 @@ function FloorplanExtrusionControl({
636
642
  unitsPerPixel: number
637
643
  }) {
638
644
  const unit = useViewer((state) => state.unit)
645
+ const locale = useLocale()
639
646
  const extrusionHeight = useMeasurementDraft((state) => state.extrusionHeight)
640
647
  const points = useMeasurementDraft((state) => state.points)
641
648
  const baseNormal = useMeasurementDraft((state) => state.baseNormal)
642
649
  const [value, setValue] = useState(() =>
643
- extrusionHeight === 0 ? '' : String(metersToLinearUnit(extrusionHeight, unit)),
650
+ extrusionHeight === 0
651
+ ? ''
652
+ : formatMeasurementInputValue(metersToLinearUnit(extrusionHeight, unit), locale),
644
653
  )
645
654
 
646
655
  useEffect(() => {
647
656
  const height = useMeasurementDraft.getState().extrusionHeight
648
- setValue(height === 0 ? '' : String(Number(metersToLinearUnit(height, unit).toFixed(3))))
649
- }, [unit])
657
+ setValue(
658
+ height === 0 ? '' : formatMeasurementInputValue(metersToLinearUnit(height, unit), locale),
659
+ )
660
+ }, [locale, unit])
650
661
 
651
662
  const width = 260 * unitsPerPixel
652
663
  const height = 72 * unitsPerPixel
@@ -654,12 +665,12 @@ function FloorplanExtrusionControl({
654
665
  const y = center[2] - height / 2
655
666
 
656
667
  const commit = () => {
657
- const numericValue = Number.parseFloat(value)
658
- if (!Number.isFinite(numericValue)) {
668
+ const meters = parseLinearMeasurementInput(value, unit, locale)
669
+ if (meters === null) {
659
670
  useMeasurementDraft.getState().setExtrusionHeight('2d', 0)
660
671
  return
661
672
  }
662
- useMeasurementDraft.getState().setExtrusionHeight('2d', linearUnitToMeters(numericValue, unit))
673
+ useMeasurementDraft.getState().setExtrusionHeight('2d', meters)
663
674
  const draft = useMeasurementDraft.getState()
664
675
  if (draft.finishExtrusion('2d')) commitMeasurementDraft('2d')
665
676
  }
@@ -710,11 +721,9 @@ function FloorplanExtrusionControl({
710
721
  onChange={(event) => {
711
722
  const next = event.target.value
712
723
  setValue(next)
713
- const numericValue = Number.parseFloat(next)
714
- if (Number.isFinite(numericValue)) {
715
- useMeasurementDraft
716
- .getState()
717
- .setExtrusionHeight('2d', linearUnitToMeters(numericValue, unit))
724
+ const meters = parseLinearMeasurementInput(next, unit, locale)
725
+ if (meters !== null) {
726
+ useMeasurementDraft.getState().setExtrusionHeight('2d', meters)
718
727
  } else {
719
728
  useMeasurementDraft.getState().setExtrusionHeight('2d', 0)
720
729
  }
@@ -728,7 +737,7 @@ function FloorplanExtrusionControl({
728
737
  }}
729
738
  placeholder="0"
730
739
  step="0.1"
731
- type="number"
740
+ type="text"
732
741
  value={value}
733
742
  />
734
743
  <span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-muted-foreground text-xs">
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import type { OpenWallEnd } from '@pascal-app/core'
3
+ import type { AnyNodeId, OpenWallEnd } from '@pascal-app/core'
4
4
  import { useViewer } from '@pascal-app/viewer'
5
5
  import { memo, useEffect, useState } from 'react'
6
6
  import { createPortal } from 'react-dom'
@@ -31,7 +31,8 @@ function sceneScreenPoint(point: [number, number]): { left: number; top: number
31
31
  * Shows where walls look joined but are not, so a room can't close: a red dot
32
32
  * on each open end, a dashed line to the wall it nearly meets with what is
33
33
  * wrong ("4 cm gap"), and — while drawing — the walls that bound no room dimmed. Hover or click a
34
- * dot for "Join walls", which applies core's join planner as one undo step.
34
+ * dot for "Join walls", which applies core's join planner as one undo step; a
35
+ * click on a dot also selects its wall.
35
36
  *
36
37
  * On while walls or rooms are being drawn (the wall tool's variants, Divide);
37
38
  * otherwise only when a wall that bounds no room has a near miss.
@@ -76,7 +77,14 @@ const ActiveOpenWallEndsLayer = memo(function ActiveOpenWallEndsLayer() {
76
77
  label={openWallEndLabel(end, unit)}
77
78
  onHoverEnd={focus.releaseHover}
78
79
  onHoverStart={() => focus.keepHover(key)}
79
- onPin={() => focus.togglePin(key)}
80
+ onPin={() => {
81
+ focus.togglePin(key)
82
+ // The dot can cover all of a wall a few centimetres long: its
83
+ // click selects that wall too, so it can be fixed or deleted.
84
+ if (!drawing && useEditor.getState().mode === 'select') {
85
+ useViewer.getState().setSelection({ selectedIds: [end.wallId as AnyNodeId] })
86
+ }
87
+ }}
80
88
  sceneRotationDeg={sceneRotationDeg}
81
89
  unitsPerPixel={unitsPerPixel}
82
90
  />
@@ -388,7 +388,13 @@ export function FloorplanRegistryMoveOverlay() {
388
388
  endTransientMarkers()
389
389
  })
390
390
  sfxEmitter.emit('sfx:item-place')
391
- useViewer.getState().setSelection({ selectedIds: initialAffectedIds })
391
+ // The moved node stays selected, as after a 3D move: the joined walls and
392
+ // rooms a wall move reshapes are part of its write, not a pick.
393
+ useViewer.getState().setSelection({
394
+ selectedIds: useScene.getState().nodes[movingNode.id]
395
+ ? [movingNode.id]
396
+ : initialAffectedIds,
397
+ })
392
398
  return
393
399
  }
394
400