@pascal-app/editor 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (317) hide show
  1. package/package.json +20 -17
  2. package/src/components/editor/action-menu-button.tsx +4 -1
  3. package/src/components/editor/alignment-3d-guide-layer.tsx +102 -11
  4. package/src/components/editor/bake-thumbnail.tsx +3 -2
  5. package/src/components/editor/custom-camera-controls.tsx +20 -8
  6. package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
  7. package/src/components/editor/first-person-controls.tsx +72 -61
  8. package/src/components/editor/floating-action-menu.tsx +27 -9
  9. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  10. package/src/components/editor/floorplan-panel.test.ts +220 -0
  11. package/src/components/editor/floorplan-panel.tsx +285 -132
  12. package/src/components/editor/footprint-height-handle.tsx +1 -1
  13. package/src/components/editor/grid.tsx +24 -15
  14. package/src/components/editor/group-actions.ts +19 -1
  15. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  16. package/src/components/editor/group-move-3d.ts +27 -2
  17. package/src/components/editor/group-selection-box-3d.tsx +4 -1
  18. package/src/components/editor/group-transform-shared.ts +20 -0
  19. package/src/components/editor/index.tsx +130 -20
  20. package/src/components/editor/inspector-extension-actions.tsx +36 -0
  21. package/src/components/editor/localized-html.tsx +22 -0
  22. package/src/components/editor/node-action-menu.tsx +46 -12
  23. package/src/components/editor/node-arrow-handles.tsx +1 -2
  24. package/src/components/editor/open-wall-ends-3d-layer.tsx +1 -1
  25. package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
  26. package/src/components/editor/registry-action-contributions.test.tsx +67 -6
  27. package/src/components/editor/registry-action-contributions.tsx +115 -15
  28. package/src/components/editor/riser-diagram-panel.tsx +59 -9
  29. package/src/components/editor/room-controls.tsx +1 -1
  30. package/src/components/editor/room-handles-2d.tsx +156 -6
  31. package/src/components/editor/room-handles.tsx +241 -51
  32. package/src/components/editor/room-highlight.tsx +2 -0
  33. package/src/components/editor/room-labels.tsx +29 -15
  34. package/src/components/editor/selection-manager.tsx +15 -102
  35. package/src/components/editor/separator-end-grip.tsx +74 -0
  36. package/src/components/editor/site-edge-labels.tsx +1 -1
  37. package/src/components/editor/thumbnail-generator.test.ts +15 -6
  38. package/src/components/editor/thumbnail-generator.tsx +26 -1
  39. package/src/components/editor/use-floorplan-background-placement.ts +55 -52
  40. package/src/components/editor/views/view-bar.tsx +111 -26
  41. package/src/components/editor/wall-measurement-label.tsx +1 -1
  42. package/src/components/editor/wall-move-side-handles.tsx +5 -1
  43. package/src/components/editor/wall-pull-grip.tsx +193 -0
  44. package/src/components/editor/wall-snap-beacon-layer.tsx +81 -1
  45. package/src/components/editor-2d/floorplan-adjacent-walls-layer.test.ts +76 -0
  46. package/src/components/editor-2d/floorplan-adjacent-walls-layer.tsx +186 -0
  47. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +63 -3
  48. package/src/components/editor-2d/floorplan-group-move.tsx +31 -3
  49. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +6 -0
  50. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +22 -13
  51. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +11 -3
  52. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +7 -1
  53. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +96 -4
  54. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +52 -12
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +12 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +197 -37
  57. package/src/components/procedural-items/inspector.tsx +59 -12
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +2 -0
  59. package/src/components/systems/ceiling/ceiling-system.test.ts +141 -0
  60. package/src/components/systems/ceiling/ceiling-system.tsx +53 -18
  61. package/src/components/tools/distribution/distribution-run-tool.tsx +50 -26
  62. package/src/components/tools/item/placement-history.test.tsx +1 -1
  63. package/src/components/tools/item/placement-strategies.test.ts +64 -1
  64. package/src/components/tools/item/placement-strategies.ts +5 -9
  65. package/src/components/tools/item/use-placement-coordinator.tsx +30 -34
  66. package/src/components/tools/registry/item-surface-move.test.ts +11 -0
  67. package/src/components/tools/select/select-candidates.test.ts +45 -0
  68. package/src/components/tools/select/select-candidates.ts +13 -2
  69. package/src/components/tools/shared/cursor-sphere.tsx +10 -3
  70. package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
  71. package/src/components/tools/shared/placement-box.tsx +1 -1
  72. package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
  73. package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
  74. package/src/components/tools/wall/wall-drafting.test.ts +299 -23
  75. package/src/components/tools/wall/wall-drafting.ts +132 -24
  76. package/src/components/tools/wall/wall-pull-snap.test.ts +274 -0
  77. package/src/components/tools/wall/wall-pull-snap.ts +563 -0
  78. package/src/components/tools/wall/wall-snap-geometry.test.ts +113 -1
  79. package/src/components/tools/wall/wall-snap-geometry.ts +137 -0
  80. package/src/components/tools/zone/zone-tool.tsx +3 -3
  81. package/src/components/ui/action-menu/action-button.tsx +2 -0
  82. package/src/components/ui/action-menu/camera-actions.tsx +8 -7
  83. package/src/components/ui/action-menu/control-modes.tsx +9 -5
  84. package/src/components/ui/action-menu/follow-pascal-pill.tsx +3 -1
  85. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +7 -1
  86. package/src/components/ui/action-menu/furnish-tools.tsx +7 -6
  87. package/src/components/ui/action-menu/index.tsx +1 -0
  88. package/src/components/ui/action-menu/measurement-control.tsx +35 -29
  89. package/src/components/ui/action-menu/point-ask-button.tsx +4 -3
  90. package/src/components/ui/action-menu/structure-tools.tsx +25 -23
  91. package/src/components/ui/action-menu/view-toggles.test.ts +18 -0
  92. package/src/components/ui/action-menu/view-toggles.tsx +256 -51
  93. package/src/components/ui/build-panel/build-panel.tsx +163 -25
  94. package/src/components/ui/command-palette/editor-commands.tsx +38 -11
  95. package/src/components/ui/controls/finish-swatch.tsx +45 -19
  96. package/src/components/ui/controls/material-picker.tsx +24 -17
  97. package/src/components/ui/controls/metric-control.test.ts +52 -0
  98. package/src/components/ui/controls/metric-control.tsx +62 -24
  99. package/src/components/ui/controls/segmented-control.tsx +5 -2
  100. package/src/components/ui/controls/slider-control.test.tsx +23 -0
  101. package/src/components/ui/controls/slider-control.tsx +30 -14
  102. package/src/components/ui/floating-level-selector.tsx +100 -48
  103. package/src/components/ui/helpers/building-helper.tsx +5 -3
  104. package/src/components/ui/helpers/contextual-helper-panel.tsx +97 -37
  105. package/src/components/ui/helpers/helper-manager.test.tsx +53 -3
  106. package/src/components/ui/helpers/helper-manager.tsx +82 -57
  107. package/src/components/ui/helpers/item-helper.tsx +6 -4
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +3 -1
  109. package/src/components/ui/level-duplicate-dialog.tsx +32 -23
  110. package/src/components/ui/level-wall-copy-prompt.tsx +124 -0
  111. package/src/components/ui/panels/collections/collections-popover.tsx +11 -11
  112. package/src/components/ui/panels/deck-boards-fields.tsx +136 -0
  113. package/src/components/ui/panels/floor-foundation-panel.tsx +113 -38
  114. package/src/components/ui/panels/floor-opening-panel.tsx +73 -25
  115. package/src/components/ui/panels/inspector-extensions.tsx +66 -0
  116. package/src/components/ui/panels/mobile-panel-sheet.tsx +3 -1
  117. package/src/components/ui/panels/mobile-selection-bar.tsx +8 -6
  118. package/src/components/ui/panels/multi-field-value.test.ts +27 -0
  119. package/src/components/ui/panels/multi-field-value.ts +20 -1
  120. package/src/components/ui/panels/multi-height-mode.tsx +8 -7
  121. package/src/components/ui/panels/multi-parametric-inspector.tsx +122 -34
  122. package/src/components/ui/panels/multi-selection-panel.tsx +75 -35
  123. package/src/components/ui/panels/node-display.ts +35 -24
  124. package/src/components/ui/panels/panel-manager.tsx +5 -3
  125. package/src/components/ui/panels/panel-wrapper.tsx +22 -109
  126. package/src/components/ui/panels/parametric-field-control.tsx +13 -10
  127. package/src/components/ui/panels/parametric-inspector.tsx +18 -12
  128. package/src/components/ui/panels/reference-panel.tsx +51 -35
  129. package/src/components/ui/panels/room-construction-rows.tsx +464 -65
  130. package/src/components/ui/panels/room-name-options.test.ts +36 -3
  131. package/src/components/ui/panels/room-name-options.ts +110 -11
  132. package/src/components/ui/panels/room-openings-field.tsx +21 -12
  133. package/src/components/ui/panels/room-panel.tsx +206 -99
  134. package/src/components/ui/panels/shape-choice.tsx +7 -5
  135. package/src/components/ui/panels/wall-region-list.tsx +7 -7
  136. package/src/components/ui/primitives/shortcut-token.tsx +10 -6
  137. package/src/components/ui/sidebar/icon-rail.tsx +11 -9
  138. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +10 -10
  139. package/src/components/ui/sidebar/panels/items-panel/index.tsx +10 -9
  140. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +3 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +102 -32
  142. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -2
  144. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +4 -2
  145. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +7 -5
  146. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +4 -2
  147. package/src/components/ui/sidebar/panels/site-panel/default-names.ts +75 -0
  148. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +4 -2
  149. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +4 -2
  150. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +4 -2
  151. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +4 -2
  152. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +4 -2
  153. package/src/components/ui/sidebar/panels/site-panel/index.tsx +117 -64
  154. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +36 -3
  155. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +4 -2
  156. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -4
  157. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +8 -2
  158. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +8 -5
  159. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +4 -2
  160. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +8 -3
  161. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +4 -2
  162. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +4 -2
  163. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +8 -7
  164. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +7 -6
  165. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +16 -1
  166. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +15 -8
  167. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +4 -2
  168. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +4 -2
  169. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +3 -1
  170. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +18 -8
  171. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +25 -14
  172. package/src/components/ui/sidebar/tab-bar.tsx +4 -1
  173. package/src/components/ui/sidebar/use-plugin-panels.tsx +19 -10
  174. package/src/components/ui/snap-target-badge.tsx +7 -6
  175. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +721 -0
  176. package/src/components/ui/wall-type/wall-type-menu.tsx +167 -0
  177. package/src/components/viewer/floorplan-compass-button.tsx +12 -3
  178. package/src/components/viewer/floorplan-preview.tsx +27 -19
  179. package/src/components/viewer/render-settings-menu.tsx +294 -0
  180. package/src/components/viewer/sky-control.tsx +710 -0
  181. package/src/components/viewer/use-viewer-ui-sounds.test.ts +15 -0
  182. package/src/components/viewer/use-viewer-ui-sounds.ts +46 -0
  183. package/src/components/viewer/viewer-controls-bar.tsx +278 -260
  184. package/src/components/viewer/viewer-hover-label.tsx +12 -4
  185. package/src/components/viewer/viewer-rooms.tsx +61 -4
  186. package/src/components/viewer/viewer-scene-header.tsx +28 -17
  187. package/src/components/viewer/viewer-stage-switcher.tsx +4 -2
  188. package/src/components/viewer-overlay.tsx +8 -0
  189. package/src/components/walkthrough-hud.tsx +75 -2
  190. package/src/hooks/use-keyboard.test.ts +85 -4
  191. package/src/hooks/use-keyboard.ts +65 -7
  192. package/src/hooks/use-selected-room.ts +12 -1
  193. package/src/i18n/floor-footprint-name.ts +30 -0
  194. package/src/i18n/level-name.ts +39 -0
  195. package/src/i18n/locales/en/messages.po +15117 -0
  196. package/src/i18n/locales/en/messages.ts +1 -0
  197. package/src/i18n/locales/en-XA/messages.ts +1 -0
  198. package/src/i18n/locales/es/messages.po +37501 -0
  199. package/src/i18n/locales/es/messages.ts +1 -0
  200. package/src/i18n/locales/fr/messages.po +37501 -0
  201. package/src/i18n/locales/fr/messages.ts +1 -0
  202. package/src/i18n/locales/ja/messages.po +37501 -0
  203. package/src/i18n/locales/ja/messages.ts +1 -0
  204. package/src/i18n/locales/zh-CN/messages.po +37501 -0
  205. package/src/i18n/locales/zh-CN/messages.ts +1 -0
  206. package/src/i18n/locales.test.ts +63 -0
  207. package/src/i18n/locales.ts +32 -0
  208. package/src/i18n/message-slot.tsx +25 -0
  209. package/src/i18n/node-messages.ts +169 -0
  210. package/src/i18n/pseudo-locale.ts +17 -0
  211. package/src/i18n/published-entry.test.ts +17 -0
  212. package/src/i18n/scene-theme-name.ts +16 -0
  213. package/src/index.tsx +156 -4
  214. package/src/lib/add-storey.test.ts +3 -3
  215. package/src/lib/contextual-help.test.ts +20 -1
  216. package/src/lib/contextual-help.ts +95 -19
  217. package/src/lib/continuation.ts +38 -11
  218. package/src/lib/floor-footprints.test.ts +2 -1
  219. package/src/lib/floor-foundation-support.test.ts +7 -3
  220. package/src/lib/floorplan/dimension-drive.test.ts +36 -2
  221. package/src/lib/floorplan/dimension-drive.ts +68 -3
  222. package/src/lib/floorplan/floorplan-export.test.ts +16 -5
  223. package/src/lib/floorplan/floorplan-export.tsx +13 -16
  224. package/src/lib/floorplan/floorplan-extension.test.ts +11 -12
  225. package/src/lib/floorplan/floorplan-extension.ts +37 -18
  226. package/src/lib/floorplan/floorplan-mode.test.ts +13 -6
  227. package/src/lib/floorplan/floorplan-mode.ts +14 -12
  228. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +1 -1
  229. package/src/lib/floorplan/floorplan-readonly.ts +3 -3
  230. package/src/lib/floorplan/schedules.ts +10 -6
  231. package/src/lib/floorplan/site-plan/site-annotations.test.ts +46 -1
  232. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -0
  233. package/src/lib/floorplan/site-plan/site-parts.ts +6 -3
  234. package/src/lib/geometry-script/author.test.ts +114 -0
  235. package/src/lib/geometry-script/author.ts +55 -23
  236. package/src/lib/geometry-script/client.ts +3 -0
  237. package/src/lib/geometry-script/finish-scripts.ts +7 -7
  238. package/src/lib/glb-export.test.ts +327 -0
  239. package/src/lib/glb-export.ts +70 -2
  240. package/src/lib/grid-frame.test.ts +56 -3
  241. package/src/lib/grid-frame.ts +28 -1
  242. package/src/lib/history.test.ts +2 -1
  243. package/src/lib/hud-title.ts +48 -24
  244. package/src/lib/ifc-export.test.ts +41 -1
  245. package/src/lib/inspector-card-mode.test.ts +3 -31
  246. package/src/lib/inspector-card-mode.ts +5 -10
  247. package/src/lib/inspector-expanded.test.ts +13 -0
  248. package/src/lib/inspector-expanded.ts +13 -1
  249. package/src/lib/keyboard-pan.test.ts +7 -0
  250. package/src/lib/keyboard-pan.ts +9 -2
  251. package/src/lib/layer-thickness.test.ts +22 -0
  252. package/src/lib/layer-thickness.ts +79 -0
  253. package/src/lib/lot/drop-in.test.ts +59 -1
  254. package/src/lib/lot/drop-in.ts +42 -50
  255. package/src/lib/material-swatch-style.ts +24 -0
  256. package/src/lib/measurement-parser.ts +7 -3
  257. package/src/lib/measurements.test.ts +17 -0
  258. package/src/lib/measurements.ts +20 -5
  259. package/src/lib/mezzanine-selection.test.ts +3 -1
  260. package/src/lib/orbit-near-plane.test.ts +12 -0
  261. package/src/lib/orbit-near-plane.ts +13 -0
  262. package/src/lib/paint-regions.test.ts +8 -5
  263. package/src/lib/paint-scope.test.ts +53 -0
  264. package/src/lib/paint-scope.ts +18 -4
  265. package/src/lib/panel-rows.ts +17 -8
  266. package/src/lib/reference-scale.ts +83 -0
  267. package/src/lib/room-built-on.test.ts +26 -12
  268. package/src/lib/room-built-on.ts +20 -2
  269. package/src/lib/room-construction.test.ts +28 -1
  270. package/src/lib/room-construction.ts +14 -0
  271. package/src/lib/room-controls-phase6.test.ts +140 -0
  272. package/src/lib/room-handle-drag.ts +557 -42
  273. package/src/lib/room-name-catalog.test.ts +8 -0
  274. package/src/lib/room-name-catalog.ts +8 -1
  275. package/src/lib/room-name-messages.test.ts +132 -0
  276. package/src/lib/room-name-messages.ts +147 -0
  277. package/src/lib/room-name-storage.test.ts +334 -0
  278. package/src/lib/room-push-dimensions.test.ts +37 -0
  279. package/src/lib/room-push-dimensions.ts +226 -2
  280. package/src/lib/room-selection.test.ts +23 -6
  281. package/src/lib/scene-clipboard.test.ts +70 -49
  282. package/src/lib/scene-clipboard.ts +9 -4
  283. package/src/lib/selection-cycle.ts +63 -0
  284. package/src/lib/separator-edit.test.ts +431 -0
  285. package/src/lib/separator-edit.ts +179 -0
  286. package/src/lib/separator-lines.ts +38 -0
  287. package/src/lib/sfx-bus.ts +27 -1
  288. package/src/lib/sfx-player.ts +16 -0
  289. package/src/lib/tool-cursor-icon.ts +5 -1
  290. package/src/lib/tool-shortcuts.ts +130 -0
  291. package/src/lib/viewer-selection.test.ts +28 -24
  292. package/src/lib/viewer-selection.ts +20 -5
  293. package/src/lib/wall-draw-variant.test.ts +7 -6
  294. package/src/lib/wall-draw-variant.ts +107 -6
  295. package/src/lib/wall-nudge.test.ts +44 -0
  296. package/src/lib/wall-nudge.ts +131 -0
  297. package/src/lib/wall-parallel-snap.test.ts +83 -0
  298. package/src/lib/wall-parallel-snap.ts +176 -0
  299. package/src/lib/wall-placement-pointer.test.ts +23 -0
  300. package/src/lib/wall-placement-pointer.ts +18 -0
  301. package/src/lib/wall-pull.test.ts +245 -0
  302. package/src/lib/wall-pull.ts +209 -0
  303. package/src/lib/wall-push-line-up.test.ts +132 -0
  304. package/src/lib/wall-region-list.ts +14 -5
  305. package/src/lib/wall-run-snap.test.ts +188 -0
  306. package/src/lib/wall-run-snap.ts +83 -0
  307. package/src/lib/wall-type.test.ts +125 -0
  308. package/src/lib/wall-type.ts +119 -0
  309. package/src/lib/window-interaction.test.ts +42 -0
  310. package/src/lib/window-interaction.ts +47 -5
  311. package/src/lib/world-grid-snap.test.ts +139 -2
  312. package/src/lib/world-grid-snap.ts +102 -105
  313. package/src/store/use-alignment-guides.ts +27 -1
  314. package/src/store/use-editor.tsx +48 -0
  315. package/src/store/use-floorplan-annotation-visibility.test.ts +64 -0
  316. package/src/store/use-floorplan-annotation-visibility.ts +47 -22
  317. package/src/store/use-wall-snap-indicator.ts +15 -2
