@pascal-app/editor 1.0.6 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (382) hide show
  1. package/package.json +18 -15
  2. package/src/components/editor/action-menu-button.tsx +4 -1
  3. package/src/components/editor/alignment-3d-guide-layer.tsx +102 -11
  4. package/src/components/editor/bake-thumbnail.tsx +3 -2
  5. package/src/components/editor/custom-camera-controls.tsx +110 -42
  6. package/src/components/editor/duplicate-node.ts +2 -1
  7. package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
  8. package/src/components/editor/first-person-controls.tsx +77 -68
  9. package/src/components/editor/fit-scene-framing.test.ts +318 -0
  10. package/src/components/editor/fit-scene-framing.ts +142 -0
  11. package/src/components/editor/floating-action-menu.tsx +31 -11
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floor-openings-overlay.tsx +1 -1
  14. package/src/components/editor/floor-region-controls.tsx +2 -2
  15. package/src/components/editor/floorplan-panel.test.ts +220 -0
  16. package/src/components/editor/floorplan-panel.tsx +458 -140
  17. package/src/components/editor/floorplan-wall-typing-keys.test.tsx +152 -0
  18. package/src/components/editor/floorplan-wall-typing-keys.ts +79 -0
  19. package/src/components/editor/footprint-height-handle.tsx +2 -2
  20. package/src/components/editor/grid.tsx +24 -15
  21. package/src/components/editor/group-actions.ts +19 -1
  22. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  23. package/src/components/editor/group-move-3d.ts +27 -2
  24. package/src/components/editor/group-selection-box-3d.tsx +4 -1
  25. package/src/components/editor/group-transform-shared.ts +23 -1
  26. package/src/components/editor/index.tsx +150 -20
  27. package/src/components/editor/inspector-extension-actions.tsx +36 -0
  28. package/src/components/editor/localized-html.tsx +22 -0
  29. package/src/components/editor/measurement-pill.tsx +1 -1
  30. package/src/components/editor/node-action-menu.tsx +46 -12
  31. package/src/components/editor/node-arrow-handles.tsx +2 -3
  32. package/src/components/editor/open-wall-ends-3d-layer.tsx +2 -3
  33. package/src/components/editor/open-wall-ends-hint.tsx +1 -1
  34. package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
  35. package/src/components/editor/quick-measurement-card.tsx +1 -1
  36. package/src/components/editor/registry-action-contributions.test.tsx +67 -6
  37. package/src/components/editor/registry-action-contributions.tsx +115 -15
  38. package/src/components/editor/riser-diagram-panel.tsx +59 -9
  39. package/src/components/editor/room-controls.tsx +4 -3
  40. package/src/components/editor/room-handles-2d.tsx +158 -7
  41. package/src/components/editor/room-handles.tsx +244 -54
  42. package/src/components/editor/room-highlight.tsx +2 -0
  43. package/src/components/editor/room-labels.tsx +33 -17
  44. package/src/components/editor/selection-manager.tsx +15 -102
  45. package/src/components/editor/separator-end-grip.tsx +74 -0
  46. package/src/components/editor/site-edge-labels.tsx +7 -3
  47. package/src/components/editor/thumbnail-generator.test.ts +15 -6
  48. package/src/components/editor/thumbnail-generator.tsx +26 -1
  49. package/src/components/editor/use-floorplan-background-placement.ts +55 -52
  50. package/src/components/editor/views/view-bar.tsx +111 -26
  51. package/src/components/editor/wall-measurement-label.tsx +31 -15
  52. package/src/components/editor/wall-move-side-handles.tsx +15 -7
  53. package/src/components/editor/wall-pull-grip.tsx +193 -0
  54. package/src/components/editor/wall-region-handles.tsx +2 -2
  55. package/src/components/editor/wall-snap-beacon-layer.tsx +134 -33
  56. package/src/components/editor-2d/floorplan-adjacent-walls-layer.test.ts +76 -0
  57. package/src/components/editor-2d/floorplan-adjacent-walls-layer.tsx +186 -0
  58. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +63 -3
  59. package/src/components/editor-2d/floorplan-group-move.tsx +32 -4
  60. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +6 -0
  61. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +33 -24
  62. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +12 -5
  63. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +2 -1
  64. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +29 -5
  65. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +96 -4
  66. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +52 -12
  67. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +7 -1
  68. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +13 -1
  69. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.test.tsx +35 -0
  70. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.tsx +65 -0
  71. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +34 -1
  72. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +211 -39
  73. package/src/components/procedural-items/inspector.tsx +59 -12
  74. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +2 -0
  75. package/src/components/systems/ceiling/ceiling-system.test.ts +141 -0
  76. package/src/components/systems/ceiling/ceiling-system.tsx +53 -18
  77. package/src/components/systems/roof/roof-edit-system.test.tsx +15 -1
  78. package/src/components/systems/roof/roof-edit-system.tsx +30 -19
  79. package/src/components/tools/distribution/distribution-run-tool.test.tsx +371 -3
  80. package/src/components/tools/distribution/distribution-run-tool.tsx +200 -58
  81. package/src/components/tools/item/move-tool.tsx +1 -6
  82. package/src/components/tools/item/placement-history.test.tsx +1 -1
  83. package/src/components/tools/item/placement-strategies.test.ts +64 -1
  84. package/src/components/tools/item/placement-strategies.ts +5 -9
  85. package/src/components/tools/item/use-placement-coordinator.tsx +31 -35
  86. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +1 -1
  87. package/src/components/tools/registry/item-surface-move.test.ts +11 -0
  88. package/src/components/tools/registry/move-registry-node-tool.test.ts +42 -1
  89. package/src/components/tools/registry/move-registry-node-tool.tsx +38 -14
  90. package/src/components/tools/select/select-candidates.test.ts +45 -0
  91. package/src/components/tools/select/select-candidates.ts +13 -2
  92. package/src/components/tools/shared/cursor-sphere.tsx +10 -3
  93. package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
  94. package/src/components/tools/shared/placement-box.tsx +2 -6
  95. package/src/components/tools/shared/placement-coordinate-input.tsx +94 -0
  96. package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
  97. package/src/components/tools/shared/segment-angle.ts +0 -4
  98. package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
  99. package/src/components/tools/tool-manager.tsx +1 -24
  100. package/src/components/tools/wall/wall-drafting.test.ts +299 -23
  101. package/src/components/tools/wall/wall-drafting.ts +132 -24
  102. package/src/components/tools/wall/wall-pull-snap.test.ts +274 -0
  103. package/src/components/tools/wall/wall-pull-snap.ts +563 -0
  104. package/src/components/tools/wall/wall-snap-geometry.test.ts +113 -1
  105. package/src/components/tools/wall/wall-snap-geometry.ts +137 -0
  106. package/src/components/tools/wall/wall-typed-entry.test.ts +99 -0
  107. package/src/components/tools/wall/wall-typed-entry.ts +70 -0
  108. package/src/components/tools/zone/zone-tool.tsx +3 -3
  109. package/src/components/ui/action-menu/action-button.tsx +2 -0
  110. package/src/components/ui/action-menu/camera-actions.tsx +8 -7
  111. package/src/components/ui/action-menu/control-modes.tsx +9 -5
  112. package/src/components/ui/action-menu/follow-pascal-pill.tsx +3 -1
  113. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +7 -1
  114. package/src/components/ui/action-menu/furnish-tools.tsx +7 -6
  115. package/src/components/ui/action-menu/index.tsx +1 -0
  116. package/src/components/ui/action-menu/measurement-control.tsx +35 -29
  117. package/src/components/ui/action-menu/point-ask-button.tsx +4 -3
  118. package/src/components/ui/action-menu/structure-tools.tsx +25 -23
  119. package/src/components/ui/action-menu/view-toggles.test.ts +18 -0
  120. package/src/components/ui/action-menu/view-toggles.tsx +256 -51
  121. package/src/components/ui/build-panel/build-panel.tsx +163 -25
  122. package/src/components/ui/command-palette/editor-commands.tsx +38 -11
  123. package/src/components/ui/controls/finish-swatch.tsx +45 -19
  124. package/src/components/ui/controls/material-picker.tsx +24 -17
  125. package/src/components/ui/controls/metric-control.test.ts +52 -0
  126. package/src/components/ui/controls/metric-control.tsx +62 -24
  127. package/src/components/ui/controls/segmented-control.tsx +5 -2
  128. package/src/components/ui/controls/slider-control.test.tsx +23 -0
  129. package/src/components/ui/controls/slider-control.tsx +47 -19
  130. package/src/components/ui/controls/tool-options-panel.tsx +74 -44
  131. package/src/components/ui/floating-level-selector.tsx +100 -48
  132. package/src/components/ui/helpers/building-helper.tsx +5 -3
  133. package/src/components/ui/helpers/contextual-helper-panel.tsx +97 -37
  134. package/src/components/ui/helpers/helper-manager.test.tsx +106 -3
  135. package/src/components/ui/helpers/helper-manager.tsx +136 -57
  136. package/src/components/ui/helpers/item-helper.tsx +6 -4
  137. package/src/components/ui/helpers/registered-tool-helper.tsx +3 -1
  138. package/src/components/ui/level-duplicate-dialog.tsx +32 -23
  139. package/src/components/ui/level-wall-copy-prompt.tsx +124 -0
  140. package/src/components/ui/panels/collections/collections-popover.tsx +11 -11
  141. package/src/components/ui/panels/deck-boards-fields.tsx +136 -0
  142. package/src/components/ui/panels/floor-foundation-panel.tsx +114 -39
  143. package/src/components/ui/panels/floor-opening-panel.tsx +74 -26
  144. package/src/components/ui/panels/inspector-extensions.tsx +66 -0
  145. package/src/components/ui/panels/mobile-panel-sheet.tsx +3 -1
  146. package/src/components/ui/panels/mobile-selection-bar.tsx +34 -28
  147. package/src/components/ui/panels/multi-field-value.test.ts +27 -0
  148. package/src/components/ui/panels/multi-field-value.ts +20 -1
  149. package/src/components/ui/panels/multi-height-mode.tsx +11 -10
  150. package/src/components/ui/panels/multi-parametric-inspector.tsx +122 -34
  151. package/src/components/ui/panels/multi-selection-panel.tsx +76 -36
  152. package/src/components/ui/panels/node-display.ts +35 -24
  153. package/src/components/ui/panels/panel-manager.tsx +9 -5
  154. package/src/components/ui/panels/panel-wrapper.tsx +63 -127
  155. package/src/components/ui/panels/parametric-field-control.tsx +13 -10
  156. package/src/components/ui/panels/parametric-inspector.tsx +18 -12
  157. package/src/components/ui/panels/reference-panel.tsx +51 -35
  158. package/src/components/ui/panels/room-construction-rows.tsx +465 -66
  159. package/src/components/ui/panels/room-name-options.test.ts +36 -3
  160. package/src/components/ui/panels/room-name-options.ts +110 -11
  161. package/src/components/ui/panels/room-openings-field.tsx +22 -13
  162. package/src/components/ui/panels/room-panel.tsx +207 -100
  163. package/src/components/ui/panels/shape-choice.tsx +7 -5
  164. package/src/components/ui/panels/wall-region-list.tsx +7 -7
  165. package/src/components/ui/primitives/shortcut-token.tsx +10 -6
  166. package/src/components/ui/right-stack.test.ts +13 -6
  167. package/src/components/ui/right-stack.tsx +21 -10
  168. package/src/components/ui/sidebar/icon-rail.tsx +11 -9
  169. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +10 -10
  170. package/src/components/ui/sidebar/panels/items-panel/index.tsx +10 -9
  171. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +3 -0
  172. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +102 -32
  173. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +1 -5
  174. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +4 -3
  175. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -3
  176. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +4 -2
  177. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +7 -5
  178. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +4 -2
  179. package/src/components/ui/sidebar/panels/site-panel/default-names.ts +75 -0
  180. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +4 -2
  181. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +4 -2
  182. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +4 -2
  183. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +4 -2
  184. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +4 -2
  185. package/src/components/ui/sidebar/panels/site-panel/index.tsx +118 -72
  186. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +36 -3
  187. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +4 -2
  188. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -4
  189. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +8 -2
  190. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +8 -5
  191. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +4 -2
  192. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -4
  193. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +4 -2
  194. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +4 -2
  195. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +8 -7
  196. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +7 -6
  197. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +16 -1
  198. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +15 -8
  199. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +4 -2
  200. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +4 -2
  201. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +3 -1
  202. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +19 -9
  203. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +25 -14
  204. package/src/components/ui/sidebar/tab-bar.tsx +4 -1
  205. package/src/components/ui/sidebar/use-plugin-panels.tsx +19 -10
  206. package/src/components/ui/snap-target-badge.tsx +7 -6
  207. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +721 -0
  208. package/src/components/ui/wall-type/wall-type-menu.tsx +167 -0
  209. package/src/components/viewer/floorplan-compass-button.tsx +12 -3
  210. package/src/components/viewer/floorplan-preview.tsx +27 -19
  211. package/src/components/viewer/render-settings-menu.tsx +294 -0
  212. package/src/components/viewer/sky-control.tsx +710 -0
  213. package/src/components/viewer/use-viewer-ui-sounds.test.ts +15 -0
  214. package/src/components/viewer/use-viewer-ui-sounds.ts +46 -0
  215. package/src/components/viewer/viewer-controls-bar.tsx +278 -260
  216. package/src/components/viewer/viewer-hover-label.tsx +12 -4
  217. package/src/components/viewer/viewer-rooms.tsx +62 -5
  218. package/src/components/viewer/viewer-scene-header.tsx +28 -17
  219. package/src/components/viewer/viewer-stage-switcher.tsx +4 -2
  220. package/src/components/viewer-overlay.tsx +8 -0
  221. package/src/components/walkthrough-hud.tsx +75 -2
  222. package/src/hooks/use-keyboard.test.ts +85 -4
  223. package/src/hooks/use-keyboard.ts +65 -7
  224. package/src/hooks/use-selected-room.ts +12 -1
  225. package/src/i18n/floor-footprint-name.ts +30 -0
  226. package/src/i18n/level-name.ts +39 -0
  227. package/src/i18n/locales/en/messages.po +15849 -0
  228. package/src/i18n/locales/en/messages.ts +1 -0
  229. package/src/i18n/locales/en-XA/messages.ts +1 -0
  230. package/src/i18n/locales/es/messages.po +39331 -0
  231. package/src/i18n/locales/es/messages.ts +1 -0
  232. package/src/i18n/locales/fr/messages.po +39331 -0
  233. package/src/i18n/locales/fr/messages.ts +1 -0
  234. package/src/i18n/locales/ja/messages.po +39331 -0
  235. package/src/i18n/locales/ja/messages.ts +1 -0
  236. package/src/i18n/locales/zh-CN/messages.po +39331 -0
  237. package/src/i18n/locales/zh-CN/messages.ts +1 -0
  238. package/src/i18n/locales.test.ts +63 -0
  239. package/src/i18n/locales.ts +32 -0
  240. package/src/i18n/message-slot.tsx +25 -0
  241. package/src/i18n/node-messages.ts +169 -0
  242. package/src/i18n/pseudo-locale.ts +17 -0
  243. package/src/i18n/published-entry.test.ts +17 -0
  244. package/src/i18n/scene-theme-name.ts +16 -0
  245. package/src/index.tsx +224 -45
  246. package/src/lib/add-storey.test.ts +3 -3
  247. package/src/lib/contextual-help.test.ts +20 -1
  248. package/src/lib/contextual-help.ts +101 -19
  249. package/src/lib/continuation.ts +38 -11
  250. package/src/lib/elevation-guides.ts +21 -6
  251. package/src/lib/elevator-support.ts +1 -72
  252. package/src/lib/floor-footprints.test.ts +3 -2
  253. package/src/lib/floor-footprints.ts +1 -9
  254. package/src/lib/floor-foundation-support.test.ts +15 -6
  255. package/src/lib/floor-opening-draft.ts +1 -1
  256. package/src/lib/floor-region-session.ts +1 -1
  257. package/src/lib/floorplan/dimension-drive.test.ts +36 -2
  258. package/src/lib/floorplan/dimension-drive.ts +68 -3
  259. package/src/lib/floorplan/floorplan-export.test.ts +16 -5
  260. package/src/lib/floorplan/floorplan-export.tsx +13 -16
  261. package/src/lib/floorplan/floorplan-extension.test.ts +11 -12
  262. package/src/lib/floorplan/floorplan-extension.ts +37 -18
  263. package/src/lib/floorplan/floorplan-mode.test.ts +13 -6
  264. package/src/lib/floorplan/floorplan-mode.ts +14 -12
  265. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +1 -1
  266. package/src/lib/floorplan/floorplan-readonly.ts +3 -3
  267. package/src/lib/floorplan/open-wall-ends.ts +2 -1
  268. package/src/lib/floorplan/schedules.ts +10 -6
  269. package/src/lib/floorplan/site-plan/site-annotations.test.ts +46 -1
  270. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -0
  271. package/src/lib/floorplan/site-plan/site-parts.ts +6 -3
  272. package/src/lib/geometry-script/author.test.ts +114 -0
  273. package/src/lib/geometry-script/author.ts +55 -23
  274. package/src/lib/geometry-script/client.ts +3 -0
  275. package/src/lib/geometry-script/finish-scripts.ts +7 -7
  276. package/src/lib/gesture-lifecycle.ts +3 -1
  277. package/src/lib/glb-export.test.ts +327 -0
  278. package/src/lib/glb-export.ts +70 -2
  279. package/src/lib/grid-frame.test.ts +56 -3
  280. package/src/lib/grid-frame.ts +28 -1
  281. package/src/lib/history.test.ts +2 -1
  282. package/src/lib/hud-title.ts +48 -24
  283. package/src/lib/ifc-export.test.ts +41 -1
  284. package/src/lib/inspector-card-mode.test.ts +3 -31
  285. package/src/lib/inspector-card-mode.ts +5 -10
  286. package/src/lib/inspector-expanded.test.ts +13 -0
  287. package/src/lib/inspector-expanded.ts +13 -1
  288. package/src/lib/interaction/scope.ts +1 -0
  289. package/src/lib/keyboard-pan.test.ts +7 -0
  290. package/src/lib/keyboard-pan.ts +9 -2
  291. package/src/lib/layer-thickness.test.ts +22 -0
  292. package/src/lib/layer-thickness.ts +79 -0
  293. package/src/lib/level-selection.test.ts +18 -2
  294. package/src/lib/level-selection.ts +31 -8
  295. package/src/lib/linear-display.ts +1 -1
  296. package/src/lib/live-gesture-round-trip.test.ts +1 -1
  297. package/src/lib/lot/drop-in.test.ts +59 -1
  298. package/src/lib/lot/drop-in.ts +42 -50
  299. package/src/lib/material-swatch-style.ts +24 -0
  300. package/src/lib/measurement-label.test.ts +1 -1
  301. package/src/lib/measurement-parser.ts +7 -3
  302. package/src/lib/measurements.test.ts +24 -6
  303. package/src/lib/measurements.ts +1 -203
  304. package/src/lib/mezzanine-selection.test.ts +3 -1
  305. package/src/lib/node-action-movement.test.tsx +115 -0
  306. package/src/lib/node-action-movement.ts +2 -0
  307. package/src/lib/orbit-near-plane.test.ts +12 -0
  308. package/src/lib/orbit-near-plane.ts +13 -0
  309. package/src/lib/paint-regions.test.ts +8 -5
  310. package/src/lib/paint-scope.test.ts +53 -0
  311. package/src/lib/paint-scope.ts +18 -4
  312. package/src/lib/panel-rows.ts +17 -8
  313. package/src/lib/placement-metadata.ts +4 -0
  314. package/src/lib/point-ask/describe-target.ts +6 -8
  315. package/src/lib/point-ask/region.ts +1 -1
  316. package/src/lib/print-shell-compiler.ts +12 -1
  317. package/src/lib/print-wall-solids.test.ts +11 -0
  318. package/src/lib/print-wall-solids.ts +9 -0
  319. package/src/lib/reference-scale.ts +83 -0
  320. package/src/lib/room-assembly-overlay.ts +1 -1
  321. package/src/lib/room-built-on.test.ts +28 -17
  322. package/src/lib/room-built-on.ts +21 -17
  323. package/src/lib/room-construction-commands.ts +3 -2
  324. package/src/lib/room-construction.test.ts +28 -1
  325. package/src/lib/room-construction.ts +14 -0
  326. package/src/lib/room-controls-phase6.test.ts +141 -1
  327. package/src/lib/room-handle-drag.ts +554 -58
  328. package/src/lib/room-name-catalog.test.ts +8 -0
  329. package/src/lib/room-name-catalog.ts +8 -1
  330. package/src/lib/room-name-messages.test.ts +132 -0
  331. package/src/lib/room-name-messages.ts +147 -0
  332. package/src/lib/room-name-storage.test.ts +334 -0
  333. package/src/lib/room-push-dimensions.test.ts +38 -1
  334. package/src/lib/room-push-dimensions.ts +226 -2
  335. package/src/lib/room-selection.test.ts +23 -6
  336. package/src/lib/scene-bounds.test.ts +144 -0
  337. package/src/lib/scene-bounds.ts +79 -15
  338. package/src/lib/scene-clipboard.test.ts +70 -49
  339. package/src/lib/scene-clipboard.ts +11 -5
  340. package/src/lib/selection-cycle.ts +63 -0
  341. package/src/lib/separator-edit.test.ts +431 -0
  342. package/src/lib/separator-edit.ts +179 -0
  343. package/src/lib/separator-lines.ts +38 -0
  344. package/src/lib/sfx-bus.ts +27 -1
  345. package/src/lib/sfx-player.ts +16 -0
  346. package/src/lib/snapping-mode.test.ts +23 -1
  347. package/src/lib/tool-cursor-icon.ts +5 -1
  348. package/src/lib/tool-shortcuts.ts +130 -0
  349. package/src/lib/viewer-selection.test.ts +28 -24
  350. package/src/lib/viewer-selection.ts +21 -6
  351. package/src/lib/wall-draw-variant.test.ts +7 -6
  352. package/src/lib/wall-draw-variant.ts +107 -6
  353. package/src/lib/wall-nudge.test.ts +44 -0
  354. package/src/lib/wall-nudge.ts +131 -0
  355. package/src/lib/wall-parallel-snap.test.ts +83 -0
  356. package/src/lib/wall-parallel-snap.ts +176 -0
  357. package/src/lib/wall-placement-pointer.test.ts +23 -0
  358. package/src/lib/wall-placement-pointer.ts +18 -0
  359. package/src/lib/wall-pull.test.ts +245 -0
  360. package/src/lib/wall-pull.ts +209 -0
  361. package/src/lib/wall-push-line-up.test.ts +132 -0
  362. package/src/lib/wall-region-list.ts +17 -6
  363. package/src/lib/wall-run-snap.test.ts +188 -0
  364. package/src/lib/wall-run-snap.ts +83 -0
  365. package/src/lib/wall-type.test.ts +125 -0
  366. package/src/lib/wall-type.ts +119 -0
  367. package/src/lib/window-interaction.test.ts +44 -0
  368. package/src/lib/world-grid-snap.test.ts +139 -2
  369. package/src/lib/world-grid-snap.ts +102 -105
  370. package/src/store/use-alignment-guides.ts +27 -1
  371. package/src/store/use-editor.tsx +53 -0
  372. package/src/store/use-floorplan-annotation-visibility.test.ts +64 -0
  373. package/src/store/use-floorplan-annotation-visibility.ts +47 -22
  374. package/src/store/use-placement-typing.test.ts +165 -0
  375. package/src/store/use-placement-typing.ts +186 -0
  376. package/src/store/use-wall-snap-indicator.ts +15 -2
  377. package/src/components/tools/elevator/elevator-defaults.ts +0 -8
  378. package/src/components/tools/elevator/elevator-tool.tsx +0 -372
  379. package/src/components/tools/elevator/move-elevator-tool.tsx +0 -262
  380. package/src/lib/door-interaction.ts +0 -102
  381. package/src/lib/measurement-label.ts +0 -68
  382. package/src/lib/window-interaction.ts +0 -100
