@pascal-app/editor 1.0.6 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (317) hide show
  1. package/package.json +18 -15
  2. package/src/components/editor/action-menu-button.tsx +4 -1
  3. package/src/components/editor/alignment-3d-guide-layer.tsx +102 -11
  4. package/src/components/editor/bake-thumbnail.tsx +3 -2
  5. package/src/components/editor/custom-camera-controls.tsx +20 -8
  6. package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
  7. package/src/components/editor/first-person-controls.tsx +72 -61
  8. package/src/components/editor/floating-action-menu.tsx +27 -9
  9. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  10. package/src/components/editor/floorplan-panel.test.ts +220 -0
  11. package/src/components/editor/floorplan-panel.tsx +285 -132
  12. package/src/components/editor/footprint-height-handle.tsx +1 -1
  13. package/src/components/editor/grid.tsx +24 -15
  14. package/src/components/editor/group-actions.ts +19 -1
  15. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  16. package/src/components/editor/group-move-3d.ts +27 -2
  17. package/src/components/editor/group-selection-box-3d.tsx +4 -1
  18. package/src/components/editor/group-transform-shared.ts +20 -0
  19. package/src/components/editor/index.tsx +130 -20
  20. package/src/components/editor/inspector-extension-actions.tsx +36 -0
  21. package/src/components/editor/localized-html.tsx +22 -0
  22. package/src/components/editor/node-action-menu.tsx +46 -12
  23. package/src/components/editor/node-arrow-handles.tsx +1 -2
  24. package/src/components/editor/open-wall-ends-3d-layer.tsx +1 -1
  25. package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
  26. package/src/components/editor/registry-action-contributions.test.tsx +67 -6
  27. package/src/components/editor/registry-action-contributions.tsx +115 -15
  28. package/src/components/editor/riser-diagram-panel.tsx +59 -9
  29. package/src/components/editor/room-controls.tsx +1 -1
  30. package/src/components/editor/room-handles-2d.tsx +156 -6
  31. package/src/components/editor/room-handles.tsx +241 -51
  32. package/src/components/editor/room-highlight.tsx +2 -0
  33. package/src/components/editor/room-labels.tsx +29 -15
  34. package/src/components/editor/selection-manager.tsx +15 -102
  35. package/src/components/editor/separator-end-grip.tsx +74 -0
  36. package/src/components/editor/site-edge-labels.tsx +1 -1
  37. package/src/components/editor/thumbnail-generator.test.ts +15 -6
  38. package/src/components/editor/thumbnail-generator.tsx +26 -1
  39. package/src/components/editor/use-floorplan-background-placement.ts +55 -52
  40. package/src/components/editor/views/view-bar.tsx +111 -26
  41. package/src/components/editor/wall-measurement-label.tsx +1 -1
  42. package/src/components/editor/wall-move-side-handles.tsx +5 -1
  43. package/src/components/editor/wall-pull-grip.tsx +193 -0
  44. package/src/components/editor/wall-snap-beacon-layer.tsx +81 -1
  45. package/src/components/editor-2d/floorplan-adjacent-walls-layer.test.ts +76 -0
  46. package/src/components/editor-2d/floorplan-adjacent-walls-layer.tsx +186 -0
  47. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +63 -3
  48. package/src/components/editor-2d/floorplan-group-move.tsx +31 -3
  49. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +6 -0
  50. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +22 -13
  51. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +11 -3
  52. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +7 -1
  53. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +96 -4
  54. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +52 -12
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +12 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +197 -37
  57. package/src/components/procedural-items/inspector.tsx +59 -12
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +2 -0
  59. package/src/components/systems/ceiling/ceiling-system.test.ts +141 -0
  60. package/src/components/systems/ceiling/ceiling-system.tsx +53 -18
  61. package/src/components/tools/distribution/distribution-run-tool.tsx +50 -26
  62. package/src/components/tools/item/placement-history.test.tsx +1 -1
  63. package/src/components/tools/item/placement-strategies.test.ts +64 -1
  64. package/src/components/tools/item/placement-strategies.ts +5 -9
  65. package/src/components/tools/item/use-placement-coordinator.tsx +30 -34
  66. package/src/components/tools/registry/item-surface-move.test.ts +11 -0
  67. package/src/components/tools/select/select-candidates.test.ts +45 -0
  68. package/src/components/tools/select/select-candidates.ts +13 -2
  69. package/src/components/tools/shared/cursor-sphere.tsx +10 -3
  70. package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
  71. package/src/components/tools/shared/placement-box.tsx +1 -1
  72. package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
  73. package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
  74. package/src/components/tools/wall/wall-drafting.test.ts +299 -23
  75. package/src/components/tools/wall/wall-drafting.ts +132 -24
  76. package/src/components/tools/wall/wall-pull-snap.test.ts +274 -0
  77. package/src/components/tools/wall/wall-pull-snap.ts +563 -0
  78. package/src/components/tools/wall/wall-snap-geometry.test.ts +113 -1
  79. package/src/components/tools/wall/wall-snap-geometry.ts +137 -0
  80. package/src/components/tools/zone/zone-tool.tsx +3 -3
  81. package/src/components/ui/action-menu/action-button.tsx +2 -0
  82. package/src/components/ui/action-menu/camera-actions.tsx +8 -7
  83. package/src/components/ui/action-menu/control-modes.tsx +9 -5
  84. package/src/components/ui/action-menu/follow-pascal-pill.tsx +3 -1
  85. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +7 -1
  86. package/src/components/ui/action-menu/furnish-tools.tsx +7 -6
  87. package/src/components/ui/action-menu/index.tsx +1 -0
  88. package/src/components/ui/action-menu/measurement-control.tsx +35 -29
  89. package/src/components/ui/action-menu/point-ask-button.tsx +4 -3
  90. package/src/components/ui/action-menu/structure-tools.tsx +25 -23
  91. package/src/components/ui/action-menu/view-toggles.test.ts +18 -0
  92. package/src/components/ui/action-menu/view-toggles.tsx +256 -51
  93. package/src/components/ui/build-panel/build-panel.tsx +163 -25
  94. package/src/components/ui/command-palette/editor-commands.tsx +38 -11
  95. package/src/components/ui/controls/finish-swatch.tsx +45 -19
  96. package/src/components/ui/controls/material-picker.tsx +24 -17
  97. package/src/components/ui/controls/metric-control.test.ts +52 -0
  98. package/src/components/ui/controls/metric-control.tsx +62 -24
  99. package/src/components/ui/controls/segmented-control.tsx +5 -2
  100. package/src/components/ui/controls/slider-control.test.tsx +23 -0
  101. package/src/components/ui/controls/slider-control.tsx +30 -14
  102. package/src/components/ui/floating-level-selector.tsx +100 -48
  103. package/src/components/ui/helpers/building-helper.tsx +5 -3
  104. package/src/components/ui/helpers/contextual-helper-panel.tsx +97 -37
  105. package/src/components/ui/helpers/helper-manager.test.tsx +53 -3
  106. package/src/components/ui/helpers/helper-manager.tsx +82 -57
  107. package/src/components/ui/helpers/item-helper.tsx +6 -4
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +3 -1
  109. package/src/components/ui/level-duplicate-dialog.tsx +32 -23
  110. package/src/components/ui/level-wall-copy-prompt.tsx +124 -0
  111. package/src/components/ui/panels/collections/collections-popover.tsx +11 -11
  112. package/src/components/ui/panels/deck-boards-fields.tsx +136 -0
  113. package/src/components/ui/panels/floor-foundation-panel.tsx +113 -38
  114. package/src/components/ui/panels/floor-opening-panel.tsx +73 -25
  115. package/src/components/ui/panels/inspector-extensions.tsx +66 -0
  116. package/src/components/ui/panels/mobile-panel-sheet.tsx +3 -1
  117. package/src/components/ui/panels/mobile-selection-bar.tsx +8 -6
  118. package/src/components/ui/panels/multi-field-value.test.ts +27 -0
  119. package/src/components/ui/panels/multi-field-value.ts +20 -1
  120. package/src/components/ui/panels/multi-height-mode.tsx +8 -7
  121. package/src/components/ui/panels/multi-parametric-inspector.tsx +122 -34
  122. package/src/components/ui/panels/multi-selection-panel.tsx +75 -35
  123. package/src/components/ui/panels/node-display.ts +35 -24
  124. package/src/components/ui/panels/panel-manager.tsx +5 -3
  125. package/src/components/ui/panels/panel-wrapper.tsx +22 -109
  126. package/src/components/ui/panels/parametric-field-control.tsx +13 -10
  127. package/src/components/ui/panels/parametric-inspector.tsx +18 -12
  128. package/src/components/ui/panels/reference-panel.tsx +51 -35
  129. package/src/components/ui/panels/room-construction-rows.tsx +464 -65
  130. package/src/components/ui/panels/room-name-options.test.ts +36 -3
  131. package/src/components/ui/panels/room-name-options.ts +110 -11
  132. package/src/components/ui/panels/room-openings-field.tsx +21 -12
  133. package/src/components/ui/panels/room-panel.tsx +206 -99
  134. package/src/components/ui/panels/shape-choice.tsx +7 -5
  135. package/src/components/ui/panels/wall-region-list.tsx +7 -7
  136. package/src/components/ui/primitives/shortcut-token.tsx +10 -6
  137. package/src/components/ui/sidebar/icon-rail.tsx +11 -9
  138. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +10 -10
  139. package/src/components/ui/sidebar/panels/items-panel/index.tsx +10 -9
  140. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +3 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +102 -32
  142. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -2
  144. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +4 -2
  145. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +7 -5
  146. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +4 -2
  147. package/src/components/ui/sidebar/panels/site-panel/default-names.ts +75 -0
  148. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +4 -2
  149. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +4 -2
  150. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +4 -2
  151. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +4 -2
  152. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +4 -2
  153. package/src/components/ui/sidebar/panels/site-panel/index.tsx +117 -64
  154. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +36 -3
  155. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +4 -2
  156. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -4
  157. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +8 -2
  158. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +8 -5
  159. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +4 -2
  160. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +8 -3
  161. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +4 -2
  162. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +4 -2
  163. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +8 -7
  164. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +7 -6
  165. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +16 -1
  166. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +15 -8
  167. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +4 -2
  168. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +4 -2
  169. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +3 -1
  170. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +18 -8
  171. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +25 -14
  172. package/src/components/ui/sidebar/tab-bar.tsx +4 -1
  173. package/src/components/ui/sidebar/use-plugin-panels.tsx +19 -10
  174. package/src/components/ui/snap-target-badge.tsx +7 -6
  175. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +721 -0
  176. package/src/components/ui/wall-type/wall-type-menu.tsx +167 -0
  177. package/src/components/viewer/floorplan-compass-button.tsx +12 -3
  178. package/src/components/viewer/floorplan-preview.tsx +27 -19
  179. package/src/components/viewer/render-settings-menu.tsx +294 -0
  180. package/src/components/viewer/sky-control.tsx +710 -0
  181. package/src/components/viewer/use-viewer-ui-sounds.test.ts +15 -0
  182. package/src/components/viewer/use-viewer-ui-sounds.ts +46 -0
  183. package/src/components/viewer/viewer-controls-bar.tsx +278 -260
  184. package/src/components/viewer/viewer-hover-label.tsx +12 -4
  185. package/src/components/viewer/viewer-rooms.tsx +61 -4
  186. package/src/components/viewer/viewer-scene-header.tsx +28 -17
  187. package/src/components/viewer/viewer-stage-switcher.tsx +4 -2
  188. package/src/components/viewer-overlay.tsx +8 -0
  189. package/src/components/walkthrough-hud.tsx +75 -2
  190. package/src/hooks/use-keyboard.test.ts +85 -4
  191. package/src/hooks/use-keyboard.ts +65 -7
  192. package/src/hooks/use-selected-room.ts +12 -1
  193. package/src/i18n/floor-footprint-name.ts +30 -0
  194. package/src/i18n/level-name.ts +39 -0
  195. package/src/i18n/locales/en/messages.po +15117 -0
  196. package/src/i18n/locales/en/messages.ts +1 -0
  197. package/src/i18n/locales/en-XA/messages.ts +1 -0
  198. package/src/i18n/locales/es/messages.po +37501 -0
  199. package/src/i18n/locales/es/messages.ts +1 -0
  200. package/src/i18n/locales/fr/messages.po +37501 -0
  201. package/src/i18n/locales/fr/messages.ts +1 -0
  202. package/src/i18n/locales/ja/messages.po +37501 -0
  203. package/src/i18n/locales/ja/messages.ts +1 -0
  204. package/src/i18n/locales/zh-CN/messages.po +37501 -0
  205. package/src/i18n/locales/zh-CN/messages.ts +1 -0
  206. package/src/i18n/locales.test.ts +63 -0
  207. package/src/i18n/locales.ts +32 -0
  208. package/src/i18n/message-slot.tsx +25 -0
  209. package/src/i18n/node-messages.ts +169 -0
  210. package/src/i18n/pseudo-locale.ts +17 -0
  211. package/src/i18n/published-entry.test.ts +17 -0
  212. package/src/i18n/scene-theme-name.ts +16 -0
  213. package/src/index.tsx +156 -4
  214. package/src/lib/add-storey.test.ts +3 -3
  215. package/src/lib/contextual-help.test.ts +20 -1
  216. package/src/lib/contextual-help.ts +95 -19
  217. package/src/lib/continuation.ts +38 -11
  218. package/src/lib/floor-footprints.test.ts +2 -1
  219. package/src/lib/floor-foundation-support.test.ts +7 -3
  220. package/src/lib/floorplan/dimension-drive.test.ts +36 -2
  221. package/src/lib/floorplan/dimension-drive.ts +68 -3
  222. package/src/lib/floorplan/floorplan-export.test.ts +16 -5
  223. package/src/lib/floorplan/floorplan-export.tsx +13 -16
  224. package/src/lib/floorplan/floorplan-extension.test.ts +11 -12
  225. package/src/lib/floorplan/floorplan-extension.ts +37 -18
  226. package/src/lib/floorplan/floorplan-mode.test.ts +13 -6
  227. package/src/lib/floorplan/floorplan-mode.ts +14 -12
  228. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +1 -1
  229. package/src/lib/floorplan/floorplan-readonly.ts +3 -3
  230. package/src/lib/floorplan/schedules.ts +10 -6
  231. package/src/lib/floorplan/site-plan/site-annotations.test.ts +46 -1
  232. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -0
  233. package/src/lib/floorplan/site-plan/site-parts.ts +6 -3
  234. package/src/lib/geometry-script/author.test.ts +114 -0
  235. package/src/lib/geometry-script/author.ts +55 -23
  236. package/src/lib/geometry-script/client.ts +3 -0
  237. package/src/lib/geometry-script/finish-scripts.ts +7 -7
  238. package/src/lib/glb-export.test.ts +327 -0
  239. package/src/lib/glb-export.ts +70 -2
  240. package/src/lib/grid-frame.test.ts +56 -3
  241. package/src/lib/grid-frame.ts +28 -1
  242. package/src/lib/history.test.ts +2 -1
  243. package/src/lib/hud-title.ts +48 -24
  244. package/src/lib/ifc-export.test.ts +41 -1
  245. package/src/lib/inspector-card-mode.test.ts +3 -31
  246. package/src/lib/inspector-card-mode.ts +5 -10
  247. package/src/lib/inspector-expanded.test.ts +13 -0
  248. package/src/lib/inspector-expanded.ts +13 -1
  249. package/src/lib/keyboard-pan.test.ts +7 -0
  250. package/src/lib/keyboard-pan.ts +9 -2
  251. package/src/lib/layer-thickness.test.ts +22 -0
  252. package/src/lib/layer-thickness.ts +79 -0
  253. package/src/lib/lot/drop-in.test.ts +59 -1
  254. package/src/lib/lot/drop-in.ts +42 -50
  255. package/src/lib/material-swatch-style.ts +24 -0
  256. package/src/lib/measurement-parser.ts +7 -3
  257. package/src/lib/measurements.test.ts +17 -0
  258. package/src/lib/measurements.ts +20 -5
  259. package/src/lib/mezzanine-selection.test.ts +3 -1
  260. package/src/lib/orbit-near-plane.test.ts +12 -0
  261. package/src/lib/orbit-near-plane.ts +13 -0
  262. package/src/lib/paint-regions.test.ts +8 -5
  263. package/src/lib/paint-scope.test.ts +53 -0
  264. package/src/lib/paint-scope.ts +18 -4
  265. package/src/lib/panel-rows.ts +17 -8
  266. package/src/lib/reference-scale.ts +83 -0
  267. package/src/lib/room-built-on.test.ts +26 -12
  268. package/src/lib/room-built-on.ts +20 -2
  269. package/src/lib/room-construction.test.ts +28 -1
  270. package/src/lib/room-construction.ts +14 -0
  271. package/src/lib/room-controls-phase6.test.ts +140 -0
  272. package/src/lib/room-handle-drag.ts +557 -42
  273. package/src/lib/room-name-catalog.test.ts +8 -0
  274. package/src/lib/room-name-catalog.ts +8 -1
  275. package/src/lib/room-name-messages.test.ts +132 -0
  276. package/src/lib/room-name-messages.ts +147 -0
  277. package/src/lib/room-name-storage.test.ts +334 -0
  278. package/src/lib/room-push-dimensions.test.ts +37 -0
  279. package/src/lib/room-push-dimensions.ts +226 -2
  280. package/src/lib/room-selection.test.ts +23 -6
  281. package/src/lib/scene-clipboard.test.ts +70 -49
  282. package/src/lib/scene-clipboard.ts +9 -4
  283. package/src/lib/selection-cycle.ts +63 -0
  284. package/src/lib/separator-edit.test.ts +431 -0
  285. package/src/lib/separator-edit.ts +179 -0
  286. package/src/lib/separator-lines.ts +38 -0
  287. package/src/lib/sfx-bus.ts +27 -1
  288. package/src/lib/sfx-player.ts +16 -0
  289. package/src/lib/tool-cursor-icon.ts +5 -1
  290. package/src/lib/tool-shortcuts.ts +130 -0
  291. package/src/lib/viewer-selection.test.ts +28 -24
  292. package/src/lib/viewer-selection.ts +20 -5
  293. package/src/lib/wall-draw-variant.test.ts +7 -6
  294. package/src/lib/wall-draw-variant.ts +107 -6
  295. package/src/lib/wall-nudge.test.ts +44 -0
  296. package/src/lib/wall-nudge.ts +131 -0
  297. package/src/lib/wall-parallel-snap.test.ts +83 -0
  298. package/src/lib/wall-parallel-snap.ts +176 -0
  299. package/src/lib/wall-placement-pointer.test.ts +23 -0
  300. package/src/lib/wall-placement-pointer.ts +18 -0
  301. package/src/lib/wall-pull.test.ts +245 -0
  302. package/src/lib/wall-pull.ts +209 -0
  303. package/src/lib/wall-push-line-up.test.ts +132 -0
  304. package/src/lib/wall-region-list.ts +14 -5
  305. package/src/lib/wall-run-snap.test.ts +188 -0
  306. package/src/lib/wall-run-snap.ts +83 -0
  307. package/src/lib/wall-type.test.ts +125 -0
  308. package/src/lib/wall-type.ts +119 -0
  309. package/src/lib/window-interaction.test.ts +42 -0
  310. package/src/lib/window-interaction.ts +47 -5
  311. package/src/lib/world-grid-snap.test.ts +139 -2
  312. package/src/lib/world-grid-snap.ts +102 -105
  313. package/src/store/use-alignment-guides.ts +27 -1
  314. package/src/store/use-editor.tsx +48 -0
  315. package/src/store/use-floorplan-annotation-visibility.test.ts +64 -0
  316. package/src/store/use-floorplan-annotation-visibility.ts +47 -22
  317. package/src/store/use-wall-snap-indicator.ts +15 -2
