@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,142 @@
1
+ export type FitSceneBounds = {
2
+ center: [number, number]
3
+ size: [number, number]
4
+ } | null
5
+
6
+ export type FitSceneLookAt = {
7
+ eyeX: number
8
+ eyeY: number
9
+ eyeZ: number
10
+ targetX: number
11
+ targetY: number
12
+ targetZ: number
13
+ /** The eye's height above `targetY`, before any keep-over-ground lift. */
14
+ eyeHeight: number
15
+ /**
16
+ * What an orthographic camera fits its zoom to: `setLookAt` leaves the
17
+ * previous project's zoom in place. Centred on the target, so fitting it
18
+ * does not move the pivot.
19
+ */
20
+ fitSphere: { center: [number, number, number]; radius: number }
21
+ }
22
+
23
+ // The empty-editor pose shows the default ±15 m site.
24
+ const DEFAULT_FIT_RADIUS = 15
25
+ // Margin around the footprint, as the frame-node path uses for orthographic fits.
26
+ const FIT_SPHERE_MARGIN = 1.3
27
+ // Keeps a lone wall from filling the viewport, like the perspective 15 m floor.
28
+ const MIN_FIT_RADIUS = 5
29
+
30
+ export type PendingFitScene = {
31
+ bounds: FitSceneBounds
32
+ }
33
+
34
+ export type FitSceneEventPlan =
35
+ | { action: 'ignore' }
36
+ | { action: 'apply'; lookAt: FitSceneLookAt }
37
+ | { action: 'queue'; pending: PendingFitScene }
38
+
39
+ export type FitSceneResumePlan = { action: 'noop' } | { action: 'apply'; lookAt: FitSceneLookAt }
40
+
41
+ /**
42
+ * Compute the orbit look-at used by `camera-controls:fit-scene`, resting the
43
+ * pivot at `groundY` (the selected level's orbit height).
44
+ * Null bounds restore the empty-editor default pose.
45
+ */
46
+ export function computeFitSceneLookAt(bounds: FitSceneBounds, groundY = 0): FitSceneLookAt {
47
+ if (!bounds) {
48
+ return {
49
+ eyeX: 20,
50
+ eyeY: 20 + groundY,
51
+ eyeZ: 20,
52
+ targetX: 0,
53
+ targetY: groundY,
54
+ targetZ: 0,
55
+ eyeHeight: 20,
56
+ fitSphere: { center: [0, groundY, 0], radius: DEFAULT_FIT_RADIUS },
57
+ }
58
+ }
59
+
60
+ const [cx, cz] = bounds.center
61
+ const [w, d] = bounds.size
62
+ // Use the longer horizontal extent to size the orbit radius so the whole
63
+ // footprint sits in view regardless of aspect ratio.
64
+ const maxExtent = Math.max(w, d)
65
+ const distance = Math.max(maxExtent * 1.4, 15)
66
+ const height = Math.max(maxExtent * 0.8, 10)
67
+ return {
68
+ eyeX: cx + distance * 0.7,
69
+ eyeY: height + groundY,
70
+ eyeZ: cz + distance * 0.7,
71
+ targetX: cx,
72
+ targetY: groundY,
73
+ targetZ: cz,
74
+ eyeHeight: height,
75
+ fitSphere: {
76
+ center: [cx, groundY, cz],
77
+ radius: Math.max((Math.hypot(w, d) / 2) * FIT_SPHERE_MARGIN, MIN_FIT_RADIUS),
78
+ },
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Decide what to do when a fit-scene event arrives.
84
+ * First-person (and briefly-unmounted orbit controls) must queue the frame
85
+ * so it can apply once orbit is back.
86
+ */
87
+ export function planFitSceneOnEvent(input: {
88
+ isPreviewMode: boolean
89
+ isFirstPersonMode: boolean
90
+ hasControls: boolean
91
+ bounds: FitSceneBounds
92
+ groundY?: number
93
+ }): FitSceneEventPlan {
94
+ if (input.isPreviewMode) return { action: 'ignore' }
95
+
96
+ const pending: PendingFitScene = { bounds: input.bounds }
97
+ if (input.isFirstPersonMode || !input.hasControls) {
98
+ return { action: 'queue', pending }
99
+ }
100
+
101
+ return { action: 'apply', lookAt: computeFitSceneLookAt(input.bounds, input.groundY) }
102
+ }
103
+
104
+ /**
105
+ * Flush a queued load frame once orbit controls can apply it. The ground is
106
+ * read at flush time: the selected level may have changed while it waited.
107
+ */
108
+ export function planFitSceneOnOrbitResume(input: {
109
+ isPreviewMode: boolean
110
+ isFirstPersonMode: boolean
111
+ hasControls: boolean
112
+ pending: PendingFitScene | null
113
+ groundY?: number
114
+ }): FitSceneResumePlan {
115
+ if (!input.pending || input.isPreviewMode || input.isFirstPersonMode || !input.hasControls) {
116
+ return { action: 'noop' }
117
+ }
118
+
119
+ return { action: 'apply', lookAt: computeFitSceneLookAt(input.pending.bounds, input.groundY) }
120
+ }
121
+
122
+ export type LevelFollowPlan = 'none' | 'default-pose' | 'rest-pivot'
123
+
124
+ /**
125
+ * What the level-follow effect does with the orbit pivot.
126
+ *
127
+ * Only a scene-less first run takes the default pose. A first run over a
128
+ * loaded scene is a fresh controls instance (mount, leaving XR, error-boundary
129
+ * recovery) whose pivot sits at the origin, so it is rested on the level like
130
+ * a camera change; on a real load the later scene-ready fit still wins.
131
+ * Afterwards the pivot follows level, level-mode and camera changes only.
132
+ */
133
+ export function planLevelFollow(input: {
134
+ firstLoad: boolean
135
+ sceneEmpty: boolean
136
+ levelChanged: boolean
137
+ modeChanged: boolean
138
+ cameraChanged: boolean
139
+ }): LevelFollowPlan {
140
+ if (input.firstLoad) return input.sceneEmpty ? 'default-pose' : 'rest-pivot'
141
+ return input.levelChanged || input.modeChanged || input.cameraChanged ? 'rest-pivot' : 'none'
142
+ }
@@ -14,6 +14,7 @@ import {
14
14
  getWallThickness,
15
15
  isCurvedWall,
16
16
  isDerivedNode,
17
+ isNodeDuplicable,
17
18
  isRegistryMovable,
18
19
  isRegistrySelectable,
19
20
  isSplineFence,
@@ -24,11 +25,11 @@ import {
24
25
  sceneRegistry,
25
26
  summarizeSystemFor,
26
27
  useLiveNodeOverrides,
28
+ usePascalI18n,
27
29
  useScene,
28
30
  type WallNode,
29
31
  } from '@pascal-app/core'
30
32
  import { useViewer } from '@pascal-app/viewer'
31
- import { Html } from '@react-three/drei'
32
33
  import { useFrame } from '@react-three/fiber'
33
34
  import { useCallback, useMemo, useRef } from 'react'
34
35
  import * as THREE from 'three'
@@ -53,6 +54,7 @@ import useInteractionScope, {
53
54
  } from '../../store/use-interaction-scope'
54
55
  import { IconRefGlyph } from '../ui/icon-ref'
55
56
  import { duplicateNodeAndPickUp } from './duplicate-node'
57
+ import { Html } from './localized-html'
56
58
  import { formatMeasurement, MeasurementPill } from './measurement-pill'
57
59
  import { NodeActionMenu } from './node-action-menu'
58
60
  import { startZoneRoomTransform } from './room-controls'
@@ -283,7 +285,7 @@ function getHeightPillDimensions(node: WallNode | FenceNode): {
283
285
  } {
284
286
  if (node.type === 'wall') {
285
287
  return {
286
- height: getWallEffectiveHeightForNodes(node, useScene.getState().nodes),
288
+ height: getWallEffectiveHeightForNodes(node, useScene.getState().nodes, 0),
287
289
  length: getWallCurveLength(node),
288
290
  thickness: getWallThickness(node),
289
291
  }
@@ -296,6 +298,7 @@ function getHeightPillDimensions(node: WallNode | FenceNode): {
296
298
  }
297
299
 
298
300
  export function FloatingActionMenu() {
301
+ const i18n = usePascalI18n()
299
302
  const reducedMotion = useReducedMotion()
300
303
  const selectedIds = useViewer((s) => s.selection.selectedIds)
301
304
  const updateNode = useScene((s) => s.updateNode)
@@ -421,7 +424,7 @@ export function FloatingActionMenu() {
421
424
  | undefined
422
425
  const fallbackHeight =
423
426
  node.type === 'wall'
424
- ? getWallEffectiveHeightForNodes(node, useScene.getState().nodes)
427
+ ? getWallEffectiveHeightForNodes(node, useScene.getState().nodes, 0)
425
428
  : FENCE_DEFAULT_HEIGHT
426
429
  const liveHeight = override?.height ?? node.height ?? fallbackHeight
427
430
  pillHeightRef.current.textContent = `H ${formatMeasurement(liveHeight, unit, metricNotation)}`
@@ -679,6 +682,7 @@ export function FloatingActionMenu() {
679
682
  onDelete={handleDelete}
680
683
  onDuplicate={
681
684
  node &&
685
+ isNodeDuplicable(node as AnyNode) &&
682
686
  node.type !== 'spawn' &&
683
687
  !DELETE_ONLY_TYPES.includes(node.type) &&
684
688
  !HOLE_TYPES.includes(node.type)
@@ -749,16 +753,21 @@ export function FloatingActionMenu() {
749
753
  {hasAxisCycling(node.type) ? (
750
754
  <div className="flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs tabular-nums shadow-sm backdrop-blur">
751
755
  <span className="font-medium text-foreground">
752
- Axis {rotationAxis.toUpperCase()}
756
+ {i18n._({ id: 'editor.floatingActionMenu.axis', message: 'Axis' })}{' '}
757
+ {rotationAxis.toUpperCase()}
753
758
  </span>
754
759
  <span aria-hidden className="text-muted-foreground">
755
760
  ·
756
761
  </span>
757
- <span className="text-muted-foreground">R/T rotate</span>
762
+ <span className="text-muted-foreground">
763
+ {i18n._({ id: 'editor.floatingActionMenu.rTRotate', message: 'R/T rotate' })}
764
+ </span>
758
765
  <span aria-hidden className="text-muted-foreground">
759
766
  ·
760
767
  </span>
761
- <span className="text-muted-foreground">⌥ axis</span>
768
+ <span className="text-muted-foreground">
769
+ {i18n._({ id: 'editor.floatingActionMenu.axis2', message: '⌥ axis' })}
770
+ </span>
762
771
  </div>
763
772
  ) : null}
764
773
  </div>
@@ -788,6 +797,7 @@ function SystemSummaryPill({
788
797
  nodeId: AnyNodeId
789
798
  unit: 'metric' | 'imperial'
790
799
  }) {
800
+ const i18n = usePascalI18n()
791
801
  const allNodes = useScene((s) => s.nodes)
792
802
  const summary = useMemo(() => summarizeSystemFor(nodeId, allNodes), [nodeId, allNodes])
793
803
  if (!summary) return null
@@ -796,7 +806,7 @@ function SystemSummaryPill({
796
806
  <span className="font-medium text-foreground">
797
807
  {summary.systems.length > 0
798
808
  ? summary.systems.map((sys) => sys[0]!.toUpperCase() + sys.slice(1)).join(' + ')
799
- : 'System'}
809
+ : i18n._({ id: 'editor.floatingActionMenu.system', message: 'System' })}
800
810
  </span>
801
811
  {summary.runCount > 0 ? (
802
812
  <>
@@ -804,8 +814,12 @@ function SystemSummaryPill({
804
814
  ·
805
815
  </span>
806
816
  <span className="text-muted-foreground">
807
- {formatMeasurement(summary.runLengthM, unit, metricNotation)} · {summary.runCount}{' '}
808
- {summary.runCount === 1 ? 'run' : 'runs'}
817
+ {formatMeasurement(summary.runLengthM, unit, metricNotation)} ·{' '}
818
+ {i18n._({
819
+ id: 'editor.floatingActionMenu.runCount',
820
+ message: '{count, plural, one {{number} run} other {{number} runs}}',
821
+ values: { count: summary.runCount, number: String(summary.runCount) },
822
+ })}
809
823
  </span>
810
824
  </>
811
825
  ) : null}
@@ -815,7 +829,11 @@ function SystemSummaryPill({
815
829
  ·
816
830
  </span>
817
831
  <span className="text-muted-foreground">
818
- {summary.terminalCount} {summary.terminalCount === 1 ? 'register' : 'registers'}
832
+ {i18n._({
833
+ id: 'editor.floatingActionMenu.registerCount',
834
+ message: '{count, plural, one {# register} other {# registers}}',
835
+ values: { count: summary.terminalCount },
836
+ })}
819
837
  </span>
820
838
  </>
821
839
  ) : null}
@@ -824,7 +842,9 @@ function SystemSummaryPill({
824
842
  <span aria-hidden className="text-muted-foreground">
825
843
  ·
826
844
  </span>
827
- <span className="font-medium text-amber-500">⚠ no equipment</span>
845
+ <span className="font-medium text-amber-500">
846
+ {i18n._({ id: 'editor.floatingActionMenu.noEquipment', message: '⚠ no equipment' })}
847
+ </span>
828
848
  </>
829
849
  )}
830
850
  </div>
@@ -2,12 +2,12 @@
2
2
 
3
3
  import { type BuildingNode, sceneRegistry, useScene } from '@pascal-app/core'
4
4
  import { useViewer } from '@pascal-app/viewer'
5
- import { Html } from '@react-three/drei'
6
5
  import { useFrame } from '@react-three/fiber'
7
6
  import { useCallback, useRef } from 'react'
8
7
  import * as THREE from 'three'
9
8
  import { sfxEmitter } from '../../lib/sfx-bus'
10
9
  import useEditor from '../../store/use-editor'
10
+ import { Html } from './localized-html'
11
11
  import { NodeActionMenu } from './node-action-menu'
12
12
 
13
13
  export function FloatingBuildingActionMenu() {
@@ -5,6 +5,7 @@ import {
5
5
  type AnyNodeId,
6
6
  type FloorOpeningNode,
7
7
  resolveCeilingHeight,
8
+ roomFloorElevation,
8
9
  sceneRegistry,
9
10
  useScene,
10
11
  } from '@pascal-app/core'
@@ -24,7 +25,6 @@ import { useCeilingEditSession } from '../../lib/ceiling-edit-session'
24
25
  import { EDITOR_LAYER } from '../../lib/constants'
25
26
  import { openingRoom, roomSurfaceOpenings, useFloorEditSession } from '../../lib/floor-edit-session'
26
27
  import { useOpeningDraft } from '../../lib/floor-opening-draft'
27
- import { roomFloorElevation } from '../../lib/room-handle-drag'
28
28
  import { PolygonEditor } from '../tools/shared/polygon-editor'
29
29
 
30
30
  // The openings of the room being edited ("Edit floor", "Edit ceiling", or a
@@ -3,8 +3,10 @@
3
3
  import {
4
4
  containsPoint,
5
5
  emitter,
6
+ formatAreaLabel,
6
7
  type MultiPolygon,
7
8
  resolveCeilingHeight,
9
+ roomFloorElevation,
8
10
  sceneRegistry,
9
11
  useScene,
10
12
  } from '@pascal-app/core'
@@ -60,14 +62,12 @@ import {
60
62
  } from '../../lib/floor-region-session'
61
63
  import { type FloorRegionPoint, snapFloorRegionPoint } from '../../lib/floor-region-snap'
62
64
  import { clientToPlan } from '../../lib/floorplan/plan-coords'
63
- import { formatAreaLabel } from '../../lib/measurements'
64
65
  import {
65
66
  cancelMezzanineDraft,
66
67
  mezzanineDraftActive,
67
68
  useMezzanineDraft,
68
69
  } from '../../lib/mezzanine-draft'
69
70
  import { usePaintRegionMode } from '../../lib/paint-region-mode'
70
- import { roomFloorElevation } from '../../lib/room-handle-drag'
71
71
  import {
72
72
  cancelTerraceDraft,
73
73
  terraceDraftActive,
@@ -0,0 +1,220 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { GuideScaleReference } from '@pascal-app/core'
3
+ import { z } from 'zod'
4
+ import { createGuideScaleReference, formatReferenceScaleLabel } from '../../lib/reference-scale'
5
+ import {
6
+ formatFloorplanNumber,
7
+ getReferenceScaleInputError,
8
+ parseReferenceScaleLength,
9
+ } from './floorplan-panel'
10
+
11
+ const Core106GuideScaleReference = z.object({
12
+ start: z.tuple([z.number(), z.number()]),
13
+ end: z.tuple([z.number(), z.number()]),
14
+ realLengthMeters: z.number().positive(),
15
+ measuredLengthUnits: z.number().positive(),
16
+ metersPerUnit: z.number().positive(),
17
+ label: z.string(),
18
+ })
19
+
20
+ function runtimeScaleReference(overrides: Record<string, unknown> = {}): GuideScaleReference {
21
+ return {
22
+ start: [0, 0],
23
+ end: [1, 0],
24
+ realLengthMeters: 2.75,
25
+ measuredLengthUnits: 1,
26
+ metersPerUnit: 2.75,
27
+ label: '2.75 m',
28
+ ...overrides,
29
+ } as unknown as GuideScaleReference
30
+ }
31
+
32
+ test('floor-plan reference scales parse with the scoped locale', () => {
33
+ expect(parseReferenceScaleLength('1,000', 'meters', 'fr')).toBe(1)
34
+ expect(parseReferenceScaleLength('1,000', 'meters', 'en')).toBe(1000)
35
+ })
36
+
37
+ test('floor-plan reference scales display with the scoped locale', () => {
38
+ expect(formatFloorplanNumber(2.75, 'fr')).toBe('2,75')
39
+ expect(formatFloorplanNumber(2.75, 'en')).toBe('2.75')
40
+ })
41
+
42
+ test('reference-scale scenes remain compatible and format numeric and legacy labels', () => {
43
+ const reference = createGuideScaleReference(
44
+ {
45
+ start: [0, 0],
46
+ end: [1, 0],
47
+ realLengthMeters: 2.75,
48
+ measuredLengthUnits: 1,
49
+ metersPerUnit: 2.75,
50
+ },
51
+ 2.75,
52
+ 'meters',
53
+ )
54
+
55
+ expect(reference).toEqual({
56
+ start: [0, 0],
57
+ end: [1, 0],
58
+ realLengthMeters: 2.75,
59
+ measuredLengthUnits: 1,
60
+ metersPerUnit: 2.75,
61
+ displayLength: 2.75,
62
+ displayUnit: 'meters',
63
+ label: '2.75 m',
64
+ })
65
+ expect(Core106GuideScaleReference.safeParse(reference).success).toBe(true)
66
+ expect(formatReferenceScaleLabel(reference, 'fr')).toBe('2,75 m')
67
+ expect(formatReferenceScaleLabel(reference, 'en')).toBe('2.75 m')
68
+ const legacyFrenchReference = GuideScaleReference.parse({
69
+ start: [0, 0],
70
+ end: [1, 0],
71
+ realLengthMeters: 2.75,
72
+ measuredLengthUnits: 1,
73
+ metersPerUnit: 2.75,
74
+ label: '2,75 m',
75
+ })
76
+ expect(formatReferenceScaleLabel(legacyFrenchReference, 'en')).toBe('2.75 m')
77
+ expect(
78
+ formatReferenceScaleLabel(
79
+ GuideScaleReference.parse({
80
+ ...legacyFrenchReference,
81
+ label: '2.75 m',
82
+ }),
83
+ 'fr',
84
+ ),
85
+ ).toBe('2,75 m')
86
+ expect(
87
+ formatReferenceScaleLabel(
88
+ GuideScaleReference.parse({
89
+ ...legacyFrenchReference,
90
+ label: 'Known span',
91
+ }),
92
+ 'fr',
93
+ ),
94
+ ).toBe('Known span')
95
+ })
96
+
97
+ test('oversized legacy reference labels bypass parsing within the input budget', () => {
98
+ const label = `${'1, '.repeat(34_000)}?`
99
+ const reference = runtimeScaleReference({ label })
100
+ const startedAt = performance.now()
101
+ const formatted = formatReferenceScaleLabel(reference, 'en')
102
+ const elapsed = performance.now() - startedAt
103
+
104
+ expect(formatted).toBe(label)
105
+ expect(elapsed).toBeLessThan(50)
106
+ })
107
+
108
+ test('reference-scale labels tolerate malformed persisted values', () => {
109
+ expect(formatReferenceScaleLabel(runtimeScaleReference({ label: 42 }), 'en')).toBe('42')
110
+ expect(formatReferenceScaleLabel(runtimeScaleReference({ label: null }), 'en')).toBe('')
111
+ expect(
112
+ formatReferenceScaleLabel(runtimeScaleReference({ label: { toString: null } }), 'en'),
113
+ ).toBe('')
114
+ expect(
115
+ formatReferenceScaleLabel(runtimeScaleReference({ label: Object.create(null) }), 'en'),
116
+ ).toBe('')
117
+ expect(formatReferenceScaleLabel(runtimeScaleReference({ label: Symbol('x') }), 'en')).toBe('')
118
+ expect(formatReferenceScaleLabel(runtimeScaleReference({ label: ['1 m'] }), 'en')).toBe('')
119
+ expect(
120
+ formatReferenceScaleLabel(
121
+ runtimeScaleReference({
122
+ displayLength: 2.75,
123
+ displayUnit: '__proto__',
124
+ label: 'fallback',
125
+ }),
126
+ 'en',
127
+ ),
128
+ ).toBe('fallback')
129
+ expect(
130
+ formatReferenceScaleLabel(
131
+ runtimeScaleReference({
132
+ displayLength: 2.75,
133
+ displayUnit: 'constructor',
134
+ label: 'fallback',
135
+ }),
136
+ 'en',
137
+ ),
138
+ ).toBe('fallback')
139
+ expect(
140
+ formatReferenceScaleLabel(
141
+ runtimeScaleReference({
142
+ displayLength: Number.POSITIVE_INFINITY,
143
+ displayUnit: 'meters',
144
+ label: 'fallback',
145
+ }),
146
+ 'en',
147
+ ),
148
+ ).toBe('fallback')
149
+ expect(
150
+ formatReferenceScaleLabel(
151
+ runtimeScaleReference({
152
+ displayLength: Number.NaN,
153
+ displayUnit: 'meters',
154
+ label: 'fallback',
155
+ }),
156
+ 'en',
157
+ ),
158
+ ).toBe('fallback')
159
+ })
160
+
161
+ test('reference-scale schema caps persisted display lengths', () => {
162
+ expect(
163
+ GuideScaleReference.safeParse(
164
+ runtimeScaleReference({
165
+ displayLength: 1_000_000,
166
+ displayUnit: 'meters',
167
+ }),
168
+ ).success,
169
+ ).toBe(true)
170
+ expect(
171
+ GuideScaleReference.safeParse(
172
+ runtimeScaleReference({
173
+ displayLength: 1_000_001,
174
+ displayUnit: 'meters',
175
+ }),
176
+ ).success,
177
+ ).toBe(false)
178
+ })
179
+
180
+ test('reference-scale calibration rejects lengths the schema would not accept', () => {
181
+ expect(getReferenceScaleInputError('1000000', 'meters', 'en')).toBeNull()
182
+ expect(getReferenceScaleInputError('2.75', 'feet', 'en')).toBeNull()
183
+ expect(getReferenceScaleInputError('1000001', 'meters', 'en')).toBe(
184
+ 'Length must be at most 1000000 m.',
185
+ )
186
+ expect(getReferenceScaleInputError('1e9', 'centimeters', 'en')).toBe(
187
+ 'Length must be at most 1000000 cm.',
188
+ )
189
+ expect(getReferenceScaleInputError('0', 'meters', 'en')).toBe('Length must be greater than 0.')
190
+ expect(getReferenceScaleInputError('', 'meters', 'en')).toBe('Enter the real length of the line.')
191
+ })
192
+
193
+ test('reference-scale creation drops display fields outside the schema range', () => {
194
+ const base = {
195
+ start: [0, 0] as [number, number],
196
+ end: [1, 0] as [number, number],
197
+ realLengthMeters: 2_000_000,
198
+ measuredLengthUnits: 1,
199
+ metersPerUnit: 2_000_000,
200
+ }
201
+ const oversized = createGuideScaleReference(base, 2_000_000, 'meters')
202
+ expect(oversized.displayLength).toBeUndefined()
203
+ expect(oversized.displayUnit).toBeUndefined()
204
+ expect(oversized.label).toBe('2000000 m')
205
+ expect(GuideScaleReference.safeParse(oversized).success).toBe(true)
206
+
207
+ const atCap = createGuideScaleReference(base, 1_000_000, 'meters')
208
+ expect(atCap.displayLength).toBe(1_000_000)
209
+ expect(GuideScaleReference.safeParse(atCap).success).toBe(true)
210
+ })
211
+
212
+ test('legacy reference-scale labels accept whitespace runs between digit groups', () => {
213
+ const label = (value: string) =>
214
+ formatReferenceScaleLabel(runtimeScaleReference({ label: value }), 'en')
215
+ expect(label('1 234,5 m')).toBe('1234.5 m')
216
+ expect(label('1 \u00a0234 cm')).toBe('1234 cm')
217
+ expect(label('12\u202f345\t\t678 ft')).toBe('12345678 ft')
218
+ expect(label('1 234 m')).toBe('1234 m')
219
+ expect(label('1 234 ')).toBe('1 234 ')
220
+ })