@pascal-app/editor 0.9.1 → 1.0.0-beta.1

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 (366) hide show
  1. package/package.json +14 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +49 -0
  4. package/src/components/editor/bake-thumbnail.tsx +95 -0
  5. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  6. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  7. package/src/components/editor/custom-camera-controls.tsx +332 -199
  8. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  9. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  10. package/src/components/editor/editor-layout-v2.tsx +18 -0
  11. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  12. package/src/components/editor/export-manager.tsx +71 -63
  13. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  14. package/src/components/editor/first-person/build-collider-world.test.ts +102 -5
  15. package/src/components/editor/first-person/build-collider-world.ts +52 -5
  16. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  17. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  18. package/src/components/editor/first-person-controls.tsx +410 -136
  19. package/src/components/editor/floating-action-menu.tsx +435 -64
  20. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  21. package/src/components/editor/floorplan-background-selection.test.ts +72 -0
  22. package/src/components/editor/floorplan-background-selection.ts +7 -20
  23. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  24. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  25. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  26. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  27. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  28. package/src/components/editor/floorplan-panel.tsx +2517 -1492
  29. package/src/components/editor/grid.tsx +166 -39
  30. package/src/components/editor/group-actions.ts +597 -0
  31. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  32. package/src/components/editor/group-move-3d.ts +397 -0
  33. package/src/components/editor/group-rotate-handle.tsx +102 -64
  34. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  35. package/src/components/editor/group-transform-shared.test.ts +192 -1
  36. package/src/components/editor/group-transform-shared.ts +240 -7
  37. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  38. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  39. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  40. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  41. package/src/components/editor/handles/preview-overrides.ts +13 -0
  42. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  43. package/src/components/editor/handles/resize-snap.ts +23 -0
  44. package/src/components/editor/handles/use-handle-drag.ts +49 -6
  45. package/src/components/editor/index.tsx +289 -35
  46. package/src/components/editor/measurement-pill.tsx +68 -23
  47. package/src/components/editor/node-action-menu.tsx +14 -1
  48. package/src/components/editor/node-arrow-handles.tsx +409 -180
  49. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  50. package/src/components/editor/quick-measurement-card.tsx +76 -0
  51. package/src/components/editor/quick-measurement-hud.tsx +34 -0
  52. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  53. package/src/components/editor/selection-manager.tsx +814 -466
  54. package/src/components/editor/site-edge-labels.tsx +28 -6
  55. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  56. package/src/components/editor/snapshot-capture-overlay.tsx +258 -115
  57. package/src/components/editor/three-context-bridge.ts +22 -0
  58. package/src/components/editor/thumbnail-generator.tsx +118 -253
  59. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  60. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  61. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  62. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  63. package/src/components/editor/use-mesh-settle-epoch.ts +27 -0
  64. package/src/components/editor/wall-measurement-label.tsx +10 -28
  65. package/src/components/editor/wall-move-side-handles.tsx +229 -39
  66. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  67. package/src/components/editor/wall-snap-beacon-layer.tsx +162 -6
  68. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  69. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  70. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +40 -9
  71. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  72. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  73. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  74. package/src/components/editor-2d/floorplan-group-move.tsx +744 -0
  75. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  76. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1672 -0
  77. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  78. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  79. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +219 -20
  80. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +327 -59
  81. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  82. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  83. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  84. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  85. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  86. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  87. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  88. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  89. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  90. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +89 -0
  91. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +106 -0
  92. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +94 -26
  93. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +587 -0
  94. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +2280 -578
  95. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  96. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  97. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +425 -61
  98. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  99. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  100. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  101. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  102. package/src/components/systems/zone/zone-system.tsx +42 -13
  103. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  104. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  105. package/src/components/tools/fence/fence-drafting.ts +107 -8
  106. package/src/components/tools/item/move-tool.tsx +17 -13
  107. package/src/components/tools/item/placement-math.test.ts +13 -1
  108. package/src/components/tools/item/placement-math.ts +28 -2
  109. package/src/components/tools/item/placement-strategies.ts +246 -5
  110. package/src/components/tools/item/placement-types.ts +13 -1
  111. package/src/components/tools/item/use-draft-node.ts +146 -57
  112. package/src/components/tools/item/use-placement-coordinator.tsx +813 -125
  113. package/src/components/tools/registry/move-registry-node-tool.tsx +636 -103
  114. package/src/components/tools/roof/roof-tool.tsx +336 -94
  115. package/src/components/tools/select/box-select-state.ts +18 -7
  116. package/src/components/tools/select/box-select-tool.tsx +176 -40
  117. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  118. package/src/components/tools/select/marquee-geometry.ts +155 -0
  119. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  120. package/src/components/tools/select/select-candidates.ts +1 -1
  121. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  122. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  123. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  124. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  125. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  126. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  127. package/src/components/tools/shared/placement-box.tsx +183 -1
  128. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  129. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  130. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  131. package/src/components/tools/site/site-boundary-editor.tsx +101 -42
  132. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  133. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  134. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  135. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  136. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  137. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  138. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  139. package/src/components/tools/stair/stair-defaults.ts +1 -0
  140. package/src/components/tools/stair/stair-tool.tsx +164 -42
  141. package/src/components/tools/tool-manager.tsx +149 -30
  142. package/src/components/tools/wall/wall-drafting.test.ts +447 -0
  143. package/src/components/tools/wall/wall-drafting.ts +304 -108
  144. package/src/components/tools/wall/wall-snap-geometry.test.ts +52 -0
  145. package/src/components/tools/wall/wall-snap-geometry.ts +137 -9
  146. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  147. package/src/components/tools/zone/zone-tool.tsx +98 -88
  148. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  149. package/src/components/ui/action-menu/control-modes.tsx +44 -94
  150. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  151. package/src/components/ui/action-menu/index.tsx +2 -4
  152. package/src/components/ui/action-menu/measurement-control.tsx +267 -0
  153. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  154. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  155. package/src/components/ui/command-palette/editor-commands.tsx +16 -3
  156. package/src/components/ui/command-palette/index.tsx +4 -3
  157. package/src/components/ui/controls/material-paint-panel.tsx +93 -18
  158. package/src/components/ui/controls/material-picker.tsx +162 -152
  159. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  160. package/src/components/ui/controls/metric-control.tsx +118 -44
  161. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  162. package/src/components/ui/controls/slider-control.tsx +88 -38
  163. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  164. package/src/components/ui/floating-level-selector.tsx +74 -20
  165. package/src/components/ui/helpers/building-helper.tsx +10 -23
  166. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  167. package/src/components/ui/helpers/helper-manager.tsx +273 -14
  168. package/src/components/ui/helpers/item-helper.tsx +28 -32
  169. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  170. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  171. package/src/components/ui/icon-ref.tsx +47 -0
  172. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  173. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  174. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  175. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  176. package/src/components/ui/panels/node-display.ts +17 -17
  177. package/src/components/ui/panels/panel-manager.tsx +36 -14
  178. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  179. package/src/components/ui/panels/parametric-inspector.tsx +19 -4
  180. package/src/components/ui/panels/reference-panel.tsx +54 -19
  181. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  182. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  183. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  184. package/src/components/ui/primitives/sidebar.tsx +1 -0
  185. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  186. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  187. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  188. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +82 -41
  189. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +76 -6
  190. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  191. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  192. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  193. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  194. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  195. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  196. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  197. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  198. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  199. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  200. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -17
  201. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  202. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  203. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  204. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  205. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  206. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  207. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  208. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  209. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  210. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  211. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  212. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  213. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  214. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  215. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  216. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  217. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  218. package/src/components/ui/snap-target-badge.tsx +88 -0
  219. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  220. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  221. package/src/components/viewer-overlay.tsx +36 -596
  222. package/src/components/viewer-zone-system.tsx +6 -1
  223. package/src/components/walkthrough-hud.tsx +111 -0
  224. package/src/hooks/use-auto-save.test.ts +14 -0
  225. package/src/hooks/use-auto-save.ts +54 -7
  226. package/src/hooks/use-ceiling-events.ts +3 -2
  227. package/src/hooks/use-drag-action.ts +4 -1
  228. package/src/hooks/use-grid-events.ts +24 -0
  229. package/src/hooks/use-keyboard.ts +438 -70
  230. package/src/index.tsx +355 -9
  231. package/src/lib/active-placement-surface.test.ts +24 -0
  232. package/src/lib/active-placement-surface.ts +42 -0
  233. package/src/lib/camera-pose.test.ts +237 -0
  234. package/src/lib/camera-pose.ts +172 -0
  235. package/src/lib/ceiling-plan-snap.ts +24 -0
  236. package/src/lib/contextual-help.test.ts +112 -0
  237. package/src/lib/contextual-help.ts +144 -0
  238. package/src/lib/continuation.ts +64 -0
  239. package/src/lib/direct-manipulation.test.ts +275 -0
  240. package/src/lib/direct-manipulation.ts +118 -0
  241. package/src/lib/door-interaction.ts +1 -1
  242. package/src/lib/editor-api.ts +23 -35
  243. package/src/lib/elevation-guides.test.ts +107 -0
  244. package/src/lib/elevation-guides.ts +236 -0
  245. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  246. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  247. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  248. package/src/lib/floorplan/apply-alignment.ts +18 -12
  249. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  250. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  251. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  252. package/src/lib/floorplan/floorplan-export.tsx +978 -0
  253. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  254. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  255. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  256. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  257. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  258. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  259. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  260. package/src/lib/floorplan/geometry.ts +52 -0
  261. package/src/lib/floorplan/index.ts +3 -0
  262. package/src/lib/floorplan/items.ts +5 -2
  263. package/src/lib/floorplan/plan-coords.ts +21 -0
  264. package/src/lib/fresh-planar-placement.test.ts +283 -3
  265. package/src/lib/fresh-planar-placement.ts +85 -1
  266. package/src/lib/glb-export.test.ts +556 -0
  267. package/src/lib/glb-export.ts +1071 -0
  268. package/src/lib/ground-surface.ts +95 -0
  269. package/src/lib/history.test.ts +96 -0
  270. package/src/lib/history.ts +31 -0
  271. package/src/lib/interaction/hot-set.test.ts +133 -0
  272. package/src/lib/interaction/hot-set.ts +67 -0
  273. package/src/lib/interaction/overlay-policy.test.ts +52 -0
  274. package/src/lib/interaction/overlay-policy.ts +59 -0
  275. package/src/lib/interaction/scope.ts +243 -0
  276. package/src/lib/level-duplication.test.ts +2 -1
  277. package/src/lib/level-selection.ts +2 -2
  278. package/src/lib/material-paint.ts +36 -49
  279. package/src/lib/measurement-kind.test.ts +30 -0
  280. package/src/lib/measurement-kind.ts +19 -0
  281. package/src/lib/measurement-label.test.ts +47 -0
  282. package/src/lib/measurement-label.ts +68 -0
  283. package/src/lib/measurement-parser.ts +116 -0
  284. package/src/lib/measurements.test.ts +188 -0
  285. package/src/lib/measurements.ts +210 -0
  286. package/src/lib/paint-scope.test.ts +454 -0
  287. package/src/lib/paint-scope.ts +461 -0
  288. package/src/lib/placement-drag-release.ts +23 -0
  289. package/src/lib/planar-cursor-placement.test.ts +57 -0
  290. package/src/lib/plugin-panels.test.ts +19 -0
  291. package/src/lib/plugin-panels.ts +91 -0
  292. package/src/lib/quick-action-feedback.ts +33 -0
  293. package/src/lib/quick-action-nodes.test.ts +94 -0
  294. package/src/lib/quick-action-nodes.ts +47 -0
  295. package/src/lib/quick-measurement.test.ts +77 -0
  296. package/src/lib/quick-measurement.ts +109 -0
  297. package/src/lib/roof-duplication.ts +6 -6
  298. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  299. package/src/lib/roof-wall-hit.ts +164 -0
  300. package/src/lib/scene-clipboard.test.ts +325 -0
  301. package/src/lib/scene-clipboard.ts +310 -21
  302. package/src/lib/scene.ts +28 -5
  303. package/src/lib/selection-routing.test.ts +313 -0
  304. package/src/lib/selection-routing.ts +180 -0
  305. package/src/lib/sfx/index.ts +1 -0
  306. package/src/lib/sfx/movement-tick.test.ts +65 -0
  307. package/src/lib/sfx/movement-tick.ts +18 -0
  308. package/src/lib/sfx-bus.ts +82 -14
  309. package/src/lib/sfx-player.test.ts +266 -0
  310. package/src/lib/sfx-player.ts +262 -69
  311. package/src/lib/site-boundary.test.ts +53 -0
  312. package/src/lib/site-boundary.ts +27 -0
  313. package/src/lib/slab-plan-snap.test.ts +93 -0
  314. package/src/lib/slab-plan-snap.ts +108 -0
  315. package/src/lib/snapping-mode.test.ts +165 -0
  316. package/src/lib/snapping-mode.ts +191 -0
  317. package/src/lib/stair-duplication.test.ts +103 -0
  318. package/src/lib/stair-duplication.ts +58 -86
  319. package/src/lib/stair-levels.ts +2 -0
  320. package/src/lib/surface-plan-snap.test.ts +71 -0
  321. package/src/lib/surface-plan-snap.ts +256 -0
  322. package/src/lib/terrain-sculpt.test.ts +429 -0
  323. package/src/lib/terrain-sculpt.ts +301 -0
  324. package/src/lib/terrain-verb-color.test.ts +51 -0
  325. package/src/lib/terrain-verb-color.ts +58 -0
  326. package/src/lib/touch-gesture-priority.test.ts +45 -0
  327. package/src/lib/touch-gesture-priority.ts +30 -0
  328. package/src/lib/use-linear-display.ts +40 -0
  329. package/src/lib/window-interaction.ts +1 -1
  330. package/src/lib/world-grid-snap.ts +37 -5
  331. package/src/store/camera-pose-store.test.ts +29 -0
  332. package/src/store/camera-pose-store.ts +27 -0
  333. package/src/store/live-draft-preview-stores.test.ts +136 -0
  334. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  335. package/src/store/navigation-sync-pose-store.ts +27 -0
  336. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  337. package/src/store/use-delete-confirmation.ts +27 -0
  338. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  339. package/src/store/use-drawing-view.test.ts +23 -0
  340. package/src/store/use-drawing-view.ts +84 -0
  341. package/src/store/use-editor.tsx +665 -126
  342. package/src/store/use-elevation-guides.ts +40 -0
  343. package/src/store/use-facing-pose.ts +44 -0
  344. package/src/store/use-fence-curve-draft.ts +51 -0
  345. package/src/store/use-first-person-hud.ts +40 -0
  346. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  347. package/src/store/use-floorplan-draft-preview.ts +151 -0
  348. package/src/store/use-floorplan-marquee.ts +50 -0
  349. package/src/store/use-floorplan-mode.ts +110 -0
  350. package/src/store/use-interaction-scope.test.ts +206 -0
  351. package/src/store/use-interaction-scope.ts +140 -0
  352. package/src/store/use-measurement-draft.test.ts +559 -0
  353. package/src/store/use-measurement-draft.ts +570 -0
  354. package/src/store/use-opening-guides.ts +41 -0
  355. package/src/store/use-path-draft-preview.ts +90 -0
  356. package/src/store/use-placement-preview.ts +11 -3
  357. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  358. package/src/store/use-quick-measurement-hud.ts +77 -0
  359. package/src/store/use-segment-draft-chain.ts +28 -0
  360. package/src/store/use-stair-build-preview.ts +53 -0
  361. package/src/store/use-wall-snap-indicator.ts +2 -0
  362. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  363. package/src/components/editor/group-move-handle.tsx +0 -316
  364. package/src/components/ui/panels/paint-panel.tsx +0 -163
  365. package/src/lib/floorplan/selection-tool.ts +0 -271
  366. package/src/lib/level-name.ts +0 -11