@@ -9,12 +9,12 @@
9
9
  * 3. `sitePatchFromParcel` — the site node patch: ring, address,
10
10
  * provenance, the street-facing front edge, planning-default setbacks
11
11
  * when the site has none, north up.
12
- * 4. The scene's site node is updated (created at the root when the scene
13
- * has none) and a building that fell outside the new ring is
14
- * re-centred on it — `building.position` only, never the walls.
15
- * 5. `elevation` — USGS ground over the lot into the site's
12
+ * 4. `elevation` — USGS ground over the lot into the site's
16
13
  * heightfield (`site.terrain`, terrain.ts; fail-soft, flat lots write
17
14
  * nothing), so a foundation can read the hill.
15
+ * 5. The parcel and terrain are applied to the current site together
16
+ * (created at the root when the scene has none). A building outside
17
+ * the new ring is re-centred — `building.position` only, never the walls.
18
18
  *
19
19
  * Used by the Lot rail panel, the Generate panel (drop in, then generate)
20
20
  * and the Site inspector's "Find parcel", so every path behaves the same.
@@ -271,71 +271,63 @@ export async function dropInLot(
271
271
  roadsFailure = 'skipped'
272
272
  }
273
273
 
274
- const scene = useScene.getState()
275
- let site = findSiteNode(options.siteId)
276
- if (!site) {
277
- site = SiteNode.parse({ id: generateId('site'), type: 'site', name: 'Site' })
278
- scene.createNodes([{ node: site as AnyNode }])
279
- }
280
- const computed = sitePatchFromParcel(site, input, data, roads, undefined, extras)
281
- if (!computed)
274
+ const geometry = sitePatchFromParcel(null, input, data, roads, undefined, extras)
275
+ if (!geometry)
282
276
  return {
283
277
  ok: false,
284
278
  error: 'parcel geometry was unusable',
285
279
  message: 'The parcel geometry was unusable.',
286
280
  }
