@pascal-app/editor 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (317) hide show
  1. package/package.json +20 -17
  2. package/src/components/editor/action-menu-button.tsx +4 -1
  3. package/src/components/editor/alignment-3d-guide-layer.tsx +102 -11
  4. package/src/components/editor/bake-thumbnail.tsx +3 -2
  5. package/src/components/editor/custom-camera-controls.tsx +20 -8
  6. package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
  7. package/src/components/editor/first-person-controls.tsx +72 -61
  8. package/src/components/editor/floating-action-menu.tsx +27 -9
  9. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  10. package/src/components/editor/floorplan-panel.test.ts +220 -0
  11. package/src/components/editor/floorplan-panel.tsx +285 -132
  12. package/src/components/editor/footprint-height-handle.tsx +1 -1
  13. package/src/components/editor/grid.tsx +24 -15
  14. package/src/components/editor/group-actions.ts +19 -1
  15. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  16. package/src/components/editor/group-move-3d.ts +27 -2
  17. package/src/components/editor/group-selection-box-3d.tsx +4 -1
  18. package/src/components/editor/group-transform-shared.ts +20 -0
  19. package/src/components/editor/index.tsx +130 -20
  20. package/src/components/editor/inspector-extension-actions.tsx +36 -0
  21. package/src/components/editor/localized-html.tsx +22 -0
  22. package/src/components/editor/node-action-menu.tsx +46 -12
  23. package/src/components/editor/node-arrow-handles.tsx +1 -2
  24. package/src/components/editor/open-wall-ends-3d-layer.tsx +1 -1
  25. package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
  26. package/src/components/editor/registry-action-contributions.test.tsx +67 -6
  27. package/src/components/editor/registry-action-contributions.tsx +115 -15
  28. package/src/components/editor/riser-diagram-panel.tsx +59 -9
  29. package/src/components/editor/room-controls.tsx +1 -1
  30. package/src/components/editor/room-handles-2d.tsx +156 -6
  31. package/src/components/editor/room-handles.tsx +241 -51
  32. package/src/components/editor/room-highlight.tsx +2 -0
  33. package/src/components/editor/room-labels.tsx +29 -15
  34. package/src/components/editor/selection-manager.tsx +15 -102
  35. package/src/components/editor/separator-end-grip.tsx +74 -0
  36. package/src/components/editor/site-edge-labels.tsx +1 -1
  37. package/src/components/editor/thumbnail-generator.test.ts +15 -6
  38. package/src/components/editor/thumbnail-generator.tsx +26 -1
  39. package/src/components/editor/use-floorplan-background-placement.ts +55 -52
  40. package/src/components/editor/views/view-bar.tsx +111 -26
  41. package/src/components/editor/wall-measurement-label.tsx +1 -1
  42. package/src/components/editor/wall-move-side-handles.tsx +5 -1
  43. package/src/components/editor/wall-pull-grip.tsx +193 -0
  44. package/src/components/editor/wall-snap-beacon-layer.tsx +81 -1
  45. package/src/components/editor-2d/floorplan-adjacent-walls-layer.test.ts +76 -0
  46. package/src/components/editor-2d/floorplan-adjacent-walls-layer.tsx +186 -0
  47. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +63 -3
  48. package/src/components/editor-2d/floorplan-group-move.tsx +31 -3
  49. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +6 -0
  50. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +22 -13
  51. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +11 -3
  52. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +7 -1
  53. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +96 -4
  54. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +52 -12
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +12 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +197 -37
  57. package/src/components/procedural-items/inspector.tsx +59 -12
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +2 -0
  59. package/src/components/systems/ceiling/ceiling-system.test.ts +141 -0
  60. package/src/components/systems/ceiling/ceiling-system.tsx +53 -18
  61. package/src/components/tools/distribution/distribution-run-tool.tsx +50 -26
  62. package/src/components/tools/item/placement-history.test.tsx +1 -1
  63. package/src/components/tools/item/placement-strategies.test.ts +64 -1
  64. package/src/components/tools/item/placement-strategies.ts +5 -9
  65. package/src/components/tools/item/use-placement-coordinator.tsx +30 -34
  66. package/src/components/tools/registry/item-surface-move.test.ts +11 -0
  67. package/src/components/tools/select/select-candidates.test.ts +45 -0
  68. package/src/components/tools/select/select-candidates.ts +13 -2
  69. package/src/components/tools/shared/cursor-sphere.tsx +10 -3
  70. package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
  71. package/src/components/tools/shared/placement-box.tsx +1 -1
  72. package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
  73. package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
  74. package/src/components/tools/wall/wall-drafting.test.ts +299 -23
  75. package/src/components/tools/wall/wall-drafting.ts +132 -24
  76. package/src/components/tools/wall/wall-pull-snap.test.ts +274 -0
  77. package/src/components/tools/wall/wall-pull-snap.ts +563 -0
  78. package/src/components/tools/wall/wall-snap-geometry.test.ts +113 -1
  79. package/src/components/tools/wall/wall-snap-geometry.ts +137 -0
  80. package/src/components/tools/zone/zone-tool.tsx +3 -3
  81. package/src/components/ui/action-menu/action-button.tsx +2 -0
  82. package/src/components/ui/action-menu/camera-actions.tsx +8 -7
  83. package/src/components/ui/action-menu/control-modes.tsx +9 -5
  84. package/src/components/ui/action-menu/follow-pascal-pill.tsx +3 -1
  85. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +7 -1
  86. package/src/components/ui/action-menu/furnish-tools.tsx +7 -6
  87. package/src/components/ui/action-menu/index.tsx +1 -0
  88. package/src/components/ui/action-menu/measurement-control.tsx +35 -29
  89. package/src/components/ui/action-menu/point-ask-button.tsx +4 -3
  90. package/src/components/ui/action-menu/structure-tools.tsx +25 -23
  91. package/src/components/ui/action-menu/view-toggles.test.ts +18 -0
  92. package/src/components/ui/action-menu/view-toggles.tsx +256 -51
  93. package/src/components/ui/build-panel/build-panel.tsx +163 -25
  94. package/src/components/ui/command-palette/editor-commands.tsx +38 -11
  95. package/src/components/ui/controls/finish-swatch.tsx +45 -19
  96. package/src/components/ui/controls/material-picker.tsx +24 -17
  97. package/src/components/ui/controls/metric-control.test.ts +52 -0
  98. package/src/components/ui/controls/metric-control.tsx +62 -24
  99. package/src/components/ui/controls/segmented-control.tsx +5 -2
  100. package/src/components/ui/controls/slider-control.test.tsx +23 -0
  101. package/src/components/ui/controls/slider-control.tsx +30 -14
  102. package/src/components/ui/floating-level-selector.tsx +100 -48
  103. package/src/components/ui/helpers/building-helper.tsx +5 -3
  104. package/src/components/ui/helpers/contextual-helper-panel.tsx +97 -37
  105. package/src/components/ui/helpers/helper-manager.test.tsx +53 -3
  106. package/src/components/ui/helpers/helper-manager.tsx +82 -57
  107. package/src/components/ui/helpers/item-helper.tsx +6 -4
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +3 -1
  109. package/src/components/ui/level-duplicate-dialog.tsx +32 -23
  110. package/src/components/ui/level-wall-copy-prompt.tsx +124 -0
  111. package/src/components/ui/panels/collections/collections-popover.tsx +11 -11
  112. package/src/components/ui/panels/deck-boards-fields.tsx +136 -0
  113. package/src/components/ui/panels/floor-foundation-panel.tsx +113 -38
  114. package/src/components/ui/panels/floor-opening-panel.tsx +73 -25
  115. package/src/components/ui/panels/inspector-extensions.tsx +66 -0
  116. package/src/components/ui/panels/mobile-panel-sheet.tsx +3 -1
  117. package/src/components/ui/panels/mobile-selection-bar.tsx +8 -6
  118. package/src/components/ui/panels/multi-field-value.test.ts +27 -0
  119. package/src/components/ui/panels/multi-field-value.ts +20 -1
  120. package/src/components/ui/panels/multi-height-mode.tsx +8 -7
  121. package/src/components/ui/panels/multi-parametric-inspector.tsx +122 -34
  122. package/src/components/ui/panels/multi-selection-panel.tsx +75 -35
  123. package/src/components/ui/panels/node-display.ts +35 -24
  124. package/src/components/ui/panels/panel-manager.tsx +5 -3
  125. package/src/components/ui/panels/panel-wrapper.tsx +22 -109
  126. package/src/components/ui/panels/parametric-field-control.tsx +13 -10
  127. package/src/components/ui/panels/parametric-inspector.tsx +18 -12
  128. package/src/components/ui/panels/reference-panel.tsx +51 -35
  129. package/src/components/ui/panels/room-construction-rows.tsx +464 -65
  130. package/src/components/ui/panels/room-name-options.test.ts +36 -3
  131. package/src/components/ui/panels/room-name-options.ts +110 -11
  132. package/src/components/ui/panels/room-openings-field.tsx +21 -12
  133. package/src/components/ui/panels/room-panel.tsx +206 -99
  134. package/src/components/ui/panels/shape-choice.tsx +7 -5
  135. package/src/components/ui/panels/wall-region-list.tsx +7 -7
  136. package/src/components/ui/primitives/shortcut-token.tsx +10 -6
  137. package/src/components/ui/sidebar/icon-rail.tsx +11 -9
  138. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +10 -10
  139. package/src/components/ui/sidebar/panels/items-panel/index.tsx +10 -9
  140. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +3 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +102 -32
  142. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -2
  144. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +4 -2
  145. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +7 -5
  146. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +4 -2
  147. package/src/components/ui/sidebar/panels/site-panel/default-names.ts +75 -0
  148. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +4 -2
  149. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +4 -2
  150. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +4 -2
  151. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +4 -2
  152. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +4 -2
  153. package/src/components/ui/sidebar/panels/site-panel/index.tsx +117 -64
  154. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +36 -3
  155. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +4 -2
  156. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -4
  157. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +8 -2
  158. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +8 -5
  159. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +4 -2
  160. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +8 -3
  161. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +4 -2
  162. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +4 -2
  163. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +8 -7
  164. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +7 -6
  165. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +16 -1
  166. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +15 -8
  167. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +4 -2
  168. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +4 -2
  169. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +3 -1
  170. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +18 -8
  171. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +25 -14
  172. package/src/components/ui/sidebar/tab-bar.tsx +4 -1
  173. package/src/components/ui/sidebar/use-plugin-panels.tsx +19 -10
  174. package/src/components/ui/snap-target-badge.tsx +7 -6
  175. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +721 -0
  176. package/src/components/ui/wall-type/wall-type-menu.tsx +167 -0
  177. package/src/components/viewer/floorplan-compass-button.tsx +12 -3
  178. package/src/components/viewer/floorplan-preview.tsx +27 -19
  179. package/src/components/viewer/render-settings-menu.tsx +294 -0
  180. package/src/components/viewer/sky-control.tsx +710 -0
  181. package/src/components/viewer/use-viewer-ui-sounds.test.ts +15 -0
  182. package/src/components/viewer/use-viewer-ui-sounds.ts +46 -0
  183. package/src/components/viewer/viewer-controls-bar.tsx +278 -260
  184. package/src/components/viewer/viewer-hover-label.tsx +12 -4
  185. package/src/components/viewer/viewer-rooms.tsx +61 -4
  186. package/src/components/viewer/viewer-scene-header.tsx +28 -17
  187. package/src/components/viewer/viewer-stage-switcher.tsx +4 -2
  188. package/src/components/viewer-overlay.tsx +8 -0
  189. package/src/components/walkthrough-hud.tsx +75 -2
  190. package/src/hooks/use-keyboard.test.ts +85 -4
  191. package/src/hooks/use-keyboard.ts +65 -7
  192. package/src/hooks/use-selected-room.ts +12 -1
  193. package/src/i18n/floor-footprint-name.ts +30 -0
  194. package/src/i18n/level-name.ts +39 -0
  195. package/src/i18n/locales/en/messages.po +15117 -0
  196. package/src/i18n/locales/en/messages.ts +1 -0
  197. package/src/i18n/locales/en-XA/messages.ts +1 -0
  198. package/src/i18n/locales/es/messages.po +37501 -0
  199. package/src/i18n/locales/es/messages.ts +1 -0
  200. package/src/i18n/locales/fr/messages.po +37501 -0
  201. package/src/i18n/locales/fr/messages.ts +1 -0
  202. package/src/i18n/locales/ja/messages.po +37501 -0
  203. package/src/i18n/locales/ja/messages.ts +1 -0
  204. package/src/i18n/locales/zh-CN/messages.po +37501 -0
  205. package/src/i18n/locales/zh-CN/messages.ts +1 -0
  206. package/src/i18n/locales.test.ts +63 -0
  207. package/src/i18n/locales.ts +32 -0
  208. package/src/i18n/message-slot.tsx +25 -0
  209. package/src/i18n/node-messages.ts +169 -0
  210. package/src/i18n/pseudo-locale.ts +17 -0
  211. package/src/i18n/published-entry.test.ts +17 -0
  212. package/src/i18n/scene-theme-name.ts +16 -0
  213. package/src/index.tsx +156 -4
  214. package/src/lib/add-storey.test.ts +3 -3
  215. package/src/lib/contextual-help.test.ts +20 -1
  216. package/src/lib/contextual-help.ts +95 -19
  217. package/src/lib/continuation.ts +38 -11
  218. package/src/lib/floor-footprints.test.ts +2 -1
  219. package/src/lib/floor-foundation-support.test.ts +7 -3
  220. package/src/lib/floorplan/dimension-drive.test.ts +36 -2
  221. package/src/lib/floorplan/dimension-drive.ts +68 -3
  222. package/src/lib/floorplan/floorplan-export.test.ts +16 -5
  223. package/src/lib/floorplan/floorplan-export.tsx +13 -16
  224. package/src/lib/floorplan/floorplan-extension.test.ts +11 -12
  225. package/src/lib/floorplan/floorplan-extension.ts +37 -18
  226. package/src/lib/floorplan/floorplan-mode.test.ts +13 -6
  227. package/src/lib/floorplan/floorplan-mode.ts +14 -12
  228. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +1 -1
  229. package/src/lib/floorplan/floorplan-readonly.ts +3 -3
  230. package/src/lib/floorplan/schedules.ts +10 -6
  231. package/src/lib/floorplan/site-plan/site-annotations.test.ts +46 -1
  232. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -0
  233. package/src/lib/floorplan/site-plan/site-parts.ts +6 -3
  234. package/src/lib/geometry-script/author.test.ts +114 -0
  235. package/src/lib/geometry-script/author.ts +55 -23
  236. package/src/lib/geometry-script/client.ts +3 -0
  237. package/src/lib/geometry-script/finish-scripts.ts +7 -7
  238. package/src/lib/glb-export.test.ts +327 -0
  239. package/src/lib/glb-export.ts +70 -2
  240. package/src/lib/grid-frame.test.ts +56 -3
  241. package/src/lib/grid-frame.ts +28 -1
  242. package/src/lib/history.test.ts +2 -1
  243. package/src/lib/hud-title.ts +48 -24
  244. package/src/lib/ifc-export.test.ts +41 -1
  245. package/src/lib/inspector-card-mode.test.ts +3 -31
  246. package/src/lib/inspector-card-mode.ts +5 -10
  247. package/src/lib/inspector-expanded.test.ts +13 -0
  248. package/src/lib/inspector-expanded.ts +13 -1
  249. package/src/lib/keyboard-pan.test.ts +7 -0
  250. package/src/lib/keyboard-pan.ts +9 -2
  251. package/src/lib/layer-thickness.test.ts +22 -0
  252. package/src/lib/layer-thickness.ts +79 -0
  253. package/src/lib/lot/drop-in.test.ts +59 -1
  254. package/src/lib/lot/drop-in.ts +42 -50
  255. package/src/lib/material-swatch-style.ts +24 -0
  256. package/src/lib/measurement-parser.ts +7 -3
  257. package/src/lib/measurements.test.ts +17 -0
  258. package/src/lib/measurements.ts +20 -5
  259. package/src/lib/mezzanine-selection.test.ts +3 -1
  260. package/src/lib/orbit-near-plane.test.ts +12 -0
  261. package/src/lib/orbit-near-plane.ts +13 -0
  262. package/src/lib/paint-regions.test.ts +8 -5
  263. package/src/lib/paint-scope.test.ts +53 -0
  264. package/src/lib/paint-scope.ts +18 -4
  265. package/src/lib/panel-rows.ts +17 -8
  266. package/src/lib/reference-scale.ts +83 -0
  267. package/src/lib/room-built-on.test.ts +26 -12
  268. package/src/lib/room-built-on.ts +20 -2
  269. package/src/lib/room-construction.test.ts +28 -1
  270. package/src/lib/room-construction.ts +14 -0
  271. package/src/lib/room-controls-phase6.test.ts +140 -0
  272. package/src/lib/room-handle-drag.ts +557 -42
  273. package/src/lib/room-name-catalog.test.ts +8 -0
  274. package/src/lib/room-name-catalog.ts +8 -1
  275. package/src/lib/room-name-messages.test.ts +132 -0
  276. package/src/lib/room-name-messages.ts +147 -0
  277. package/src/lib/room-name-storage.test.ts +334 -0
  278. package/src/lib/room-push-dimensions.test.ts +37 -0
  279. package/src/lib/room-push-dimensions.ts +226 -2
  280. package/src/lib/room-selection.test.ts +23 -6
  281. package/src/lib/scene-clipboard.test.ts +70 -49
  282. package/src/lib/scene-clipboard.ts +9 -4
  283. package/src/lib/selection-cycle.ts +63 -0
  284. package/src/lib/separator-edit.test.ts +431 -0
  285. package/src/lib/separator-edit.ts +179 -0
  286. package/src/lib/separator-lines.ts +38 -0
  287. package/src/lib/sfx-bus.ts +27 -1
  288. package/src/lib/sfx-player.ts +16 -0
  289. package/src/lib/tool-cursor-icon.ts +5 -1
  290. package/src/lib/tool-shortcuts.ts +130 -0
  291. package/src/lib/viewer-selection.test.ts +28 -24
  292. package/src/lib/viewer-selection.ts +20 -5
  293. package/src/lib/wall-draw-variant.test.ts +7 -6
  294. package/src/lib/wall-draw-variant.ts +107 -6
  295. package/src/lib/wall-nudge.test.ts +44 -0
  296. package/src/lib/wall-nudge.ts +131 -0
  297. package/src/lib/wall-parallel-snap.test.ts +83 -0
  298. package/src/lib/wall-parallel-snap.ts +176 -0
  299. package/src/lib/wall-placement-pointer.test.ts +23 -0
  300. package/src/lib/wall-placement-pointer.ts +18 -0
  301. package/src/lib/wall-pull.test.ts +245 -0
  302. package/src/lib/wall-pull.ts +209 -0
  303. package/src/lib/wall-push-line-up.test.ts +132 -0
  304. package/src/lib/wall-region-list.ts +14 -5
  305. package/src/lib/wall-run-snap.test.ts +188 -0
  306. package/src/lib/wall-run-snap.ts +83 -0
  307. package/src/lib/wall-type.test.ts +125 -0
  308. package/src/lib/wall-type.ts +119 -0
  309. package/src/lib/window-interaction.test.ts +42 -0
  310. package/src/lib/window-interaction.ts +47 -5
  311. package/src/lib/world-grid-snap.test.ts +139 -2
  312. package/src/lib/world-grid-snap.ts +102 -105
  313. package/src/store/use-alignment-guides.ts +27 -1
  314. package/src/store/use-editor.tsx +48 -0
  315. package/src/store/use-floorplan-annotation-visibility.test.ts +64 -0
  316. package/src/store/use-floorplan-annotation-visibility.ts +47 -22
  317. package/src/store/use-wall-snap-indicator.ts +15 -2
