@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
@@ -0,0 +1,721 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type Assembly,
5
+ type AssemblyLayer,
6
+ BRICK_AIR_SPACE,
7
+ BRICK_VENEER,
8
+ FIBER_CEMENT,
9
+ GYPSUM_HALF,
10
+ GYPSUM_SHEATHING,
11
+ type MessageDescriptor,
12
+ newWallTypeId,
13
+ runAsSingleSceneHistoryStep,
14
+ SIDING_LAP,
15
+ STONE_VENEER_UNVERIFIED,
16
+ STUCCO_3_COAT,
17
+ usePascalI18n,
18
+ useScene,
19
+ WALL_TYPE_CORES,
20
+ type WallTypeCore,
21
+ type WallTypeDefinition,
22
+ WSP_SHEATHING,
23
+ wallTypeAssembly,
24
+ wallTypeCoreOf,
25
+ } from '@pascal-app/core'
26
+ import { useViewer } from '@pascal-app/viewer'
27
+ import { ArrowDown, ArrowUp, Plus, X } from 'lucide-react'
28
+ import { useEffect, useState } from 'react'
29
+ import { formatLayerThickness, parseLayerThickness } from '../../../lib/layer-thickness'
30
+ import type { LinearUnit } from '@pascal-app/core'
31
+ import { cn } from '../../../lib/utils'
32
+ import {
33
+ saveProjectWallType,
34
+ WALL_TYPE_LAYER_LIMIT,
35
+ WALL_TYPE_SAVE_FAILURE,
36
+ type WallTypePlacement,
37
+ type WallTypeSaveResult,
38
+ } from '../../../lib/wall-type'
39
+ import { Button } from '../primitives/button'
40
+ import {
41
+ Dialog,
42
+ DialogContent,
43
+ DialogDescription,
44
+ DialogFooter,
45
+ DialogHeader,
46
+ DialogTitle,
47
+ } from '../primitives/dialog'
48
+
49
+ /** What the editor opens on: a blank type for a placement, a project type to edit, or a type to copy. */
50
+ export type WallTypeEditorRequest =
51
+ | { mode: 'new'; placement: WallTypePlacement }
52
+ | { mode: 'edit'; placement: WallTypePlacement; id: string; label: string; assembly: Assembly }
53
+ | { mode: 'duplicate'; placement: WallTypePlacement; label: string; assembly: Assembly }
54
+
55
+ /** The roles a layer takes in the editor; `core` is the one framing layer. */
56
+ type DraftRole = 'finish' | 'air' | 'sheathing' | 'insulation' | 'core' | 'lining'
57
+
58
+ type DraftLayer = {
59
+ key: number
60
+ role: DraftRole
61
+ material: string
62
+ thickness: number
63
+ }
64
+
65
+ const IN = 0.0254
66
+
67
+ const ROLE_LABEL: Record<DraftRole, MessageDescriptor> = {
68
+ finish: /* i18n */ { id: 'editor.buildPanel.wallTypeEditorDialog.finish', message: 'Finish' },
69
+ air: /* i18n */ { id: 'editor.buildPanel.wallTypeEditorDialog.airSpace', message: 'Air space' },
70
+ sheathing: /* i18n */ {
71
+ id: 'editor.buildPanel.wallTypeEditorDialog.sheathing',
72
+ message: 'Sheathing',
73
+ },
74
+ insulation: /* i18n */ {
75
+ id: 'editor.buildPanel.wallTypeEditorDialog.insulation',
76
+ message: 'Insulation',
77
+ },
78
+ core: /* i18n */ {
79
+ id: 'editor.buildPanel.wallTypeEditorDialog.framingCore',
80
+ message: 'Framing core',
81
+ },
82
+ lining: /* i18n */ { id: 'editor.buildPanel.wallTypeEditorDialog.lining', message: 'Lining' },
83
+ }
84
+
85
+ /**
86
+ * Each role's materials, as the construction kinds the inspector and the framers
87
+ * read, and the cited thickness one starts at (the `wall-assembly.ts` constants;
88
+ * insulation is the product's own, typed in).
89
+ */
90
+ const LAYER_MATERIALS: Record<
91
+ Exclude<DraftRole, 'core'>,
92
+ ReadonlyArray<{ value: string; label: MessageDescriptor; thickness: number }>
93
+ > = {
94
+ finish: [
95
+ {
96
+ value: 'siding',
97
+ label: /* i18n */ {
98
+ id: 'editor.buildPanel.wallTypeEditorDialog.lapSiding',
99
+ message: 'Lap siding',
100
+ },
101
+ thickness: SIDING_LAP,
102
+ },
103
+ {
104
+ value: 'fiber-cement',
105
+ label: /* i18n */ {
106
+ id: 'editor.buildPanel.wallTypeEditorDialog.fiberCement',
107
+ message: 'Fiber cement',
108
+ },
109
+ thickness: FIBER_CEMENT,
110
+ },
111
+ {
112
+ value: 'stucco',
113
+ label: /* i18n */ { id: 'editor.buildPanel.wallTypeEditorDialog.stucco', message: 'Stucco' },
114
+ thickness: STUCCO_3_COAT,
115
+ },
116
+ {
117
+ value: 'brick',
118
+ label: /* i18n */ {
119
+ id: 'editor.buildPanel.wallTypeEditorDialog.brickVeneer',
120
+ message: 'Brick veneer',
121
+ },
122
+ thickness: BRICK_VENEER,
123
+ },
124
+ {
125
+ value: 'stone',
126
+ label: /* i18n */ {
127
+ id: 'editor.buildPanel.wallTypeEditorDialog.stoneVeneer',
128
+ message: 'Stone veneer',
129
+ },
130
+ thickness: STONE_VENEER_UNVERIFIED,
131
+ },
132
+ ],
133
+ air: [
134
+ {
135
+ value: 'air',
136
+ label: /* i18n */ {
137
+ id: 'editor.buildPanel.wallTypeEditorDialog.airSpace',
138
+ message: 'Air space',
139
+ },
140
+ thickness: BRICK_AIR_SPACE,
141
+ },
142
+ ],
143
+ sheathing: [
144
+ {
145
+ value: 'osb',
146
+ label: /* i18n */ { id: 'editor.buildPanel.wallTypeEditorDialog.osb', message: 'OSB' },
147
+ thickness: WSP_SHEATHING,
148
+ },
149
+ {
150
+ value: 'plywood',
151
+ label: /* i18n */ {
152
+ id: 'editor.buildPanel.wallTypeEditorDialog.plywood',
153
+ message: 'Plywood',
154
+ },
155
+ thickness: WSP_SHEATHING,
156
+ },
157
+ {
158
+ value: 'gypsum',
159
+ label: /* i18n */ {
160
+ id: 'editor.buildPanel.wallTypeEditorDialog.gypsumSheathing',
161
+ message: 'Gypsum sheathing',
162
+ },
163
+ thickness: GYPSUM_SHEATHING,
164
+ },
165
+ ],
166
+ insulation: [
167
+ {
168
+ value: 'rigid-foam',
169
+ label: /* i18n */ {
170
+ id: 'editor.buildPanel.wallTypeEditorDialog.rigidFoam',
171
+ message: 'Rigid foam',
172
+ },
173
+ thickness: 1 * IN,
174
+ },
175
+ {
176
+ value: 'mineral-wool',
177
+ label: /* i18n */ {
178
+ id: 'editor.buildPanel.wallTypeEditorDialog.mineralWoolBoard',
179
+ message: 'Mineral wool board',
180
+ },
181
+ thickness: 1.5 * IN,
182
+ },
183
+ ],
184
+ lining: [
185
+ {
186
+ value: 'drywall',
187
+ label: /* i18n */ {
188
+ id: 'editor.buildPanel.wallTypeEditorDialog.drywall',
189
+ message: 'Drywall',
190
+ },
191
+ thickness: GYPSUM_HALF,
192
+ },
193
+ {
194
+ value: 'plaster',
195
+ label: /* i18n */ {
196
+ id: 'editor.buildPanel.wallTypeEditorDialog.plaster',
197
+ message: 'Plaster',
198
+ },
199
+ thickness: STUCCO_3_COAT,
200
+ },
201
+ ],
202
+ }
203
+
204
+ const ADDABLE_ROLES: ReadonlyArray<Exclude<DraftRole, 'core'>> = [
205
+ 'finish',
206
+ 'air',
207
+ 'sheathing',
208
+ 'insulation',
209
+ 'lining',
210
+ ]
211
+
212
+ const DEFAULT_CORE: Record<WallTypePlacement, WallTypeCore> = {
213
+ exterior: WALL_TYPE_CORES.find((core) => core.id === 'wood-2x6-16')!,
214
+ interior: WALL_TYPE_CORES.find((core) => core.id === 'wood-2x4-16')!,
215
+ }
216
+
217
+ let nextKey = 1
218
+
219
+ function layerOf(role: Exclude<DraftRole, 'core'>, material?: string): DraftLayer {
220
+ const options = LAYER_MATERIALS[role]
221
+ const option = options.find((entry) => entry.value === material) ?? options[0]!
222
+ return { key: nextKey++, role, material: option.value, thickness: option.thickness }
223
+ }
224
+
225
+ function coreLayer(core: WallTypeCore): DraftLayer {
226
+ return { key: nextKey++, role: 'core', material: core.id, thickness: core.thickness }
227
+ }
228
+
229
+ function draftRoleOf(layer: AssemblyLayer): DraftRole {
230
+ if (layer.core) return 'core'
231
+ switch (layer.role) {
232
+ case 'finish':
233
+ case 'air':
234
+ case 'sheathing':
235
+ case 'insulation':
236
+ case 'lining':
237
+ return layer.role
238
+ default:
239
+ return 'sheathing'
240
+ }
241
+ }
242
+
243
+ /** The editor's layers for a request: a stack to start from, or a typical one for the placement. */
244
+ function initialLayers(request: WallTypeEditorRequest): DraftLayer[] {
245
+ if (request.mode === 'new') {
246
+ return request.placement === 'exterior'
247
+ ? [
248
+ layerOf('finish', 'siding'),
249
+ layerOf('sheathing', 'osb'),
250
+ coreLayer(DEFAULT_CORE.exterior),
251
+ layerOf('lining', 'drywall'),
252
+ ]
253
+ : [
254
+ layerOf('lining', 'drywall'),
255
+ coreLayer(DEFAULT_CORE.interior),
256
+ layerOf('lining', 'drywall'),
257
+ ]
258
+ }
259
+ const core = wallTypeCoreOf(request.assembly) ?? DEFAULT_CORE[request.placement]
260
+ const layers = request.assembly.layers.map((layer): DraftLayer => {
261
+ const role = draftRoleOf(layer)
262
+ if (role === 'core') return coreLayer(core)
263
+ return {
264
+ key: nextKey++,
265
+ role,
266
+ material: layer.material ?? LAYER_MATERIALS[role][0]!.value,
267
+ thickness: layer.thickness,
268
+ }
269
+ })
270
+ return layers.some((layer) => layer.role === 'core') ? layers : [...layers, coreLayer(core)]
271
+ }
272
+
273
+ /** Where a new layer of `role` goes: a finish outermost, sheathing and insulation just outside the core, a lining innermost. */
274
+ function insertIndex(layers: readonly DraftLayer[], role: DraftRole): number {
275
+ const coreIndex = layers.findIndex((layer) => layer.role === 'core')
276
+ if (role === 'finish') return 0
277
+ if (role === 'air') {
278
+ const finish = layers.findIndex((layer) => layer.role === 'finish')
279
+ return finish < 0 ? 0 : finish + 1
280
+ }
281
+ if (role === 'lining') return layers.length
282
+ if (role === 'insulation') {
283
+ const sheathing = layers.findIndex((layer) => layer.role === 'sheathing')
284
+ return sheathing >= 0 && sheathing < coreIndex ? sheathing : coreIndex
285
+ }
286
+ return coreIndex
287
+ }
288
+
289
+ /** The saved stack: each layer as an assembly layer, the core with its framing size and spacing. */
290
+ function draftAssembly(id: string, layers: readonly DraftLayer[]): Assembly {
291
+ return wallTypeAssembly(
292
+ id,
293
+ layers.map((layer): Omit<AssemblyLayer, 'id'> => {
294
+ if (layer.role === 'core') {
295
+ const core = WALL_TYPE_CORES.find((entry) => entry.id === layer.material)!
296
+ return {
297
+ role: 'structure',
298
+ core: true,
299
+ material: core.material,
300
+ thickness: core.thickness,
301
+ ...(core.spacing !== undefined ? { spacing: core.spacing } : {}),
302
+ }
303
+ }
304
+ return {
305
+ role: layer.role,
306
+ thickness: layer.thickness,
307
+ ...(layer.role === 'air' ? {} : { material: layer.material }),
308
+ }
309
+ }),
310
+ )
311
+ }
312
+
313
+ const FIELD_CLASS =
314
+ 'h-8 w-full min-w-0 rounded-md border border-border bg-background px-2 text-xs text-foreground outline-none focus:border-primary'
315
+
316
+ function ThicknessField({
317
+ metres,
318
+ onCommit,
319
+ unit,
320
+ }: {
321
+ metres: number
322
+ onCommit: (metres: number) => void
323
+ unit: LinearUnit
324
+ }) {
325
+ const i18n = usePascalI18n()
326
+ const [draft, setDraft] = useState<string | null>(null)
327
+ return (
328
+ <input
329
+ aria-label={i18n._({
330
+ id: 'editor.buildPanel.wallTypeEditorDialog.thickness',
331
+ message: 'Thickness',
332
+ })}
333
+ className={cn(FIELD_CLASS, 'text-right tabular-nums')}
334
+ onBlur={() => {
335
+ if (draft != null) {
336
+ const parsed = parseLayerThickness(draft, unit)
337
+ if (parsed != null && parsed > 0) onCommit(parsed)
338
+ }
339
+ setDraft(null)
340
+ }}
341
+ onChange={(event) => setDraft(event.target.value)}
342
+ onKeyDown={(event) => {
343
+ if (event.key === 'Enter') event.currentTarget.blur()
344
+ if (event.key === 'Escape') {
345
+ setDraft(null)
346
+ event.currentTarget.blur()
347
+ }
348
+ }}
349
+ spellCheck={false}
350
+ value={draft ?? formatLayerThickness(metres, unit)}
351
+ />
352
+ )
353
+ }
354
+
355
+ const TITLE: Record<WallTypeEditorRequest['mode'], MessageDescriptor> = {
356
+ new: /* i18n */ {
357
+ id: 'editor.buildPanel.wallTypeEditorDialog.newWallType',
358
+ message: 'New wall type',
359
+ },
360
+ edit: /* i18n */ {
361
+ id: 'editor.buildPanel.wallTypeEditorDialog.editWallType',
362
+ message: 'Edit wall type',
363
+ },
364
+ duplicate: /* i18n */ {
365
+ id: 'editor.buildPanel.wallTypeEditorDialog.duplicateWallType',
366
+ message: 'Duplicate wall type',
367
+ },
368
+ }
369
+
370
+ /**
371
+ * The wall type editor: a name and the
372
+ * layers from the outside in — finish, air space, sheathing, insulation, lining —
373
+ * around exactly one framing core, whose size and spacing are what the framers stud
374
+ * the wall at. The total updates as layers change; saving keeps the type with the
375
+ * project (`saveProjectWallType`) and hands it to `onSaved` in the same undo step,
376
+ * so a wall the type is applied to there undoes with it.
377
+ */
378
+ export function WallTypeEditorDialog({
379
+ onClose,
380
+ onSaved,
381
+ request,
382
+ }: {
383
+ onClose: () => void
384
+ onSaved: (type: WallTypeDefinition) => void
385
+ request: WallTypeEditorRequest | null
386
+ }) {
387
+ const i18n = usePascalI18n()
388
+ const unit = useViewer((state) => state.unit)
389
+ const [name, setName] = useState('')
390
+ const [layers, setLayers] = useState<DraftLayer[]>([])
391
+ const [failure, setFailure] = useState<Exclude<WallTypeSaveResult, 'saved'> | null>(null)
392
+
393
+ useEffect(() => {
394
+ if (!request) return
395
+ setLayers(initialLayers(request))
396
+ setName(
397
+ request.mode === 'new'
398
+ ? request.placement === 'exterior'
399
+ ? 'My exterior wall'
400
+ : 'My interior wall'
401
+ : request.mode === 'duplicate'
402
+ ? `${request.label} (copy)`
403
+ : request.label,
404
+ )
405
+ setFailure(null)
406
+ }, [request])
407
+
408
+ const total = layers.reduce((sum, layer) => sum + layer.thickness, 0)
409
+ const trimmed = name.trim()
410
+ const canSave = trimmed.length > 0 && layers.every((layer) => layer.thickness > 0)
411
+ // A wall type's stack holds `WALL_TYPE_LAYER_LIMIT` layers at most; past it the type would not save.
412
+ const full = layers.length >= WALL_TYPE_LAYER_LIMIT
413
+ const fullReason = i18n._({
414
+ id: 'editor.buildPanel.wallTypeEditorDialog.aWallTypeHoldsAtMost',
415
+ message: 'A wall type holds at most {limit, plural, one {# layer} other {# layers}}.',
416
+ values: { limit: WALL_TYPE_LAYER_LIMIT },
417
+ })
418
+
419
+ const update = (key: number, patch: Partial<DraftLayer>) =>
420
+ setLayers((current) =>
421
+ current.map((layer) => (layer.key === key ? { ...layer, ...patch } : layer)),
422
+ )
423
+ const move = (index: number, by: -1 | 1) =>
424
+ setLayers((current) => {
425
+ const target = index + by
426
+ if (target < 0 || target >= current.length) return current
427
+ const next = [...current]
428
+ ;[next[index], next[target]] = [next[target]!, next[index]!]
429
+ return next
430
+ })
431
+
432
+ const save = () => {
433
+ if (!(request && canSave)) return
434
+ const id = request.mode === 'edit' ? request.id : newWallTypeId()
435
+ const type: WallTypeDefinition = {
436
+ id,
437
+ label: trimmed.slice(0, 60),
438
+ assembly: draftAssembly(id, layers),
439
+ }
440
+ const result = runAsSingleSceneHistoryStep(useScene, () => {
441
+ const saved = saveProjectWallType(type)
442
+ if (saved === 'saved') onSaved(type)
443
+ return saved
444
+ })
445
+ if (result !== 'saved') {
446
+ setFailure(result)
447
+ return
448
+ }
449
+ onClose()
450
+ }
451
+
452
+ return (
453
+ <Dialog
454
+ onOpenChange={(open) => {
455
+ if (!open) onClose()
456
+ }}
457
+ open={request !== null}
458
+ >
459
+ <DialogContent className="sm:max-w-2xl">
460
+ <DialogHeader>
461
+ <DialogTitle>
462
+ {request
463
+ ? i18n._(TITLE[request.mode])
464
+ : i18n._({
465
+ id: 'editor.buildPanel.wallTypeEditorDialog.wallType',
466
+ message: 'Wall type',
467
+ })}
468
+ </DialogTitle>
469
+ <DialogDescription>
470
+ {i18n._({
471
+ id: 'editor.wallTypeEditorDialog.listTheLayersAppliedToThisWall',
472
+ message:
473
+ 'List the layers from the outside in, around one framing core. The type is saved with the project and applied to this wall; other walls keep their layers.',
474
+ })}
475
+ </DialogDescription>
476
+ </DialogHeader>
477
+
478
+ <label className="flex flex-col gap-1 text-xs">
479
+ <span className="text-muted-foreground">
480
+ {i18n._({ id: 'editor.buildPanel.wallTypeEditorDialog.name', message: 'Name' })}
481
+ </span>
482
+ <input
483
+ className={FIELD_CLASS}
484
+ maxLength={60}
485
+ onChange={(event) => setName(event.target.value)}
486
+ value={name}
487
+ />
488
+ </label>
489
+
490
+ <div className="flex flex-col gap-1">
491
+ <div className="grid grid-cols-[7.5rem_1fr_5.5rem_4.75rem] gap-1.5 px-0.5 text-[11px] text-muted-foreground">
492
+ <span>
493
+ {i18n._({ id: 'editor.buildPanel.wallTypeEditorDialog.layer', message: 'Layer' })}
494
+ </span>
495
+ <span>
496
+ {i18n._({
497
+ id: 'editor.buildPanel.wallTypeEditorDialog.material',
498
+ message: 'Material',
499
+ })}
500
+ </span>
501
+ <span className="text-right">
502
+ {i18n._({
503
+ id: 'editor.buildPanel.wallTypeEditorDialog.thickness',
504
+ message: 'Thickness',
505
+ })}
506
+ </span>
507
+ <span />
508
+ </div>
509
+ <div className="px-0.5 text-[10px] text-muted-foreground uppercase tracking-wide">
510
+ {i18n._({ id: 'editor.buildPanel.wallTypeEditorDialog.outside', message: 'Outside' })}
511
+ </div>
512
+ {layers.map((layer, index) => (
513
+ <div
514
+ className="grid grid-cols-[7.5rem_1fr_5.5rem_4.75rem] items-center gap-1.5"
515
+ data-wall-type-layer={layer.role}
516
+ key={layer.key}
517
+ >
518
+ {layer.role === 'core' ? (
519
+ <span className="px-0.5 font-medium text-xs">{i18n._(ROLE_LABEL.core)}</span>
520
+ ) : (
521
+ <select
522
+ aria-label={i18n._({
523
+ id: 'editor.buildPanel.wallTypeEditorDialog.layerRole',
524
+ message: 'Layer role',
525
+ })}
526
+ className={FIELD_CLASS}
527
+ onChange={(event) => {
528
+ const role = event.target.value as Exclude<DraftRole, 'core'>
529
+ const fresh = layerOf(role)
530
+ update(layer.key, {
531
+ role,
532
+ material: fresh.material,
533
+ thickness: fresh.thickness,
534
+ })
535
+ }}
536
+ value={layer.role}
537
+ >
538
+ {ADDABLE_ROLES.map((role) => (
539
+ <option key={role} value={role}>
540
+ {i18n._(ROLE_LABEL[role])}
541
+ </option>
542
+ ))}
543
+ </select>
544
+ )}
545
+ {layer.role === 'core' ? (
546
+ <select
547
+ aria-label={i18n._({
548
+ id: 'editor.buildPanel.wallTypeEditorDialog.framingCore',
549
+ message: 'Framing core',
550
+ })}
551
+ className={FIELD_CLASS}
552
+ onChange={(event) => {
553
+ const core = WALL_TYPE_CORES.find((entry) => entry.id === event.target.value)
554
+ if (core) update(layer.key, { material: core.id, thickness: core.thickness })
555
+ }}
556
+ value={layer.material}
557
+ >
558
+ {WALL_TYPE_CORES.map((core) => (
559
+ <option key={core.id} value={core.id}>
560
+ {core.label}
561
+ </option>
562
+ ))}
563
+ </select>
564
+ ) : (
565
+ <select
566
+ aria-label={i18n._({
567
+ id: 'editor.buildPanel.wallTypeEditorDialog.material',
568
+ message: 'Material',
569
+ })}
570
+ className={FIELD_CLASS}
571
+ disabled={layer.role === 'air'}
572
+ onChange={(event) => {
573
+ const option = LAYER_MATERIALS[layer.role as Exclude<DraftRole, 'core'>].find(
574
+ (entry) => entry.value === event.target.value,
575
+ )
576
+ if (option)
577
+ update(layer.key, { material: option.value, thickness: option.thickness })
578
+ }}
579
+ value={layer.material}
580
+ >
581
+ {LAYER_MATERIALS[layer.role as Exclude<DraftRole, 'core'>].map((option) => (
582
+ <option key={option.value} value={option.value}>
583
+ {i18n._(option.label)}
584
+ </option>
585
+ ))}
586
+ {LAYER_MATERIALS[layer.role as Exclude<DraftRole, 'core'>].some(
587
+ (option) => option.value === layer.material,
588
+ ) ? null : (
589
+ <option value={layer.material}>{layer.material}</option>
590
+ )}
591
+ </select>
592
+ )}
593
+ {layer.role === 'core' ? (
594
+ <span className="px-2 text-right text-xs tabular-nums">
595
+ {formatLayerThickness(layer.thickness, unit)}
596
+ </span>
597
+ ) : (
598
+ <ThicknessField
599
+ metres={layer.thickness}
600
+ onCommit={(thickness) => update(layer.key, { thickness })}
601
+ unit={unit}
602
+ />
603
+ )}
604
+ <div className="flex items-center justify-end gap-0.5">
605
+ <button
606
+ aria-label={i18n._({
607
+ id: 'editor.buildPanel.wallTypeEditorDialog.moveOut',
608
+ message: 'Move out',
609
+ })}
610
+ className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30"
611
+ disabled={index === 0}
612
+ onClick={() => move(index, -1)}
613
+ type="button"
614
+ >
615
+ <ArrowUp className="size-3.5" />
616
+ </button>
617
+ <button
618
+ aria-label={i18n._({
619
+ id: 'editor.buildPanel.wallTypeEditorDialog.moveIn',
620
+ message: 'Move in',
621
+ })}
622
+ className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30"
623
+ disabled={index === layers.length - 1}
624
+ onClick={() => move(index, 1)}
625
+ type="button"
626
+ >
627
+ <ArrowDown className="size-3.5" />
628
+ </button>
629
+ <button
630
+ aria-label={i18n._({
631
+ id: 'editor.buildPanel.wallTypeEditorDialog.removeLayer',
632
+ message: 'Remove layer',
633
+ })}
634
+ className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30"
635
+ // The core stays: a wall type frames around exactly one.
636
+ disabled={layer.role === 'core'}
637
+ onClick={() =>
638
+ setLayers((current) => current.filter((entry) => entry.key !== layer.key))
639
+ }
640
+ title={
641
+ layer.role === 'core'
642
+ ? i18n._({
643
+ id: 'editor.buildPanel.wallTypeEditorDialog.everyWallTypeHasOneFraming',
644
+ message: 'Every wall type has one framing core',
645
+ })
646
+ : undefined
647
+ }
648
+ type="button"
649
+ >
650
+ <X className="size-3.5" />
651
+ </button>
652
+ </div>
653
+ </div>
654
+ ))}
655
+ <div className="px-0.5 text-[10px] text-muted-foreground uppercase tracking-wide">
656
+ {i18n._({ id: 'editor.buildPanel.wallTypeEditorDialog.inside', message: 'Inside' })}
657
+ </div>
658
+ <div className="flex flex-wrap items-center gap-1.5 pt-1">
659
+ <span className="text-[11px] text-muted-foreground">
660
+ {i18n._({ id: 'editor.buildPanel.wallTypeEditorDialog.add', message: 'Add' })}
661
+ </span>
662
+ {ADDABLE_ROLES.map((role) => (
663
+ <button
664
+ className="flex items-center gap-1 rounded-full border border-border/60 px-2 py-0.5 text-[11px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
665
+ disabled={full}
666
+ key={role}
667
+ onClick={() =>
668
+ setLayers((current) => {
669
+ if (current.length >= WALL_TYPE_LAYER_LIMIT) return current
670
+ const next = [...current]
671
+ next.splice(insertIndex(current, role), 0, layerOf(role))
672
+ return next
673
+ })
674
+ }
675
+ title={full ? fullReason : undefined}
676
+ type="button"
677
+ >
678
+ <Plus className="size-3" />
679
+ {i18n._(ROLE_LABEL[role])}
680
+ </button>
681
+ ))}
682
+ {full ? (
683
+ <span className="text-[11px] text-muted-foreground" data-wall-type-layers-full>
684
+ {i18n._({
685
+ id: 'editor.buildPanel.wallTypeEditorDialog.fullreasonRemoveOneToAddAnother',
686
+ message: '{fullReason} Remove one to add another.',
687
+ values: { fullReason },
688
+ })}
689
+ </span>
690
+ ) : null}
691
+ </div>
692
+ </div>
693
+
694
+ <div className="flex items-baseline justify-end gap-3 rounded-lg bg-black/4 px-3 py-2 dark:bg-muted/40">
695
+ <span className="font-medium text-sm tabular-nums">
696
+ {i18n._({
697
+ id: 'editor.buildPanel.wallTypeEditorDialog.totalThickness',
698
+ message: 'Total {thickness}',
699
+ values: { thickness: formatLayerThickness(total, unit) },
700
+ })}
701
+ </span>
702
+ </div>
703
+ {failure ? (
704
+ <p className="text-destructive text-xs">{WALL_TYPE_SAVE_FAILURE[failure]}</p>
705
+ ) : null}
706
+
707
+ <DialogFooter>
708
+ <Button onClick={onClose} variant="outline">
709
+ {i18n._({ id: 'editor.buildPanel.wallTypeEditorDialog.cancel', message: 'Cancel' })}
710
+ </Button>
711
+ <Button disabled={!canSave} onClick={save}>
712
+ {i18n._({
713
+ id: 'editor.buildPanel.wallTypeEditorDialog.saveWallType',
714
+ message: 'Save wall type',
715
+ })}
716
+ </Button>
717
+ </DialogFooter>
718
+ </DialogContent>
719
+ </Dialog>
720
+ )
721
+ }