@@ -1,7 +1,13 @@
1
1
  import { afterEach, describe, expect, test } from 'bun:test'
2
- import { type AnyNode, registerNode, useInteractive } from '@pascal-app/core'
2
+ import { type AnyNode, type MechanismPart, registerNode, useInteractive } from '@pascal-app/core'
3
+ import type { MouseEvent, ReactElement } from 'react'
3
4
  import { renderToStaticMarkup } from 'react-dom/server'
4
- import { MechanismButton, selectedMechanismNode } from './registry-action-contributions'
5
+ import {
6
+ MechanismButton,
7
+ MechanismPartButton,
8
+ NodeMechanismActions,
9
+ soleSelectedNode,
10
+ } from './registry-action-contributions'
5
11
 
6
12
  const switchMechanism = {
7
13
  has: (node: AnyNode) => (node as unknown as { joints: number }).joints > 0,
@@ -23,6 +29,30 @@ function articulated(joints: number): AnyNode {
23
29
  return { object: 'node', id: `${kind}_1`, type: kind, joints } as unknown as AnyNode
24
30
  }
25
31
 
32
+ const shutters: MechanismPart = {
33
+ id: 'shutters',
34
+ label: 'Shutters',
35
+ icon: 'shutters',
36
+ verb: 'open',
37
+ has: () => true,
38
+ isOn: (node, state) => Boolean(state.mechanisms[`${node.id}:shutters` as never]),
39
+ set: (node, on) => useInteractive.getState().setMechanism(`${node.id}:shutters` as never, on),
40
+ }
41
+
42
+ function shuttered(): AnyNode {
43
+ const kind = `test:shuttered-${crypto.randomUUID()}`
44
+ registerNode({
45
+ kind,
46
+ schemaVersion: 1,
47
+ category: 'structure',
48
+ defaults: () => ({}),
49
+ capabilities: {
50
+ mechanism: { ...switchMechanism, verb: 'open', icon: 'window', parts: [shutters] },
51
+ },
52
+ } as never)
53
+ return { object: 'node', id: `${kind}_1`, type: kind, joints: 1 } as unknown as AnyNode
54
+ }
55
+
26
56
  const byId = (...nodes: AnyNode[]) => Object.fromEntries(nodes.map((node) => [node.id, node]))
27
57
 
28
58
  afterEach(() => {
@@ -33,17 +63,20 @@ afterEach(() => {
33
63
  describe('mechanism action in the action menu', () => {
34
64
  test('the only selected node gets Play when its kind declares a mechanism', () => {
35
65
  const node = articulated(2)
36
- expect(selectedMechanismNode([node.id], byId(node))).toBe(node)
66
+ expect(soleSelectedNode([node.id], byId(node))).toBe(node)
67
+ expect(renderToStaticMarkup(<NodeMechanismActions node={node} />)).toContain(
68
+ 'aria-label="Play"',
69
+ )
37
70
  })
38
71
 
39
72
  test('no Play without moving parts, for a multi-selection, or without the capability', () => {
40
73
  const still = articulated(0)
41
- expect(selectedMechanismNode([still.id], byId(still))).toBeUndefined()
74
+ expect(renderToStaticMarkup(<NodeMechanismActions node={still} />)).toBe('')
42
75
 
43
76
  const node = articulated(2)
44
77
  const other = { object: 'node', id: 'other_1', type: 'other' } as unknown as AnyNode
45
- expect(selectedMechanismNode([node.id, other.id], byId(node, other))).toBeUndefined()
46
- expect(selectedMechanismNode([other.id], byId(other))).toBeUndefined()
78
+ expect(soleSelectedNode([node.id, other.id], byId(node, other))).toBeUndefined()
79
+ expect(renderToStaticMarkup(<NodeMechanismActions node={other} />)).toBe('')
47
80
  })
48
81
 
49
82
  test('the button reads Play, then Stop while the mechanism runs', () => {
@@ -61,3 +94,31 @@ describe('mechanism action in the action menu', () => {
61
94
  expect(running).toContain('aria-pressed="true"')
62
95
  })
63
96
  })
97
+
98
+ describe('mechanism parts in the action menu', () => {
99
+ test('a window with shutters gets Open shutters beside Open', () => {
100
+ const markup = renderToStaticMarkup(<NodeMechanismActions node={shuttered()} />)
101
+ expect(markup.match(/aria-label="[^"]*"/g)).toEqual([
102
+ 'aria-label="Open"',
103
+ 'aria-label="Open shutters"',
104
+ ])
105
+ })
106
+
107
+ test('the shutters button opens them alone, then reads Close shutters', () => {
108
+ const node = shuttered()
109
+ let button: ReactElement<{ onClick: (event: MouseEvent) => void }> | undefined
110
+ // Rendering the button as a hook call hands over the element it returns, onClick included.
111
+ function Probe() {
112
+ button = MechanismPartButton({ node, part: shutters, running: false }) as typeof button
113
+ return null
114
+ }
115
+ renderToStaticMarkup(<Probe />)
116
+ button?.props.onClick({ stopPropagation: () => {} } as MouseEvent)
117
+ expect(shutters.isOn(node, useInteractive.getState())).toBe(true)
118
+ expect(switchMechanism.isOn(node, useInteractive.getState())).toBe(false)
119
+
120
+ const open = renderToStaticMarkup(<MechanismPartButton node={node} part={shutters} running />)
121
+ expect(open).toContain('aria-label="Close shutters"')
122
+ expect(open).toContain('aria-pressed="true"')
123
+ })
124
+ })
@@ -4,10 +4,14 @@ import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
6
  type MechanismCapability,
7
+ type MechanismPart,
7
8
  nodeMechanism,
9
+ nodeMechanismParts,
8
10
  nodeRegistry,
9
11
  toggleMechanism,
12
+ toggleMechanismPart,
10
13
  useInteractive,
14
+ usePascalI18n,
11
15
  useScene,
12
16
  } from '@pascal-app/core'
13
17
  import { useViewer } from '@pascal-app/viewer'
@@ -16,6 +20,7 @@ import { type ComponentType, lazy, Suspense } from 'react'
16
20
  import { useShallow } from 'zustand/react/shallow'
17
21
  import { getFloorplanNodeExtension } from '../../lib/floorplan/floorplan-extension'
18
22
  import { ActionMenuButton } from './action-menu-button'
23
+ import { InspectorExtensionActions } from './inspector-extension-actions'
19
24
 
20
25
  type Loader = () => Promise<{ default: ComponentType }>
21
26
  const lazyCache = new WeakMap<Loader, ComponentType>()
@@ -30,13 +35,12 @@ function contribution(kind: string): ComponentType | null {
30
35
  return component
31
36
  }
32
37
 
33
- /** The node the action menu's Play/Stop runs: the only selected node, when its kind declares a mechanism. */
34
- export function selectedMechanismNode(
38
+ /** The node the action menu's mechanism buttons run: the only selected node. */
39
+ export function soleSelectedNode(
35
40
  selectedIds: readonly string[],
36
41
  nodes: Readonly<Record<string, AnyNode>>,
37
42
  ): AnyNode | undefined {
38
- const node = selectedIds.length === 1 ? nodes[selectedIds[0]!] : undefined
39
- return nodeMechanism(node) ? node : undefined
43
+ return selectedIds.length === 1 ? nodes[selectedIds[0]!] : undefined
40
44
  }
41
45
 
42
46
  function MechanismGlyph({
@@ -61,6 +65,34 @@ function MechanismGlyph({
61
65
  return <Glyph className="h-4 w-4" />
62
66
  }
63
67
 
68
+ /** Two louvred leaves: shut across the window, or swung out to its sides. */
69
+ function ShuttersGlyph({ open }: { open: boolean }) {
70
+ return (
71
+ <svg
72
+ aria-hidden="true"
73
+ className="h-4 w-4"
74
+ fill="none"
75
+ stroke="currentColor"
76
+ strokeLinecap="round"
77
+ strokeLinejoin="round"
78
+ strokeWidth={2}
79
+ viewBox="0 0 24 24"
80
+ >
81
+ {open ? (
82
+ <>
83
+ <rect height="16" rx="1" width="8" x="8" y="4" />
84
+ <path d="M8 4 3 6v12l5 2M16 4l5 2v12l-5 2" />
85
+ </>
86
+ ) : (
87
+ <>
88
+ <rect height="18" rx="1" width="16" x="4" y="3" />
89
+ <path d="M12 3v18M7 8h2M7 12h2M7 16h2M15 8h2M15 12h2M15 16h2" />
90
+ </>
91
+ )}
92
+ </svg>
93
+ )
94
+ }
95
+
64
96
  export function MechanismButton({
65
97
  node,
66
98
  mechanism,
@@ -70,7 +102,12 @@ export function MechanismButton({
70
102
  mechanism: MechanismCapability
71
103
  running: boolean
72
104
  }) {
73
- const label = mechanism.icon ? (running ? 'Close' : 'Open') : running ? 'Stop' : 'Play'
105
+ const i18n = usePascalI18n()
106
+ const label = i18n._({
107
+ id: 'editor.mechanismAction',
108
+ message: '{action, select, close {Close} open {Open} stop {Stop} other {Play}}',
109
+ values: { action: mechanism.icon ? (running ? 'close' : 'open') : running ? 'stop' : 'play' },
110
+ })
74
111
  return (
75
112
  <ActionMenuButton
76
113
  keys={['E']}
@@ -86,21 +123,83 @@ export function MechanismButton({
86
123
  )
87
124
  }
88
125
 
89
- /** Play/Stop for a single selected node whose kind declares `capabilities.mechanism`. */
90
- function MechanismAction() {
91
- const selected = useViewer((s) => s.selection.selectedIds)
92
- const node = useScene((s) => selectedMechanismNode(selected, s.nodes))
126
+ /** A second control beside the main one (a window's shutters), with its own running state. */
127
+ export function MechanismPartButton({
128
+ node,
129
+ part,
130
+ running,
131
+ }: {
132
+ node: AnyNode
133
+ part: MechanismPart
134
+ running: boolean
135
+ }) {
136
+ const i18n = usePascalI18n()
137
+ const label =
138
+ part.icon === 'shutters'
139
+ ? i18n._({
140
+ id: 'editor.mechanismPartAction.shutters',
141
+ message: '{action, select, close {Close shutters} other {Open shutters}}',
142
+ values: { action: running ? 'close' : 'open' },
143
+ })
144
+ : i18n._({
145
+ id: 'editor.mechanismAction',
146
+ message: '{action, select, close {Close} open {Open} stop {Stop} other {Play}}',
147
+ values: {
148
+ action: part.verb === 'open' ? (running ? 'close' : 'open') : running ? 'stop' : 'play',
149
+ },
150
+ })
151
+ return (
152
+ <ActionMenuButton
153
+ label={label}
154
+ onClick={(event) => {
155
+ event.stopPropagation()
156
+ toggleMechanismPart(part, node)
157
+ }}
158
+ pressed={running}
159
+ >
160
+ {part.icon === 'shutters' ? (
161
+ <ShuttersGlyph open={!running} />
162
+ ) : (
163
+ <MechanismGlyph icon={undefined} running={running} />
164
+ )}
165
+ </ActionMenuButton>
166
+ )
167
+ }
168
+
169
+ function MechanismPartAction({ node, part }: { node: AnyNode; part: MechanismPart }) {
170
+ const running = useInteractive((s) => part.isOn(node, s))
171
+ return <MechanismPartButton node={node} part={part} running={running} />
172
+ }
173
+
174
+ /**
175
+ * Play/Stop for a kind declaring `capabilities.mechanism`, then one button per
176
+ * part the node has. Parts don't need the main mechanism: a fixed window can
177
+ * still have shutters.
178
+ */
179
+ export function NodeMechanismActions({ node }: { node: AnyNode }) {
93
180
  const mechanism = nodeMechanism(node)
94
- const running = useInteractive((s) => (node && mechanism ? mechanism.isOn(node, s) : false))
95
- return node && mechanism ? (
96
- <MechanismButton mechanism={mechanism} node={node} running={running} />
97
- ) : null
181
+ const running = useInteractive((s) => (mechanism ? mechanism.isOn(node, s) : false))
182
+ return (
183
+ <>
184
+ {mechanism ? <MechanismButton mechanism={mechanism} node={node} running={running} /> : null}
185
+ {nodeMechanismParts(node).map((part) => (
186
+ <MechanismPartAction key={part.id} node={node} part={part} />
187
+ ))}
188
+ </>
189
+ )
190
+ }
191
+
192
+ function MechanismActions() {
193
+ const selected = useViewer((s) => s.selection.selectedIds)
194
+ const node = useScene((s) => soleSelectedNode(selected, s.nodes))
195
+ return node ? <NodeMechanismActions node={node} /> : null
98
196
  }
99
197
 
100
198
  /**
101
199
  * The buttons kinds add to the action menu for selections holding them
102
200
  * (`extensions['pascal:editor/floorplan'].actionMenu.actions`), in 2D and 3D
103
- * alike. Each contribution reads the selection and decides its own visibility.
201
+ * alike, then one per plugin inspector extension of the selected node. Each
202
+ * contribution reads the selection and decides its own visibility.
104
203
  */
105
204
  export function RegistryActionContributions() {
106
205
  const selectedIds = useViewer((s) => s.selection.selectedIds)
@@ -118,7 +217,7 @@ export function RegistryActionContributions() {
118
217
  )
119
218
  return (
120
219
  <>
121
- <MechanismAction />
220
+ <MechanismActions />
122
221
  {kinds.map((kind) => {
123
222
  const Contribution = contribution(kind)
124
223
  return Contribution ? (
@@ -127,6 +226,7 @@ export function RegistryActionContributions() {
127
226
  </Suspense>
128
227
  ) : null
129
228
  })}
229
+ <InspectorExtensionActions />
130
230
  </>
131
231
  )
132
232
  }
@@ -1,9 +1,18 @@
1
1
  'use client'
2
2
 
3
- import { type AnyNodeId, buildRiserDiagram, useScene } from '@pascal-app/core'
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ buildRiserDiagram,
7
+ type PascalI18n,
8
+ type RiserMarker,
9
+ usePascalI18n,
10
+ useScene,
11
+ } from '@pascal-app/core'
4
12
  import { useViewer } from '@pascal-app/viewer'
5
13
  import { X } from 'lucide-react'
6
14
  import { useMemo } from 'react'
15
+ import { inspectorOptionDescriptor } from '../../i18n/node-messages'
7
16
  import useEditor from '../../store/use-editor'
8
17
 
9
18
  // Set per theme on the panel root; the light values are darker so the 9-unit
@@ -33,6 +42,7 @@ export function RiserDiagramPanel() {
33
42
  }
34
43
 
35
44
  function RiserDiagramContent() {
45
+ const i18n = usePascalI18n()
36
46
  const setRiserOpen = useEditor((s) => s.setRiserOpen)
37
47
  const nodes = useScene((s) => s.nodes)
38
48
  const selectedIds = useViewer((s) => s.selection.selectedIds)
@@ -47,15 +57,30 @@ function RiserDiagramContent() {
47
57
  const ty = diagram ? -diagram.bounds.minY * SCALE + PADDING : 0
48
58
 
49
59
  return (
50
- <div className="pointer-events-auto absolute top-4 right-4 z-30 flex max-h-[80vh] w-[26rem] flex-col overflow-hidden rounded-2xl border border-border/40 bg-background/95 text-foreground shadow-lg backdrop-blur-xl [--riser-vent:#9333ea] [--riser-waste:#0369a1] dark:[--riser-vent:#a855f7] dark:[--riser-waste:#0ea5e9]">
60
+ <div
61
+ data-surface="riser-diagram"
62
+ className="pointer-events-auto absolute top-4 right-4 z-30 flex max-h-[80vh] w-[26rem] flex-col overflow-hidden rounded-2xl border border-border/40 bg-background/95 text-foreground shadow-lg backdrop-blur-xl [--riser-vent:#9333ea] [--riser-waste:#0369a1] dark:[--riser-vent:#a855f7] dark:[--riser-waste:#0ea5e9]"
63
+ >
51
64
  <div className="flex items-center justify-between border-border/40 border-b px-4 py-2.5">
52
65
  <div className="flex flex-col">
53
- <span className="font-medium text-sm">Riser Diagram</span>
54
- <span className="text-muted-foreground text-xs">DWV plumbing isometric</span>
66
+ <span className="font-medium text-sm">
67
+ {i18n._({ id: 'editor.riserDiagramPanel.riserDiagram', message: 'Riser Diagram' })}
68
+ </span>
69
+ <span className="text-muted-foreground text-xs">
70
+ {i18n._({
71
+ id: 'editor.riserDiagramPanel.dwvPlumbingIsometric',
72
+ message: 'DWV plumbing isometric',
73
+ })}
74
+ </span>
55
75
  </div>
56
76
  <button
77
+ aria-label={i18n._({
78
+ id: 'editor.riserDiagramPanel.close',
79
+ message: 'Close riser diagram',
80
+ })}
57
81
  className="flex h-7 w-7 items-center justify-center rounded-md transition-colors hover:bg-black/6 dark:hover:bg-white/10"
58
82
  onClick={() => setRiserOpen(false)}
83
+ title={i18n._({ id: 'editor.riserDiagramPanel.close', message: 'Close riser diagram' })}
59
84
  >
60
85
  <X className="h-4 w-4 text-muted-foreground" />
61
86
  </button>
@@ -63,11 +88,12 @@ function RiserDiagramContent() {
63
88
 
64
89
  <div className="flex items-center gap-3 border-border/40 border-b px-4 py-2 text-xs">
65
90
  <span className="flex items-center gap-1.5">
66
- <span className="h-0.5 w-4" style={{ background: WASTE_COLOR }} /> Waste
91
+ <span className="h-0.5 w-4" style={{ background: WASTE_COLOR }} />{' '}
92
+ {i18n._({ id: 'editor.riserDiagramPanel.waste', message: 'Waste' })}
67
93
  </span>
68
94
  <span className="flex items-center gap-1.5">
69
95
  <span className="h-0 w-4 border-t-2 border-dashed" style={{ borderColor: VENT_COLOR }} />{' '}
70
- Vent
96
+ {i18n._({ id: 'editor.riserDiagramPanel.vent', message: 'Vent' })}
71
97
  </span>
72
98
  </div>
73
99
 
@@ -76,7 +102,10 @@ function RiserDiagramContent() {
76
102
  <svg
77
103
  height={Math.max(height, 120)}
78
104
  role="img"
79
- aria-label="DWV riser diagram"
105
+ aria-label={i18n._({
106
+ id: 'editor.riserDiagramPanel.dwvRiserDiagram',
107
+ message: 'DWV riser diagram',
108
+ })}
80
109
  viewBox={`0 0 ${Math.max(width, 200)} ${Math.max(height, 120)}`}
81
110
  width="100%"
82
111
  >
@@ -127,7 +156,7 @@ function RiserDiagramContent() {
127
156
  <circle fill={MARKER_COLOR} r={3} stroke={MARKER_COLOR} strokeWidth={1.5} />
128
157
  )}
129
158
  <text fill={MARKER_COLOR} fontSize={9} x={8} y={3}>
130
- {marker.label}
159
+ {markerLabel(marker, nodes, i18n)}
131
160
  </text>
132
161
  </g>
133
162
  ))}
@@ -135,10 +164,31 @@ function RiserDiagramContent() {
135
164
  </svg>
136
165
  ) : (
137
166
  <div className="flex h-32 items-center justify-center px-6 text-center text-muted-foreground text-sm">
138
- No drain, waste, or vent pipes yet. Draw plumbing to see the riser diagram.
167
+ {i18n._({
168
+ id: 'editor.riserDiagramPanel.noDrainWasteOrVentPipes',
169
+ message:
170
+ 'No drain, waste, or vent pipes yet. Draw plumbing to see the riser diagram.',
171
+ })}
139
172
  </div>
140
173
  )}
141
174
  </div>
142
175
  </div>
143
176
  )
144
177
  }
178
+
179
+ /** Core's marker labels are English ("2\" P-trap"); the panel words them in the UI language. */
180
+ function markerLabel(marker: RiserMarker, nodes: Record<string, AnyNode>, i18n: PascalI18n) {
181
+ const node = nodes[marker.nodeId]
182
+ if (marker.kind === 'fitting' && node?.type === 'pipe-fitting')
183
+ return i18n._(inspectorOptionDescriptor(node.fittingType))
184
+ if (!(node && 'diameter' in node && typeof node.diameter === 'number')) return marker.label
185
+ const values = { diameter: String(node.diameter) }
186
+ return marker.kind === 'trap'
187
+ ? i18n._({ id: 'editor.riserDiagramPanel.trap', message: '{diameter}" P-trap', values })
188
+ : i18n._({
189
+ id: 'editor.riserDiagramPanel.ventTermination',
190
+ message: '{diameter}" VTR',
191
+ comment: 'Vent through roof: where a plumbing vent stack exits the roof.',
192
+ values,
193
+ })
194
+ }
@@ -12,7 +12,6 @@ import {
12
12
  useScene,
13
13
  } from '@pascal-app/core'
14
14
  import { getSceneTheme, setSurfaceRaycastLayers, useViewer } from '@pascal-app/viewer'
15
- import { Html } from '@react-three/drei'
16
15
  import { useFrame, useThree } from '@react-three/fiber'
17
16
  import { SquareSplitHorizontal } from 'lucide-react'
18
17
  import Image from 'next/image'
@@ -69,6 +68,7 @@ import {
69
68
  } from '../tools/shared/draft-measurement-label'
70
69
  import { ActionMenuButton } from './action-menu-button'
71
70
  import { getMenuYOffset } from './floating-action-menu'
71
+ import { Html } from './localized-html'
72
72
  import { NodeActionMenu } from './node-action-menu'
73
73
  import { RoomFloorHighlight3D } from './room-floor-highlight'
74
74
  import { RoomHandleDragPreview3D, RoomHandles3D } from './room-handles'
@@ -1,10 +1,20 @@
1
1
  'use client'
2
2
 
3
- import { type FloorplanGeometry, type Point, roomDrawnFloor, useScene } from '@pascal-app/core'
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ type FloorplanGeometry,
7
+ nodeRegistry,
8
+ type Point,
9
+ roomDrawnFloor,
10
+ useScene,
11
+ } from '@pascal-app/core'
4
12
  import { useViewer } from '@pascal-app/viewer'
5
13
  import { Minus, Plus } from 'lucide-react'
6
14
  import { type PointerEvent as ReactPointerEvent, useMemo, useState } from 'react'
7
- import { useSelectedRoom } from '../../hooks/use-selected-room'
15
+ import { useShallow } from 'zustand/react/shallow'
16
+ import { useRoomRecords, useSelectedRoom } from '../../hooks/use-selected-room'
17
+ import { getFloorplanNodeExtension } from '../../lib/floorplan/floorplan-extension'
8
18
  import { formatLinearMeasurement, MEASUREMENT_FLOORPLAN_COLOR } from '../../lib/measurements'
9
19
  import {
10
20
  commitRoomFloorElevation,
@@ -21,8 +31,13 @@ import {
21
31
  useRoomHandleDrag,
22
32
  type WallPushHandle,
23
33
  } from '../../lib/room-handle-drag'
24
- import type { RoomDimension } from '../../lib/room-push-dimensions'
34
+ import {
35
+ mergeSelectionDimensions,
36
+ type OwnedSelectionDimension,
37
+ type RoomDimension,
38
+ } from '../../lib/room-push-dimensions'
25
39
  import useEditor from '../../store/use-editor'
40
+ import useFloorplanAnnotationVisibility from '../../store/use-floorplan-annotation-visibility'
26
41
  import useInteractionScope from '../../store/use-interaction-scope'
27
42
  import { useFloorplanRender } from '../editor-2d/floorplan-render-context'
28
43
  import { FloorplanGeometryRenderer } from '../editor-2d/renderers/floorplan-geometry-renderer'
@@ -158,9 +173,10 @@ function startArrow(event: ReactPointerEvent<SVGPathElement>, run: () => unknown
158
173
 
159
174
  /**
160
175
  * The selected room's arrows on the plan: one push/pull chevron per boundary
161
- * span (on the far face, along the normal the handle supplies) and, for a
162
- * mezzanine, one per plate edge. Mounted after every node's handles so a
163
- * wall outline never takes the press. The drags are the 3D arrows' own.
176
+ * wall span (on the far face, along the normal the handle supplies), one per
177
+ * straight edge of invisible walls and, for a mezzanine, one per plate edge.
178
+ * Mounted after every node's handles so a wall outline never takes the press.
179
+ * The drags are the 3D arrows' own.
164
180
  */
165
181
  export function RoomHandles2D({ levelId }: { levelId: string | null }) {
166
182
  const room = useRoomControls()
@@ -327,6 +343,13 @@ export function RoomHandleDragPreview2D({ levelId }: { levelId: string | null })
327
343
  const unit = useViewer((s) => s.unit)
328
344
  const metricNotation = useViewer((s) => s.metricNotation)
329
345
  if (!drag || drag.levelId !== levelId || drag.kind === 'elevation') return null
346
+ if (drag.kind === 'wall-group-push')
347
+ return (
348
+ <g data-room-drag-preview-2d pointerEvents="none">
349
+ <PlanRoomDimensions dimensions={drag.dimensions} />
350
+ {drag.message && <PlanReadout at={drag.anchor} refused text={drag.message} />}
351
+ </g>
352
+ )
330
353
  const plan = drag as PlanDrag
331
354
  const at: Point = [
332
355
  plan.anchor[0] + plan.outward[0] * plan.distance,
@@ -352,3 +375,130 @@ export function RoomHandleDragPreview2D({ levelId }: { levelId: string | null })
352
375
  </g>
353
376
  )
354
377
  }
378
+
379
+ function selectionDimensionsOf(node: AnyNode | undefined) {
380
+ return node
381
+ ? getFloorplanNodeExtension(nodeRegistry.get(node.type))?.selectionDimensions
382
+ : undefined
383
+ }
384
+
385
+ const NO_NODES: readonly AnyNode[] = []
386
+
387
+ /**
388
+ * The selected nodes that show their temporary dimensions now: Select mode,
389
+ * nothing in progress, Display → Dimensions on, kinds with `selectionDimensions`
390
+ * only (a wall among other selected things still shows its own), on the first
391
+ * one's level. A new array only when one of them changes.
392
+ */
393
+ export function useSelectionDimensionNodes(): readonly AnyNode[] {
394
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
395
+ const enabled = useEditor((s) => s.mode === 'select')
396
+ const idle = useInteractionScope((s) => s.scope.kind === 'idle')
397
+ const dragging = useRoomHandleDrag((s) => s.drag !== null)
398
+ const shown = useFloorplanAnnotationVisibility((s) => s.showDimensions)
399
+ const active = enabled && idle && !dragging && shown
400
+ return useScene(
401
+ useShallow((s) => {
402
+ if (!active) return NO_NODES
403
+ const found: AnyNode[] = []
404
+ for (const id of selectedIds) {
405
+ const node = s.nodes[id as AnyNodeId]
406
+ if (!node || !selectionDimensionsOf(node)) continue
407
+ if (found.length && node.parentId !== found[0]!.parentId) continue
408
+ found.push(node)
409
+ }
410
+ return found
411
+ }),
412
+ )
413
+ }
414
+
415
+ /**
416
+ * The selected nodes' temporary dimensions (`useSelectionDimensionNodes`),
417
+ * from each kind's `selectionDimensions` (a wall: the room widths its push
418
+ * shows and, for an exterior wall, the outside size, as Display → Measure
419
+ * walls asks). Several selected walls show each one's, a room two of them
420
+ * bound measured once. Shared by the plan and the 3D view; typing into one
421
+ * drives the node it runs from. Recomputed only when a measured node or its
422
+ * level's rooms change (the room index keeps its records while unrelated
423
+ * nodes edit).
424
+ */
425
+ export function useSelectedWallDimensions(): {
426
+ levelId: string
427
+ dimensions: readonly OwnedSelectionDimension[]
428
+ } | null {
429
+ const measured = useSelectionDimensionNodes()
430
+ const faces = useFloorplanAnnotationVisibility((s) => s.wallDimensionFaces)
431
+ const levelId = measured[0]?.parentId ?? null
432
+ const rooms = useRoomRecords(levelId)
433
+ return useMemo(() => {
434
+ // `rooms` is the invalidation key: it changes when the level's room
435
+ // topology, slabs, ceilings or zones do.
436
+ if (!levelId || !rooms) return null
437
+ const nodes = useScene.getState().nodes
438
+ const dimensions = mergeSelectionDimensions(
439
+ measured.map((node) => ({
440
+ ownerId: node.id,
441
+ dimensions: selectionDimensionsOf(node)?.({ node, nodes, faces }) ?? [],
442
+ })),
443
+ )
444
+ return dimensions.length ? { levelId, dimensions } : null
445
+ }, [faces, levelId, measured, rooms])
446
+ }
447
+
448
+ /**
449
+ * The selected walls' temporary dimensions on the plan, drawn as the push
450
+ * drag draws its room widths (indigo line, end dots). Each is a `dimension`
451
+ * owned by the wall it runs from, so a click on its value opens the plan's
452
+ * type-a-value box (`FloorplanDimensionEditOverlay`) and the typed size slides
453
+ * that wall.
454
+ */
455
+ export function SelectedWallDimensions2D({ levelId }: { levelId: string | null }) {
456
+ const selection = useSelectedWallDimensions()
457
+ const context = useFloorplanRender()
458
+ const unit = useViewer((s) => s.unit)
459
+ const metricNotation = useViewer((s) => s.metricNotation)
460
+ const owners = useMemo(() => {
461
+ const byOwner = new Map<string, FloorplanGeometry[]>()
462
+ for (const { from, to, distance, ownerId } of selection?.dimensions ?? []) {
463
+ const length = Math.hypot(to[0] - from[0], to[1] - from[1]) || 1
464
+ const geometry: FloorplanGeometry = {
465
+ kind: 'dimension',
466
+ start: from,
467
+ end: to,
468
+ offsetNormal: [-(to[1] - from[1]) / length, (to[0] - from[0]) / length],
469
+ offsetDistance: 0,
470
+ extensionOvershoot: 0,
471
+ extensionStartGap: 0,
472
+ terminator: 'dot',
473
+ text: formatLinearMeasurement(distance, unit, metricNotation),
474
+ stroke: MEASUREMENT_FLOORPLAN_COLOR,
475
+ }
476
+ const list = byOwner.get(ownerId)
477
+ if (list) list.push(geometry)
478
+ else byOwner.set(ownerId, [geometry])
479
+ }
480
+ return [...byOwner].map(([ownerId, children]) => ({
481
+ ownerId,
482
+ geometry: { kind: 'group', children } satisfies FloorplanGeometry,
483
+ }))
484
+ }, [metricNotation, selection, unit])
485
+ if (!selection || selection.levelId !== levelId) return null
486
+ return (
487
+ <g
488
+ data-selected-wall-dimensions-2d=""
489
+ // A press on a value is the type-a-value box's, not the plan's marquee or pan.
490
+ onPointerDown={(event) => event.stopPropagation()}
491
+ >
492
+ {owners.map(({ ownerId, geometry }) => (
493
+ <g data-node-id={ownerId} key={ownerId}>
494
+ <FloorplanGeometryRenderer
495
+ geometry={geometry}
496
+ pointerEventsOverride="none"
497
+ sceneRotationDeg={context?.sceneRotationDeg ?? 0}
498
+ screenUnitsPerPixel={context?.unitsPerPixel}
499
+ />
500
+ </g>
501
+ ))}
502
+ </g>
503
+ )
504
+ }