@pascal-app/editor 1.0.6 → 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 +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 +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
@@ -1,10 +1,11 @@
1
- import type { TerrainVerb } from '@pascal-app/core'
1
+ import { emitter, type InteractionSound, type TerrainVerb } from '@pascal-app/core'
2
2
  import mitt from 'mitt'
3
3
  import useAudio from '../store/use-audio'
4
4
  import {
5
5
  disposeSFX,
6
6
  type LoopSFXName,
7
7
  playSFX,
8
+ type SFXName,
8
9
  startLoopSFX,
9
10
  stopLoopSFX,
10
11
  updateSFXVolumes,
@@ -74,6 +75,29 @@ const handleTerrainSculptStart = (verb: TerrainVerb) => startLoopSFX(TERRAIN_LOO
74
75
  const handleTerrainSculptStop = () => stopLoopSFX()
75
76
  let unsubscribeAudio: (() => void) | null = null
76
77
 
78
+ /** Windows ride the door recordings, softer: a sash weighs less than a leaf. */
79
+ const INTERACTION_SFX: Record<InteractionSound, [SFXName, number]> = {
80
+ 'door-open': ['doorOpen', 1],
81
+ 'door-swing': ['doorSwing', 1],
82
+ 'door-close': ['doorClose', 1],
83
+ 'window-open': ['doorOpen', 0.6],
84
+ 'window-swing': ['doorSwing', 0.6],
85
+ 'window-close': ['doorClose', 0.6],
86
+ garage: ['garageDoor', 1],
87
+ 'shutters-open': ['shuttersOpen', 1],
88
+ 'shutters-close': ['shuttersClose', 1],
89
+ // Cabinet doors and drawers: the panel recordings, as the marketing house plays its cabinets.
90
+ 'panel-open': ['shuttersOpen', 0.75],
91
+ 'panel-swing': ['doorSwing', 0.45],
92
+ 'panel-close': ['shuttersClose', 0.75],
93
+ 'switch-on': ['switchOn', 1],
94
+ 'switch-off': ['switchOff', 1],
95
+ }
96
+ const handleInteractionSound = (sound: InteractionSound) => {
97
+ const [name, volumeMultiplier] = INTERACTION_SFX[sound]
98
+ playSFX(name, { volumeMultiplier })
99
+ }
100
+
77
101
  /**
78
102
  * Initialize SFX Bus - connects SFX events to actual sound playback.
79
103
  * Safe to call multiple times; re-registration is a no-op once initialized.
@@ -100,6 +124,7 @@ export function initSFXBus() {
100
124
  sfxEmitter.on('sfx:turn-needs-input', handleTurnNeedsInput)
101
125
  sfxEmitter.on('sfx:terrain-sculpt-start', handleTerrainSculptStart)
102
126
  sfxEmitter.on('sfx:terrain-sculpt-stop', handleTerrainSculptStop)
127
+ emitter.on('interaction:sound', handleInteractionSound)
103
128
  unsubscribeAudio = useAudio.subscribe(updateSFXVolumes)
104
129
  updateSFXVolumes()
105
130
  }
@@ -125,6 +150,7 @@ export function disposeSFXBus() {
125
150
  sfxEmitter.off('sfx:turn-needs-input', handleTurnNeedsInput)
126
151
  sfxEmitter.off('sfx:terrain-sculpt-start', handleTerrainSculptStart)
127
152
  sfxEmitter.off('sfx:terrain-sculpt-stop', handleTerrainSculptStop)
153
+ emitter.off('interaction:sound', handleInteractionSound)
128
154
  unsubscribeAudio?.()
129
155
  unsubscribeAudio = null
130
156
  sfxBusInitialized = false
@@ -126,6 +126,22 @@ export const SFX: Record<string, SFXConfig> = {
126
126
  turnDone: { src: '/audios/sfx/turn_done.mp3' },
127
127
  turnFailed: { src: '/audios/sfx/turn_failed.mp3' },
128
128
  turnNeedsInput: { src: '/audios/sfx/turn_needs_input.mp3' },
129
+ // Operating the building from a viewer's callout or a walkthrough: recorded
130
+ // door and shutter movements; the switches and the viewer's hover, select and
131
+ // deselect ticks are the marketing house's synth cues (`scripts/synth-viewer-cues.ts`),
132
+ // already mixed against the doors, so they play at full volume without jitter.
133
+ doorOpen: { src: '/audios/sfx/door_open.mp3', rateRange: [0.96, 1.04], volumeRange: [0.9, 1] },
134
+ doorClose: { src: '/audios/sfx/door_close.mp3', rateRange: [0.96, 1.04], volumeRange: [0.9, 1] },
135
+ // Air moving as a leaf swings shut, ahead of the latch (`scripts/synth-viewer-cues.ts`).
136
+ doorSwing: { src: '/audios/sfx/door_swing.mp3', rateRange: [0.97, 1.03] },
137
+ garageDoor: { src: '/audios/sfx/garage_door.mp3', volumeRange: [0.9, 1] },
138
+ shuttersOpen: { src: '/audios/sfx/shutters_open.mp3', rateRange: [0.96, 1.04] },
139
+ shuttersClose: { src: '/audios/sfx/shutters_close.mp3', rateRange: [0.96, 1.04] },
140
+ switchOn: { src: '/audios/sfx/switch_on.mp3' },
141
+ switchOff: { src: '/audios/sfx/switch_off.mp3' },
142
+ viewerHover: { src: '/audios/sfx/viewer_hover.mp3', minIntervalMs: 95 },
143
+ viewerSelect: { src: '/audios/sfx/viewer_select.mp3', minIntervalMs: 90 },
144
+ viewerDeselect: { src: '/audios/sfx/viewer_deselect.mp3' },
129
145
  } as const
130
146
 
131
147
  export type SFXName = keyof typeof SFX
@@ -1,3 +1,4 @@
1
+ import type { MessageDescriptor } from '@pascal-app/core'
1
2
  import type { ContinuationMode } from './continuation'
2
3
  import { getWallDrawVariantInfo, wallDrawVariantOf } from './wall-draw-variant'
3
4
 
@@ -5,7 +6,10 @@ import { getWallDrawVariantInfo, wallDrawVariantOf } from './wall-draw-variant'
5
6
  * The cursor bubble's icon for the wall tool: the Rooms variant in hand
6
7
  * (Rectangle / Polygon / Walls), the same art as the Build panel tile.
7
8
  */
8
- export function wallCursorIcon(mode: ContinuationMode): { label: string; iconSrc: string } {
9
+ export function wallCursorIcon(mode: ContinuationMode): {
10
+ label: MessageDescriptor
11
+ iconSrc: string
12
+ } {
9
13
  const variant = getWallDrawVariantInfo(wallDrawVariantOf(mode))
10
14
  return { label: variant.title, iconSrc: variant.iconSrc }
11
15
  }
@@ -0,0 +1,130 @@
1
+ import useEditor from '../store/use-editor'
2
+ import useInteractionScope from '../store/use-interaction-scope'
3
+ import { openSidebarPanel } from './sidebar-panel'
4
+
5
+ /**
6
+ * The single-key tools of the editor, and the keys tooltips print for them.
7
+ *
8
+ * The drafting tools (Door, Window, Stairs, Roof) sit on Shift with their
9
+ * letter, as the bare key was taken: W/A/S/D pan the view in 2D and 3D,
10
+ * R flips or rotates the selection and the armed placement, and E operates the
11
+ * selected door, window or cabinet. Pan stands down for those Shift chords
12
+ * (`claimsKeyboardPan`), and R/E still work bare as before.
13
+ */
14
+ export type ToolShortcutId =
15
+ | 'select'
16
+ | 'delete'
17
+ | 'wall'
18
+ | 'item'
19
+ | 'zone'
20
+ | 'measurement'
21
+ | 'paint'
22
+ | 'terrain'
23
+ | 'door'
24
+ | 'window'
25
+ | 'stair'
26
+ | 'roof'
27
+
28
+ type ArmedByChord = {
29
+ /** `KeyboardEvent.code`: the physical key, as pan reads it. */
30
+ code: string
31
+ shift: boolean
32
+ }
33
+
34
+ /** Keys as `ShortcutToken` names them. */
35
+ export const TOOL_SHORTCUT_KEYS: Record<ToolShortcutId, readonly string[]> = {
36
+ select: ['V'],
37
+ delete: ['X'],
38
+ wall: ['B'],
39
+ item: ['F'],
40
+ zone: ['Z'],
41
+ measurement: ['M'],
42
+ paint: ['P'],
43
+ terrain: ['G'],
44
+ door: ['Shift', 'D'],
45
+ window: ['Shift', 'W'],
46
+ stair: ['Shift', 'S'],
47
+ roof: ['Shift', 'R'],
48
+ }
49
+
50
+ /** The tools this module arms itself; the older keys stay in `use-keyboard`. */
51
+ const CHORDS: Partial<Record<ToolShortcutId, ArmedByChord>> = {
52
+ door: { code: 'KeyD', shift: true },
53
+ window: { code: 'KeyW', shift: true },
54
+ stair: { code: 'KeyS', shift: true },
55
+ roof: { code: 'KeyR', shift: true },
56
+ }
57
+
58
+ /** A tool's shortcut as a tooltip prints it: `Shift+D`, `B`. Null for a tool without one. */
59
+ export function toolShortcutLabel(tool: string): string | null {
60
+ const keys = TOOL_SHORTCUT_KEYS[tool as ToolShortcutId]
61
+ return keys ? keys.join('+') : null
62
+ }
63
+
64
+ /** A tool's tooltip with its shortcut: `Door (Shift+D)`; the bare label when it has none. */
65
+ export function withToolShortcut(label: string, tool: string | undefined): string {
66
+ const shortcut = tool ? toolShortcutLabel(tool) : null
67
+ return shortcut ? `${label} (${shortcut})` : label
68
+ }
69
+
70
+ // Gestures a held Shift modifies (free move, free rotate, force place): a Shift
71
+ // chord there is part of the gesture, never a tool switch.
72
+ const SHIFT_GESTURE_SCOPES = new Set([
73
+ 'room-divide',
74
+ 'placing',
75
+ 'moving',
76
+ 'handle-drag',
77
+ 'mesh-editing',
78
+ 'reshaping',
79
+ 'box-select',
80
+ 'painting',
81
+ 'sculpting',
82
+ 'pointing',
83
+ ])
84
+
85
+ /**
86
+ * The tool a key press arms, or null: the physical key and Shift, with no other
87
+ * modifier and no repeat. A Shift chord is off while a gesture holds Shift, so
88
+ * Shift+WASD keeps panning there.
89
+ */
90
+ export function toolShortcutForKey(
91
+ event: Pick<KeyboardEvent, 'code' | 'shiftKey' | 'metaKey' | 'ctrlKey' | 'altKey' | 'repeat'>,
92
+ ): ToolShortcutId | null {
93
+ if (event.metaKey || event.ctrlKey || event.altKey || event.repeat) return null
94
+ const entry = Object.entries(CHORDS).find(
95
+ ([, chord]) => chord.code === event.code && chord.shift === event.shiftKey,
96
+ )
97
+ if (!entry) return null
98
+ if (event.shiftKey && SHIFT_GESTURE_SCOPES.has(useInteractionScope.getState().scope.kind))
99
+ return null
100
+ return entry[0] as ToolShortcutId
101
+ }
102
+
103
+ /** Whether WASD pan must leave this key to a tool shortcut (Shift+W/S/D). */
104
+ export function claimsKeyboardPan(
105
+ event: Pick<KeyboardEvent, 'code' | 'shiftKey' | 'metaKey' | 'ctrlKey' | 'altKey' | 'repeat'>,
106
+ ): boolean {
107
+ // A held key repeats: once claimed, the repeats stay claimed too. (Fields one by
108
+ // one: a DOM event's are getters a spread would drop.)
109
+ return (
110
+ event.shiftKey &&
111
+ toolShortcutForKey({
112
+ code: event.code,
113
+ shiftKey: event.shiftKey,
114
+ metaKey: event.metaKey,
115
+ ctrlKey: event.ctrlKey,
116
+ altKey: event.altKey,
117
+ repeat: false,
118
+ }) !== null
119
+ )
120
+ }
121
+
122
+ /** Arm the tool `id` names from the keyboard, as its Build panel tile does, with the panel showing. */
123
+ export function armToolShortcut(id: ToolShortcutId): void {
124
+ const editor = useEditor.getState()
125
+ editor.setPhase('building')
126
+ openSidebarPanel(['build'])
127
+ // A raw tool, as a tile click arms it: no preset carried over from earlier.
128
+ editor.setToolDefaults(id, null)
129
+ editor.armToolMode({ mode: 'build', tool: id })
130
+ }
@@ -38,6 +38,10 @@ import {
38
38
  viewerSidebarSections,
39
39
  } from './viewer-selection'
40
40
 
41
+ const english = {
42
+ _: (descriptor: { id: string; message?: string }) => descriptor.message ?? descriptor.id,
43
+ }
44
+
41
45
  const buildingId = 'building_viewer'
42
46
  const levelId = 'level_viewer'
43
47
  const upperId = 'level_viewer_upper'
@@ -251,10 +255,10 @@ describe('viewer rooms by name', () => {
251
255
 
252
256
  test('the hover pill names what a click would select', () => {
253
257
  const { nodes, kitchen } = scene()
254
- expect(viewerHoverLabel(sofa.id, nodes)).toBe('Sofa')
255
- expect(viewerHoverLabel(kitchen, nodes)).toBe('Kitchen')
256
- expect(viewerHoverLabel('wall_shared', nodes)).toBe('Wall')
257
- expect(viewerHoverLabel(null, nodes)).toBeNull()
258
+ expect(viewerHoverLabel(sofa.id, nodes, english)).toBe('Sofa')
259
+ expect(viewerHoverLabel(kitchen, nodes, english)).toBe('Kitchen')
260
+ expect(viewerHoverLabel('wall_shared', nodes, english)).toBe('Wall')
261
+ expect(viewerHoverLabel(null, nodes, english)).toBeNull()
258
262
  })
259
263
  })
260
264
 
@@ -262,7 +266,7 @@ describe('viewer breadcrumb', () => {
262
266
  test('an item reads floor › room › item; the room is shown, not selected', () => {
263
267
  const { nodes, kitchen } = scene()
264
268
  const selection = { buildingId, levelId, zoneId: null, selectedIds: [sofa.id] }
265
- const crumbs = viewerBreadcrumb(selection as ViewerSelection, null, nodes, (id) =>
269
+ const crumbs = viewerBreadcrumb(selection as ViewerSelection, null, nodes, english, (id) =>
266
270
  id === sofa.id ? kitchen : null,
267
271
  )
268
272
  expect(crumbs.map((crumb) => [crumb.kind, crumb.label])).toEqual([
@@ -277,18 +281,20 @@ describe('viewer breadcrumb', () => {
277
281
  const unit = UnitNode.parse({ id: 'unit_a', name: 'Apartment A', parentId: buildingId })
278
282
  const { nodes, living } = scene([unit])
279
283
  const room = { buildingId, levelId, zoneId: living, selectedIds: [] } as ViewerSelection
280
- expect(viewerBreadcrumb(room, null, nodes).map((crumb) => crumb.label)).toEqual([
284
+ expect(viewerBreadcrumb(room, null, nodes, english).map((crumb) => crumb.label)).toEqual([
281
285
  'House',
282
286
  'Ground floor',
283
287
  'Living',
284
288
  ])
285
289
  const floor = { ...room, zoneId: null }
286
- expect(viewerBreadcrumb(floor, 'unit_a', nodes).map((crumb) => crumb.label)).toEqual([
290
+ expect(viewerBreadcrumb(floor, 'unit_a', nodes, english).map((crumb) => crumb.label)).toEqual([
287
291
  'House',
288
292
  'Ground floor',
289
293
  'Apartment A',
290
294
  ])
291
- expect(viewerBreadcrumb(nothing, null, nodes).map((crumb) => crumb.kind)).toEqual(['building'])
295
+ expect(viewerBreadcrumb(nothing, null, nodes, english).map((crumb) => crumb.kind)).toEqual([
296
+ 'building',
297
+ ])
292
298
  })
293
299
  })
294
300
 
@@ -305,25 +311,21 @@ describe('viewer sidebar', () => {
305
311
  ],
306
312
  })
307
313
  const plain = scene([terrace])
308
- expect(viewerSidebarSections(levelId, plain.nodes).map((section) => section.kind)).toEqual([
309
- 'rooms',
310
- 'zones',
311
- ])
312
- expect(viewerSidebarSections(levelId, plain.nodes)[0]!.rows.map((row) => row.label)).toEqual([
313
- 'Kitchen',
314
- 'Living',
315
- ])
316
- expect(viewerSidebarSections(upperId, plain.nodes)).toEqual([])
314
+ expect(
315
+ viewerSidebarSections(levelId, plain.nodes, english).map((section) => section.kind),
316
+ ).toEqual(['rooms', 'zones'])
317
+ expect(
318
+ viewerSidebarSections(levelId, plain.nodes, english)[0]!.rows.map((row) => row.label),
319
+ ).toEqual(['Kitchen', 'Living'])
320
+ expect(viewerSidebarSections(upperId, plain.nodes, english)).toEqual([])
317
321
 
318
322
  const unit = UnitNode.parse({ id: 'unit_a', name: 'Apartment A', parentId: buildingId })
319
323
  const withUnit = scene([unit, terrace])
320
324
  withUnit.nodes.unit_a = { ...unit, members: [withUnit.kitchen as ZoneNode['id']] }
321
- expect(viewerSidebarSections(levelId, withUnit.nodes).map((section) => section.kind)).toEqual([
322
- 'units',
323
- 'rooms',
324
- 'zones',
325
- ])
326
- expect(viewerSidebarSections(upperId, withUnit.nodes)).toEqual([])
325
+ expect(
326
+ viewerSidebarSections(levelId, withUnit.nodes, english).map((section) => section.kind),
327
+ ).toEqual(['units', 'rooms', 'zones'])
328
+ expect(viewerSidebarSections(upperId, withUnit.nodes, english)).toEqual([])
327
329
  })
328
330
  })
329
331
 
@@ -546,7 +548,9 @@ test('baked and parametric picks agree for room faces, mezzanine plates, doors,
546
548
  const baked = resolveGlbPick([{ object: foreign, point: new Vector3() }], identity)
547
549
  expect(resolveViewerClick(nothing, baked, nodes)).toEqual(nothing)
548
550
  expect(
549
- viewerBreadcrumb(nothing, null, nodes).some((crumb) => crumb.label === 'Unpublished name'),
551
+ viewerBreadcrumb(nothing, null, nodes, english).some(
552
+ (crumb) => crumb.label === 'Unpublished name',
553
+ ),
550
554
  ).toBe(false)
551
555
  })
552
556
 
@@ -30,6 +30,7 @@ import {
30
30
  import { useBelowGrade } from './below-grade'
31
31
  import { nodeDisplayLabel } from './node-display-label'
32
32
  import { roomFloorElevation } from './room-handle-drag'
33
+ import { type DescriptorTranslator, displayRoomName } from './room-name-messages'
33
34
  import { RoomSelectionIndex, type RoomSelectionRecord, resolveRoomHit } from './room-selection'
34
35
  import type { SelectionModifierKeys } from './selection-routing'
35
36
 
@@ -455,8 +456,16 @@ export type ViewerCrumb = {
455
456
  label: string
456
457
  }
457
458
 
458
- export function zoneLabel(zone: ZoneNode): string {
459
- return zone.name?.trim() || (zone.spaceRole === 'room' ? 'Room' : 'Zone')
459
+ /**
460
+ * The viewer chrome's name for a zone, in the editor's UI language (the scene
461
+ * keeps its stored name). `i18n` is the caller's scoped `usePascalI18n()`.
462
+ */
463
+ export function zoneLabel(zone: ZoneNode, i18n: DescriptorTranslator): string {
464
+ const name = zone.name?.trim()
465
+ if (name) return displayRoomName(name, i18n)
466
+ return zone.spaceRole === 'room'
467
+ ? i18n._({ id: 'editor.roomLabel.room', message: 'Room' })
468
+ : i18n._({ id: 'editor.roomLabel.zone', message: 'Zone' })
460
469
  }
461
470
 
462
471
  /**
@@ -468,6 +477,7 @@ export function viewerBreadcrumb(
468
477
  selection: ViewerSelection,
469
478
  focusedUnitId: string | null,
470
479
  nodes: ViewerNodes,
480
+ i18n: DescriptorTranslator,
471
481
  roomOf: (nodeId: string, levelId: string) => string | null = () => null,
472
482
  ): ViewerCrumb[] {
473
483
  const crumbs: ViewerCrumb[] = []
@@ -486,7 +496,8 @@ export function viewerBreadcrumb(
486
496
  if (unit?.type === 'unit') crumbs.push({ kind: 'unit', id: unit.id, label: unit.name })
487
497
  const roomId = selection.zoneId ?? (element ? roomOf(element.id, level.id) : null)
488
498
  const room = roomId ? nodes[roomId] : undefined
489
- if (room?.type === 'zone') crumbs.push({ kind: 'room', id: room.id, label: zoneLabel(room) })
499
+ if (room?.type === 'zone')
500
+ crumbs.push({ kind: 'room', id: room.id, label: zoneLabel(room, i18n) })
490
501
  if (element) crumbs.push({ kind: 'node', id: element.id, label: nodeDisplayLabel(element) })
491
502
  return crumbs
492
503
  }
@@ -531,14 +542,18 @@ export type ViewerSidebarSection = { kind: 'units' | 'rooms' | 'zones'; rows: Vi
531
542
  * What sits under the current floor: its units, its rooms, its other zones.
532
543
  * An empty section is left out.
533
544
  */
534
- export function viewerSidebarSections(levelId: string, nodes: ViewerNodes): ViewerSidebarSection[] {
545
+ export function viewerSidebarSections(
546
+ levelId: string,
547
+ nodes: ViewerNodes,
548
+ i18n: DescriptorTranslator,
549
+ ): ViewerSidebarSection[] {
535
550
  const zones = Object.values(nodes).filter(
536
551
  (node): node is ZoneNode =>
537
552
  node.type === 'zone' && node.parentId === levelId && node.visible !== false,
538
553
  )
539
554
  const zoneRow = (zone: ZoneNode): ViewerSidebarRow => ({
540
555
  id: zone.id,
541
- label: zoneLabel(zone),
556
+ label: zoneLabel(zone, i18n),
542
557
  color: zone.color,
543
558
  areaM2: area([{ outer: zone.polygon, holes: zone.holes ?? [] }]),
544
559
  })
@@ -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
+ })