287
- // the surveyed contour lines ride the site with the patch (the site plan
288
- // draws them over the heightfield's own); absent without 3DEP lines
289
- const contours = dossier && dossierOrigin ? contourLinesFromDossier(dossier, dossierOrigin) : null
290
- useScene
291
- .getState()
292
- .updateNode(
293
- site.id as AnyNodeId,
294
- { ...computed.patch, terrainContours: contours ?? undefined } as Partial<AnyNode>,
295
- )
296
-
297
281
  // The ground over the lot — fail-soft. A sloping lot writes the
298
282
  // heightfield; a flat one (or a failed read) clears any terrain the
299
283
  // previous lot left behind so the site never shows another parcel's hill.
300
284
  let terrain: TerrainSampleSummary | null = null
301
285
  let terrainFailure = ''
286
+ let terrainRead: TerrainSampleResult | null = null
302
287
  if (options.terrain !== false) {
303
- const read: TerrainSampleResult = data.originLngLat
304
- ? await sampleLotTerrain(computed.patch.polygon?.points ?? [], data.originLngLat, {
288
+ terrainRead = data.originLngLat
289
+ ? await sampleLotTerrain(geometry.patch.polygon?.points ?? [], data.originLngLat, {
305
290
  provider,
306
291
  ...(options.terrainDeadlineMs ? { deadlineMs: options.terrainDeadlineMs } : {}),
307
292
  })
308
293
  : { ok: false, reason: 'no parcel origin' }
309
- const current = useScene.getState().nodes[site.id as AnyNodeId]
310
- const { terrainSample: _previous, ...meta } =
311
- current &&
312
- typeof current.metadata === 'object' &&
313
- current.metadata !== null &&
314
- !Array.isArray(current.metadata)
315
- ? (current.metadata as Record<string, unknown>)
316
- : {}
317
- if (read.ok && read.summary) {
318
- terrain = read.summary
319
- useScene.getState().updateNode(
320
- site.id as AnyNodeId,
321
- {
322
- terrain: read.terrain,
323
- metadata: { ...meta, terrainSample: read.summary },
324
- } as unknown as Partial<AnyNode>,
325
- )
326
- } else {
327
- terrainFailure = read.reason ?? 'no terrain'
328
- useScene
329
- .getState()
330
- .updateNode(
331
- site.id as AnyNodeId,
332
- { terrain: undefined, metadata: meta } as unknown as Partial<AnyNode>,
333
- )
334
- }
294
+ if (terrainRead.ok && terrainRead.summary) terrain = terrainRead.summary
295
+ else terrainFailure = terrainRead.reason ?? 'no terrain'
335
296
  } else {
336
297
  terrainFailure = 'skipped'
337
298
  }
338
299
 
300
+ // Hydration or a person's edit can replace the site while the providers run.
301
+ // Read it after every await, preserve its current settings, and commit the
302
+ // parcel with its terrain so a late read cannot leave the starter polygon.
303
+ const scene = useScene.getState()
304
+ let site = findSiteNode(options.siteId)
305
+ const computed = sitePatchFromParcel(site, input, data, roads, undefined, extras)
306
+ if (!computed)
307
+ return {
308
+ ok: false,
309
+ error: 'parcel geometry was unusable',
310
+ message: 'The parcel geometry was unusable.',
311
+ }
312
+ const contours = dossier && dossierOrigin ? contourLinesFromDossier(dossier, dossierOrigin) : null
313
+ const patch: Partial<SiteNode> = {
314
+ ...computed.patch,
315
+ terrainContours: contours ?? undefined,
316
+ }
317
+ if (terrainRead) {
318
+ const { terrainSample: _previous, ...metadata } = computed.patch.metadata as Record<
319
+ string,
320
+ unknown
321
+ >
322
+ patch.terrain = terrainRead.ok ? terrainRead.terrain : undefined
323
+ patch.metadata = terrain ? { ...metadata, terrainSample: terrain } : metadata
324
+ }
325
+ if (site) scene.updateNode(site.id as AnyNodeId, patch as Partial<AnyNode>)
326
+ else {
327
+ site = SiteNode.parse({ id: generateId('site'), type: 'site', name: 'Site', ...patch })
328
+ scene.createNodes([{ node: site as AnyNode }])
329
+ }
330
+
339
331
  // A house the new lot leaves past its lines stands centred on the buildable area again, facing
340
332
  // as it did.
341
333
  let recentred = false
@@ -0,0 +1,24 @@
1
+ import type { MaterialSwatch } from '@pascal-app/core'
2
+ import type { CSSProperties } from 'react'
3
+
4
+ /**
5
+ * The CSS that paints a material swatch: the image over its colour, and a tinted
6
+ * albedo multiplied by the colour, as the 3D material tints its map.
7
+ */
8
+ export function materialSwatchStyle({
9
+ color,
10
+ imageUrl,
11
+ tinted,
12
+ }: Pick<MaterialSwatch, 'color' | 'imageUrl'> & { tinted?: boolean }): CSSProperties {
13
+ return {
14
+ backgroundColor: color,
15
+ ...(imageUrl
16
+ ? {
17
+ backgroundImage: `url(${JSON.stringify(imageUrl)})`,
18
+ backgroundPosition: 'center',
19
+ backgroundSize: 'cover',
20
+ ...(tinted ? { backgroundBlendMode: 'multiply' } : {}),
21
+ }
22
+ : {}),
23
+ }
24
+ }
@@ -1,6 +1,7 @@
1
1
  // Must precede the lingo import: lingo clones its kind table with
2
2
  // structuredClone while its module body evaluates. See the file's own comment.
3
3
  import './structured-clone-fallback'
4
+ import { normalizeDecimalInput } from '@pascal-app/core'
4
5
  import { type Kind, parseQuantity, quantity } from '@pascal-app/lingo'
5
6
 
6
7
  /**
@@ -56,6 +57,8 @@ export interface ParseMeasurementOptions {
56
57
  bareUnit?: string
57
58
  /** Disambiguates gal/ton/cup families; harmless for length/angle. */
58
59
  system?: 'metric' | 'us' | 'imperial'
60
+ /** Locale whose decimal separator is accepted in bare values and quantities. */
61
+ locale?: string
59
62
  }
60
63
 
61
64
  /**
@@ -68,7 +71,7 @@ export function parseMeasurement(
68
71
  spec: LingoUnitSpec,
69
72
  options: ParseMeasurementOptions = {},
70
73
  ): number | null {
71
- const result = parseQuantity(raw, {
74
+ const result = parseQuantity(normalizeDecimalInput(raw, options.locale), {
72
75
  kind: spec.kind,
73
76
  unit: options.bareUnit ?? spec.unitId,
74
77
  system: options.system,
@@ -105,9 +108,10 @@ export function measurementHint(
105
108
  options: MeasurementHintOptions = {},
106
109
  ): string | null {
107
110
  const trimmed = raw.trim()
108
- if (!trimmed || PLAIN_NUMBER.test(trimmed)) return null
111
+ const normalized = normalizeDecimalInput(trimmed, options.locale)
112
+ if (!normalized || PLAIN_NUMBER.test(normalized)) return null
109
113
 
110
- const parsed = parseMeasurement(trimmed, spec, options)
114
+ const parsed = parseMeasurement(normalized, spec, options)
111
115
  if (parsed === null) return null
112
116
  const stored = options.clamp ? options.clamp(parsed) : parsed
113
117
 
@@ -18,6 +18,7 @@ import {
18
18
  measurementFloorplanPresentationColor,
19
19
  measurementPresentationColor,
20
20
  metersToLinearUnit,
21
+ parseLinearMeasurementInput,
21
22
  squareMetersToAreaUnit,
22
23
  } from './measurements'
23
24
 
@@ -129,6 +130,11 @@ describe('linear measurements', () => {
129
130
  expect(linearControlValueToMeters(3.5, 'metric')).toBe(3.5)
130
131
  })
131
132
 
133
+ test('parses floor-plan measurement input with the scoped locale', () => {
134
+ expect(parseLinearMeasurementInput('2,75', 'metric', 'fr')).toBe(2.75)
135
+ expect(parseLinearMeasurementInput('2,75', 'metric', 'en')).toBeNull()
136
+ })
137
+
132
138
  test('clamps numeric control input after converting to meters', () => {
133
139
  expect(linearControlValueToMeters(0.1, 'imperial', { minMeters: 0.1 })).toBe(0.1)
134
140
  expect(linearControlValueToMeters(0.3, 'imperial', { minMeters: 0.1 })).toBe(0.1)
@@ -212,4 +218,15 @@ describe('typed measurement fields', () => {
212
218
  expect(measurementHint('180cm', length)).toBe('= 1.80 m')
213
219
  expect(measurementHint('6', length)).toBeNull()
214
220
  })
221
+
222
+ test('only treats the active locale decimal separator as a plain decimal', () => {
223
+ const length = { kind: 'length', unitId: 'm' } as const
224
+
225
+ expect(measurementHint('1,000', length, { locale: 'en' })).not.toBeNull()
226
+ expect(measurementHint('-1,000', length, { locale: 'en' })).not.toBeNull()
227
+ expect(measurementHint('1,5', length, { locale: 'en' })).not.toBeNull()
228
+
229
+ expect(measurementHint('1,5', length, { locale: 'fr' })).toBeNull()
230
+ expect(parseMeasurement('2,75 m', length, { locale: 'fr' })).toBe(2.75)
231
+ })
215
232
  })
@@ -1,4 +1,4 @@
1
- import type { MeasurementPoint } from '@pascal-app/core'
1
+ import { formatNumber, type MeasurementPoint, parseLocalizedNumber } from '@pascal-app/core'
2
2
 
3
3
  export type LinearUnit = 'metric' | 'imperial'
4
4
  export type MetricNotation = 'meters' | 'millimeters'
@@ -119,6 +119,15 @@ export function linearUnitToMeters(value: number, unit: LinearUnit): number {
119
119
  return unit === 'imperial' ? value * METERS_PER_FOOT : value
120
120
  }
121
121
 
122
+ export function parseLinearMeasurementInput(
123
+ value: string,
124
+ unit: LinearUnit,
125
+ locale: string,
126
+ ): number | null {
127
+ const parsed = parseLocalizedNumber(value, locale)
128
+ return parsed === null ? null : linearUnitToMeters(parsed, unit)
129
+ }
130
+
122
131
  export function linearControlValueToMeters(
123
132
  value: number,
124
133
  unit: LinearUnit,
@@ -153,7 +162,10 @@ export function formatAreaLabel(
153
162
  ): string {
154
163
  if (!Number.isFinite(squareMeters)) return '--'
155
164
 
156
- return `${squareMetersToAreaUnit(squareMeters, unit).toFixed(fractionDigits)}${getAreaUnitLabel(unit)}`
165
+ return `${formatNumber(squareMetersToAreaUnit(squareMeters, unit), {
166
+ min: fractionDigits,
167
+ max: fractionDigits,
168
+ })}${getAreaUnitLabel(unit)}`
157
169
  }
158
170
 
159
171
  export function cubicMetersToVolumeUnit(cubicMeters: number, unit: LinearUnit): number {
@@ -171,7 +183,10 @@ export function formatVolumeLabel(
171
183
  ): string {
172
184
  if (!Number.isFinite(cubicMeters)) return '--'
173
185
 
174
- return `${cubicMetersToVolumeUnit(cubicMeters, unit).toFixed(fractionDigits)}${getVolumeUnitLabel(unit)}`
186
+ return `${formatNumber(cubicMetersToVolumeUnit(cubicMeters, unit), {
187
+ min: fractionDigits,
188
+ max: fractionDigits,
189
+ })}${getVolumeUnitLabel(unit)}`
175
190
  }
176
191
 
177
192
  export function formatLinearMeasurement(
@@ -200,11 +215,11 @@ export function formatLinearMeasurement(
200
215
  if (metricNotation === 'millimeters') {
201
216
  const roundedMillimeters = Math.round(absoluteMeters * 1000)
202
217
  const sign = meters < 0 && roundedMillimeters !== 0 ? '-' : ''
203
- return `${sign}${roundedMillimeters}mm`
218
+ return `${sign}${formatNumber(roundedMillimeters, { max: 0 })}mm`
204
219
  }
205
220
 
206
221
  const roundedMeters = Number.parseFloat(absoluteMeters.toFixed(2))
207
222
  const sign = meters < 0 && roundedMeters !== 0 ? '-' : ''
208
223
 
209
- return `${sign}${roundedMeters}m`
224
+ return `${sign}${formatNumber(roundedMeters, { max: 2 })}m`
210
225
  }
@@ -169,7 +169,9 @@ describe('mezzanine selection', () => {
169
169
  expect(gesture(hostId, ROOM_MOVE_DRAG_LABEL)).toBeNull()
170
170
  expect(gesture(mezzanineId, 'height')).toBeNull()
171
171
  expect(mezzanineGesture(null, nodes())).toBeNull()
172
- expect(MEZZANINE_GESTURE_HINTS.move[0]!.label).toBe('Place inside the room')
172
+ expect(MEZZANINE_GESTURE_HINTS.move[0]!.label).toMatchObject({
173
+ message: 'Place inside the room',
174
+ })
173
175
  })
174
176
 
175
177
  test('the delete dialog speaks of the mezzanine, and of its items only when it has some', () => {
@@ -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: {
@@ -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
+ }