@pascal-app/editor 1.0.6 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (382) hide show
  1. package/package.json +18 -15
  2. package/src/components/editor/action-menu-button.tsx +4 -1
  3. package/src/components/editor/alignment-3d-guide-layer.tsx +102 -11
  4. package/src/components/editor/bake-thumbnail.tsx +3 -2
  5. package/src/components/editor/custom-camera-controls.tsx +110 -42
  6. package/src/components/editor/duplicate-node.ts +2 -1
  7. package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
  8. package/src/components/editor/first-person-controls.tsx +77 -68
  9. package/src/components/editor/fit-scene-framing.test.ts +318 -0
  10. package/src/components/editor/fit-scene-framing.ts +142 -0
  11. package/src/components/editor/floating-action-menu.tsx +31 -11
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floor-openings-overlay.tsx +1 -1
  14. package/src/components/editor/floor-region-controls.tsx +2 -2
  15. package/src/components/editor/floorplan-panel.test.ts +220 -0
  16. package/src/components/editor/floorplan-panel.tsx +458 -140
  17. package/src/components/editor/floorplan-wall-typing-keys.test.tsx +152 -0
  18. package/src/components/editor/floorplan-wall-typing-keys.ts +79 -0
  19. package/src/components/editor/footprint-height-handle.tsx +2 -2
  20. package/src/components/editor/grid.tsx +24 -15
  21. package/src/components/editor/group-actions.ts +19 -1
  22. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  23. package/src/components/editor/group-move-3d.ts +27 -2
  24. package/src/components/editor/group-selection-box-3d.tsx +4 -1
  25. package/src/components/editor/group-transform-shared.ts +23 -1
  26. package/src/components/editor/index.tsx +150 -20
  27. package/src/components/editor/inspector-extension-actions.tsx +36 -0
  28. package/src/components/editor/localized-html.tsx +22 -0
  29. package/src/components/editor/measurement-pill.tsx +1 -1
  30. package/src/components/editor/node-action-menu.tsx +46 -12
  31. package/src/components/editor/node-arrow-handles.tsx +2 -3
  32. package/src/components/editor/open-wall-ends-3d-layer.tsx +2 -3
  33. package/src/components/editor/open-wall-ends-hint.tsx +1 -1
  34. package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
  35. package/src/components/editor/quick-measurement-card.tsx +1 -1
  36. package/src/components/editor/registry-action-contributions.test.tsx +67 -6
  37. package/src/components/editor/registry-action-contributions.tsx +115 -15
  38. package/src/components/editor/riser-diagram-panel.tsx +59 -9
  39. package/src/components/editor/room-controls.tsx +4 -3
  40. package/src/components/editor/room-handles-2d.tsx +158 -7
  41. package/src/components/editor/room-handles.tsx +244 -54
  42. package/src/components/editor/room-highlight.tsx +2 -0
  43. package/src/components/editor/room-labels.tsx +33 -17
  44. package/src/components/editor/selection-manager.tsx +15 -102
  45. package/src/components/editor/separator-end-grip.tsx +74 -0
  46. package/src/components/editor/site-edge-labels.tsx +7 -3
  47. package/src/components/editor/thumbnail-generator.test.ts +15 -6
  48. package/src/components/editor/thumbnail-generator.tsx +26 -1
  49. package/src/components/editor/use-floorplan-background-placement.ts +55 -52
  50. package/src/components/editor/views/view-bar.tsx +111 -26
  51. package/src/components/editor/wall-measurement-label.tsx +31 -15
  52. package/src/components/editor/wall-move-side-handles.tsx +15 -7
  53. package/src/components/editor/wall-pull-grip.tsx +193 -0
  54. package/src/components/editor/wall-region-handles.tsx +2 -2
  55. package/src/components/editor/wall-snap-beacon-layer.tsx +134 -33
  56. package/src/components/editor-2d/floorplan-adjacent-walls-layer.test.ts +76 -0
  57. package/src/components/editor-2d/floorplan-adjacent-walls-layer.tsx +186 -0
  58. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +63 -3
  59. package/src/components/editor-2d/floorplan-group-move.tsx +32 -4
  60. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +6 -0
  61. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +33 -24
  62. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +12 -5
  63. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +2 -1
  64. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +29 -5
  65. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +96 -4
  66. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +52 -12
  67. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +7 -1
  68. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +13 -1
  69. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.test.tsx +35 -0
  70. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.tsx +65 -0
  71. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +34 -1
  72. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +211 -39
  73. package/src/components/procedural-items/inspector.tsx +59 -12
  74. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +2 -0
  75. package/src/components/systems/ceiling/ceiling-system.test.ts +141 -0
  76. package/src/components/systems/ceiling/ceiling-system.tsx +53 -18
  77. package/src/components/systems/roof/roof-edit-system.test.tsx +15 -1
  78. package/src/components/systems/roof/roof-edit-system.tsx +30 -19
  79. package/src/components/tools/distribution/distribution-run-tool.test.tsx +371 -3
  80. package/src/components/tools/distribution/distribution-run-tool.tsx +200 -58
  81. package/src/components/tools/item/move-tool.tsx +1 -6
  82. package/src/components/tools/item/placement-history.test.tsx +1 -1
  83. package/src/components/tools/item/placement-strategies.test.ts +64 -1
  84. package/src/components/tools/item/placement-strategies.ts +5 -9
  85. package/src/components/tools/item/use-placement-coordinator.tsx +31 -35
  86. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +1 -1
  87. package/src/components/tools/registry/item-surface-move.test.ts +11 -0
  88. package/src/components/tools/registry/move-registry-node-tool.test.ts +42 -1
  89. package/src/components/tools/registry/move-registry-node-tool.tsx +38 -14
  90. package/src/components/tools/select/select-candidates.test.ts +45 -0
  91. package/src/components/tools/select/select-candidates.ts +13 -2
  92. package/src/components/tools/shared/cursor-sphere.tsx +10 -3
  93. package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
  94. package/src/components/tools/shared/placement-box.tsx +2 -6
  95. package/src/components/tools/shared/placement-coordinate-input.tsx +94 -0
  96. package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
  97. package/src/components/tools/shared/segment-angle.ts +0 -4
  98. package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
  99. package/src/components/tools/tool-manager.tsx +1 -24
  100. package/src/components/tools/wall/wall-drafting.test.ts +299 -23
  101. package/src/components/tools/wall/wall-drafting.ts +132 -24
  102. package/src/components/tools/wall/wall-pull-snap.test.ts +274 -0
  103. package/src/components/tools/wall/wall-pull-snap.ts +563 -0
  104. package/src/components/tools/wall/wall-snap-geometry.test.ts +113 -1
  105. package/src/components/tools/wall/wall-snap-geometry.ts +137 -0
  106. package/src/components/tools/wall/wall-typed-entry.test.ts +99 -0
  107. package/src/components/tools/wall/wall-typed-entry.ts +70 -0
  108. package/src/components/tools/zone/zone-tool.tsx +3 -3
  109. package/src/components/ui/action-menu/action-button.tsx +2 -0
  110. package/src/components/ui/action-menu/camera-actions.tsx +8 -7
  111. package/src/components/ui/action-menu/control-modes.tsx +9 -5
  112. package/src/components/ui/action-menu/follow-pascal-pill.tsx +3 -1
  113. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +7 -1
  114. package/src/components/ui/action-menu/furnish-tools.tsx +7 -6
  115. package/src/components/ui/action-menu/index.tsx +1 -0
  116. package/src/components/ui/action-menu/measurement-control.tsx +35 -29
  117. package/src/components/ui/action-menu/point-ask-button.tsx +4 -3
  118. package/src/components/ui/action-menu/structure-tools.tsx +25 -23
  119. package/src/components/ui/action-menu/view-toggles.test.ts +18 -0
  120. package/src/components/ui/action-menu/view-toggles.tsx +256 -51
  121. package/src/components/ui/build-panel/build-panel.tsx +163 -25
  122. package/src/components/ui/command-palette/editor-commands.tsx +38 -11
  123. package/src/components/ui/controls/finish-swatch.tsx +45 -19
  124. package/src/components/ui/controls/material-picker.tsx +24 -17
  125. package/src/components/ui/controls/metric-control.test.ts +52 -0
  126. package/src/components/ui/controls/metric-control.tsx +62 -24
  127. package/src/components/ui/controls/segmented-control.tsx +5 -2
  128. package/src/components/ui/controls/slider-control.test.tsx +23 -0
  129. package/src/components/ui/controls/slider-control.tsx +47 -19
  130. package/src/components/ui/controls/tool-options-panel.tsx +74 -44
  131. package/src/components/ui/floating-level-selector.tsx +100 -48
  132. package/src/components/ui/helpers/building-helper.tsx +5 -3
  133. package/src/components/ui/helpers/contextual-helper-panel.tsx +97 -37
  134. package/src/components/ui/helpers/helper-manager.test.tsx +106 -3
  135. package/src/components/ui/helpers/helper-manager.tsx +136 -57
  136. package/src/components/ui/helpers/item-helper.tsx +6 -4
  137. package/src/components/ui/helpers/registered-tool-helper.tsx +3 -1
  138. package/src/components/ui/level-duplicate-dialog.tsx +32 -23
  139. package/src/components/ui/level-wall-copy-prompt.tsx +124 -0
  140. package/src/components/ui/panels/collections/collections-popover.tsx +11 -11
  141. package/src/components/ui/panels/deck-boards-fields.tsx +136 -0
  142. package/src/components/ui/panels/floor-foundation-panel.tsx +114 -39
  143. package/src/components/ui/panels/floor-opening-panel.tsx +74 -26
  144. package/src/components/ui/panels/inspector-extensions.tsx +66 -0
  145. package/src/components/ui/panels/mobile-panel-sheet.tsx +3 -1
  146. package/src/components/ui/panels/mobile-selection-bar.tsx +34 -28
  147. package/src/components/ui/panels/multi-field-value.test.ts +27 -0
  148. package/src/components/ui/panels/multi-field-value.ts +20 -1
  149. package/src/components/ui/panels/multi-height-mode.tsx +11 -10
  150. package/src/components/ui/panels/multi-parametric-inspector.tsx +122 -34
  151. package/src/components/ui/panels/multi-selection-panel.tsx +76 -36
  152. package/src/components/ui/panels/node-display.ts +35 -24
  153. package/src/components/ui/panels/panel-manager.tsx +9 -5
  154. package/src/components/ui/panels/panel-wrapper.tsx +63 -127
  155. package/src/components/ui/panels/parametric-field-control.tsx +13 -10
  156. package/src/components/ui/panels/parametric-inspector.tsx +18 -12
  157. package/src/components/ui/panels/reference-panel.tsx +51 -35
  158. package/src/components/ui/panels/room-construction-rows.tsx +465 -66
  159. package/src/components/ui/panels/room-name-options.test.ts +36 -3
  160. package/src/components/ui/panels/room-name-options.ts +110 -11
  161. package/src/components/ui/panels/room-openings-field.tsx +22 -13
  162. package/src/components/ui/panels/room-panel.tsx +207 -100
  163. package/src/components/ui/panels/shape-choice.tsx +7 -5
  164. package/src/components/ui/panels/wall-region-list.tsx +7 -7
  165. package/src/components/ui/primitives/shortcut-token.tsx +10 -6
  166. package/src/components/ui/right-stack.test.ts +13 -6
  167. package/src/components/ui/right-stack.tsx +21 -10
  168. package/src/components/ui/sidebar/icon-rail.tsx +11 -9
  169. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +10 -10
  170. package/src/components/ui/sidebar/panels/items-panel/index.tsx +10 -9
  171. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +3 -0
  172. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +102 -32
  173. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +1 -5
  174. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +4 -3
  175. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -3
  176. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +4 -2
  177. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +7 -5
  178. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +4 -2
  179. package/src/components/ui/sidebar/panels/site-panel/default-names.ts +75 -0
  180. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +4 -2
  181. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +4 -2
  182. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +4 -2
  183. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +4 -2
  184. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +4 -2
  185. package/src/components/ui/sidebar/panels/site-panel/index.tsx +118 -72
  186. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +36 -3
  187. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +4 -2
  188. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -4
  189. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +8 -2
  190. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +8 -5
  191. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +4 -2
  192. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -4
  193. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +4 -2
  194. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +4 -2
  195. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +8 -7
  196. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +7 -6
  197. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +16 -1
  198. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +15 -8
  199. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +4 -2
  200. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +4 -2
  201. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +3 -1
  202. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +19 -9
  203. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +25 -14
  204. package/src/components/ui/sidebar/tab-bar.tsx +4 -1
  205. package/src/components/ui/sidebar/use-plugin-panels.tsx +19 -10
  206. package/src/components/ui/snap-target-badge.tsx +7 -6
  207. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +721 -0
  208. package/src/components/ui/wall-type/wall-type-menu.tsx +167 -0
  209. package/src/components/viewer/floorplan-compass-button.tsx +12 -3
  210. package/src/components/viewer/floorplan-preview.tsx +27 -19
  211. package/src/components/viewer/render-settings-menu.tsx +294 -0
  212. package/src/components/viewer/sky-control.tsx +710 -0
  213. package/src/components/viewer/use-viewer-ui-sounds.test.ts +15 -0
  214. package/src/components/viewer/use-viewer-ui-sounds.ts +46 -0
  215. package/src/components/viewer/viewer-controls-bar.tsx +278 -260
  216. package/src/components/viewer/viewer-hover-label.tsx +12 -4
  217. package/src/components/viewer/viewer-rooms.tsx +62 -5
  218. package/src/components/viewer/viewer-scene-header.tsx +28 -17
  219. package/src/components/viewer/viewer-stage-switcher.tsx +4 -2
  220. package/src/components/viewer-overlay.tsx +8 -0
  221. package/src/components/walkthrough-hud.tsx +75 -2
  222. package/src/hooks/use-keyboard.test.ts +85 -4
  223. package/src/hooks/use-keyboard.ts +65 -7
  224. package/src/hooks/use-selected-room.ts +12 -1
  225. package/src/i18n/floor-footprint-name.ts +30 -0
  226. package/src/i18n/level-name.ts +39 -0
  227. package/src/i18n/locales/en/messages.po +15849 -0
  228. package/src/i18n/locales/en/messages.ts +1 -0
  229. package/src/i18n/locales/en-XA/messages.ts +1 -0
  230. package/src/i18n/locales/es/messages.po +39331 -0
  231. package/src/i18n/locales/es/messages.ts +1 -0
  232. package/src/i18n/locales/fr/messages.po +39331 -0
  233. package/src/i18n/locales/fr/messages.ts +1 -0
  234. package/src/i18n/locales/ja/messages.po +39331 -0
  235. package/src/i18n/locales/ja/messages.ts +1 -0
  236. package/src/i18n/locales/zh-CN/messages.po +39331 -0
  237. package/src/i18n/locales/zh-CN/messages.ts +1 -0
  238. package/src/i18n/locales.test.ts +63 -0
  239. package/src/i18n/locales.ts +32 -0
  240. package/src/i18n/message-slot.tsx +25 -0
  241. package/src/i18n/node-messages.ts +169 -0
  242. package/src/i18n/pseudo-locale.ts +17 -0
  243. package/src/i18n/published-entry.test.ts +17 -0
  244. package/src/i18n/scene-theme-name.ts +16 -0
  245. package/src/index.tsx +224 -45
  246. package/src/lib/add-storey.test.ts +3 -3
  247. package/src/lib/contextual-help.test.ts +20 -1
  248. package/src/lib/contextual-help.ts +101 -19
  249. package/src/lib/continuation.ts +38 -11
  250. package/src/lib/elevation-guides.ts +21 -6
  251. package/src/lib/elevator-support.ts +1 -72
  252. package/src/lib/floor-footprints.test.ts +3 -2
  253. package/src/lib/floor-footprints.ts +1 -9
  254. package/src/lib/floor-foundation-support.test.ts +15 -6
  255. package/src/lib/floor-opening-draft.ts +1 -1
  256. package/src/lib/floor-region-session.ts +1 -1
  257. package/src/lib/floorplan/dimension-drive.test.ts +36 -2
  258. package/src/lib/floorplan/dimension-drive.ts +68 -3
  259. package/src/lib/floorplan/floorplan-export.test.ts +16 -5
  260. package/src/lib/floorplan/floorplan-export.tsx +13 -16
  261. package/src/lib/floorplan/floorplan-extension.test.ts +11 -12
  262. package/src/lib/floorplan/floorplan-extension.ts +37 -18
  263. package/src/lib/floorplan/floorplan-mode.test.ts +13 -6
  264. package/src/lib/floorplan/floorplan-mode.ts +14 -12
  265. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +1 -1
  266. package/src/lib/floorplan/floorplan-readonly.ts +3 -3
  267. package/src/lib/floorplan/open-wall-ends.ts +2 -1
  268. package/src/lib/floorplan/schedules.ts +10 -6
  269. package/src/lib/floorplan/site-plan/site-annotations.test.ts +46 -1
  270. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -0
  271. package/src/lib/floorplan/site-plan/site-parts.ts +6 -3
  272. package/src/lib/geometry-script/author.test.ts +114 -0
  273. package/src/lib/geometry-script/author.ts +55 -23
  274. package/src/lib/geometry-script/client.ts +3 -0
  275. package/src/lib/geometry-script/finish-scripts.ts +7 -7
  276. package/src/lib/gesture-lifecycle.ts +3 -1
  277. package/src/lib/glb-export.test.ts +327 -0
  278. package/src/lib/glb-export.ts +70 -2
  279. package/src/lib/grid-frame.test.ts +56 -3
  280. package/src/lib/grid-frame.ts +28 -1
  281. package/src/lib/history.test.ts +2 -1
  282. package/src/lib/hud-title.ts +48 -24
  283. package/src/lib/ifc-export.test.ts +41 -1
  284. package/src/lib/inspector-card-mode.test.ts +3 -31
  285. package/src/lib/inspector-card-mode.ts +5 -10
  286. package/src/lib/inspector-expanded.test.ts +13 -0
  287. package/src/lib/inspector-expanded.ts +13 -1
  288. package/src/lib/interaction/scope.ts +1 -0
  289. package/src/lib/keyboard-pan.test.ts +7 -0
  290. package/src/lib/keyboard-pan.ts +9 -2
  291. package/src/lib/layer-thickness.test.ts +22 -0
  292. package/src/lib/layer-thickness.ts +79 -0
  293. package/src/lib/level-selection.test.ts +18 -2
  294. package/src/lib/level-selection.ts +31 -8
  295. package/src/lib/linear-display.ts +1 -1
  296. package/src/lib/live-gesture-round-trip.test.ts +1 -1
  297. package/src/lib/lot/drop-in.test.ts +59 -1
  298. package/src/lib/lot/drop-in.ts +42 -50
  299. package/src/lib/material-swatch-style.ts +24 -0
  300. package/src/lib/measurement-label.test.ts +1 -1
  301. package/src/lib/measurement-parser.ts +7 -3
  302. package/src/lib/measurements.test.ts +24 -6
  303. package/src/lib/measurements.ts +1 -203
  304. package/src/lib/mezzanine-selection.test.ts +3 -1
  305. package/src/lib/node-action-movement.test.tsx +115 -0
  306. package/src/lib/node-action-movement.ts +2 -0
  307. package/src/lib/orbit-near-plane.test.ts +12 -0
  308. package/src/lib/orbit-near-plane.ts +13 -0
  309. package/src/lib/paint-regions.test.ts +8 -5
  310. package/src/lib/paint-scope.test.ts +53 -0
  311. package/src/lib/paint-scope.ts +18 -4
  312. package/src/lib/panel-rows.ts +17 -8
  313. package/src/lib/placement-metadata.ts +4 -0
  314. package/src/lib/point-ask/describe-target.ts +6 -8
  315. package/src/lib/point-ask/region.ts +1 -1
  316. package/src/lib/print-shell-compiler.ts +12 -1
  317. package/src/lib/print-wall-solids.test.ts +11 -0
  318. package/src/lib/print-wall-solids.ts +9 -0
  319. package/src/lib/reference-scale.ts +83 -0
  320. package/src/lib/room-assembly-overlay.ts +1 -1
  321. package/src/lib/room-built-on.test.ts +28 -17
  322. package/src/lib/room-built-on.ts +21 -17
  323. package/src/lib/room-construction-commands.ts +3 -2
  324. package/src/lib/room-construction.test.ts +28 -1
  325. package/src/lib/room-construction.ts +14 -0
  326. package/src/lib/room-controls-phase6.test.ts +141 -1
  327. package/src/lib/room-handle-drag.ts +554 -58
  328. package/src/lib/room-name-catalog.test.ts +8 -0
  329. package/src/lib/room-name-catalog.ts +8 -1
  330. package/src/lib/room-name-messages.test.ts +132 -0
  331. package/src/lib/room-name-messages.ts +147 -0
  332. package/src/lib/room-name-storage.test.ts +334 -0
  333. package/src/lib/room-push-dimensions.test.ts +38 -1
  334. package/src/lib/room-push-dimensions.ts +226 -2
  335. package/src/lib/room-selection.test.ts +23 -6
  336. package/src/lib/scene-bounds.test.ts +144 -0
  337. package/src/lib/scene-bounds.ts +79 -15
  338. package/src/lib/scene-clipboard.test.ts +70 -49
  339. package/src/lib/scene-clipboard.ts +11 -5
  340. package/src/lib/selection-cycle.ts +63 -0
  341. package/src/lib/separator-edit.test.ts +431 -0
  342. package/src/lib/separator-edit.ts +179 -0
  343. package/src/lib/separator-lines.ts +38 -0
  344. package/src/lib/sfx-bus.ts +27 -1
  345. package/src/lib/sfx-player.ts +16 -0
  346. package/src/lib/snapping-mode.test.ts +23 -1
  347. package/src/lib/tool-cursor-icon.ts +5 -1
  348. package/src/lib/tool-shortcuts.ts +130 -0
  349. package/src/lib/viewer-selection.test.ts +28 -24
  350. package/src/lib/viewer-selection.ts +21 -6
  351. package/src/lib/wall-draw-variant.test.ts +7 -6
  352. package/src/lib/wall-draw-variant.ts +107 -6
  353. package/src/lib/wall-nudge.test.ts +44 -0
  354. package/src/lib/wall-nudge.ts +131 -0
  355. package/src/lib/wall-parallel-snap.test.ts +83 -0
  356. package/src/lib/wall-parallel-snap.ts +176 -0
  357. package/src/lib/wall-placement-pointer.test.ts +23 -0
  358. package/src/lib/wall-placement-pointer.ts +18 -0
  359. package/src/lib/wall-pull.test.ts +245 -0
  360. package/src/lib/wall-pull.ts +209 -0
  361. package/src/lib/wall-push-line-up.test.ts +132 -0
  362. package/src/lib/wall-region-list.ts +17 -6
  363. package/src/lib/wall-run-snap.test.ts +188 -0
  364. package/src/lib/wall-run-snap.ts +83 -0
  365. package/src/lib/wall-type.test.ts +125 -0
  366. package/src/lib/wall-type.ts +119 -0
  367. package/src/lib/window-interaction.test.ts +44 -0
  368. package/src/lib/world-grid-snap.test.ts +139 -2
  369. package/src/lib/world-grid-snap.ts +102 -105
  370. package/src/store/use-alignment-guides.ts +27 -1
  371. package/src/store/use-editor.tsx +53 -0
  372. package/src/store/use-floorplan-annotation-visibility.test.ts +64 -0
  373. package/src/store/use-floorplan-annotation-visibility.ts +47 -22
  374. package/src/store/use-placement-typing.test.ts +165 -0
  375. package/src/store/use-placement-typing.ts +186 -0
  376. package/src/store/use-wall-snap-indicator.ts +15 -2
  377. package/src/components/tools/elevator/elevator-defaults.ts +0 -8
  378. package/src/components/tools/elevator/elevator-tool.tsx +0 -372
  379. package/src/components/tools/elevator/move-elevator-tool.tsx +0 -262
  380. package/src/lib/door-interaction.ts +0 -102
  381. package/src/lib/measurement-label.ts +0 -68
  382. package/src/lib/window-interaction.ts +0 -100