@@ -0,0 +1,116 @@
1
+ import { type Kind, parseQuantity, quantity } from '@pascal-app/lingo'
2
+
3
+ /**
4
+ * Natural-language measurement parsing for editor property fields, backed by
5
+ * `@pascal-app/lingo`. Lets a user type `6ft`, `180cm`, `1m80`, `5'11"`, `45°`
6
+ * or `1.57rad` into any measurement field and have it canonicalized to the
7
+ * unit the field stores its value in — independent of the metric/imperial
8
+ * display toggle.
9
+ *
10
+ * The editor stores linear values in meters and angular values in radians (a
11
+ * few fields store inches or degrees); lingo's `length` base is meters and its
12
+ * `angle` base is radians, so a field's `unit` prop already names the stored /
13
+ * canonical unit and doubles as the parse target.
14
+ */
15
+
16
+ export interface LingoUnitSpec {
17
+ kind: Kind
18
+ /** Unit id the field's stored `value` is expressed in — the parse target. */
19
+ unitId: string
20
+ }
21
+
22
+ /**
23
+ * Map an editor field `unit` prop to a lingo kind + canonical unit. Returns
24
+ * `null` for units we deliberately do NOT natural-language-parse (plain
25
+ * numbers, percentages, angular rates, counts) so those keep exact
26
+ * `Number.parseFloat` behavior.
27
+ */
28
+ export function lingoUnitSpec(unit: string | undefined): LingoUnitSpec | null {
29
+ switch (unit) {
30
+ case 'm':
31
+ case 'cm':
32
+ case 'mm':
33
+ case 'in':
34
+ case 'ft':
35
+ return { kind: 'length', unitId: unit }
36
+ case '°':
37
+ case 'deg':
38
+ case 'degrees':
39
+ return { kind: 'angle', unitId: 'deg' }
40
+ case 'rad':
41
+ case 'radians':
42
+ return { kind: 'angle', unitId: 'rad' }
43
+ default:
44
+ return null
45
+ }
46
+ }
47
+
48
+ export interface ParseMeasurementOptions {
49
+ /**
50
+ * Implied unit for a BARE number (e.g. `6` → `6 ft`). Defaults to the field's
51
+ * own `unitId`. A typed unit (`180cm`) is always honored regardless.
52
+ */
53
+ bareUnit?: string
54
+ /** Disambiguates gal/ton/cup families; harmless for length/angle. */
55
+ system?: 'metric' | 'us' | 'imperial'
56
+ }
57
+
58
+ /**
59
+ * Parse typed field text into the field's stored numeric unit. Returns `null`
60
+ * when the text can't be read as a quantity, so the caller can fall back to a
61
+ * plain number parse or revert to the previous value.
62
+ */
63
+ export function parseMeasurement(
64
+ raw: string,
65
+ spec: LingoUnitSpec,
66
+ options: ParseMeasurementOptions = {},
67
+ ): number | null {
68
+ const result = parseQuantity(raw, {
69
+ kind: spec.kind,
70
+ unit: options.bareUnit ?? spec.unitId,
71
+ system: options.system,
72
+ strictness: 'forgiving',
73
+ })
74
+ if (!result.ok) return null
75
+ const value = result.quantity.to(spec.unitId).value
76
+ return Number.isFinite(value) ? value : null
77
+ }
78
+
79
+ export interface MeasurementHintOptions extends ParseMeasurementOptions {
80
+ /** Unit id the preview is rendered in (the field's displayed unit). */
81
+ displayUnit?: string
82
+ /** Max fraction digits in the preview. */
83
+ precision?: number
84
+ /**
85
+ * Clamp applied to the parsed stored value before previewing, so the hint
86
+ * reflects what a commit would actually store on a bounded field.
87
+ */
88
+ clamp?: (stored: number) => number
89
+ }
90
+
91
+ const PLAIN_NUMBER = /^[+-]?\d*\.?\d*$/
92
+
93
+ /**
94
+ * A faint "= 1.83 m" preview of the value currently being typed, shown only
95
+ * when the user typed something beyond a plain decimal in the field's own unit
96
+ * (an explicit unit, a compound like `1m80`, or a number word) and it parses.
97
+ * Returns `null` when there's nothing useful to preview.
98
+ */
99
+ export function measurementHint(
100
+ raw: string,
101
+ spec: LingoUnitSpec,
102
+ options: MeasurementHintOptions = {},
103
+ ): string | null {
104
+ const trimmed = raw.trim()
105
+ if (!trimmed || PLAIN_NUMBER.test(trimmed)) return null
106
+
107
+ const parsed = parseMeasurement(trimmed, spec, options)
108
+ if (parsed === null) return null
109
+ const stored = options.clamp ? options.clamp(parsed) : parsed
110
+
111
+ const displayUnit = options.displayUnit ?? spec.unitId
112
+ const precision = options.precision ?? 2
113
+ const q = quantity(stored, spec.unitId)
114
+ const shown = displayUnit === spec.unitId ? q : q.to(displayUnit)
115
+ return `= ${shown.format({ precision })}`
116
+ }
@@ -0,0 +1,188 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ buildMeasurementAngleArcPoints,
4
+ cubicMetersToVolumeUnit,
5
+ formatAreaLabel,
6
+ formatLinearMeasurement,
7
+ formatVolumeLabel,
8
+ getAreaUnitLabel,
9
+ getLinearUnitLabel,
10
+ getVolumeUnitLabel,
11
+ linearControlValueToMeters,
12
+ linearUnitToMeters,
13
+ MEASUREMENT_ACTIVE_COLOR,
14
+ MEASUREMENT_DANGLING_COLOR,
15
+ MEASUREMENT_FLOORPLAN_COLOR,
16
+ MEASUREMENT_PERSISTENT_COLOR,
17
+ measurementFloorplanPresentationColor,
18
+ measurementPresentationColor,
19
+ metersToLinearUnit,
20
+ squareMetersToAreaUnit,
21
+ } from './measurements'
22
+
23
+ describe('measurement presentation', () => {
24
+ test('uses black at rest, indigo while active, and red for dangling references', () => {
25
+ expect(measurementPresentationColor(false, false)).toBe(MEASUREMENT_PERSISTENT_COLOR)
26
+ expect(measurementPresentationColor(false, true)).toBe(MEASUREMENT_ACTIVE_COLOR)
27
+ expect(measurementPresentationColor(true, false)).toBe(MEASUREMENT_DANGLING_COLOR)
28
+ expect(measurementPresentationColor(true, true)).toBe(MEASUREMENT_DANGLING_COLOR)
29
+ })
30
+
31
+ test('uses an indigo analysis color for resting 2D measurements', () => {
32
+ expect(measurementFloorplanPresentationColor(false, false)).toBe(MEASUREMENT_FLOORPLAN_COLOR)
33
+ expect(measurementFloorplanPresentationColor(false, true)).toBe(MEASUREMENT_ACTIVE_COLOR)
34
+ expect(measurementFloorplanPresentationColor(true, false)).toBe(MEASUREMENT_DANGLING_COLOR)
35
+ })
36
+ })
37
+
38
+ describe('angle arc presentation', () => {
39
+ test('samples the smaller angle from the first ray to the second', () => {
40
+ const arc = buildMeasurementAngleArcPoints([1, 0, 0], [0, 0, 0], [0, 0, 1], {
41
+ radius: 0.25,
42
+ sampleCount: 8,
43
+ })
44
+
45
+ expect(arc).toHaveLength(9)
46
+ expect(arc[0]?.[0]).toBeCloseTo(0.25)
47
+ expect(arc[0]?.[2]).toBeCloseTo(0)
48
+ expect(arc.at(-1)?.[0]).toBeCloseTo(0)
49
+ expect(arc.at(-1)?.[2]).toBeCloseTo(0.25)
50
+ expect(arc[4]?.[0]).toBeGreaterThan(0)
51
+ expect(arc[4]?.[2]).toBeGreaterThan(0)
52
+ })
53
+
54
+ test('keeps a constant radius on an arbitrary 3D angle plane', () => {
55
+ const arc = buildMeasurementAngleArcPoints([1, 0, 0], [0, 0, 0], [0, 1, 0], {
56
+ radius: 0.3,
57
+ })
58
+
59
+ expect(arc.length).toBeGreaterThan(4)
60
+ for (const point of arc) expect(Math.hypot(...point)).toBeCloseTo(0.3)
61
+ expect(arc.at(-1)?.[1]).toBeCloseTo(0.3)
62
+ })
63
+
64
+ test('omits an arc when either ray is degenerate', () => {
65
+ expect(buildMeasurementAngleArcPoints([0, 0, 0], [0, 0, 0], [1, 0, 0])).toEqual([])
66
+ })
67
+ })
68
+
69
+ describe('linear measurements', () => {
70
+ test('formats metric measurements in meters', () => {
71
+ expect(formatLinearMeasurement(3, 'metric')).toBe('3m')
72
+ expect(formatLinearMeasurement(3.456, 'metric')).toBe('3.46m')
73
+ })
74
+
75
+ test('formats metric measurements in whole millimeters', () => {
76
+ expect(formatLinearMeasurement(3.456, 'metric', 'millimeters')).toBe('3456mm')
77
+ expect(formatLinearMeasurement(-0.1524, 'metric', 'millimeters')).toBe('-152mm')
78
+ })
79
+
80
+ test('formats imperial measurements as feet and inches', () => {
81
+ expect(formatLinearMeasurement(3.048, 'imperial')).toBe(`10'0"`)
82
+ expect(formatLinearMeasurement(3.2004, 'imperial')).toBe(`10'6"`)
83
+ })
84
+
85
+ test('carries rounded 12 inches into the next foot', () => {
86
+ expect(formatLinearMeasurement(3.047, 'imperial')).toBe(`10'0"`)
87
+ })
88
+
89
+ test('returns a placeholder for non-finite measurements', () => {
90
+ expect(formatLinearMeasurement(NaN, 'imperial')).toBe('--')
91
+ expect(formatLinearMeasurement(Infinity, 'imperial')).toBe('--')
92
+ expect(formatLinearMeasurement(NaN, 'metric')).toBe('--')
93
+ })
94
+
95
+ test('formats zero measurements', () => {
96
+ expect(formatLinearMeasurement(0, 'imperial')).toBe(`0'0"`)
97
+ expect(formatLinearMeasurement(0, 'metric')).toBe('0m')
98
+ })
99
+
100
+ test('formats sub-foot imperial measurements', () => {
101
+ expect(formatLinearMeasurement(0.1524, 'imperial')).toBe(`0'6"`)
102
+ })
103
+
104
+ test('formats negative measurements with a sign', () => {
105
+ expect(formatLinearMeasurement(-0.1524, 'imperial')).toBe(`-0'6"`)
106
+ expect(formatLinearMeasurement(-0.1524, 'metric')).toBe('-0.15m')
107
+ })
108
+
109
+ test('converts between meters and the active linear unit', () => {
110
+ expect(metersToLinearUnit(0, 'imperial')).toBe(0)
111
+ expect(linearUnitToMeters(0, 'imperial')).toBe(0)
112
+
113
+ expect(metersToLinearUnit(1, 'metric')).toBe(1)
114
+ expect(linearUnitToMeters(1, 'metric')).toBe(1)
115
+
116
+ expect(metersToLinearUnit(0.3048, 'imperial')).toBeCloseTo(1)
117
+ expect(linearUnitToMeters(1, 'imperial')).toBeCloseTo(0.3048)
118
+ })
119
+
120
+ test('converts numeric control input back to meters for wall panel edits', () => {
121
+ expect(linearControlValueToMeters(10, 'imperial')).toBeCloseTo(3.048)
122
+ expect(linearControlValueToMeters(0.5, 'imperial')).toBeCloseTo(0.1524)
123
+ expect(linearControlValueToMeters(-1, 'imperial')).toBeCloseTo(-0.3048)
124
+ expect(linearControlValueToMeters(3.5, 'metric')).toBe(3.5)
125
+ })
126
+
127
+ test('clamps numeric control input after converting to meters', () => {
128
+ expect(linearControlValueToMeters(0.1, 'imperial', { minMeters: 0.1 })).toBe(0.1)
129
+ expect(linearControlValueToMeters(0.3, 'imperial', { minMeters: 0.1 })).toBe(0.1)
130
+ expect(linearControlValueToMeters(19.7, 'imperial', { maxMeters: 6 })).toBe(6)
131
+ expect(linearControlValueToMeters(0.2, 'metric', { minMeters: 0.1 })).toBe(0.2)
132
+ expect(linearControlValueToMeters(0.2, 'metric', { maxMeters: 0.15 })).toBe(0.15)
133
+ })
134
+
135
+ test('returns the display label for numeric controls', () => {
136
+ expect(getLinearUnitLabel('metric')).toBe('m')
137
+ expect(getLinearUnitLabel('imperial')).toBe('ft')
138
+ })
139
+ })
140
+
141
+ describe('area measurements', () => {
142
+ test('converts square meters to the active area unit', () => {
143
+ expect(squareMetersToAreaUnit(0, 'imperial')).toBe(0)
144
+ expect(squareMetersToAreaUnit(12.5, 'metric')).toBe(12.5)
145
+ expect(squareMetersToAreaUnit(1, 'imperial')).toBeCloseTo(10.7639)
146
+ })
147
+
148
+ test('returns the display label for area readouts', () => {
149
+ expect(getAreaUnitLabel('metric')).toBe('m²')
150
+ expect(getAreaUnitLabel('imperial')).toBe('ft²')
151
+ })
152
+
153
+ test('formats an area label with value and unit', () => {
154
+ expect(formatAreaLabel(12.34, 'metric')).toBe('12.3m²')
155
+ expect(formatAreaLabel(1, 'imperial')).toBe('10.8ft²')
156
+ expect(formatAreaLabel(12.34, 'metric', 2)).toBe('12.34m²')
157
+ })
158
+
159
+ test('returns a placeholder for non-finite areas', () => {
160
+ expect(formatAreaLabel(NaN, 'metric')).toBe('--')
161
+ expect(formatAreaLabel(Infinity, 'imperial')).toBe('--')
162
+ })
163
+ })
164
+
165
+ describe('volume measurements', () => {
166
+ test('converts cubic meters to the active volume unit', () => {
167
+ expect(cubicMetersToVolumeUnit(0, 'imperial')).toBe(0)
168
+ expect(cubicMetersToVolumeUnit(12.5, 'metric')).toBe(12.5)
169
+ expect(cubicMetersToVolumeUnit(1, 'imperial')).toBeCloseTo(35.3147)
170
+ })
171
+
172
+ test('returns the display label for volume readouts', () => {
173
+ expect(getVolumeUnitLabel('metric')).toBe('m³')
174
+ expect(getVolumeUnitLabel('imperial')).toBe('ft³')
175
+ })
176
+
177
+ test('formats a volume label with value and unit', () => {
178
+ expect(formatVolumeLabel(12.34, 'metric')).toBe('12.3m³')
179
+ expect(formatVolumeLabel(1, 'imperial')).toBe('35.3ft³')
180
+ expect(formatVolumeLabel(12.34, 'metric', 2)).toBe('12.34m³')
181
+ })
182
+
183
+ test('returns a placeholder for non-finite volumes', () => {
184
+ expect(formatVolumeLabel(NaN, 'metric')).toBe('--')
185
+ expect(formatVolumeLabel(Infinity, 'imperial')).toBe('--')
186
+ expect(formatVolumeLabel(Number.NEGATIVE_INFINITY, 'metric')).toBe('--')
187
+ })
188
+ })
@@ -0,0 +1,210 @@
1
+ import type { MeasurementPoint } from '@pascal-app/core'
2
+
3
+ export type LinearUnit = 'metric' | 'imperial'
4
+ export type MetricNotation = 'meters' | 'millimeters'
5
+
6
+ export const MEASUREMENT_ACTIVE_COLOR = '#6366f1'
7
+ export const MEASUREMENT_DANGLING_COLOR = '#dc2626'
8
+ export const MEASUREMENT_FLOORPLAN_COLOR = '#4f46e5'
9
+ export const MEASUREMENT_PERSISTENT_COLOR = '#111827'
10
+
11
+ export function measurementPresentationColor(dangling: boolean, active: boolean): string {
12
+ if (dangling) return MEASUREMENT_DANGLING_COLOR
13
+ return active ? MEASUREMENT_ACTIVE_COLOR : MEASUREMENT_PERSISTENT_COLOR
14
+ }
15
+
16
+ export function measurementFloorplanPresentationColor(dangling: boolean, active: boolean): string {
17
+ if (dangling) return MEASUREMENT_DANGLING_COLOR
18
+ return active ? MEASUREMENT_ACTIVE_COLOR : MEASUREMENT_FLOORPLAN_COLOR
19
+ }
20
+
21
+ type MeasurementAngleArcOptions = {
22
+ radius?: number
23
+ sampleCount?: number
24
+ }
25
+
26
+ const ANGLE_ARC_EPSILON = 1e-9
27
+
28
+ const subtractPoint = (point: MeasurementPoint, origin: MeasurementPoint): MeasurementPoint => [
29
+ point[0] - origin[0],
30
+ point[1] - origin[1],
31
+ point[2] - origin[2],
32
+ ]
33
+
34
+ const pointLength = (point: MeasurementPoint): number => Math.hypot(...point)
35
+
36
+ const scalePoint = (point: MeasurementPoint, scale: number): MeasurementPoint => [
37
+ point[0] * scale,
38
+ point[1] * scale,
39
+ point[2] * scale,
40
+ ]
41
+
42
+ const crossPoint = (first: MeasurementPoint, second: MeasurementPoint): MeasurementPoint => [
43
+ first[1] * second[2] - first[2] * second[1],
44
+ first[2] * second[0] - first[0] * second[2],
45
+ first[0] * second[1] - first[1] * second[0],
46
+ ]
47
+
48
+ const dotPoint = (first: MeasurementPoint, second: MeasurementPoint): number =>
49
+ first[0] * second[0] + first[1] * second[1] + first[2] * second[2]
50
+
51
+ export function buildMeasurementAngleArcPoints(
52
+ start: MeasurementPoint,
53
+ vertex: MeasurementPoint,
54
+ end: MeasurementPoint,
55
+ options: MeasurementAngleArcOptions = {},
56
+ ): MeasurementPoint[] {
57
+ if (![...start, ...vertex, ...end].every(Number.isFinite)) return []
58
+
59
+ const startVector = subtractPoint(start, vertex)
60
+ const endVector = subtractPoint(end, vertex)
61
+ const startLength = pointLength(startVector)
62
+ const endLength = pointLength(endVector)
63
+ if (startLength <= ANGLE_ARC_EPSILON || endLength <= ANGLE_ARC_EPSILON) return []
64
+
65
+ const startDirection = scalePoint(startVector, 1 / startLength)
66
+ const endDirection = scalePoint(endVector, 1 / endLength)
67
+ const cosine = Math.max(-1, Math.min(1, dotPoint(startDirection, endDirection)))
68
+ const angle = Math.acos(cosine)
69
+ if (angle <= 1e-4) return []
70
+
71
+ let normal = crossPoint(startDirection, endDirection)
72
+ let normalLength = pointLength(normal)
73
+ if (normalLength <= ANGLE_ARC_EPSILON) {
74
+ const reference: MeasurementPoint = Math.abs(startDirection[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0]
75
+ normal = crossPoint(startDirection, reference)
76
+ normalLength = pointLength(normal)
77
+ }
78
+ if (normalLength <= ANGLE_ARC_EPSILON) return []
79
+ normal = scalePoint(normal, 1 / normalLength)
80
+ const tangent = crossPoint(normal, startDirection)
81
+
82
+ const shortestSide = Math.min(startLength, endLength)
83
+ const preferredRadius = options.radius ?? Math.min(Math.max(shortestSide * 0.28, 0.08), 0.75)
84
+ const radius = Math.min(Math.max(preferredRadius, 0), shortestSide * 0.45)
85
+ if (radius <= ANGLE_ARC_EPSILON) return []
86
+
87
+ const sampleCount = Math.min(
88
+ 64,
89
+ Math.max(4, Math.round(options.sampleCount ?? Math.max(8, (angle / Math.PI) * 32))),
90
+ )
91
+ return Array.from({ length: sampleCount + 1 }, (_, index) => {
92
+ const sampleAngle = angle * (index / sampleCount)
93
+ const direction: MeasurementPoint = [
94
+ startDirection[0] * Math.cos(sampleAngle) + tangent[0] * Math.sin(sampleAngle),
95
+ startDirection[1] * Math.cos(sampleAngle) + tangent[1] * Math.sin(sampleAngle),
96
+ startDirection[2] * Math.cos(sampleAngle) + tangent[2] * Math.sin(sampleAngle),
97
+ ]
98
+ return [
99
+ vertex[0] + direction[0] * radius,
100
+ vertex[1] + direction[1] * radius,
101
+ vertex[2] + direction[2] * radius,
102
+ ]
103
+ })
104
+ }
105
+
106
+ const METERS_PER_FOOT = 0.3048
107
+ const FEET_PER_METER = 1 / METERS_PER_FOOT
108
+
109
+ type LinearControlValueOptions = {
110
+ minMeters?: number
111
+ maxMeters?: number
112
+ }
113
+
114
+ export function metersToLinearUnit(meters: number, unit: LinearUnit): number {
115
+ return unit === 'imperial' ? meters * FEET_PER_METER : meters
116
+ }
117
+
118
+ export function linearUnitToMeters(value: number, unit: LinearUnit): number {
119
+ return unit === 'imperial' ? value * METERS_PER_FOOT : value
120
+ }
121
+
122
+ export function linearControlValueToMeters(
123
+ value: number,
124
+ unit: LinearUnit,
125
+ options: LinearControlValueOptions = {},
126
+ ): number {
127
+ const meters = linearUnitToMeters(value, unit)
128
+ const minMeters = options.minMeters ?? Number.NEGATIVE_INFINITY
129
+ const maxMeters = options.maxMeters ?? Number.POSITIVE_INFINITY
130
+
131
+ return Math.min(Math.max(meters, minMeters), maxMeters)
132
+ }
133
+
134
+ export function getLinearUnitLabel(unit: LinearUnit): string {
135
+ return unit === 'imperial' ? 'ft' : 'm'
136
+ }
137
+
138
+ const SQUARE_FEET_PER_SQUARE_METER = FEET_PER_METER * FEET_PER_METER
139
+ const CUBIC_FEET_PER_CUBIC_METER = SQUARE_FEET_PER_SQUARE_METER * FEET_PER_METER
140
+
141
+ export function squareMetersToAreaUnit(squareMeters: number, unit: LinearUnit): number {
142
+ return unit === 'imperial' ? squareMeters * SQUARE_FEET_PER_SQUARE_METER : squareMeters
143
+ }
144
+
145
+ export function getAreaUnitLabel(unit: LinearUnit): string {
146
+ return unit === 'imperial' ? 'ft²' : 'm²'
147
+ }
148
+
149
+ export function formatAreaLabel(
150
+ squareMeters: number,
151
+ unit: LinearUnit,
152
+ fractionDigits = 1,
153
+ ): string {
154
+ if (!Number.isFinite(squareMeters)) return '--'
155
+
156
+ return `${squareMetersToAreaUnit(squareMeters, unit).toFixed(fractionDigits)}${getAreaUnitLabel(unit)}`
157
+ }
158
+
159
+ export function cubicMetersToVolumeUnit(cubicMeters: number, unit: LinearUnit): number {
160
+ return unit === 'imperial' ? cubicMeters * CUBIC_FEET_PER_CUBIC_METER : cubicMeters
161
+ }
162
+
163
+ export function getVolumeUnitLabel(unit: LinearUnit): string {
164
+ return unit === 'imperial' ? 'ft³' : 'm³'
165
+ }
166
+
167
+ export function formatVolumeLabel(
168
+ cubicMeters: number,
169
+ unit: LinearUnit,
170
+ fractionDigits = 1,
171
+ ): string {
172
+ if (!Number.isFinite(cubicMeters)) return '--'
173
+
174
+ return `${cubicMetersToVolumeUnit(cubicMeters, unit).toFixed(fractionDigits)}${getVolumeUnitLabel(unit)}`
175
+ }
176
+
177
+ export function formatLinearMeasurement(
178
+ meters: number,
179
+ unit: LinearUnit,
180
+ metricNotation: MetricNotation = 'meters',
181
+ ): string {
182
+ if (!Number.isFinite(meters)) return '--'
183
+
184
+ const absoluteMeters = Math.abs(meters)
185
+
186
+ if (unit === 'imperial') {
187
+ const feet = metersToLinearUnit(absoluteMeters, unit)
188
+ let wholeFeet = Math.floor(feet)
189
+ let inches = Math.round((feet - wholeFeet) * 12)
190
+ if (inches === 12) {
191
+ wholeFeet += 1
192
+ inches = 0
193
+ }
194
+
195
+ const sign = meters < 0 && (wholeFeet !== 0 || inches !== 0) ? '-' : ''
196
+
197
+ return `${sign}${wholeFeet}'${inches}"`
198
+ }
199
+
200
+ if (metricNotation === 'millimeters') {
201
+ const roundedMillimeters = Math.round(absoluteMeters * 1000)
202
+ const sign = meters < 0 && roundedMillimeters !== 0 ? '-' : ''
203
+ return `${sign}${roundedMillimeters}mm`
204
+ }
205
+
206
+ const roundedMeters = Number.parseFloat(absoluteMeters.toFixed(2))
207
+ const sign = meters < 0 && roundedMeters !== 0 ? '-' : ''
208
+
209
+ return `${sign}${roundedMeters}m`
210
+ }