@pascal-app/editor 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (317) hide show
  1. package/package.json +20 -17
  2. package/src/components/editor/action-menu-button.tsx +4 -1
  3. package/src/components/editor/alignment-3d-guide-layer.tsx +102 -11
  4. package/src/components/editor/bake-thumbnail.tsx +3 -2
  5. package/src/components/editor/custom-camera-controls.tsx +20 -8
  6. package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
  7. package/src/components/editor/first-person-controls.tsx +72 -61
  8. package/src/components/editor/floating-action-menu.tsx +27 -9
  9. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  10. package/src/components/editor/floorplan-panel.test.ts +220 -0
  11. package/src/components/editor/floorplan-panel.tsx +285 -132
  12. package/src/components/editor/footprint-height-handle.tsx +1 -1
  13. package/src/components/editor/grid.tsx +24 -15
  14. package/src/components/editor/group-actions.ts +19 -1
  15. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  16. package/src/components/editor/group-move-3d.ts +27 -2
  17. package/src/components/editor/group-selection-box-3d.tsx +4 -1
  18. package/src/components/editor/group-transform-shared.ts +20 -0
  19. package/src/components/editor/index.tsx +130 -20
  20. package/src/components/editor/inspector-extension-actions.tsx +36 -0
  21. package/src/components/editor/localized-html.tsx +22 -0
  22. package/src/components/editor/node-action-menu.tsx +46 -12
  23. package/src/components/editor/node-arrow-handles.tsx +1 -2
  24. package/src/components/editor/open-wall-ends-3d-layer.tsx +1 -1
  25. package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
  26. package/src/components/editor/registry-action-contributions.test.tsx +67 -6
  27. package/src/components/editor/registry-action-contributions.tsx +115 -15
  28. package/src/components/editor/riser-diagram-panel.tsx +59 -9
  29. package/src/components/editor/room-controls.tsx +1 -1
  30. package/src/components/editor/room-handles-2d.tsx +156 -6
  31. package/src/components/editor/room-handles.tsx +241 -51
  32. package/src/components/editor/room-highlight.tsx +2 -0
  33. package/src/components/editor/room-labels.tsx +29 -15
  34. package/src/components/editor/selection-manager.tsx +15 -102
  35. package/src/components/editor/separator-end-grip.tsx +74 -0
  36. package/src/components/editor/site-edge-labels.tsx +1 -1
  37. package/src/components/editor/thumbnail-generator.test.ts +15 -6
  38. package/src/components/editor/thumbnail-generator.tsx +26 -1
  39. package/src/components/editor/use-floorplan-background-placement.ts +55 -52
  40. package/src/components/editor/views/view-bar.tsx +111 -26
  41. package/src/components/editor/wall-measurement-label.tsx +1 -1
  42. package/src/components/editor/wall-move-side-handles.tsx +5 -1
  43. package/src/components/editor/wall-pull-grip.tsx +193 -0
  44. package/src/components/editor/wall-snap-beacon-layer.tsx +81 -1
  45. package/src/components/editor-2d/floorplan-adjacent-walls-layer.test.ts +76 -0
  46. package/src/components/editor-2d/floorplan-adjacent-walls-layer.tsx +186 -0
  47. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +63 -3
  48. package/src/components/editor-2d/floorplan-group-move.tsx +31 -3
  49. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +6 -0
  50. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +22 -13
  51. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +11 -3
  52. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +7 -1
  53. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +96 -4
  54. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +52 -12
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +12 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +197 -37
  57. package/src/components/procedural-items/inspector.tsx +59 -12
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +2 -0
  59. package/src/components/systems/ceiling/ceiling-system.test.ts +141 -0
  60. package/src/components/systems/ceiling/ceiling-system.tsx +53 -18
  61. package/src/components/tools/distribution/distribution-run-tool.tsx +50 -26
  62. package/src/components/tools/item/placement-history.test.tsx +1 -1
  63. package/src/components/tools/item/placement-strategies.test.ts +64 -1
  64. package/src/components/tools/item/placement-strategies.ts +5 -9
  65. package/src/components/tools/item/use-placement-coordinator.tsx +30 -34
  66. package/src/components/tools/registry/item-surface-move.test.ts +11 -0
  67. package/src/components/tools/select/select-candidates.test.ts +45 -0
  68. package/src/components/tools/select/select-candidates.ts +13 -2
  69. package/src/components/tools/shared/cursor-sphere.tsx +10 -3
  70. package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
  71. package/src/components/tools/shared/placement-box.tsx +1 -1
  72. package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
  73. package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
  74. package/src/components/tools/wall/wall-drafting.test.ts +299 -23
  75. package/src/components/tools/wall/wall-drafting.ts +132 -24
  76. package/src/components/tools/wall/wall-pull-snap.test.ts +274 -0
  77. package/src/components/tools/wall/wall-pull-snap.ts +563 -0
  78. package/src/components/tools/wall/wall-snap-geometry.test.ts +113 -1
  79. package/src/components/tools/wall/wall-snap-geometry.ts +137 -0
  80. package/src/components/tools/zone/zone-tool.tsx +3 -3
  81. package/src/components/ui/action-menu/action-button.tsx +2 -0
  82. package/src/components/ui/action-menu/camera-actions.tsx +8 -7
  83. package/src/components/ui/action-menu/control-modes.tsx +9 -5
  84. package/src/components/ui/action-menu/follow-pascal-pill.tsx +3 -1
  85. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +7 -1
  86. package/src/components/ui/action-menu/furnish-tools.tsx +7 -6
  87. package/src/components/ui/action-menu/index.tsx +1 -0
  88. package/src/components/ui/action-menu/measurement-control.tsx +35 -29
  89. package/src/components/ui/action-menu/point-ask-button.tsx +4 -3
  90. package/src/components/ui/action-menu/structure-tools.tsx +25 -23
  91. package/src/components/ui/action-menu/view-toggles.test.ts +18 -0
  92. package/src/components/ui/action-menu/view-toggles.tsx +256 -51
  93. package/src/components/ui/build-panel/build-panel.tsx +163 -25
  94. package/src/components/ui/command-palette/editor-commands.tsx +38 -11
  95. package/src/components/ui/controls/finish-swatch.tsx +45 -19
  96. package/src/components/ui/controls/material-picker.tsx +24 -17
  97. package/src/components/ui/controls/metric-control.test.ts +52 -0
  98. package/src/components/ui/controls/metric-control.tsx +62 -24
  99. package/src/components/ui/controls/segmented-control.tsx +5 -2
  100. package/src/components/ui/controls/slider-control.test.tsx +23 -0
  101. package/src/components/ui/controls/slider-control.tsx +30 -14
  102. package/src/components/ui/floating-level-selector.tsx +100 -48
  103. package/src/components/ui/helpers/building-helper.tsx +5 -3
  104. package/src/components/ui/helpers/contextual-helper-panel.tsx +97 -37
  105. package/src/components/ui/helpers/helper-manager.test.tsx +53 -3
  106. package/src/components/ui/helpers/helper-manager.tsx +82 -57
  107. package/src/components/ui/helpers/item-helper.tsx +6 -4
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +3 -1
  109. package/src/components/ui/level-duplicate-dialog.tsx +32 -23
  110. package/src/components/ui/level-wall-copy-prompt.tsx +124 -0
  111. package/src/components/ui/panels/collections/collections-popover.tsx +11 -11
  112. package/src/components/ui/panels/deck-boards-fields.tsx +136 -0
  113. package/src/components/ui/panels/floor-foundation-panel.tsx +113 -38
  114. package/src/components/ui/panels/floor-opening-panel.tsx +73 -25
  115. package/src/components/ui/panels/inspector-extensions.tsx +66 -0
  116. package/src/components/ui/panels/mobile-panel-sheet.tsx +3 -1
  117. package/src/components/ui/panels/mobile-selection-bar.tsx +8 -6
  118. package/src/components/ui/panels/multi-field-value.test.ts +27 -0
  119. package/src/components/ui/panels/multi-field-value.ts +20 -1
  120. package/src/components/ui/panels/multi-height-mode.tsx +8 -7
  121. package/src/components/ui/panels/multi-parametric-inspector.tsx +122 -34
  122. package/src/components/ui/panels/multi-selection-panel.tsx +75 -35
  123. package/src/components/ui/panels/node-display.ts +35 -24
  124. package/src/components/ui/panels/panel-manager.tsx +5 -3
  125. package/src/components/ui/panels/panel-wrapper.tsx +22 -109
  126. package/src/components/ui/panels/parametric-field-control.tsx +13 -10
  127. package/src/components/ui/panels/parametric-inspector.tsx +18 -12
  128. package/src/components/ui/panels/reference-panel.tsx +51 -35
  129. package/src/components/ui/panels/room-construction-rows.tsx +464 -65
  130. package/src/components/ui/panels/room-name-options.test.ts +36 -3
  131. package/src/components/ui/panels/room-name-options.ts +110 -11
  132. package/src/components/ui/panels/room-openings-field.tsx +21 -12
  133. package/src/components/ui/panels/room-panel.tsx +206 -99
  134. package/src/components/ui/panels/shape-choice.tsx +7 -5
  135. package/src/components/ui/panels/wall-region-list.tsx +7 -7
  136. package/src/components/ui/primitives/shortcut-token.tsx +10 -6
  137. package/src/components/ui/sidebar/icon-rail.tsx +11 -9
  138. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +10 -10
  139. package/src/components/ui/sidebar/panels/items-panel/index.tsx +10 -9
  140. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +3 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +102 -32
  142. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -2
  144. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +4 -2
  145. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +7 -5
  146. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +4 -2
  147. package/src/components/ui/sidebar/panels/site-panel/default-names.ts +75 -0
  148. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +4 -2
  149. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +4 -2
  150. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +4 -2
  151. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +4 -2
  152. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +4 -2
  153. package/src/components/ui/sidebar/panels/site-panel/index.tsx +117 -64
  154. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +36 -3
  155. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +4 -2
  156. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -4
  157. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +8 -2
  158. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +8 -5
  159. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +4 -2
  160. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +8 -3
  161. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +4 -2
  162. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +4 -2
  163. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +8 -7
  164. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +7 -6
  165. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +16 -1
  166. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +15 -8
  167. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +4 -2
  168. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +4 -2
  169. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +3 -1
  170. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +18 -8
  171. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +25 -14
  172. package/src/components/ui/sidebar/tab-bar.tsx +4 -1
  173. package/src/components/ui/sidebar/use-plugin-panels.tsx +19 -10
  174. package/src/components/ui/snap-target-badge.tsx +7 -6
  175. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +721 -0
  176. package/src/components/ui/wall-type/wall-type-menu.tsx +167 -0
  177. package/src/components/viewer/floorplan-compass-button.tsx +12 -3
  178. package/src/components/viewer/floorplan-preview.tsx +27 -19
  179. package/src/components/viewer/render-settings-menu.tsx +294 -0
  180. package/src/components/viewer/sky-control.tsx +710 -0
  181. package/src/components/viewer/use-viewer-ui-sounds.test.ts +15 -0
  182. package/src/components/viewer/use-viewer-ui-sounds.ts +46 -0
  183. package/src/components/viewer/viewer-controls-bar.tsx +278 -260
  184. package/src/components/viewer/viewer-hover-label.tsx +12 -4
  185. package/src/components/viewer/viewer-rooms.tsx +61 -4
  186. package/src/components/viewer/viewer-scene-header.tsx +28 -17
  187. package/src/components/viewer/viewer-stage-switcher.tsx +4 -2
  188. package/src/components/viewer-overlay.tsx +8 -0
  189. package/src/components/walkthrough-hud.tsx +75 -2
  190. package/src/hooks/use-keyboard.test.ts +85 -4
  191. package/src/hooks/use-keyboard.ts +65 -7
  192. package/src/hooks/use-selected-room.ts +12 -1
  193. package/src/i18n/floor-footprint-name.ts +30 -0
  194. package/src/i18n/level-name.ts +39 -0
  195. package/src/i18n/locales/en/messages.po +15117 -0
  196. package/src/i18n/locales/en/messages.ts +1 -0
  197. package/src/i18n/locales/en-XA/messages.ts +1 -0
  198. package/src/i18n/locales/es/messages.po +37501 -0
  199. package/src/i18n/locales/es/messages.ts +1 -0
  200. package/src/i18n/locales/fr/messages.po +37501 -0
  201. package/src/i18n/locales/fr/messages.ts +1 -0
  202. package/src/i18n/locales/ja/messages.po +37501 -0
  203. package/src/i18n/locales/ja/messages.ts +1 -0
  204. package/src/i18n/locales/zh-CN/messages.po +37501 -0
  205. package/src/i18n/locales/zh-CN/messages.ts +1 -0
  206. package/src/i18n/locales.test.ts +63 -0
  207. package/src/i18n/locales.ts +32 -0
  208. package/src/i18n/message-slot.tsx +25 -0
  209. package/src/i18n/node-messages.ts +169 -0
  210. package/src/i18n/pseudo-locale.ts +17 -0
  211. package/src/i18n/published-entry.test.ts +17 -0
  212. package/src/i18n/scene-theme-name.ts +16 -0
  213. package/src/index.tsx +156 -4
  214. package/src/lib/add-storey.test.ts +3 -3
  215. package/src/lib/contextual-help.test.ts +20 -1
  216. package/src/lib/contextual-help.ts +95 -19
  217. package/src/lib/continuation.ts +38 -11
  218. package/src/lib/floor-footprints.test.ts +2 -1
  219. package/src/lib/floor-foundation-support.test.ts +7 -3
  220. package/src/lib/floorplan/dimension-drive.test.ts +36 -2
  221. package/src/lib/floorplan/dimension-drive.ts +68 -3
  222. package/src/lib/floorplan/floorplan-export.test.ts +16 -5
  223. package/src/lib/floorplan/floorplan-export.tsx +13 -16
  224. package/src/lib/floorplan/floorplan-extension.test.ts +11 -12
  225. package/src/lib/floorplan/floorplan-extension.ts +37 -18
  226. package/src/lib/floorplan/floorplan-mode.test.ts +13 -6
  227. package/src/lib/floorplan/floorplan-mode.ts +14 -12
  228. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +1 -1
  229. package/src/lib/floorplan/floorplan-readonly.ts +3 -3
  230. package/src/lib/floorplan/schedules.ts +10 -6
  231. package/src/lib/floorplan/site-plan/site-annotations.test.ts +46 -1
  232. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -0
  233. package/src/lib/floorplan/site-plan/site-parts.ts +6 -3
  234. package/src/lib/geometry-script/author.test.ts +114 -0
  235. package/src/lib/geometry-script/author.ts +55 -23
  236. package/src/lib/geometry-script/client.ts +3 -0
  237. package/src/lib/geometry-script/finish-scripts.ts +7 -7
  238. package/src/lib/glb-export.test.ts +327 -0
  239. package/src/lib/glb-export.ts +70 -2
  240. package/src/lib/grid-frame.test.ts +56 -3
  241. package/src/lib/grid-frame.ts +28 -1
  242. package/src/lib/history.test.ts +2 -1
  243. package/src/lib/hud-title.ts +48 -24
  244. package/src/lib/ifc-export.test.ts +41 -1
  245. package/src/lib/inspector-card-mode.test.ts +3 -31
  246. package/src/lib/inspector-card-mode.ts +5 -10
  247. package/src/lib/inspector-expanded.test.ts +13 -0
  248. package/src/lib/inspector-expanded.ts +13 -1
  249. package/src/lib/keyboard-pan.test.ts +7 -0
  250. package/src/lib/keyboard-pan.ts +9 -2
  251. package/src/lib/layer-thickness.test.ts +22 -0
  252. package/src/lib/layer-thickness.ts +79 -0
  253. package/src/lib/lot/drop-in.test.ts +59 -1
  254. package/src/lib/lot/drop-in.ts +42 -50
  255. package/src/lib/material-swatch-style.ts +24 -0
  256. package/src/lib/measurement-parser.ts +7 -3
  257. package/src/lib/measurements.test.ts +17 -0
  258. package/src/lib/measurements.ts +20 -5
  259. package/src/lib/mezzanine-selection.test.ts +3 -1
  260. package/src/lib/orbit-near-plane.test.ts +12 -0
  261. package/src/lib/orbit-near-plane.ts +13 -0
  262. package/src/lib/paint-regions.test.ts +8 -5
  263. package/src/lib/paint-scope.test.ts +53 -0
  264. package/src/lib/paint-scope.ts +18 -4
  265. package/src/lib/panel-rows.ts +17 -8
  266. package/src/lib/reference-scale.ts +83 -0
  267. package/src/lib/room-built-on.test.ts +26 -12
  268. package/src/lib/room-built-on.ts +20 -2
  269. package/src/lib/room-construction.test.ts +28 -1
  270. package/src/lib/room-construction.ts +14 -0
  271. package/src/lib/room-controls-phase6.test.ts +140 -0
  272. package/src/lib/room-handle-drag.ts +557 -42
  273. package/src/lib/room-name-catalog.test.ts +8 -0
  274. package/src/lib/room-name-catalog.ts +8 -1
  275. package/src/lib/room-name-messages.test.ts +132 -0
  276. package/src/lib/room-name-messages.ts +147 -0
  277. package/src/lib/room-name-storage.test.ts +334 -0
  278. package/src/lib/room-push-dimensions.test.ts +37 -0
  279. package/src/lib/room-push-dimensions.ts +226 -2
  280. package/src/lib/room-selection.test.ts +23 -6
  281. package/src/lib/scene-clipboard.test.ts +70 -49
  282. package/src/lib/scene-clipboard.ts +9 -4
  283. package/src/lib/selection-cycle.ts +63 -0
  284. package/src/lib/separator-edit.test.ts +431 -0
  285. package/src/lib/separator-edit.ts +179 -0
  286. package/src/lib/separator-lines.ts +38 -0
  287. package/src/lib/sfx-bus.ts +27 -1
  288. package/src/lib/sfx-player.ts +16 -0
  289. package/src/lib/tool-cursor-icon.ts +5 -1
  290. package/src/lib/tool-shortcuts.ts +130 -0
  291. package/src/lib/viewer-selection.test.ts +28 -24
  292. package/src/lib/viewer-selection.ts +20 -5
  293. package/src/lib/wall-draw-variant.test.ts +7 -6
  294. package/src/lib/wall-draw-variant.ts +107 -6
  295. package/src/lib/wall-nudge.test.ts +44 -0
  296. package/src/lib/wall-nudge.ts +131 -0
  297. package/src/lib/wall-parallel-snap.test.ts +83 -0
  298. package/src/lib/wall-parallel-snap.ts +176 -0
  299. package/src/lib/wall-placement-pointer.test.ts +23 -0
  300. package/src/lib/wall-placement-pointer.ts +18 -0
  301. package/src/lib/wall-pull.test.ts +245 -0
  302. package/src/lib/wall-pull.ts +209 -0
  303. package/src/lib/wall-push-line-up.test.ts +132 -0
  304. package/src/lib/wall-region-list.ts +14 -5
  305. package/src/lib/wall-run-snap.test.ts +188 -0
  306. package/src/lib/wall-run-snap.ts +83 -0
  307. package/src/lib/wall-type.test.ts +125 -0
  308. package/src/lib/wall-type.ts +119 -0
  309. package/src/lib/window-interaction.test.ts +42 -0
  310. package/src/lib/window-interaction.ts +47 -5
  311. package/src/lib/world-grid-snap.test.ts +139 -2
  312. package/src/lib/world-grid-snap.ts +102 -105
  313. package/src/store/use-alignment-guides.ts +27 -1
  314. package/src/store/use-editor.tsx +48 -0
  315. package/src/store/use-floorplan-annotation-visibility.test.ts +64 -0
  316. package/src/store/use-floorplan-annotation-visibility.ts +47 -22
  317. package/src/store/use-wall-snap-indicator.ts +15 -2
@@ -0,0 +1,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 '../../../lib/measurements'
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
+ }