@@ -8,6 +8,7 @@ import {
8
8
  roomNameSections,
9
9
  roomNameUseCount,
10
10
  } from './room-name-catalog'
11
+ import { ROOM_NAME_MESSAGES, ROOM_NAME_SECTION_TITLES } from './room-name-messages'
11
12
 
12
13
  const square: [number, number][] = [
13
14
  [0, 0],
@@ -88,3 +89,10 @@ describe('room name catalog', () => {
88
89
  expect(ids(graph(zone('zone_room')), 'zone_room')).not.toContain('mezzanine')
89
90
  })
90
91
  })
92
+
93
+ test('every catalog section and name has a translatable message', () => {
94
+ for (const section of [...ROOM_NAME_SECTIONS, MEZZANINE_NAME_SECTION]) {
95
+ expect(ROOM_NAME_SECTION_TITLES[section.id]?.message).toBe(section.title)
96
+ for (const name of section.names) expect(ROOM_NAME_MESSAGES[name]?.message).toBe(name)
97
+ }
98
+ })
@@ -1,7 +1,14 @@
1
1
  import type { AnyNode } from '@pascal-app/core'
2
2
 
3
3
  /** A titled group of the room-name combobox. */
4
- export type RoomNameSection = { id: string; title: string; names: readonly string[] }
4
+ export type RoomNameSection = {
5
+ id: string
6
+ title: string
7
+ /** Stored, matched and numbered as data: always the English catalog name. */
8
+ names: readonly string[]
9
+ /** What the combobox shows for each name, in the UI language; defaults to the name. */
10
+ labels?: readonly string[]
11
+ }
5
12
 