@@ -0,0 +1,12 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { CLOSE_UP_NEAR, ORBIT_NEAR, orbitNearPlane } from './orbit-near-plane'
3
+
4
+ describe('the orbit near plane follows the eye in', () => {
5
+ test("standing back it is the viewer's own; close in, a tenth of the distance; never under an inch-off look", () => {
6
+ expect(orbitNearPlane(40)).toBe(ORBIT_NEAR)
7
+ expect(orbitNearPlane(3)).toBe(ORBIT_NEAR)
8
+ expect(orbitNearPlane(1)).toBeCloseTo(0.1, 9)
9
+ expect(orbitNearPlane(0.5)).toBeCloseTo(0.05, 9)
10
+ expect(orbitNearPlane(0.01)).toBe(CLOSE_UP_NEAR)
11
+ })
12
+ })
@@ -0,0 +1,13 @@
1
+ /** The viewer's near plane (`viewerCameraClipping`), kept while the eye stands back. */
2
+ export const ORBIT_NEAR = 0.3
3
+ /** How close the near plane comes in when the eye does: a casing seen from an inch off. */
4
+ export const CLOSE_UP_NEAR = 0.01
5
+
6
+ /**
7
+ * The near plane for an orbit `distance` from its target: a tenth of it, between `CLOSE_UP_NEAR`
8
+ * and the viewer's own, so zooming in on a window's trim never clips it while the eye standing
9
+ * back keeps the depth precision it had.
10
+ */
11
+ export function orbitNearPlane(distance: number): number {
12
+ return Math.min(ORBIT_NEAR, Math.max(CLOSE_UP_NEAR, distance * 0.1))
13
+ }
@@ -136,10 +136,11 @@ describe('paint region sub-mode', () => {
136
136
  })