@@ -6,7 +6,7 @@ import {
6
6
  floorPlacedCollides,
7
7
  nodeRegistry,
8
8
  type TerrainVerb,
9
- useScene,
9
+ useScene, usePascalI18n,
10
10
  } from '@pascal-app/core'
11
11
  import { useViewer } from '@pascal-app/viewer'
12
12
  import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
@@ -42,7 +42,7 @@ import {
42
42
  terrainHudTitle,
43
43
  toolHudTitle,
44
44
  } from '../../../lib/hud-title'
45
- import type { ReshapeKind } from '../../../lib/interaction/scope'
45
+ import type { InteractionScope, ReshapeKind } from '../../../lib/interaction/scope'
46
46
  import {
47
47
  type MezzanineShape,
48
48
  useMezzanineDraft,
@@ -94,7 +94,7 @@ function reshapingHints(reshape: ReshapeKind): ContextualShortcutHint[] {
94
94
  : 'Move corner'
95
95
  return [
96
96
  { keys: ['Drag'], label: action },
97
- { keys: ['Esc'], label: 'Cancel' },
97
+ { keys: ['Esc'], label: /* i18n */ { id: 'editor.helpers.helperManager.cancel', message: 'Cancel' } },
98
98
  ]
99
99
  }
100
100
 
@@ -106,8 +106,8 @@ function reshapingHints(reshape: ReshapeKind): ContextualShortcutHint[] {
106
106
  function terrainSculptHints(verb: TerrainVerb, sampling: boolean): ContextualShortcutHint[] {
107
107
  if (sampling) {
108
108
  return [
109
- { keys: ['Click'], label: 'Pick target height' },
110
- { keys: ['Esc'], label: 'Cancel picking' },
109
+ { keys: ['Click'], label: /* i18n */ { id: 'editor.helpers.helperManager.pickTargetHeight', message: 'Pick target height' } },
110
+ { keys: ['Esc'], label: /* i18n */ { id: 'editor.helpers.helperManager.cancelPicking', message: 'Cancel picking' } },
111
111
  ]
112
112
  }
113
113
  const action =
@@ -122,8 +122,8 @@ function terrainSculptHints(verb: TerrainVerb, sampling: boolean): ContextualSho
122
122
  { keys: ['Drag'], label: action },
123
123
  // Nested, so the two render as alternatives ("[ / ]") rather than a chord —
124
124
  // a flat `['[', ']']` joins with "+" and would read as "press both".
125
- { keys: [['[', ']']], label: 'Brush size' },
126
- { keys: ['Esc'], label: 'Cancel stroke' },
125
+ { keys: [['[', ']']], label: /* i18n */ { id: 'editor.helpers.helperManager.brushSize', message: 'Brush size' } },
126
+ { keys: ['Esc'], label: /* i18n */ { id: 'editor.helpers.helperManager.cancelStroke', message: 'Cancel stroke' } },
127
127
  ]
128
128
  }
129
129
 
@@ -141,12 +141,12 @@ const EMPTY_CONTEXTUAL_HINTS: ContextualShortcutHint[] = []
141
141
  export function paintHints(mode: PaintMode): ContextualShortcutHint[] {
142
142
  if (mode === 'pick')
143
143
  return [
144
- { keys: ['Left click'], label: 'Paint with this material' },
145
- { keys: ['Esc'], label: 'Cancel' },
144
+ { keys: ['Left click'], label: /* i18n */ { id: 'editor.helpers.helperManager.paintWithThisMaterial', message: 'Paint with this material' } },
145
+ { keys: ['Esc'], label: /* i18n */ { id: 'editor.helpers.helperManager.cancel', message: 'Cancel' } },
146
146
  ]
147
147
  if (mode === 'erase')
148
- return [{ keys: ['Left click'], label: 'Remove a painted part, or reset a surface' }]
149
- if (mode === 'surface') return [{ keys: [['Alt', 'Option']], label: 'Hold to pick a material' }]
148
+ return [{ keys: ['Left click'], label: /* i18n */ { id: 'editor.helpers.helperManager.removeAPaintedPartOrReset', message: 'Remove a painted part, or reset a surface' } }]
149
+ if (mode === 'surface') return [{ keys: [['Alt', 'Option']], label: /* i18n */ { id: 'editor.helpers.helperManager.holdToPickAMaterial', message: 'Hold to pick a material' } }]
150
150
  return []
151
151
  }
152
152
 
@@ -156,43 +156,45 @@ export function paintRegionHints(
156
156
  hovering = true,
157
157
  ): ContextualShortcutHint[] {
158
158
  const gesture: Record<typeof mode, ContextualShortcutHint> = {
159
- rectangle: { keys: ['Drag'], label: 'Press anywhere, drag to the opposite corner' },
160
- polygon: { keys: ['Left click'], label: 'Add point · click the first to close' },
159
+ rectangle: { keys: ['Drag'], label: /* i18n */ { id: 'editor.helpers.helperManager.pressAnywhereDragToTheOpposite', message: 'Press anywhere, drag to the opposite corner' } },
160
+ polygon: { keys: ['Left click'], label: /* i18n */ { id: 'editor.helpers.helperManager.addPointClickTheFirstTo', message: 'Add point · click the first to close' } },
161
161
  }
162
162
  // Off anything the sub-mode draws on, the HUD says where to go instead.
163
163
  if (!hovering)
164
164
  return [
165
165
  ...(notice ? [{ keys: ['!'], label: notice, active: true }] : []),
166
166
  { keys: ['Hover'], label: paintRegionHoverHint(mode), active: true },
167
- { keys: ['Esc'], label: 'Cancel' },
167
+ { keys: ['Esc'], label: /* i18n */ { id: 'editor.helpers.helperManager.cancel', message: 'Cancel' } },
168
168
  ]
169
169
  return [
170
170
  ...(notice ? [{ keys: ['!'], label: notice, active: true }] : []),
171
171
  gesture[mode],
172
- ...(mode === 'polygon' ? [{ keys: ['Backspace'], label: 'Remove last point' }] : []),
173
- { keys: ['Esc'], label: 'Cancel' },
172
+ ...(mode === 'polygon' ? [{ keys: ['Backspace'], label: /* i18n */ { id: 'editor.helpers.helperManager.removeLastPoint', message: 'Remove last point' } }] : []),
173
+ { keys: ['Esc'], label: /* i18n */ { id: 'editor.helpers.helperManager.cancel', message: 'Cancel' } },
174
174
  ]
175
175
  }
176
+ const DRAW_MEZZANINE = /* i18n */ { id: 'editor.helpers.helperManager.drawTheMezzanine', message: 'Draw the mezzanine' }
177
+ const DRAW_OPENING = /* i18n */ { id: 'editor.helpers.helperManager.drawTheOpening', message: 'Draw the opening' }
176
178
  // "Add mezzanine": the outline gesture for the shape picked in the room panel,
177
179
  // then Esc; the snapping chip sits below.
178
180
  export function mezzanineHints(shape: MezzanineShape): ContextualShortcutHint[] {
179
181
  return shape === 'rectangle'
180
182
  ? [
181
- { keys: ['Left click'], label: 'Set one corner, then the opposite' },
182
- { keys: ['Drag'], label: 'Draw the mezzanine' },
183
- { keys: ['Esc'], label: 'Cancel' },
183
+ { keys: ['Left click'], label: /* i18n */ { id: 'editor.helpers.helperManager.setOneCornerThenTheOpposite', message: 'Set one corner, then the opposite' } },
184
+ { keys: ['Drag'], label: DRAW_MEZZANINE },
185
+ { keys: ['Esc'], label: /* i18n */ { id: 'editor.helpers.helperManager.cancel', message: 'Cancel' } },
184
186
  ]
185
187
  : [
186
- { keys: ['Left click'], label: 'Add point · click the first to close' },
187
- { keys: ['Backspace'], label: 'Remove last point' },
188
- { keys: ['Enter'], label: 'Finish' },
189
- { keys: ['Esc'], label: 'Cancel' },
188
+ { keys: ['Left click'], label: /* i18n */ { id: 'editor.helpers.helperManager.addPointClickTheFirstTo', message: 'Add point · click the first to close' } },
189
+ { keys: ['Backspace'], label: /* i18n */ { id: 'editor.helpers.helperManager.removeLastPoint', message: 'Remove last point' } },
190
+ { keys: ['Enter'], label: /* i18n */ { id: 'editor.helpers.helperManager.finish', message: 'Finish' } },
191
+ { keys: ['Esc'], label: /* i18n */ { id: 'editor.helpers.helperManager.cancel', message: 'Cancel' } },
190
192
  ]
191
193
  }
192
194
  /** "Cut opening": the outline gesture for the chosen shape, then Esc. */
193
195
  export function openingHints(shape: OpeningShape): ContextualShortcutHint[] {
194
196
  return mezzanineHints(shape).map((hint) =>
195
- hint.label === 'Draw the mezzanine' ? { ...hint, label: 'Draw the opening' } : hint,
197
+ typeof hint.label !== 'string' && hint.label.id === DRAW_MEZZANINE.id ? { ...hint, label: DRAW_OPENING } : hint,
196
198
  )
197
199
  }
198
200
  const TERRACE_CHIP_HINTS = [TERRACE_SHAPE_HINT]
@@ -200,22 +202,22 @@ const TERRACE_CHIP_HINTS = [TERRACE_SHAPE_HINT]
200
202
  export function terraceHints(shape: TerraceShape): ContextualShortcutHint[] {
201
203
  return shape === 'rectangle'
202
204
  ? [
203
- { keys: ['Left click'], label: 'Set one corner, then the opposite' },
204
- { keys: ['Drag'], label: 'Draw the terrace' },
205
- { keys: ['Esc'], label: 'Done' },
205
+ { keys: ['Left click'], label: /* i18n */ { id: 'editor.helpers.helperManager.setOneCornerThenTheOpposite', message: 'Set one corner, then the opposite' } },
206
+ { keys: ['Drag'], label: /* i18n */ { id: 'editor.helpers.helperManager.drawTheTerrace', message: 'Draw the terrace' } },
207
+ { keys: ['Esc'], label: /* i18n */ { id: 'editor.helpers.helperManager.done', message: 'Done' } },
206
208
  ]
207
209
  : [
208
- { keys: ['Left click'], label: 'Add point · click the first to close' },
209
- { keys: ['Backspace'], label: 'Remove last point' },
210
- { keys: ['Enter'], label: 'Finish' },
211
- { keys: ['Esc'], label: 'Done' },
210
+ { keys: ['Left click'], label: /* i18n */ { id: 'editor.helpers.helperManager.addPointClickTheFirstTo', message: 'Add point · click the first to close' } },
211
+ { keys: ['Backspace'], label: /* i18n */ { id: 'editor.helpers.helperManager.removeLastPoint', message: 'Remove last point' } },
212
+ { keys: ['Enter'], label: /* i18n */ { id: 'editor.helpers.helperManager.finish', message: 'Finish' } },
213
+ { keys: ['Esc'], label: /* i18n */ { id: 'editor.helpers.helperManager.done', message: 'Done' } },
212
214
  ]
213
215
  }
214
216
  export const ROOM_DIVIDE_HINTS: ContextualShortcutHint[] = [
215
- { keys: ['Left click'], label: 'Add point' },
216
- { keys: ['Backspace'], label: 'Remove last point' },
217
- { keys: ['Enter'], label: 'Finish at the edge' },
218
- { keys: ['Esc'], label: 'Cancel' },
217
+ { keys: ['Left click'], label: /* i18n */ { id: 'editor.helpers.helperManager.addPoint', message: 'Add point' } },
218
+ { keys: ['Backspace'], label: /* i18n */ { id: 'editor.helpers.helperManager.removeLastPoint', message: 'Remove last point' } },
219
+ { keys: ['Enter'], label: /* i18n */ { id: 'editor.helpers.helperManager.finishAtTheEdge', message: 'Finish at the edge' } },
220
+ { keys: ['Esc'], label: /* i18n */ { id: 'editor.helpers.helperManager.cancel', message: 'Cancel' } },
219
221
  ]
220
222
  const NO_CONTEXTUAL_HELP_SUBSCRIPTION = () => () => {}
221
223
 
@@ -277,22 +279,42 @@ export function mezzanineGesture(
277
279
 
278
280
  export const MEZZANINE_GESTURE_HINTS: Record<MezzanineGesture, ContextualShortcutHint[]> = {
279
281
  raise: [
280
- { keys: ['Drag'], label: 'Raise or lower' },
281
- { keys: ['Esc'], label: 'Cancel' },
282
+ { keys: ['Drag'], label: /* i18n */ { id: 'editor.helpers.helperManager.raiseOrLower', message: 'Raise or lower' } },
283
+ { keys: ['Esc'], label: /* i18n */ { id: 'editor.helpers.helperManager.cancel', message: 'Cancel' } },
282
284
  ],
283
285
  resize: [
284
- { keys: ['Drag'], label: 'Resize' },
285
- { keys: ['Esc'], label: 'Cancel' },
286
+ { keys: ['Drag'], label: /* i18n */ { id: 'editor.helpers.helperManager.resize', message: 'Resize' } },
287
+ { keys: ['Esc'], label: /* i18n */ { id: 'editor.helpers.helperManager.cancel', message: 'Cancel' } },
286
288
  ],
287
289
  move: [
288
- { keys: ['Left click'], label: 'Place inside the room' },
289
- { keys: [['R', 'T']], label: 'Rotate ±45°' },
290
- { keys: ['Alt'], label: 'Free move' },
291
- { keys: ['Esc'], label: 'Cancel' },
290
+ { keys: ['Left click'], label: /* i18n */ { id: 'editor.helpers.helperManager.placeInsideTheRoom', message: 'Place inside the room' } },
291
+ { keys: [['R', 'T']], label: /* i18n */ { id: 'editor.helpers.helperManager.rotate45', message: 'Rotate ±45°' } },
292
+ { keys: ['Alt'], label: /* i18n */ { id: 'editor.helpers.helperManager.freeMove', message: 'Free move' } },
293
+ { keys: ['Esc'], label: /* i18n */ { id: 'editor.helpers.helperManager.cancel', message: 'Cancel' } },
292
294
  ],
293
295
  }
294
296
 
297
+ /**
298
+ * Whether the armed tool's own hints (`toolHints`) win over the node in hand: a registry tool that
299
+ * holds its draft in a placing scope (the roof's footprint, a fitting, a block) says what its clicks
300
+ * do, where the generic rows misnamed it ("Move roof · Left click: Place" while a click draws a
301
+ * footprint).
302
+ */
303
+ export function toolHintsOwnPlacing(
304
+ scope: InteractionScope,
305
+ tool: string | null,
306
+ toolHintCount: number,
307
+ ): boolean {
308
+ return (
309
+ scope.kind === 'placing' &&
310
+ scope.driver === 'registry-tool' &&
311
+ scope.nodeType === tool &&
312
+ toolHintCount > 0
313
+ )
314
+ }
315
+
295
316
  export function HelperManager() {
317
+ const i18n = usePascalI18n()
296
318
  const mode = useEditor((s) => s.mode)
297
319
  const tool = useEditor((s) => s.tool)
298
320
  const terrainVerb = useEditor((s) => s.terrainVerb)
@@ -417,7 +439,7 @@ export function HelperManager() {
417
439
  if (activeHandleDrag?.label === GROUP_MOVE_DRAG_LABEL) {
418
440
  return (
419
441
  <ContextualHelperPanel
420
- hints={[{ keys: ['R / T'], label: 'Rotate the selection ±45°' }]}
442
+ hints={[{ keys: ['R / T'], label: i18n._({ id: 'editor.helpers.helperManager.rotateTheSelection45', message: 'Rotate the selection ±45°' }) }]}
421
443
  snapContext={snapContext}
422
444
  title={MOVE_SELECTION_HUD_TITLE}
423
445
  />
@@ -440,10 +462,10 @@ export function HelperManager() {
440
462
  return (
441
463
  <ContextualHelperPanel
442
464
  hints={[
443
- { keys: ['Left click'], label: 'Place' },
444
- { keys: [['R', 'T']], label: 'Rotate ±45°' },
445
- { keys: ['Alt'], label: 'Free move · slide doors and windows' },
446
- { keys: ['Esc'], label: 'Cancel' },
465
+ { keys: ['Left click'], label: i18n._({ id: 'editor.helpers.helperManager.place', message: 'Place' }) },
466
+ { keys: [['R', 'T']], label: i18n._({ id: 'editor.helpers.helperManager.rotate45', message: 'Rotate ±45°' }) },
467
+ { keys: ['Alt'], label: i18n._({ id: 'editor.helpers.helperManager.freeMoveSlideDoorsAndWindows', message: 'Free move · slide doors and windows' }) },
468
+ { keys: ['Esc'], label: i18n._({ id: 'editor.helpers.helperManager.cancel', message: 'Cancel' }) },
447
469
  ]}
448
470
  snapContext={snapContext}
449
471
  title={MOVE_ROOM_HUD_TITLE}
@@ -504,8 +526,8 @@ export function HelperManager() {
504
526
  return (
505
527
  <ContextualHelperPanel
506
528
  hints={[
507
- { keys: ['Drag'], label: 'Resize' },
508
- { keys: ['Esc'], label: 'Cancel' },
529
+ { keys: ['Drag'], label: i18n._({ id: 'editor.helpers.helperManager.resize', message: 'Resize' }) },
530
+ { keys: ['Esc'], label: i18n._({ id: 'editor.helpers.helperManager.cancel', message: 'Cancel' }) },
509
531
  ]}
510
532
  snapContext={snapContext}
511
533
  title={RESIZE_HUD_TITLE}
@@ -522,7 +544,7 @@ export function HelperManager() {
522
544
  ? nodeRegistry.get(reshapingNode.type)?.affordanceHints?.[scope.reshape]
523
545
  : undefined
524
546
  const title = reshapingNode
525
- ? nodeKindHudTitle(reshapingNode.type, scope.reshape === 'split' ? 'Split' : 'Edit')
547
+ ? nodeKindHudTitle(reshapingNode.type, scope.reshape === 'split' ? 'split' : 'edit')
526
548
  : null
527
549
  if (hints) {
528
550
  return (
@@ -543,12 +565,15 @@ export function HelperManager() {
543
565
  )
544
566
  }
545
567
 
546
- if (movingNode) {
568
+ if (
569
+ movingNode &&
570
+ !toolHintsOwnPlacing(scope, tool, tool ? (nodeRegistry.get(tool)?.toolHints?.length ?? 0) : 0)
571
+ ) {
547
572
  // A fresh placement (e.g. a positioned preset like a shelf) advertises its
548
573
  // once/repeat continuation, exactly like the GLB item tool — but an existing
549
574
  // node being *moved* is not a placement, so it gets no continuation chip.
550
575
  const isFreshPlacement = isFreshPlacementMetadata(movingNode.metadata)
551
- const movingTitle = nodeKindHudTitle(movingNode.type, isFreshPlacement ? undefined : 'Move')
576
+ const movingTitle = nodeKindHudTitle(movingNode.type, isFreshPlacement ? undefined : 'move')
552
577
  if (movingNode.type === 'building') return <BuildingHelper showRotate title={movingTitle} />
553
578
  const movingContinuationContext = isFreshPlacement
554
579
  ? keyCyclableContinuationContext(continuationContextOf(movingNode.type))
@@ -611,7 +636,7 @@ export function HelperManager() {
611
636
  <ContextualHelperPanel
612
637
  hints={contextualEditHints}
613
638
  snapContext={snapContext}
614
- title={contextualHelpNode ? nodeKindHudTitle(contextualHelpNode.type, 'Edit') : null}
639
+ title={contextualHelpNode ? nodeKindHudTitle(contextualHelpNode.type, 'edit') : null}
615
640
  />
616
641
  )
617
642
  }
@@ -639,9 +664,9 @@ export function HelperManager() {
639
664
  return (
640
665
  <ContextualHelperPanel
641
666
  hints={[
642
- { keys: ['Hover'], label: 'Inspect surface dimensions' },
643
- { keys: ['Click'], label: 'Pin measurement lens' },
644
- { keys: ['Esc'], label: 'Exit smart measure' },
667
+ { keys: ['Hover'], label: i18n._({ id: 'editor.helpers.helperManager.inspectSurfaceDimensions', message: 'Inspect surface dimensions' }) },
668
+ { keys: ['Click'], label: i18n._({ id: 'editor.helpers.helperManager.pinMeasurementLens', message: 'Pin measurement lens' }) },
669
+ { keys: ['Esc'], label: i18n._({ id: 'editor.helpers.helperManager.exitSmartMeasure', message: 'Exit smart measure' }) },
645
670
  ]}
646
671
  title={SMART_MEASURE_HUD_TITLE}
647
672
  />
@@ -1,3 +1,4 @@
1
+ import { usePascalI18n } from '@pascal-app/core'
1
2
  import type { ContinuationContext } from '../../../lib/continuation'
2
3
  import type { HudTitle } from '../../../lib/hud-title'
3
4
  import type { SnapContext } from '../../../lib/snapping-mode'
@@ -33,17 +34,18 @@ export function ItemHelper({
33
34
  extraHints = [],
34
35
  replacesPlace = false,
35
36
  }: ItemHelperProps) {
37
+ const i18n = usePascalI18n()
36
38
  return (
37
39
  <ContextualHelperPanel
38
40
  continuationContext={continuationContext}
39
41
  notice={notice}
40
42
  hints={[
41
43
  // Where a click does something else (joining a corner), that row says so instead.
42
- ...(replacesPlace ? [] : [{ keys: ['Left click'], label: 'Place' }]),
44
+ ...(replacesPlace ? [] : [{ keys: ['Left click'], label: i18n._({ id: 'editor.helpers.itemHelper.place', message: 'Place' }) }]),
43
45
  ...extraHints,
44
- { keys: ['R', 'T'], label: 'Rotate' },
45
- ...(showForce ? [{ keys: ['Alt'], label: 'Force place' }] : []),
46
- { keys: [showEsc ? 'Esc' : 'Right click'], label: 'Cancel' },
46
+ { keys: ['R', 'T'], label: i18n._({ id: 'editor.helpers.itemHelper.rotate', message: 'Rotate' }) },
47
+ ...(showForce ? [{ keys: ['Alt'], label: i18n._({ id: 'editor.helpers.itemHelper.forcePlace', message: 'Force place' }) }] : []),
48
+ { keys: [showEsc ? 'Esc' : 'Right click'], label: i18n._({ id: 'editor.helpers.itemHelper.cancel', message: 'Cancel' }) },
47
49
  ]}
48
50
  snapContext={snapContext}
49
51
  title={title}
@@ -1,3 +1,4 @@
1
+ import { usePascalI18n } from '@pascal-app/core'
1
2
  import type { ToolHint } from '@pascal-app/core'
2
3
  import type { ContinuationContext } from '../../../lib/continuation'
3
4
  import type { HudTitle } from '../../../lib/hud-title'
@@ -29,6 +30,7 @@ export function RegisteredToolHelper({
29
30
  title?: HudTitle | null
30
31
  notice?: string | null
31
32
  }) {
33
+ const i18n = usePascalI18n()
32
34
  const visible = useVisibleToolHints(hints)
33
35
  if (visible.length === 0 && !snapContext && !continuationContext && !notice) return null
34
36
  // Hints carrying a live-state `chip` render as mode chips next to the
@@ -44,7 +46,7 @@ export function RegisteredToolHelper({
44
46
  const isBypassHint = hint.key === 'Shift'
45
47
  return {
46
48
  keys: [hint.key],
47
- label: shiftPressed && isBypassHint ? 'Guided constraints bypassed' : hint.label,
49
+ label: shiftPressed && isBypassHint ? i18n._({ id: 'editor.helpers.registeredToolHelper.guidedConstraintsBypassed', message: 'Guided constraints bypassed' }) : hint.label,
48
50
  active: shiftPressed && isBypassHint,
49
51
  }
50
52
  })}
@@ -1,9 +1,10 @@
1
1
  'use client'
2
2
 
3
- import type { LevelNode } from '@pascal-app/core'
3
+ import type { LevelNode, MessageDescriptor } from '@pascal-app/core'
4
4
  import { useEffect, useState } from 'react'
5
5
  import type { LevelDuplicatePreset } from '@pascal-app/core/building'
6
- import { getLevelDisplayName } from '@pascal-app/core'
6
+ import { useLocale, usePascalI18n } from '@pascal-app/core'
7
+ import { localizedLevelName } from '../../i18n/level-name'
7
8
  import { cn } from '../../lib/utils'
8
9
  import {
9
10
  Dialog,
@@ -16,36 +17,31 @@ import {
16
17
 
17
18
  const DUPLICATE_PRESETS: Array<{
18
19
  id: LevelDuplicatePreset
19
- label: string
20
- description: string
20
+ label: MessageDescriptor
21
+ description: MessageDescriptor
21
22
  }> = [
22
23
  {
23
24
  id: 'everything',
24
- label: 'Everything',
25
- description: 'Structure, materials, furniture, and references.',
25
+ label: /* i18n */ { id: 'editor.levelDuplicateDialog.everything', message: 'Everything' },
26
+ description: /* i18n */ { id: 'editor.levelDuplicateDialog.structureMaterialsFurnitureAndReferences', message: 'Structure, materials, furniture, and references.' },
26
27
  },
27
28
  {
28
29
  id: 'structure',
29
- label: 'Structure only',
30
- description: 'Walls, slabs, roofs, stairs, windows, and doors without finishes.',
30
+ label: /* i18n */ { id: 'editor.levelDuplicateDialog.structureOnly', message: 'Structure only' },
31
+ description: /* i18n */ { id: 'editor.levelDuplicateDialog.wallsSlabsRoofsStairsWindowsAnd', message: 'Walls, slabs, roofs, stairs, windows, and doors without finishes.' },
31
32
  },
32
33
  {
33
34
  id: 'structure-materials',
34
- label: 'Structure + materials',
35
- description: 'Structure with the current material and finish assignments.',
35
+ label: /* i18n */ { id: 'editor.levelDuplicateDialog.structureMaterials', message: 'Structure + materials' },
36
+ description: /* i18n */ { id: 'editor.levelDuplicateDialog.structureWithTheCurrentMaterialAnd', message: 'Structure with the current material and finish assignments.' },
36
37
  },
37
38
  {
38
39
  id: 'structure-furniture',
39
- label: 'Structure + furniture',
40
- description: 'Structure, finishes, and placed items, without guide references.',
40
+ label: /* i18n */ { id: 'editor.levelDuplicateDialog.structureFurniture', message: 'Structure + furniture' },
41
+ description: /* i18n */ { id: 'editor.levelDuplicateDialog.structureFinishesAndPlacedItemsWithout', message: 'Structure, finishes, and placed items, without guide references.' },
41
42
  },
42
43
  ]
43
44
 
44
- function getLevelLabel(level: LevelNode | null) {
45
- if (!level) return 'this level'
46
- return getLevelDisplayName(level)
47
- }
48
-
49
45
  export function LevelDuplicateDialog({
50
46
  open,
51
47
  level,
@@ -57,6 +53,8 @@ export function LevelDuplicateDialog({
57
53
  onConfirm: (preset: LevelDuplicatePreset) => void
58
54
  onOpenChange: (open: boolean) => void
59
55
  }) {
56
+ const i18n = usePascalI18n()
57
+ const locale = useLocale()
60
58
  const [preset, setPreset] = useState<LevelDuplicatePreset>('everything')
61
59
 
62
60
  useEffect(() => {
@@ -69,8 +67,19 @@ export function LevelDuplicateDialog({
69
67
  <Dialog onOpenChange={onOpenChange} open={open}>
70
68
  <DialogContent className="sm:max-w-md" showCloseButton={false}>
71
69
  <DialogHeader>
72
- <DialogTitle>Duplicate Level</DialogTitle>
73
- <DialogDescription>Choose what to copy from {getLevelLabel(level)}.</DialogDescription>
70
+ <DialogTitle>{i18n._({ id: 'editor.levelDuplicateDialog.duplicateLevel', message: 'Duplicate Level' })}</DialogTitle>
71
+ <DialogDescription>
72
+ {level
73
+ ? i18n._({
74
+ id: 'editor.levelDuplicateDialog.chooseWhatToCopyFromLevel',
75
+ message: 'Choose what to copy from {levelName}.',
76
+ values: { levelName: localizedLevelName(level, i18n, locale) },
77
+ })
78
+ : i18n._({
79
+ id: 'editor.levelDuplicateDialog.chooseWhatToCopyFromThisLevel',
80
+ message: 'Choose what to copy from this level.',
81
+ })}
82
+ </DialogDescription>
74
83
  </DialogHeader>
75
84
 
76
85
  <div className="grid gap-2">
@@ -86,8 +95,8 @@ export function LevelDuplicateDialog({
86
95
  onClick={() => setPreset(option.id)}
87
96
  type="button"
88
97
  >
89
- <div className="font-medium text-sm">{option.label}</div>
90
- <div className="mt-1 text-muted-foreground text-xs">{option.description}</div>
98
+ <div className="font-medium text-sm">{i18n._(option.label)}</div>
99
+ <div className="mt-1 text-muted-foreground text-xs">{i18n._(option.description)}</div>
91
100
  </button>
92
101
  ))}
93
102
  </div>
@@ -98,14 +107,14 @@ export function LevelDuplicateDialog({
98
107
  onClick={() => onOpenChange(false)}
99
108
  type="button"
100
109
  >
101
- Cancel
110
+ {i18n._({ id: 'editor.levelDuplicateDialog.cancel', message: 'Cancel' })}
102
111
  </button>
103
112
  <button
104
113
  className="cursor-pointer rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm transition-opacity hover:opacity-90"
105
114
  onClick={() => onConfirm(preset)}
106
115
  type="button"
107
116
  >
108
- Duplicate
117
+ {i18n._({ id: 'editor.levelDuplicateDialog.duplicate', message: 'Duplicate' })}
109
118
  </button>
110
119
  </DialogFooter>
111
120
  </DialogContent>
@@ -0,0 +1,124 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ getLevelAbove,
7
+ getLevelBelow,
8
+ getLevelDisplayName,
9
+ useScene, usePascalI18n,
10
+ } from '@pascal-app/core'
11
+ import { buildExteriorWallCopy, levelExteriorWalls } from '@pascal-app/core/building'
12
+ import { useViewer } from '@pascal-app/viewer'
13
+ import { create } from 'zustand'
14
+
15
+ type Offer = { levelId: string; sourceLevelId: string; direction: 'below' | 'above' }
16
+
17
+ /**
18
+ * The one open "Copy exterior walls?" offer, for the level just added. While it is open the plan
19
+ * traces the source level's walls on the new one; that trace is the offer's, never the saved
20
+ * Walls below / above preference, and it goes with the offer.
21
+ */
22
+ export const useLevelWallCopyOffer = create<{
23
+ offer: Offer | null
24
+ setOffer: (offer: Offer | null) => void
25
+ }>((set) => ({ offer: null, setOffer: (offer) => set({ offer }) }))
26
+
27
+ let stopWatchingLevel: (() => void) | null = null
28
+
29
+ /** Opens `offer` (or closes the open one); picking another level closes it. */
30
+ function setOffer(offer: Offer | null) {
31
+ stopWatchingLevel?.()
32
+ stopWatchingLevel = null
33
+ useLevelWallCopyOffer.getState().setOffer(offer)
34
+ if (!offer) return
35
+ stopWatchingLevel = useViewer.subscribe((state) => {
36
+ if (state.selection.levelId !== offer.levelId) setOffer(null)
37
+ })
38
+ }
39
+
40
+ /**
41
+ * After a level is added next to `sourceLevelId` (by default the level under it, else the one over
42
+ * it, for a basement), and selected: when that level has an outline, offer to copy its exterior
43
+ * walls up or down, tracing them dashed on the plan while the offer is open.
44
+ */
45
+ export function offerExteriorWallCopy(levelId: string, sourceLevelId?: string) {
46
+ const nodes = useScene.getState().nodes as Record<string, AnyNode>
47
+ const graph = nodes as Record<AnyNodeId, AnyNode>
48
+ sourceLevelId ??= (getLevelBelow(levelId, graph) ?? getLevelAbove(levelId, graph))?.id
49
+ const source = sourceLevelId ? nodes[sourceLevelId] : undefined
50
+ const level = nodes[levelId]
51
+ if (!sourceLevelId || source?.type !== 'level' || level?.type !== 'level') return setOffer(null)
52
+ const direction = source.level < level.level ? 'below' : 'above'
53
+ setOffer(
54
+ levelExteriorWalls(nodes, sourceLevelId).length ? { levelId, sourceLevelId, direction } : null,
55
+ )
56
+ }
57
+
58
+ /** Closes the open offer without copying. */
59
+ export function dismissExteriorWallCopy() {
60
+ setOffer(null)
61
+ }
62
+
63
+ /** Copies the offered walls in place, as one undo step of its own. */
64
+ export function acceptExteriorWallCopy(offer: Offer): number {
65
+ const walls = buildExteriorWallCopy({
66
+ nodes: useScene.getState().nodes as Record<string, AnyNode>,
67
+ fromLevelId: offer.sourceLevelId,
68
+ toLevelId: offer.levelId,
69
+ })
70
+ if (walls.length) useScene.getState().applyNodeChanges({ create: walls })
71
+ setOffer(null)
72
+ return walls.length
73
+ }
74
+
75
+ /**
76
+ * "Copy exterior walls from the level below?" — a small card under the level list, never in the
77
+ * way: it goes when answered or when another level is picked, and hides once the level has walls.
78
+ */
79
+ export function LevelWallCopyPrompt() {
80
+ const i18n = usePascalI18n()
81
+ const offer = useLevelWallCopyOffer((state) => state.offer)
82
+ const activeLevelId = useViewer((state) => state.selection.levelId)
83
+ const sourceName = useScene((state) => {
84
+ const source = offer ? state.nodes[offer.sourceLevelId as never] : undefined
85
+ return source?.type === 'level' ? getLevelDisplayName(source) : null
86
+ })
87
+ const levelHasWalls = useScene((state) => {
88
+ const level = offer ? state.nodes[offer.levelId as AnyNodeId] : undefined
89
+ return (
90
+ level?.type === 'level' &&
91
+ level.children.some((id) => state.nodes[id as AnyNodeId]?.type === 'wall')
92
+ )
93
+ })
94
+ if (!offer || !sourceName || activeLevelId !== offer.levelId || levelHasWalls) return null
95
+ return (
96
+ <div
97
+ className="mt-2 w-56 rounded-xl border border-border bg-background/95 p-3 text-xs shadow-2xl backdrop-blur-md"
98
+ data-level-wall-copy={offer.levelId}
99
+ role="status"
100
+ >
101
+ <p className="font-medium text-foreground text-sm">
102
+ {i18n._({ id: 'editor.levelWallCopyPrompt.copyExteriorWallsFromTheLevel', message: 'Copy exterior walls from the level {direction}?', values: { direction: offer.direction } })}</p>
103
+ <p className="mt-0.5 text-muted-foreground">
104
+ {i18n._({ id: 'editor.levelWallCopyPrompt.sourcenameSOutsideWallsInPlace', message: '{sourceName}’s outside walls, in place, so this level lines up with it.', values: { sourceName } })}</p>
105
+ <div className="mt-2.5 flex gap-2">
106
+ <button
107
+ className="flex-1 rounded-full bg-foreground px-3 py-1.5 font-medium text-background transition-opacity hover:opacity-90"
108
+ data-level-wall-copy-accept
109
+ onClick={() => acceptExteriorWallCopy(offer)}
110
+ type="button"
111
+ >
112
+ {i18n._({ id: 'editor.levelWallCopyPrompt.copy', message: 'Copy' })}
113
+ </button>
114
+ <button
115
+ className="flex-1 rounded-full border border-border px-3 py-1.5 text-foreground transition-colors hover:bg-accent"
116
+ onClick={dismissExteriorWallCopy}
117
+ type="button"
118
+ >
119
+ {i18n._({ id: 'editor.levelWallCopyPrompt.noThanks', message: 'No thanks' })}
120
+ </button>
121
+ </div>
122
+ </div>
123
+ )
124
+ }