6
13
  /** What the room reads as, which decides the section shown first. */
7
14
  export type RoomNameContext = 'room' | 'outdoor' | 'mezzanine'
@@ -0,0 +1,132 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { type AnyNode, type MessageDescriptor, ZoneNode } from '@pascal-app/core'
3
+ import { roomNameOptions } from '../components/ui/panels/room-name-options'
4
+ import { roomNameSections } from './room-name-catalog'
5
+ import { displayRoomName, editedRoomName, localizedRoomNameSections } from './room-name-messages'
6
+
7
+ const FRENCH: Record<string, string> = {
8
+ 'editor.roomName.bedroom': 'Chambre',
9
+ 'editor.roomName.kitchen': 'Cuisine',
10
+ 'editor.roomName.livingRoom': 'Salon',
11
+ 'editor.roomName.lounge': 'Salon',
12
+ 'editor.roomName.section.sleeping': 'Nuit',
13
+ }
14
+ const french = { _: (d: MessageDescriptor) => FRENCH[d.id] ?? d.message ?? d.id }
15
+ const english = { _: (d: MessageDescriptor) => d.message ?? d.id }
16
+
17
+ function zone(id: string, name = '') {
18
+ return ZoneNode.parse({
19
+ id,
20
+ name,
21
+ spaceRole: 'room',
22
+ parentId: 'level_names',
23
+ polygon: [
24
+ [0, 0],
25
+ [4, 0],
26
+ [4, 4],
27
+ [0, 4],
28
+ ],
29
+ boundaryWallIds: ['wall_a'],
30
+ }) as AnyNode
31
+ }
32
+
33
+ describe('room-name suggestions in another UI language', () => {
34
+ const nodes = Object.fromEntries(
35
+ [zone('zone_existing', 'Bedroom'), zone('zone_new')].map((node) => [node.id, node]),
36
+ )
37
+
38
+ test('the rows show and match the translated labels', () => {
39
+ const sections = localizedRoomNameSections(roomNameSections(nodes, 'zone_new'), french)
40
+ const rows = roomNameOptions('chamb', sections).groups.flatMap((group) => group.options)
41
+ expect(rows.map((row) => [row.value, row.label])).toEqual([['Bedroom', 'Chambre']])
42
+ expect(sections.find((section) => section.id === 'sleeping')?.title).toBe('Nuit')
43
+ })
44
+
45
+ test('the typed row is offered unless the text is exactly an English name', () => {
46
+ const sections = localizedRoomNameSections(roomNameSections(nodes, 'zone_new'), french)
47
+ expect(roomNameOptions('Atelier de Marie', sections).typed?.value).toBe('Atelier de Marie')
48
+ expect(roomNameOptions('Chambre', sections).typed?.value).toBe('Chambre')
49
+ expect(roomNameOptions('Bedroom', sections).typed).toBeNull()
50
+ })
51
+
52
+ test('display translates catalog names and keeps their number; other names show as written', () => {
53
+ expect(displayRoomName('Bedroom 2', french)).toBe('Chambre 2')
54
+ expect(displayRoomName('Bedroom', french)).toBe('Chambre')
55
+ expect(displayRoomName('Bedroom 2', english)).toBe('Bedroom 2')
56
+ expect(displayRoomName('Bedroom of Marie', french)).toBe('Bedroom of Marie')
57
+ expect(displayRoomName('Atelier', french)).toBe('Atelier')
58
+ expect(displayRoomName(' Bedroom 2 ', french)).toBe(' Chambre 2 ')
59
+ })
60
+ })
61
+
62
+ describe('editing a translated name in the field', () => {
63
+ test("only the room's own name with a changed number stores English", () => {
64
+ expect(editedRoomName('Bedroom 2', 'Chambre 3', french)).toBe('Bedroom 3')
65
+ expect(editedRoomName('Bedroom 2', 'Chambre', french)).toBe('Bedroom')
66
+ expect(editedRoomName('Bedroom', 'Chambre 2', french)).toBe('Bedroom 2')
67
+ expect(editedRoomName('Bedroom 2', 'Bedroom 3', english)).toBe('Bedroom 3')
68
+ })
69
+
70
+ test('a shared translation keeps the room its own name', () => {
71
+ expect(editedRoomName('Lounge 2', 'Salon 3', french)).toBe('Lounge 3')
72
+ expect(editedRoomName('Living room 2', 'Salon 3', french)).toBe('Living room 3')
73
+ })
74
+
75
+ test('anything else is stored exactly as typed', () => {
76
+ // A different catalog name's translation, a free-text room, other text, case.
77
+ expect(editedRoomName('Bedroom 2', 'Cuisine', french)).toBe('Cuisine')
78
+ expect(editedRoomName('Atelier', 'Chambre 2', french)).toBe('Chambre 2')
79
+ expect(editedRoomName('Bedroom 2', 'Chambre de Marie', french)).toBe('Chambre de Marie')
80
+ expect(editedRoomName('Bedroom 2', 'chambre 3', french)).toBe('chambre 3')
81
+ })
82
+
83
+ test('surrounding spaces are kept as typed, as English keeps them', () => {
84
+ expect(editedRoomName('Bedroom 2', 'Chambre 3 ', french)).toBe('Bedroom 3 ')
85
+ expect(editedRoomName('Bedroom 2', 'Bedroom 3 ', english)).toBe('Bedroom 3 ')
86
+ expect(editedRoomName(' Bedroom 2 ', ' Chambre 3 ', french)).toBe(' Bedroom 3 ')
87
+ })
88
+ })
89
+
90
+ describe('a partly translated pack', () => {
91
+ // Only Bedroom is translated; Primary bedroom still shows in English.
92
+ const nodes = Object.fromEntries([zone('zone_new')].map((node) => [node.id, node]))
93
+ const sections = localizedRoomNameSections(roomNameSections(nodes, 'zone_new'), french)
94
+
95
+ test('typing the English name makes it active, with no typed row', () => {
96
+ const { options, typed, active } = roomNameOptions('Bedroom', sections)
97
+ expect(typed).toBeNull()
98
+ expect(options[active]).toMatchObject({
99
+ kind: 'suggestion',
100
+ value: 'Bedroom',
101
+ label: 'Chambre',
102
+ })
103
+ expect(options.map((row) => row.value)).toContain('Primary bedroom')
104
+ })
105
+
106
+ test('typing the translated name makes it active too', () => {
107
+ const { options, active } = roomNameOptions('chambre', sections)
108
+ expect(options[active]).toMatchObject({ value: 'Bedroom' })
109
+ })
110
+
111
+ test('without an exact name the first suggestion stays active', () => {
112
+ const { options, active } = roomNameOptions('prim', sections)
113
+ expect(options[active]).toMatchObject({ value: 'Primary bedroom' })
114
+ })
115
+
116
+ test('a label two names share is not an exact match', () => {
117
+ const { options, active } = roomNameOptions('salon', sections)
118
+ expect(options[active]?.kind).toBe('suggestion')
119
+ expect(options[active]?.value).toBe(options.find((row) => row.kind === 'suggestion')?.value)
120
+ })
121
+ })
122
+
123
+ describe('names that are object prototype keys', () => {
124
+ test.each(['constructor', 'toString', '__proto__', 'hasOwnProperty', 'valueOf 3'])(
125
+ '%s shows and stores as written',
126
+ (name) => {
127
+ expect(displayRoomName(name, french)).toBe(name)
128
+ expect(displayRoomName(name, english)).toBe(name)
129
+ expect(editedRoomName(name, `${name} 2`, french)).toBe(`${name} 2`)
130
+ },
131
+ )
132
+ })
@@ -0,0 +1,147 @@
1
+ import type { MessageDescriptor } from '@pascal-app/core'
2
+ import type { RoomNameSection } from './room-name-catalog'
3
+
4
+ /**
5
+ * The room-name catalog in the UI language. The catalog stays English (it is
6
+ * matched, numbered and stored as data); the combobox and room labels show these.
7
+ * `room-name-catalog.test.ts` fails when a catalog name has no entry here.
8
+ */
9
+ /** Anything that translates a descriptor: a `PascalI18n`, or a stand-in in tests. */
10
+ export type DescriptorTranslator = { _(descriptor: MessageDescriptor): string }
11
+
12
+ export const ROOM_NAME_SECTION_TITLES: Record<string, MessageDescriptor> = {
13
+ living: /* i18n */ { id: 'editor.roomName.section.living', message: 'Living' },
14
+ sleeping: /* i18n */ { id: 'editor.roomName.section.sleeping', message: 'Sleeping' },
15
+ bathrooms: /* i18n */ { id: 'editor.roomName.section.bathrooms', message: 'Bathrooms' },
16
+ work: /* i18n */ { id: 'editor.roomName.section.work', message: 'Work & hobbies' },
17
+ circulation: /* i18n */ { id: 'editor.roomName.section.circulation', message: 'Circulation' },
18
+ utility: /* i18n */ { id: 'editor.roomName.section.utility', message: 'Utility & storage' },
19
+ outdoor: /* i18n */ { id: 'editor.roomName.section.outdoor', message: 'Outdoor' },
20
+ mezzanine: /* i18n */ { id: 'editor.roomName.section.mezzanine', message: 'Mezzanine' },
21
+ }
22
+
23
+ export const ROOM_NAME_MESSAGES: Record<string, MessageDescriptor> = {
24
+ 'Living room': /* i18n */ { id: 'editor.roomName.livingRoom', message: 'Living room' },
25
+ 'Family room': /* i18n */ { id: 'editor.roomName.familyRoom', message: 'Family room' },
26
+ 'Dining room': /* i18n */ { id: 'editor.roomName.diningRoom', message: 'Dining room' },
27
+ Kitchen: /* i18n */ { id: 'editor.roomName.kitchen', message: 'Kitchen' },
28
+ Kitchenette: /* i18n */ { id: 'editor.roomName.kitchenette', message: 'Kitchenette' },
29
+ Pantry: /* i18n */ { id: 'editor.roomName.pantry', message: 'Pantry' },
30
+ Lounge: /* i18n */ { id: 'editor.roomName.lounge', message: 'Lounge' },
31
+ Den: /* i18n */ { id: 'editor.roomName.den', message: 'Den' },
32
+ Library: /* i18n */ { id: 'editor.roomName.library', message: 'Library' },
33
+ Playroom: /* i18n */ { id: 'editor.roomName.playroom', message: 'Playroom' },
34
+ 'Home cinema': /* i18n */ { id: 'editor.roomName.homeCinema', message: 'Home cinema' },
35
+ 'Games room': /* i18n */ { id: 'editor.roomName.gamesRoom', message: 'Games room' },
36
+ Sunroom: /* i18n */ { id: 'editor.roomName.sunroom', message: 'Sunroom' },
37
+ Bedroom: /* i18n */ { id: 'editor.roomName.bedroom', message: 'Bedroom' },
38
+ 'Primary bedroom': /* i18n */ {
39
+ id: 'editor.roomName.primaryBedroom',
40
+ message: 'Primary bedroom',
41
+ },
42
+ 'Guest room': /* i18n */ { id: 'editor.roomName.guestRoom', message: 'Guest room' },
43
+ "Kids' room": /* i18n */ { id: 'editor.roomName.kidsRoom', message: "Kids' room" },
44
+ Nursery: /* i18n */ { id: 'editor.roomName.nursery', message: 'Nursery' },
45
+ 'Dressing room': /* i18n */ { id: 'editor.roomName.dressingRoom', message: 'Dressing room' },
46
+ 'Walk-in closet': /* i18n */ { id: 'editor.roomName.walkInCloset', message: 'Walk-in closet' },
47
+ Bathroom: /* i18n */ { id: 'editor.roomName.bathroom', message: 'Bathroom' },
48
+ 'En-suite': /* i18n */ { id: 'editor.roomName.enSuite', message: 'En-suite' },
49
+ 'Shower room': /* i18n */ { id: 'editor.roomName.showerRoom', message: 'Shower room' },
50
+ Toilet: /* i18n */ { id: 'editor.roomName.toilet', message: 'Toilet' },
51
+ 'Powder room': /* i18n */ { id: 'editor.roomName.powderRoom', message: 'Powder room' },
52
+ Office: /* i18n */ { id: 'editor.roomName.office', message: 'Office' },
53
+ Study: /* i18n */ { id: 'editor.roomName.study', message: 'Study' },
54
+ Studio: /* i18n */ { id: 'editor.roomName.studio', message: 'Studio' },
55
+ Workshop: /* i18n */ { id: 'editor.roomName.workshop', message: 'Workshop' },
56
+ Gym: /* i18n */ { id: 'editor.roomName.gym', message: 'Gym' },
57
+ Entrance: /* i18n */ { id: 'editor.roomName.entrance', message: 'Entrance' },
58
+ Hallway: /* i18n */ { id: 'editor.roomName.hallway', message: 'Hallway' },
59
+ Corridor: /* i18n */ { id: 'editor.roomName.corridor', message: 'Corridor' },
60
+ Landing: /* i18n */ { id: 'editor.roomName.landing', message: 'Landing' },
61
+ Stairwell: /* i18n */ { id: 'editor.roomName.stairwell', message: 'Stairwell' },
62
+ Mudroom: /* i18n */ { id: 'editor.roomName.mudroom', message: 'Mudroom' },
63
+ Laundry: /* i18n */ { id: 'editor.roomName.laundry', message: 'Laundry' },
64
+ 'Utility room': /* i18n */ { id: 'editor.roomName.utilityRoom', message: 'Utility room' },
65
+ Storage: /* i18n */ { id: 'editor.roomName.storage', message: 'Storage' },
66
+ Closet: /* i18n */ { id: 'editor.roomName.closet', message: 'Closet' },
67
+ 'Boiler room': /* i18n */ { id: 'editor.roomName.boilerRoom', message: 'Boiler room' },
68
+ Cellar: /* i18n */ { id: 'editor.roomName.cellar', message: 'Cellar' },
69
+ Basement: /* i18n */ { id: 'editor.roomName.basement', message: 'Basement' },
70
+ Attic: /* i18n */ { id: 'editor.roomName.attic', message: 'Attic' },
71
+ Garage: /* i18n */ { id: 'editor.roomName.garage', message: 'Garage' },
72
+ Garden: /* i18n */ { id: 'editor.roomName.garden', message: 'Garden' },
73
+ Patio: /* i18n */ { id: 'editor.roomName.patio', message: 'Patio' },
74
+ Terrace: /* i18n */ { id: 'editor.roomName.terrace', message: 'Terrace' },
75
+ Balcony: /* i18n */ { id: 'editor.roomName.balcony', message: 'Balcony' },
76
+ Porch: /* i18n */ { id: 'editor.roomName.porch', message: 'Porch' },
77
+ Veranda: /* i18n */ { id: 'editor.roomName.veranda', message: 'Veranda' },
78
+ Deck: /* i18n */ { id: 'editor.roomName.deck', message: 'Deck' },
79
+ Courtyard: /* i18n */ { id: 'editor.roomName.courtyard', message: 'Courtyard' },
80
+ 'Pool area': /* i18n */ { id: 'editor.roomName.poolArea', message: 'Pool area' },
81
+ Carport: /* i18n */ { id: 'editor.roomName.carport', message: 'Carport' },
82
+ 'Garden shed': /* i18n */ { id: 'editor.roomName.gardenShed', message: 'Garden shed' },
83
+ Mezzanine: /* i18n */ { id: 'editor.roomName.mezzanine', message: 'Mezzanine' },
84
+ Loft: /* i18n */ { id: 'editor.roomName.loft', message: 'Loft' },
85
+ 'Reading nook': /* i18n */ { id: 'editor.roomName.readingNook', message: 'Reading nook' },
86
+ Gallery: /* i18n */ { id: 'editor.roomName.gallery', message: 'Gallery' },
87
+ }
88
+
89
+ export function localizedRoomNameSections(
90
+ sections: readonly RoomNameSection[],
91
+ i18n: DescriptorTranslator,
92
+ ): RoomNameSection[] {
93
+ return sections.map((section) => {
94
+ const title = Object.hasOwn(ROOM_NAME_SECTION_TITLES, section.id)
95
+ ? ROOM_NAME_SECTION_TITLES[section.id]
96
+ : undefined
97
+ return {
98
+ ...section,
99
+ title: title ? i18n._(title) : section.title,
100
+ labels: section.names.map((name) => displayRoomName(name, i18n)),
101
+ }
102
+ })
103
+ }
104
+
105
+ /**
106
+ * A room name in the UI language. Catalog names stay English in the scene
107
+ * (numbering, MCP and exports read them); a catalog name with the number the
108
+ * picker adds shows translated with that number ("Bedroom 2" → "Chambre 2").
109
+ * Anything else the user typed shows as written.
110
+ */
111
+ export function displayRoomName(name: string, i18n: DescriptorTranslator): string {
112
+ const { lead, base, number, trail } = roomNameParts(name)
113
+ const label = catalogLabel(base, i18n)
114
+ return label === undefined ? name : `${lead}${label}${number}${trail}`
115
+ }
116
+
117
+ /**
118
+ * What an edit of a room's name field stores. The field shows the room's own
119
+ * catalog name translated ("Lounge 2" as "Salon 2"); when the edit only
120
+ * changes, adds or removes that name's number ("Salon 3"), it stores the
121
+ * English name with the new number ("Lounge 3"), as editing it in English
122
+ * would. Any other text, an exact translation of a different name included, is
123
+ * stored exactly as typed.
124
+ */
125
+ export function editedRoomName(stored: string, text: string, i18n: DescriptorTranslator): string {
126
+ const own = roomNameParts(stored).base
127
+ const label = catalogLabel(own, i18n)
128
+ const edited = roomNameParts(text)
129
+ if (label === undefined || edited.base !== label) return text
130
+ return `${edited.lead}${own}${edited.number}${edited.trail}`
131
+ }
132
+
133
+ function roomNameParts(name: string) {
134
+ const match = /^(\s*)(.*?)(\s+\d+)?(\s*)$/.exec(name)
135
+ return {
136
+ lead: match?.[1] ?? '',
137
+ base: match?.[2] ?? '',
138
+ number: match?.[3] ?? '',
139
+ trail: match?.[4] ?? '',
140
+ }
141
+ }
142
+
143
+ function catalogLabel(english: string, i18n: DescriptorTranslator): string | undefined {
144
+ // Own keys only: a room named "constructor" or "__proto__" is free text.
145
+ if (!(english && Object.hasOwn(ROOM_NAME_MESSAGES, english))) return undefined
146
+ return i18n._(ROOM_NAME_MESSAGES[english]!)
147
+ }
@@ -0,0 +1,334 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeId,
5
+ applyZoneTransformPlan,
6
+ createI18n,
7
+ createZone,
8
+ duplicateZone,
9
+ LevelNode,
10
+ type PascalI18n,
11
+ registerMessages,
12
+ setLocale,
13
+ useScene,
14
+ ZoneNode,
15
+ } from '@pascal-app/core'
16
+ import { AGENT_OPERATIONS, applyAgentOutcome } from '@pascal-app/core/agent-operations'
17
+ import { createAgentHost, liveSceneRuntime } from '@pascal-app/core/runtime'
18
+ import {
19
+ commitRoomNameEdit,
20
+ commitRoomNamePick,
21
+ commitTreeRoomRename,
22
+ roomNameOptions,
23
+ } from '../components/ui/panels/room-name-options'
24
+ import { roomNameSections } from './room-name-catalog'
25
+ import { displayRoomName, localizedRoomNameSections } from './room-name-messages'
26
+ import { applyRoomPlan, renameRoom } from './room-structure-commands'
27
+ import { copySelectedNodesToEditorClipboard, pasteEditorClipboardToLevel } from './scene-clipboard'
28
+
29
+ /**
30
+ * Every path that writes a room name stores in a French UI what the same edit
31
+ * stores in English (what main stores): the UI only displays catalog names
32
+ * translated. The room panel is driven through the commit and pick functions
33
+ * its name field calls.
34
+ */
35
+
36
+ const levelId = 'level_names' as LevelNode['id']
37
+ const otherLevelId = 'level_names-paste' as LevelNode['id']
38
+ const square = (x: number): [number, number][] => [
39
+ [x, 0],
40
+ [x + 4, 0],
41
+ [x + 4, 4],
42
+ [x, 4],
43
+ ]
44
+
45
+ const seeded = new Set(['zone_existing', 'zone_new'])
46
+ let english: PascalI18n
47
+ let french: PascalI18n
48
+
49
+ beforeAll(async () => {
50
+ // A reviewed French pack for the names these paths touch.
51
+ await registerMessages('fr', '', {
52
+ 'editor.roomName.bedroom': 'Chambre',
53
+ 'editor.roomName.kitchen': 'Cuisine',
54
+ // Shared labels, as in the French drafts.
55
+ 'editor.roomName.livingRoom': 'Salon',
56
+ 'editor.roomName.lounge': 'Salon',
57
+ 'editor.roomName.office': 'Bureau',
58
+ 'editor.roomName.study': 'Bureau',
59
+ 'editor.roomName.section.sleeping': 'Nuit',
60
+ })
61
+ english = createI18n({ locale: 'en' })
62
+ french = createI18n({ locale: 'fr' })
63
+ })
64
+ afterAll(() => {
65
+ setLocale('en')
66
+ english.destroy()
67
+ french.destroy()
68
+ })
69
+
70
+ function seed() {
71
+ const level = LevelNode.parse({ id: levelId, children: ['zone_existing', 'zone_new'] })
72
+ const other = LevelNode.parse({ id: otherLevelId, level: 1 })
73
+ const zone = (id: string, name: string, x: number) =>
74
+ ZoneNode.parse({ id, name, spaceRole: 'room', parentId: levelId, polygon: square(x) })
75
+ const nodes = {
76
+ [level.id]: level,
77
+ [other.id]: other,
78
+ zone_existing: zone('zone_existing', 'Bedroom', 0),
79
+ zone_new: zone('zone_new', '', 10),
80
+ } as unknown as Record<string, AnyNode>
81
+ useScene.setState({ nodes, rootNodeIds: [level.id, other.id], materials: {} } as never)
82
+ useScene.temporal.getState().clear()
83
+ }
84
+
85
+ const nameOf = (id: string) => useScene.getState().nodes[id as AnyNodeId]?.name
86
+ const zonesOn = (level: string) =>
87
+ Object.values(useScene.getState().nodes).filter(
88
+ (node) => node.type === 'zone' && node.parentId === level,
89
+ )
90
+
91
+ /** Runs `write` with the UI in `locale` and returns the names it stored. */
92
+ function storedBy(locale: 'en' | 'fr', write: (i18n: PascalI18n) => void) {
93
+ seed()
94
+ setLocale(locale)
95
+ write(locale === 'en' ? english : french)
96
+ setLocale('en')
97
+ // Seeded rooms by id; rooms a path creates get fresh ids, so they are listed by name.
98
+ const zones = Object.values(useScene.getState().nodes).filter((node) => node.type === 'zone')
99
+ return {
100
+ ...Object.fromEntries(
101
+ zones.filter((zone) => seeded.has(zone.id)).map((zone) => [zone.id, zone.name]),
102
+ ),
103
+ created: zones
104
+ .filter((zone) => !seeded.has(zone.id))
105
+ .map((zone) => zone.name ?? '')
106
+ .sort(),
107
+ } as Record<string, string | undefined> & { created: string[] }
108
+ }
109
+
110
+ function expectSameEnglish(write: (i18n: PascalI18n) => void) {
111
+ const inEnglish = storedBy('en', write)
112
+ const inFrench = storedBy('fr', write)
113
+ expect(inFrench).toEqual(inEnglish)
114
+ return inEnglish
115
+ }
116
+
117
+ const sectionsFor = (i18n: PascalI18n, zoneId: string) =>
118
+ localizedRoomNameSections(roomNameSections(useScene.getState().nodes, zoneId), i18n)
119
+
120
+ /** Types `text` in the panel field and picks the suggestion shown as `label`. */
121
+ const pick = (i18n: PascalI18n, zoneId: string, text: string, label = text) => {
122
+ const option = roomNameOptions(text, sectionsFor(i18n, zoneId)).options.find(
123
+ (row) => row.kind === 'suggestion' && row.label === label,
124
+ )
125
+ if (!option) throw new Error(`no suggestion shown as ${label}`)
126
+ commitRoomNamePick(zoneId, nameOf(zoneId) ?? '', option, i18n)
127
+ }
128
+ /** Types `text` in the panel field and clicks its 'Use "…"' row. */
129
+ const useTyped = (i18n: PascalI18n, zoneId: string, text: string) => {
130
+ const typed = roomNameOptions(text, sectionsFor(i18n, zoneId)).typed
131
+ if (!typed) throw new Error(`no typed row for ${text}`)
132
+ commitRoomNamePick(zoneId, nameOf(zoneId) ?? '', typed, i18n)
133
+ }
134
+ /** Edits the field from what it shows, then blurs it or presses Enter on the typed row. */
135
+ const edit = (i18n: PascalI18n, zoneId: string, draft: (shown: string) => string) => {
136
+ const stored = nameOf(zoneId) ?? ''
137
+ const shown = displayRoomName(stored, i18n)
138
+ commitRoomNameEdit(zoneId, stored, shown, draft(shown), i18n)
139
+ }
140
+ /** Types `text` in the panel field and presses Enter: the active row, as the field does. */
141
+ const enter = (i18n: PascalI18n, zoneId: string, text: string) => {
142
+ const stored = nameOf(zoneId) ?? ''
143
+ const { options, active } = roomNameOptions(text, sectionsFor(i18n, zoneId), true, stored)
144
+ const chosen = options[active]
145
+ if (chosen?.kind === 'suggestion') commitRoomNamePick(zoneId, stored, chosen, i18n)
146
+ else commitRoomNameEdit(zoneId, stored, displayRoomName(stored, i18n), text, i18n)
147
+ }
148
+ const named = (zoneId: string, name: string) => renameRoom(zoneId, name)
149
+
150
+ describe('room names are stored in English whatever the UI language', () => {
151
+ test('a suggestion pick', () => {
152
+ const names = expectSameEnglish((i18n) =>
153
+ pick(i18n, 'zone_new', i18n === french ? 'Chambre' : 'Bedroom'),
154
+ )
155
+ expect(names.zone_new).toBe('Bedroom 2')
156
+ })
157
+
158
+ test('editing the field: renumbering, removing or adding the number', () => {
159
+ const renumber = (i18n: PascalI18n) => {
160
+ named('zone_new', 'Bedroom 2')
161
+ edit(i18n, 'zone_new', (shown) => shown.replace(/2$/, '3'))
162
+ }
163
+ expect(expectSameEnglish(renumber).zone_new).toBe('Bedroom 3')
164
+ const unnumber = (i18n: PascalI18n) => {
165
+ named('zone_new', 'Bedroom 2')
166
+ edit(i18n, 'zone_new', (shown) => shown.replace(/ 2$/, ''))
167
+ }
168
+ expect(expectSameEnglish(unnumber).zone_new).toBe('Bedroom')
169
+ const number = (i18n: PascalI18n) => edit(i18n, 'zone_existing', (shown) => `${shown} 2`)
170
+ expect(expectSameEnglish(number).zone_existing).toBe('Bedroom 2')
171
+ })
172
+
173
+ test('a shared translation keeps the room its own name', () => {
174
+ // "Salon" is both Living room and Lounge; "Bureau" both Office and Study.
175
+ const lounge = (i18n: PascalI18n) => {
176
+ named('zone_new', 'Lounge 2')
177
+ edit(i18n, 'zone_new', (shown) => shown.replace(/2$/, '3'))
178
+ }
179
+ expect(expectSameEnglish(lounge).zone_new).toBe('Lounge 3')
180
+ const study = (i18n: PascalI18n) => {
181
+ named('zone_new', 'Study')
182
+ edit(i18n, 'zone_new', (shown) => `${shown} 2`)
183
+ }
184
+ expect(expectSameEnglish(study).zone_new).toBe('Study 2')
185
+ })
186
+
187
+ test('Enter on a shared label keeps the room its own entry', () => {
188
+ // "Salon" is shown for both Living room and Lounge; Enter on a Lounge keeps Lounge.
189
+ const lounge = (i18n: PascalI18n) => {
190
+ named('zone_new', 'Lounge')
191
+ enter(i18n, 'zone_new', displayRoomName('Lounge', i18n))
192
+ }
193
+ expect(expectSameEnglish(lounge).zone_new).toBe('Lounge')
194
+ const living = (i18n: PascalI18n) => {
195
+ named('zone_new', 'Living room')
196
+ enter(i18n, 'zone_new', displayRoomName('Living room', i18n))
197
+ }
198
+ expect(expectSameEnglish(living).zone_new).toBe('Living room')
199
+ })
200
+
201
+ test('free text, including an exact translation, is stored as typed', () => {
202
+ const atelier = (i18n: PascalI18n) => edit(i18n, 'zone_new', () => 'Atelier de Marie')
203
+ expect(expectSameEnglish(atelier).zone_new).toBe('Atelier de Marie')
204
+ const cuisine = (i18n: PascalI18n) => edit(i18n, 'zone_existing', () => 'Cuisine')
205
+ expect(expectSameEnglish(cuisine).zone_existing).toBe('Cuisine')
206
+ // Another room's translation with a number is not this room's name.
207
+ const salon = (i18n: PascalI18n) => edit(i18n, 'zone_existing', () => 'Salon 2')
208
+ expect(expectSameEnglish(salon).zone_existing).toBe('Salon 2')
209
+ })
210
+
211
+ test("clicking 'Use' stores the text as typed; an English name has no 'Use' row", () => {
212
+ expect(storedBy('fr', (i18n) => useTyped(i18n, 'zone_new', 'Chambre')).zone_new).toBe('Chambre')
213
+ expect(storedBy('en', (i18n) => useTyped(i18n, 'zone_new', 'Chambre')).zone_new).toBe('Chambre')
214
+ expect(storedBy('fr', (i18n) => useTyped(i18n, 'zone_new', 'Cuisine')).zone_new).toBe('Cuisine')
215
+ for (const i18n of [english, french]) {
216
+ expect(roomNameOptions('Bedroom', sectionsFor(i18n, 'zone_new')).typed).toBeNull()
217
+ }
218
+ })
219
+
220
+ test('surrounding spaces are stored as typed', () => {
221
+ const trailing = (i18n: PascalI18n) => {
222
+ named('zone_new', 'Bedroom 2')
223
+ edit(i18n, 'zone_new', (shown) => shown.replace(/2$/, '3 '))
224
+ }
225
+ expect(expectSameEnglish(trailing).zone_new).toBe('Bedroom 3 ')
226
+ const padded = (i18n: PascalI18n) => {
227
+ named('zone_new', ' Bedroom 2 ')
228
+ edit(i18n, 'zone_new', (shown) => shown.replace('2', '3'))
229
+ }
230
+ expect(expectSameEnglish(padded).zone_new).toBe(' Bedroom 3 ')
231
+ })
232
+
233
+ test('the layer tree rename edits the shown name and stores the English', () => {
234
+ /** The tree field shows `displayRoomName`, and passes the trimmed text or undefined. */
235
+ const treeEdit = (i18n: PascalI18n, zoneId: string, draft: (shown: string) => string) => {
236
+ const shown = displayRoomName(nameOf(zoneId) ?? '', i18n)
237
+ const text = draft(shown).trim()
238
+ if (text !== shown) commitTreeRoomRename(zoneId, text || undefined, i18n)
239
+ }
240
+ const renumber = (i18n: PascalI18n) => {
241
+ named('zone_new', 'Lounge 2')
242
+ treeEdit(i18n, 'zone_new', (shown) => shown.replace(/2$/, '3'))
243
+ }
244
+ expect(expectSameEnglish(renumber).zone_new).toBe('Lounge 3')
245
+ const padded = (i18n: PascalI18n) => {
246
+ named('zone_new', ' Kitchen ')
247
+ treeEdit(i18n, 'zone_new', (shown) => shown)
248
+ }
249
+ expect(expectSameEnglish(padded).zone_new).toBe('Kitchen')
250
+ const free = (i18n: PascalI18n) => treeEdit(i18n, 'zone_existing', () => ' Atelier ')
251
+ expect(expectSameEnglish(free).zone_existing).toBe('Atelier')
252
+ const cleared = (i18n: PascalI18n) => treeEdit(i18n, 'zone_existing', () => '')
253
+ expect(expectSameEnglish(cleared).zone_existing).toBeUndefined()
254
+ })
255
+
256
+ test('an unchanged field writes nothing', () => {
257
+ for (const name of ['Bedroom', ' Bedroom 2 ', 'Lounge 2', 'Atelier']) {
258
+ seed()
259
+ named('zone_existing', name)
260
+ const before = useScene.getState().nodes
261
+ edit(french, 'zone_existing', (shown) => shown)
262
+ expect(useScene.getState().nodes).toBe(before)
263
+ }
264
+ })
265
+
266
+ test('creating a room', () => {
267
+ const names = expectSameEnglish(() => {
268
+ let i = 0
269
+ const plan = createZone(useScene.getState().nodes, {
270
+ levelId,
271
+ polygon: square(20),
272
+ mintId: (kind: string) => `${kind}_created${++i}` as never,
273
+ })
274
+ expect(applyRoomPlan(plan)).toBe(true)
275
+ })
276
+ expect(names.created).toEqual(['Room'])
277
+ })
278
+
279
+ test('duplicating a room', () => {
280
+ const names = expectSameEnglish(() => {
281
+ let i = 0
282
+ const mintId = ((kind: string) => `${kind}_copy${++i}`) as never
283
+ // Duplicating needs an enclosed room: build one named as the panel stores it.
284
+ const created = createZone(useScene.getState().nodes, {
285
+ levelId,
286
+ polygon: square(30),
287
+ enclose: true,
288
+ name: 'Bedroom',
289
+ mintId,
290
+ })
291
+ expect(applyRoomPlan(created)).toBe(true)
292
+ const plan = duplicateZone(useScene.getState().nodes, {
293
+ zoneId: created.zoneId,
294
+ translate: [0, 10],
295
+ mintId,
296
+ })
297
+ expect(plan.conflicts).toBeUndefined()
298
+ applyZoneTransformPlan(plan)
299
+ })
300
+ expect(names.created.filter((name) => name.startsWith('Bedroom'))).toHaveLength(2)
301
+ })
302
+
303
+ test('copy and paste to another level', () => {
304
+ const names = expectSameEnglish(() => {
305
+ expect(copySelectedNodesToEditorClipboard(['zone_existing' as AnyNodeId])).toBe(true)
306
+ expect(pasteEditorClipboardToLevel(otherLevelId)?.pastedIds.length).toBeGreaterThan(0)
307
+ expect(zonesOn(otherLevelId).map((zone) => zone.name)).toEqual(['Bedroom'])
308
+ })
309
+ expect(names.created).toEqual(['Bedroom'])
310
+ })
311
+
312
+ test('MCP and agents: set_zone_intent stores the name as given', () => {
313
+ const names = expectSameEnglish(() => {
314
+ const host = createAgentHost(liveSceneRuntime)
315
+ const outcome = AGENT_OPERATIONS.set_zone_intent(
316
+ host.getNodes(),
317
+ { zoneId: 'zone_new', patch: { name: 'Kitchen' } },
318
+ {} as never,
319
+ )
320
+ applyAgentOutcome(outcome, host)
321
+ })
322
+ expect(names.zone_new).toBe('Kitchen')
323
+ })
324
+
325
+ test('undo restores the stored English', () => {
326
+ const names = expectSameEnglish((i18n) => {
327
+ renameRoom('zone_existing', 'Bedroom 4')
328
+ edit(i18n, 'zone_existing', (shown) => shown.replace(/4$/, '5'))
329
+ expect(nameOf('zone_existing')).toBe('Bedroom 5')
330
+ useScene.temporal.getState().undo()
331
+ })
332
+ expect(names.zone_existing).toBe('Bedroom 4')
333
+ })
334
+ })