137
137
 
138
138
  test('off anything it draws on, the HUD says where to go: a floor for Polygon', () => {
139
- expect(paintRegionHints('polygon', null, false).map((hint) => hint.label)).toEqual([
140
- 'Hover a floor or ceiling',
141
- 'Cancel',
142
- ])
139
+ expect(
140
+ paintRegionHints('polygon', null, false).map((hint) =>
141
+ typeof hint.label === 'string' ? hint.label : hint.label.message,
142
+ ),
143
+ ).toEqual(['Hover a floor or ceiling', 'Cancel'])
143
144
  expect(paintRegionHints('rectangle', null, false)[0]).toMatchObject({
144
145
  label: 'Hover a wall, floor or ceiling',
145
146
  active: true,
@@ -158,7 +159,9 @@ describe('paint region sub-mode', () => {
158
159
 
159
160
  test('the whole-surface HUD says what a click does; only painting teaches the held eyedropper', () => {
160
161
  const labels = (mode: Parameters<typeof paintHints>[0]) =>
161
- paintHints(mode).map((hint) => hint.label)
162
+ paintHints(mode).map((hint) =>
163
+ typeof hint.label === 'string' ? hint.label : (hint.label.message ?? ''),
164
+ )
162
165
  expect(labels('surface')).toEqual(['Hold to pick a material'])
163
166
  expect(labels('erase')).toEqual(['Remove a painted part, or reset a surface'])
164
167
  expect(labels('erase').some((label) => /pick|colour|color|paint with/i.test(label))).toBe(false)
@@ -790,3 +790,56 @@ describe('generated floor plates: scopes per surface', () => {
790
790
  expect(paintScopeRole(manual, 'surface', 'room')).toBe('surface')
791
791
  })
792
792
  })
793
+
794
+ describe('a surface that declares its own scopes', () => {
795
+ it('offers its scopes and labels and paints the role it names for the scope', () => {
796
+ const kind = 'paint-surface-scope-test'
797
+ if (!nodeRegistry.has(kind))
798
+ registerNode({
799
+ kind,
800
+ schemaVersion: 1,
801
+ schema: z.object({ type: z.literal(kind) }) as never,
802
+ category: 'structure',
803
+ defaults: () => ({}),
804
+ capabilities: {
805
+ slots: () => [{ slotId: 'end:1', label: 'End' }],
806
+ paint: {
807
+ resolveRole: () => null,
808
+ buildPatch: () => ({}),
809
+ applyPreview: () => null,
810
+ roleLabel: (_node: AnyNode, role: string) => (role === 'end:1' ? 'This end' : null),
811
+ scopes: (_node: AnyNode, role: string) =>
812
+ role === 'end:1'
813
+ ? {
814
+ scopes: ['single', 'matching'],
815
+ labels: { matching: 'Every end' },
816
+ roleFor: (scope: PaintScope) => (scope === 'matching' ? 'ends:all' : role),
817
+ }
818
+ : null,
819
+ },
820
+ },
821
+ floorplanScope: 'level',
822
+ renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
823
+ } as unknown as AnyNodeDefinition)
824
+ const node = { id: 'scoped_1', type: kind } as unknown as AnyNode
825
+ const hover = paintHoverInfo(node, 'end:1', [])
826
+ expect(hover.scopes).toEqual(['single', 'matching'])
827
+ expect(paintScopeLabel('single', hover)).toBe('This end')
828
+ expect(paintScopeLabel('matching', hover)).toBe('Every end')
829
+ const targets = (scope: PaintScope) =>
830
+ resolvePaintScopeTargets({
831
+ node,
832
+ role: 'end:1',
833
+ scope,
834
+ nodes: { scoped_1: node },
835
+ spaces: {},
836
+ slotRolesOf: () => [],
837
+ }).map((target) => `${target.nodeId}:${target.role}`)
838
+ expect(targets('single')).toEqual(['scoped_1:end:1'])
839
+ expect(targets('matching')).toEqual(['scoped_1:ends:all'])
840
+ // a scope the surface does not offer paints the surface alone
841
+ expect(targets('room')).toEqual(['scoped_1:end:1'])
842
+ // other roles of the kind keep the derived set
843
+ expect(availablePaintScopes({ node, slotRoles: [], role: 'side' })).toEqual(['single'])
844
+ })
845
+ })
@@ -55,6 +55,11 @@ function nodeOffersRoomScope(node: AnyNode): boolean {
55
55
  return nodeRegistry.get(node.type)?.capabilities?.paint?.roomScope === true
56
56
  }
57
57
 
58
+ /** The scopes the hovered surface declares for itself (`capabilities.paint.scopes`), if any. */
59
+ function surfaceScopes(node: AnyNode, role: string) {
60
+ return nodeRegistry.get(node.type)?.capabilities?.paint?.scopes?.(node, role) ?? null
61
+ }
62
+
58
63
  const isFloorPlate = (node: AnyNode): boolean => node.type === 'slab' && node.boundary === 'auto'
59
64
 
60
65
  const FOOTPRINT_FACE_LABELS: Record<string, string> = {
@@ -104,6 +109,8 @@ export function availablePaintScopes(args: {
104
109
  if (args.node.type === 'wall') return ['single', 'room']
105
110
  const plate = args.role === undefined ? null : platePaintScopes(args.node, args.role)
106
111
  if (plate) return plate.scopes
112
+ const own = args.role === undefined ? null : surfaceScopes(args.node, args.role)
113
+ if (own) return own.scopes
107
114
  const scopes: PaintScope[] = ['single']
108
115
  if (args.slotRoles.length > 1) scopes.push('object')
109
116
  if (nodeHasAsset(args.node)) scopes.push('matching')
@@ -123,7 +130,7 @@ export function paintHoverInfo(node: AnyNode, role: string, slotRoles: string[])
123
130
  scopes: availablePaintScopes({ node, slotRoles, role }),
124
131
  slotLabel: paintSurfaceLabel(node, role),
125
132
  nodeNoun: node.type,
126
- labels: platePaintScopes(node, role)?.labels,
133
+ labels: platePaintScopes(node, role)?.labels ?? surfaceScopes(node, role)?.labels,
127
134
  }
128
135
  }
129
136
 
@@ -168,12 +175,13 @@ export function paintSurfaceLabel(node: AnyNode, role: string): string {
168
175
  return slotDisplayLabel(node, role)
169
176
  }
170
177
 
171
- /** Display label for the hovered slot — declared label wins, else derived from the id. */
178
+ /** Display label for the hovered slot, with a capability override for contextual copy. */
172
179
  export function slotDisplayLabel(node: AnyNode, role: string): string {
173
180
  const capabilities = nodeRegistry.get(node.type)?.capabilities
181
+ const roleLabel = capabilities?.paint?.roleLabel?.(node, role)
182
+ if (roleLabel) return roleLabel
174
183
  const declared = capabilities?.slots?.(node)?.find((slot) => slot.slotId === role)
175
- if (declared) return declared.label
176
- return capabilities?.paint?.roleLabel?.(node, role) ?? slotLabelFromId(role)
184
+ return declared?.label ?? slotLabelFromId(role)
177
185
  }
178
186
 
179
187
  // ── Fan-out resolution ──────────────────────────────────────────────────────
@@ -506,6 +514,12 @@ export function resolvePaintScopeTargets(args: {
506
514
  .map((other) => ({ nodeId: other.id, role: stepRole }))
507
515
  return targets.length ? targets : [{ nodeId: node.id as AnyNodeId, role: stepRole }]
508
516
  }
517
+ // A surface with its own scope set paints the role it names for the scope.
518
+ const own = surfaceScopes(node, role)
519
+ if (own) {
520
+ const roleFor = own.roleFor?.(effectivePaintScope(scope, own.scopes)) ?? role
521
+ return [{ nodeId: node.id as AnyNodeId, role: roleFor }]
522
+ }
509
523
  if (scope === 'single') return single
510
524
 
511
525
  // Whole object: paint every slot of the clicked node. Generic across any
@@ -65,19 +65,28 @@ export const PANEL_MODEL_EXTENSION = 'pascal:editor/panel-model'
65
65
  /**
66
66
  * A control the multi-selection inspector renders for every selected node of
67
67
  * a kind at once (derived settings the raw parametrics can't express). `value`
68
- * null means the selection is mixed: no option shows as active.
68
+ * null means the selection is mixed: no option shows as active. `segmented`
69
+ * suits a few short options, `select` a longer list; `readout` only shows a
70
+ * value the selection cannot edit here.
69
71
  */
70
72
  export type MultiPanelControl = {
71
73
  id: string
72
- kind: 'segmented'
73
- /** The parametrics group title the control sits in. */
74
+ /**
75
+ * The section the control sits in: a parametrics group title, or a section
76
+ * of its own, shown after the groups in the order controls name them.
77
+ */
74
78
  section: string
75
79
  label: string
76
- options: ReadonlyArray<{ value: string; label: string }>
77
- value: string | null
78
- /** Applies to every selected node in one undo step. */
79
- onChange: (value: string) => void
80
- }
80
+ } & (
81
+ | {
82
+ kind: 'segmented' | 'select'
83
+ options: ReadonlyArray<{ value: string; label: string }>
84
+ value: string | null
85
+ /** Applies to every selected node in one undo step. */
86
+ onChange: (value: string) => void
87
+ }
88
+ | { kind: 'readout'; value: string; detail?: string }
89
+ )
81
90
 
82
91
  export type NodePanelModel<N extends AnyNode = AnyNode> = {
83
92
  rows: (context: {
@@ -17,6 +17,10 @@ export function isFreshPlacementMetadata(metadata: unknown): boolean {
17
17
  return getPlacementMetadataRecord(metadata).isNew === true
18
18
  }
19
19
 
20
+ export function stripPlacementMetadataFlags(
21
+ metadata: Record<string, unknown>,
22
+ ): Record<string, unknown>
23
+ export function stripPlacementMetadataFlags(metadata: unknown): unknown
20
24
  export function stripPlacementMetadataFlags(metadata: unknown): unknown {
21
25
  if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
22
26
  return metadata
@@ -1,25 +1,23 @@
1
1
  import {
2
2
  type AnyNode,
3
3
  area,
4
+ formatLinearMeasurement,
5
+ getAreaUnitLabel,
4
6
  getScaledDimensions,
5
7
  getWallCurveLength,
6
8
  getWallEffectiveHeightForNodes,
7
9
  getWallThickness,
8
10
  type ItemNode,
11
+ type LinearUnit,
12
+ type MetricNotation,
9
13
  resolveLevelId,
10
14
  type SlabNode,
11
15
  type StairNode,
16
+ squareMetersToAreaUnit,
12
17
  type WallNode,
13
18
  type WindowNode,
14
19
  type ZoneNode,
15
20
  } from '@pascal-app/core'
16
- import {
17
- formatLinearMeasurement,
18
- getAreaUnitLabel,
19
- type LinearUnit,
20
- type MetricNotation,
21
- squareMetersToAreaUnit,
22
- } from '../measurements'
23
21
  import type { PointKind, PointTarget } from './types'
24
22
 
25
23
  // Point and ask names what the finger is on (the owner, 8 October): the label chip and the
@@ -115,7 +113,7 @@ const roomOfWall = (nodes: Nodes, levelId: string, wallId: string) =>
115
113
 
116
114
  function wallHeight(wall: WallNode, nodes: Nodes): number {
117
115
  try {
118
- const height = getWallEffectiveHeightForNodes(wall, nodes as Record<string, AnyNode>)
116
+ const height = getWallEffectiveHeightForNodes(wall, nodes as Record<string, AnyNode>, 0)
119
117
  if (Number.isFinite(height) && height > 0) return height
120
118
  } catch {
121
119
  // No slab election to consult in a bare scene: the wall's own height stands.
@@ -1,4 +1,4 @@
1
- import { formatLinearMeasurement } from '../measurements'
1
+ import { formatLinearMeasurement } from '@pascal-app/core'
2
2
  import type { Rect, ScreenPoint } from './types'
3
3
 
4
4
  // A region is a view, not a selection (spec 2.3, "A region is a view, not a selection"): a
@@ -148,8 +148,19 @@ function preparedWallHeight(
148
148
  },
149
149
  }
150
150
  }
151
+ if (Math.abs(node.endHeightOffset ?? 0) > 1e-5) {
152
+ return {
153
+ height: null,
154
+ diagnostic: {
155
+ severity: 'error',
156
+ code: 'unsupported_wall_print_slope',
157
+ message: `Wall ${node.id} has a sloped top that does not yet have a canonical printable solid.`,
158
+ nodeIds: [node.id],
159
+ },
160
+ }
161
+ }
151
162
 
152
- const height = getWallEffectiveHeightForNodes(node, nodes)
163
+ const height = getWallEffectiveHeightForNodes(node, nodes, 0)
153
164
  if (!Number.isFinite(height) || height <= 1e-7) {
154
165
  return {
155
166
  height: null,
@@ -155,6 +155,11 @@ describe('buildPrintableWallSolids', () => {
155
155
  { effectiveHeight: 2.5 },
156
156
  { [shaped.id]: shaped },
157
157
  )
158
+ const sloped = buildPrintableWallSolids(
159
+ { ...wall, endHeightOffset: 1.0 },
160
+ { effectiveHeight: 2.5 },
161
+ { [shaped.id]: shaped },
162
+ )
158
163
  const terrain = buildPrintableWallSolids(
159
164
  { ...wall, children: [], fillToTerrain: true },
160
165
  { effectiveHeight: 2.5 },
@@ -172,6 +177,12 @@ describe('buildPrintableWallSolids', () => {
172
177
  diagnostics: [expect.objectContaining({ code: 'unsupported_wall_print_curve' })],
173
178
  }),
174
179
  )
180
+ expect(sloped).toEqual(
181
+ expect.objectContaining({
182
+ status: 'blocked',
183
+ diagnostics: [expect.objectContaining({ code: 'unsupported_wall_print_slope' })],
184
+ }),
185
+ )
175
186
  expect(terrain).toEqual(
176
187
  expect.objectContaining({
177
188
  status: 'blocked',
@@ -27,6 +27,7 @@ export type PrintWallSolidDiagnostic = {
27
27
  code:
28
28
  | 'invalid_wall_print_dimensions'
29
29
  | 'unsupported_wall_print_curve'
30
+ | 'unsupported_wall_print_slope'
30
31
  | 'unsupported_wall_print_terrain'
31
32
  | 'unsupported_wall_print_opening_shape'
32
33
  | 'invalid_wall_print_opening'
@@ -258,6 +259,14 @@ export function buildPrintableWallSolids(
258
259
  nodeIds: [node.id],
259
260
  })
260
261
  }
262
+ if (Math.abs(node.endHeightOffset ?? 0) > DIMENSION_EPSILON) {
263
+ diagnostics.push({
264
+ severity: 'error',
265
+ code: 'unsupported_wall_print_slope',
266
+ message: `Sloped wall ${node.id} does not yet have a canonical printable solid.`,
267
+ nodeIds: [node.id],
268
+ })
269
+ }
261
270
  if (node.fillToTerrain) {
262
271
  diagnostics.push({
263
272
  severity: 'error',
@@ -0,0 +1,83 @@
1
+ import { formatNumber, type GuideScaleReference } from '@pascal-app/core'
2
+
3
+ export type ReferenceScaleUnit = 'meters' | 'centimeters' | 'feet' | 'inches'
4
+
5
+ const MAX_REFERENCE_SCALE_LABEL_LENGTH = 64
6
+
7
+ /** Mirrors `GuideScaleReference.displayLength`'s `.max()` in core. */
8
+ export const MAX_REFERENCE_SCALE_DISPLAY_LENGTH = 1_000_000
9
+
10
+ export function isValidReferenceScaleDisplayLength(value: number): boolean {
11
+ return Number.isFinite(value) && value > 0 && value <= MAX_REFERENCE_SCALE_DISPLAY_LENGTH
12
+ }
13
+
14
+ const REFERENCE_SCALE_UNIT_LABELS: Record<ReferenceScaleUnit, string> = {
15
+ meters: 'm',
16
+ centimeters: 'cm',
17
+ feet: 'ft',
18
+ inches: 'in',
19
+ }
20
+
21
+ function formatScaleValue(value: number, unit: string, locale: string): string {
22
+ return `${formatNumber(value, { locale, max: 2 })} ${unit}`
23
+ }
24
+
25
+ function getReferenceScaleUnitLabel(unit: unknown): string | null {
26
+ if (typeof unit !== 'string' || !Object.hasOwn(REFERENCE_SCALE_UNIT_LABELS, unit)) return null
27
+ return REFERENCE_SCALE_UNIT_LABELS[unit as ReferenceScaleUnit]
28
+ }
29
+
30
+ function parseLegacyScaleLabel(label: string): { unit: string; value: number } | null {
31
+ // `\s` covers NBSP and narrow NBSP; the whitespace run and digit group are
32
+ // disjoint, so each repetition has exactly one way to match.
33
+ const match = label.trim().match(/^([+−-]?[\d.,]+(?:\s+[\d.,]+)*)\s*(m|cm|ft|in)$/i)
34
+ if (!match) return null
35
+
36
+ const compact = match[1]!.replace(/\s/g, '').replace('−', '-')
37
+ const lastDot = compact.lastIndexOf('.')
38
+ const lastComma = compact.lastIndexOf(',')
39
+ const separatorIndex = Math.max(lastDot, lastComma)
40
+ const trailingDigits = separatorIndex === -1 ? 0 : compact.length - separatorIndex - 1
41
+ const decimalSeparator =
42
+ separatorIndex !== -1 && trailingDigits > 0 && trailingDigits <= 2
43
+ ? compact[separatorIndex]
44
+ : null
45
+ const normalized = decimalSeparator
46
+ ? `${compact.slice(0, separatorIndex).replace(/[.,]/g, '')}.${compact
47
+ .slice(separatorIndex + 1)
48
+ .replace(/[.,]/g, '')}`
49
+ : compact.replace(/[.,]/g, '')
50
+
51
+ if (!/^[+-]?\d+(?:\.\d+)?$/.test(normalized)) return null
52
+ const value = Number(normalized)
53
+ return Number.isFinite(value) ? { unit: match[2]!.toLowerCase(), value } : null
54
+ }
55
+
56
+ export function createGuideScaleReference(
57
+ reference: Omit<GuideScaleReference, 'displayLength' | 'displayUnit' | 'label'>,
58
+ displayLength: number,
59
+ displayUnit: ReferenceScaleUnit,
60
+ ): GuideScaleReference {
61
+ const unitLabel = REFERENCE_SCALE_UNIT_LABELS[displayUnit]
62
+ const label = formatScaleValue(displayLength, unitLabel, 'en')
63
+ if (!isValidReferenceScaleDisplayLength(displayLength)) return { ...reference, label }
64
+ return { ...reference, displayLength, displayUnit, label }
65
+ }
66
+
67
+ export function formatReferenceScaleLabel(reference: GuideScaleReference, locale: string): string {
68
+ const unitLabel = getReferenceScaleUnitLabel(reference.displayUnit)
69
+ if (
70
+ typeof reference.displayLength === 'number' &&
71
+ Number.isFinite(reference.displayLength) &&
72
+ unitLabel
73
+ ) {
74
+ return formatScaleValue(reference.displayLength, unitLabel, locale)
75
+ }
76
+
77
+ const label: unknown = reference.label
78
+ if (typeof label !== 'string') return typeof label === 'number' ? String(label) : ''
79
+ if (label.length > MAX_REFERENCE_SCALE_LABEL_LENGTH) return label
80
+
81
+ const parsed = parseLegacyScaleLabel(label)
82
+ return parsed ? formatScaleValue(parsed.value, parsed.unit, locale) : label
83
+ }
@@ -77,7 +77,7 @@ export function resolveRoomAssemblyHeights(
77
77
  const wall = nodes[id]
78
78
  if (wall?.type !== 'wall') continue
79
79
  const base = getWallBaseElevationForNodes(wall, sceneNodes)
80
- wallTops.set(id, getWallTop(wall, levelId, sceneNodes, base))
80
+ wallTops.set(id, getWallTop(wall, levelId, sceneNodes, base, 0.5))
81
81
  }
82
82
  const ceilingNode = room.ceilingId ? nodes[room.ceilingId] : undefined
83
83
  const ceiling =
@@ -3,10 +3,12 @@ import {
3
3
  type AnyNode,
4
4
  BuildingNode,
5
5
  clearSceneHistory,
6
+ createI18n,
6
7
  createZone,
7
8
  generateId,
8
9
  initSpaceDetectionSync,
9
10
  LevelNode,
11
+ roomFloorElevation,
10
12
  SlabNode,
11
13
  type SlabNode as SlabNodeType,
12
14
  useScene,
@@ -23,11 +25,7 @@ import {
23
25
  setRoomRelativeFloorHeight,
24
26
  stepRoomFloorElevation,
25
27
  } from './room-construction-commands'
26
- import {
27
- commitRoomElevation,
28
- ownFloorElevationBounds,
29
- roomFloorElevation,
30
- } from './room-handle-drag'
28
+ import { commitRoomElevation, ownFloorElevationBounds } from './room-handle-drag'
31
29
  import { applyRoomPlan } from './room-structure-commands'
32
30
  import { showRoomNotice, useRoomTransform } from './room-transform-session'
33
31
 
@@ -67,6 +65,7 @@ const scene = (...nodes: AnyNode[]): Record<string, AnyNode> =>
67
65
  const living = zone('zone_living', 'Living', rect(0, 0, 4, 4))
68
66
  const lanai = zone('zone_lanai', 'Lanai', rect(4, 0, 6, 4))
69
67
  const keyedLanai = zone('zone_lanai', 'Lanai', rect(4, 0, 6, 4), 'floor_lanai')
68
+ const english = createI18n()
70
69
  const length = (meters: number) => `${meters.toFixed(2)} m`
71
70
 
72
71
  describe('Built on: when the picker shows and what it offers', () => {
@@ -116,7 +115,11 @@ describe('Built on: when the picker shows and what it offers', () => {
116
115
  expect(model.shared).toBeNull()
117
116
  expect(separateFloorOffset(nodes, keyedLanai.id)).toBeNull()
118
117
  expect(
119
- separateFloorSummary({ ...model, offset: separateFloorOffset(nodes, keyedLanai.id) }, length),
118
+ separateFloorSummary(
119
+ { ...model, offset: separateFloorOffset(nodes, keyedLanai.id) },
120
+ length,
121
+ english,
122
+ ),
120
123
  ).toBe('Lanai floor')
121
124
  })
122
125
 
@@ -159,18 +162,22 @@ describe('Built on: the Floor row summary', () => {
159
162
  const lanaiFloor = { name: 'Lanai floor' }
160
163
  test('says how a separate floor sits against the shared floor', () => {
161
164
  const shared = { name: 'Shared floor' }
162
- expect(separateFloorSummary({ current: lanaiFloor, shared, offset: -0.15 }, length)).toBe(
163
- 'Lanai floor · 0.15 m below Shared floor',
164
- )
165
165
  expect(
166
- separateFloorSummary({ current: lanaiFloor, shared: { name: 'Cabin' }, offset: 0.3 }, length),
166
+ separateFloorSummary({ current: lanaiFloor, shared, offset: -0.15 }, length, english),
167
+ ).toBe('Lanai floor · 0.15 m below Shared floor')
168
+ expect(
169
+ separateFloorSummary(
170
+ { current: lanaiFloor, shared: { name: 'Cabin' }, offset: 0.3 },
171
+ length,
172
+ english,
173
+ ),
167
174
  ).toBe('Lanai floor · 0.30 m above Cabin')
168
- expect(separateFloorSummary({ current: lanaiFloor, shared, offset: 0.002 }, length)).toBe(
169
- 'Lanai floor · level with Shared floor',
170
- )
171
- expect(separateFloorSummary({ current: lanaiFloor, shared: null, offset: null }, length)).toBe(
172
- 'Lanai floor',
173
- )
175
+ expect(
176
+ separateFloorSummary({ current: lanaiFloor, shared, offset: 0.002 }, length, english),
177
+ ).toBe('Lanai floor · level with Shared floor')
178
+ expect(
179
+ separateFloorSummary({ current: lanaiFloor, shared: null, offset: null }, length, english),
180
+ ).toBe('Lanai floor')
174
181
  })
175
182
 
176
183
  test("compares the room's walking surface with the shared-floor room beside it, not plate with plate", () => {
@@ -188,7 +195,11 @@ describe('Built on: the Floor row summary', () => {
188
195
  expect(separateFloorOffset(nodes, keyedLanai.id)).toBeCloseTo(-0.3)
189
196
  const model = roomBuiltOn(nodes, keyedLanai.id)!
190
197
  expect(
191
- separateFloorSummary({ ...model, offset: separateFloorOffset(nodes, keyedLanai.id) }, length),
198
+ separateFloorSummary(
199
+ { ...model, offset: separateFloorOffset(nodes, keyedLanai.id) },
200
+ length,
201
+ english,
202
+ ),
192
203
  ).toBe('Lanai floor · 0.30 m below Living floor')
193
204
  // Nothing shared across a wall or open side: the shared plate's top.
194
205
  expect(separateFloorOffset(scene(raised, keyedLanai, house, own), keyedLanai.id)).toBeCloseTo(
@@ -1,11 +1,12 @@
1
1
  import {
2
2
  type AnyNode,
3
3
  roomFloorChoices as coreRoomFloorChoices,
4
+ levelFootprints,
5
+ type PascalI18n,
4
6
  roomDrawnFloor,
5
7
  type SlabNode,
6
8
  type ZoneNode,
7
9
  } from '@pascal-app/core'
8
- import { levelFootprints } from './floor-footprints'
9
10
 
10
11
  // "Built on": which floor a room stands on. By default a room is part of its
11
12
  // connected footprint's shared floor (`floor.footprint` absent); a floor key
@@ -60,20 +61,6 @@ export function roomFloorChoices(nodes: Nodes, zoneId: string): PlateChoice[] {
60
61
  .map(({ key, plateId, name }) => ({ key, plateId, name }))
61
62
  }
62
63
 
63
- /**
64
- * The plate a room on a separate floor stands on alone: its height is that
65
- * plate's floor height (nothing else stands on it). Null for a room on the
66
- * shared floor or sharing its separate floor with other rooms.
67
- */
68
- export function roomOwnPlate(nodes: Nodes, zoneId: string): SlabNode | null {
69
- const zone = nodes[zoneId]
70
- if (!isRoom(zone) || !zone.floor?.footprint || zone.floor.support === 'open') return null
71
- const plate = listingPlate(nodes, zone)
72
- if (!plate) return null
73
- const alone = !(plate.zoneIds ?? []).some((id) => id !== zoneId && isGroundRoom(nodes[id]))
74
- return alone ? plate : null
75
- }
76
-
77
64
  /**
78
65
  * The shared floor beside a room on a separate floor, where it goes back to.
79
66
  * For a room on the shared floor, its own plate.
@@ -130,9 +117,26 @@ export function roomBuiltOn(nodes: Nodes, zoneId: string): RoomBuiltOnModel | nu
130
117
  export function separateFloorSummary(
131
118
  model: { current: { name: string }; shared: { name: string } | null; offset: number | null },
132
119
  length: (meters: number) => string,
120
+ i18n: PascalI18n,
133
121
  ): string {
134
122
  const { current, shared, offset } = model
135
123
  if (!shared || offset === null) return current.name
136
- if (Math.abs(offset) < 0.005) return `${current.name} · level with ${shared.name}`
137
- return `${current.name} · ${length(Math.abs(offset))} ${offset > 0 ? 'above' : 'below'} ${shared.name}`
124
+ const values = { current: current.name, shared: shared.name, distance: length(Math.abs(offset)) }
125
+ if (Math.abs(offset) < 0.005)
126
+ return i18n._({
127
+ id: 'editor.room.builtOn.levelWith',
128
+ message: '{current} · level with {shared}',
129
+ values,
130
+ })
131
+ return offset > 0
132
+ ? i18n._({
133
+ id: 'editor.room.builtOn.above',
134
+ message: '{current} · {distance} above {shared}',
135
+ values,
136
+ })
137
+ : i18n._({
138
+ id: 'editor.room.builtOn.below',
139
+ message: '{current} · {distance} below {shared}',
140
+ values,
141
+ })
138
142
  }
@@ -17,7 +17,9 @@ import {
17
17
  type NodeChange,
18
18
  planWallJustification,
19
19
  resolveCeilingHeight,
20
+ roomFloorElevation,
20
21
  roomFloorElevationFromRelative,
22
+ roomOwnPlate,
21
23
  roomSideFaces,
22
24
  runAsSingleSceneHistoryStep,
23
25
  type StructureConflict,
@@ -32,13 +34,12 @@ import useDeleteConfirmation, {
32
34
  } from '../store/use-delete-confirmation'
33
35
  import useEditor from '../store/use-editor'
34
36
  import { footprintHeightValue } from './floor-footprints'
35
- import { roomOwnPlate, roomSharedFootprint } from './room-built-on'
37
+ import { roomSharedFootprint } from './room-built-on'
36
38
  import { roomClearPolygon, roomConstructionState, roomFloorContents } from './room-construction'
37
39
  import {
38
40
  mezzanineElevationBounds,
39
41
  ownFloorElevationBounds,
40
42
  planRoomElevation,
41
- roomFloorElevation,
42
43
  } from './room-handle-drag'
43
44
  import { applyRoomPlan } from './room-structure-commands'
44
45
  import { showRoomNotice } from './room-transform-session'
@@ -4,6 +4,7 @@ import {
4
4
  area,
5
5
  BuildingNode,
6
6
  CeilingNode,
7
+ ColumnNode,
7
8
  clearSceneHistory,
8
9
  createZone,
9
10
  DoorNode,
@@ -25,7 +26,12 @@ import { deleteConfirmationContent } from '../components/editor/delete-confirmat
25
26
  import { constructionActions, wallsSwitch } from '../components/ui/panels/room-construction-rows'
26
27
  import useDeleteConfirmation from '../store/use-delete-confirmation'
27
28
  import useEditor from '../store/use-editor'
28
- import { roomClearPolygon, roomConstructionState, roomFloorContents } from './room-construction'
29
+ import {
30
+ roomClearPolygon,
31
+ roomConstructionState,
32
+ roomFloorContents,
33
+ roomPorchFramingCounts,
34
+ } from './room-construction'
29
35
  import {
30
36
  addRoomConstruction,
31
37
  adoptExistingCeiling,
@@ -177,6 +183,27 @@ describe('room construction state', () => {
177
183
  expect(constructionActions(room, 'floor')).toEqual({ add: false, remove: true })
178
184
  })
179
185
 
186
+ test('porch framing counts the room’s generated parts by marker, across node kinds', () => {
187
+ const zone = nodes()[zoneId as AnyNodeId] as ZoneNode
188
+ const parts = [
189
+ ColumnNode.parse({ metadata: { porchOf: zoneId, porchPart: 'corner:0' } }),
190
+ ItemNode.parse({
191
+ ...item('item_side_post', 'Post', LEVEL, [0, 0, 0]),
192
+ metadata: { porchOf: zoneId, porchPart: 'side:0:1' },
193
+ }),
194
+ ColumnNode.parse({ metadata: { porchOf: zoneId, porchPart: 'beam:0' } }),
195
+ ColumnNode.parse({ metadata: { porchOf: 'zone_other', porchPart: 'corner:0' } }),
196
+ ColumnNode.parse({}),
197
+ ]
198
+ const level = nodes()[LEVEL as AnyNodeId] as LevelNode
199
+ const scene = {
200
+ ...nodes(),
201
+ ...Object.fromEntries(parts.map((part) => [part.id, part])),
202
+ [level.id]: { ...level, children: [...level.children, ...parts.map((part) => part.id)] },
203
+ }
204
+ expect(roomPorchFramingCounts(scene, zone)).toEqual({ posts: 2, beams: 1 })
205
+ })
206
+
180
207
  test('memoized per nodes identity', () => {
181
208
  expect(roomConstructionState(nodes(), zoneId)).toBe(roomConstructionState(nodes(), zoneId))
182
209
  expect(roomConstructionState(nodes(), 'zone_missing')).toBeNull()