@pascal-app/editor 0.8.0 → 0.9.2

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 (350) hide show
  1. package/package.json +15 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +168 -0
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +795 -28
  5. package/src/components/editor/editor-layout-mobile.tsx +8 -3
  6. package/src/components/editor/editor-layout-v2.tsx +76 -51
  7. package/src/components/editor/export-manager.tsx +69 -32
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +182 -0
  10. package/src/components/editor/first-person/build-collider-world.ts +228 -83
  11. package/src/components/editor/first-person-controls.tsx +943 -27
  12. package/src/components/editor/floating-action-menu.tsx +676 -208
  13. package/src/components/editor/floating-building-action-menu.tsx +2 -2
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +6916 -12137
  17. package/src/components/editor/grid.tsx +197 -36
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +420 -0
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +396 -0
  24. package/src/components/editor/group-transform-shared.ts +489 -0
  25. package/src/components/editor/handles/handle-arrow.tsx +581 -0
  26. package/src/components/editor/handles/use-handle-drag.ts +246 -0
  27. package/src/components/editor/index.tsx +412 -162
  28. package/src/components/editor/measurement-pill.tsx +115 -0
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +1646 -0
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +1106 -483
  36. package/src/components/editor/site-edge-labels.tsx +45 -21
  37. package/src/components/editor/slab-hole-highlights.tsx +474 -0
  38. package/src/components/editor/snapshot-capture-overlay.tsx +369 -155
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +140 -41
  41. package/src/components/editor/use-floorplan-background-placement.ts +190 -35
  42. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  43. package/src/components/editor/use-floorplan-scene-data.ts +19 -7
  44. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  45. package/src/components/editor/wall-measurement-label.tsx +14 -19
  46. package/src/components/editor/wall-move-side-handles.tsx +870 -0
  47. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  48. package/src/components/editor/wall-snap-beacon-layer.tsx +304 -0
  49. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +15 -6
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  52. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  53. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  54. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  56. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +392 -0
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +857 -0
  59. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  60. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +77 -0
  61. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  62. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  63. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  64. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  65. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  66. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +62 -0
  67. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  68. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +3325 -0
  69. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  70. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  71. package/src/components/editor-2d/svg-paths.ts +10 -2
  72. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +439 -57
  73. package/src/components/systems/ceiling/ceiling-system.tsx +109 -12
  74. package/src/components/systems/roof/roof-edit-system.tsx +1851 -22
  75. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  76. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  77. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  78. package/src/components/systems/zone/zone-system.tsx +42 -13
  79. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  80. package/src/components/tools/elevator/elevator-tool.tsx +375 -0
  81. package/src/components/tools/elevator/move-elevator-tool.tsx +263 -0
  82. package/src/components/tools/fence/fence-drafting.ts +141 -27
  83. package/src/components/tools/item/move-tool.tsx +45 -105
  84. package/src/components/tools/item/placement-math.test.ts +22 -0
  85. package/src/components/tools/item/placement-math.ts +59 -7
  86. package/src/components/tools/item/placement-strategies.ts +432 -16
  87. package/src/components/tools/item/placement-types.ts +22 -1
  88. package/src/components/tools/item/use-draft-node.ts +51 -2
  89. package/src/components/tools/item/use-placement-coordinator.tsx +1399 -256
  90. package/src/components/tools/registry/move-registry-node-tool.tsx +1064 -0
  91. package/src/components/tools/roof/roof-tool.tsx +350 -28
  92. package/src/components/tools/select/box-select-state.ts +88 -0
  93. package/src/components/tools/select/box-select-tool.tsx +442 -520
  94. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  95. package/src/components/tools/select/marquee-geometry.ts +155 -0
  96. package/src/components/tools/select/plane-box-select-tool.tsx +570 -0
  97. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  98. package/src/components/tools/select/select-candidates.test.ts +109 -0
  99. package/src/components/tools/select/select-candidates.ts +85 -0
  100. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  101. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  102. package/src/components/tools/shared/drag-bounding-box.tsx +195 -0
  103. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  104. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  105. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  106. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  107. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  108. package/src/components/tools/shared/placement-box.tsx +305 -0
  109. package/src/components/tools/shared/polygon-editor.tsx +866 -175
  110. package/src/components/tools/shared/segment-angle.ts +56 -0
  111. package/src/components/tools/site/site-boundary-editor.tsx +520 -16
  112. package/src/components/tools/site/site-flag-model.ts +18 -0
  113. package/src/components/tools/stair/stair-defaults.ts +2 -1
  114. package/src/components/tools/stair/stair-tool.tsx +342 -37
  115. package/src/components/tools/tool-manager.tsx +239 -62
  116. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  117. package/src/components/tools/wall/wall-drafting.ts +248 -171
  118. package/src/components/tools/wall/wall-snap-geometry.test.ts +123 -0
  119. package/src/components/tools/wall/wall-snap-geometry.ts +331 -0
  120. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  121. package/src/components/tools/zone/zone-tool.tsx +86 -82
  122. package/src/components/ui/action-menu/action-button.tsx +21 -1
  123. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  124. package/src/components/ui/action-menu/control-modes.tsx +40 -170
  125. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  126. package/src/components/ui/action-menu/index.tsx +3 -117
  127. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  128. package/src/components/ui/action-menu/structure-tools.tsx +27 -93
  129. package/src/components/ui/action-menu/view-toggles.tsx +279 -90
  130. package/src/components/ui/command-palette/editor-commands.tsx +20 -15
  131. package/src/components/ui/command-palette/index.tsx +5 -3
  132. package/src/components/ui/controls/material-paint-panel.tsx +152 -0
  133. package/src/components/ui/controls/material-picker.tsx +87 -177
  134. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  135. package/src/components/ui/controls/metric-control.tsx +142 -46
  136. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  137. package/src/components/ui/controls/slider-control.tsx +88 -38
  138. package/src/components/ui/floating-level-selector.tsx +47 -10
  139. package/src/components/ui/helpers/building-helper.tsx +10 -23
  140. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  141. package/src/components/ui/helpers/helper-manager.tsx +235 -23
  142. package/src/components/ui/helpers/item-helper.tsx +28 -32
  143. package/src/components/ui/helpers/registered-tool-helper.tsx +59 -0
  144. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  145. package/src/components/ui/icon-ref.tsx +47 -0
  146. package/src/components/ui/item-catalog/catalog-items.tsx +40 -1
  147. package/src/components/ui/item-catalog/item-catalog.tsx +16 -36
  148. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  149. package/src/components/ui/panels/node-display.ts +17 -16
  150. package/src/components/ui/panels/panel-manager.tsx +55 -58
  151. package/src/components/ui/panels/panel-wrapper.tsx +238 -12
  152. package/src/components/ui/panels/parametric-inspector.tsx +482 -0
  153. package/src/components/ui/panels/reference-panel.tsx +54 -19
  154. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  155. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  156. package/src/components/ui/primitives/sidebar.tsx +1 -0
  157. package/src/components/ui/scene-loader.tsx +1 -3
  158. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  159. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  160. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  161. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  162. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  163. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +122 -41
  164. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +66 -7
  165. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +267 -0
  166. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  167. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +9 -5
  168. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +86 -0
  169. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  170. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  171. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +92 -0
  172. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  173. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +3 -3
  174. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +86 -0
  175. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -21
  176. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  177. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  178. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +140 -0
  179. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +48 -8
  180. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +132 -0
  181. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -5
  182. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +86 -0
  183. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  184. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  185. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +112 -39
  186. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  187. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  188. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  189. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  190. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  191. package/src/components/ui/sidebar/tab-bar.tsx +75 -1
  192. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  193. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  194. package/src/components/ui/snap-target-badge.tsx +88 -0
  195. package/src/components/viewer-overlay.tsx +253 -61
  196. package/src/components/viewer-zone-system.tsx +7 -2
  197. package/src/hooks/use-auto-save.test.ts +14 -0
  198. package/src/hooks/use-auto-save.ts +64 -10
  199. package/src/hooks/use-ceiling-events.ts +176 -0
  200. package/src/hooks/use-drag-action.ts +127 -0
  201. package/src/hooks/use-keyboard.ts +493 -95
  202. package/src/hooks/use-selection.ts +64 -0
  203. package/src/index.tsx +451 -6
  204. package/src/lib/active-placement-surface.ts +35 -0
  205. package/src/lib/ceiling-plan-snap.ts +24 -0
  206. package/src/lib/constants.ts +6 -2
  207. package/src/lib/contextual-help.test.ts +112 -0
  208. package/src/lib/contextual-help.ts +144 -0
  209. package/src/lib/continuation.ts +64 -0
  210. package/src/lib/direct-manipulation.test.ts +192 -0
  211. package/src/lib/direct-manipulation.ts +109 -0
  212. package/src/lib/editor-api.ts +50 -0
  213. package/src/lib/elevator-support.ts +96 -0
  214. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  215. package/src/lib/floorplan/apply-alignment.ts +117 -0
  216. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  217. package/src/lib/floorplan/geometry.ts +53 -0
  218. package/src/lib/floorplan/index.ts +10 -0
  219. package/src/lib/floorplan/items.ts +5 -2
  220. package/src/lib/floorplan/plan-coords.ts +21 -0
  221. package/src/lib/floorplan/selection-tool.ts +40 -0
  222. package/src/lib/floorplan/stairs.ts +12 -1
  223. package/src/lib/fresh-planar-placement.test.ts +339 -0
  224. package/src/lib/fresh-planar-placement.ts +130 -0
  225. package/src/lib/glb-export.test.ts +441 -0
  226. package/src/lib/glb-export.ts +874 -0
  227. package/src/lib/history.ts +11 -1
  228. package/src/lib/interaction/hot-set.test.ts +133 -0
  229. package/src/lib/interaction/hot-set.ts +67 -0
  230. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  231. package/src/lib/interaction/overlay-policy.ts +59 -0
  232. package/src/lib/interaction/scope.ts +211 -0
  233. package/src/lib/level-duplication.ts +54 -42
  234. package/src/lib/level-selection.ts +2 -2
  235. package/src/lib/material-paint.ts +227 -48
  236. package/src/lib/measurement-kind.test.ts +30 -0
  237. package/src/lib/measurement-kind.ts +19 -0
  238. package/src/lib/measurement-label.test.ts +47 -0
  239. package/src/lib/measurement-label.ts +68 -0
  240. package/src/lib/measurement-parser.ts +116 -0
  241. package/src/lib/measurements.test.ts +183 -0
  242. package/src/lib/measurements.ts +199 -0
  243. package/src/lib/paint-scope.test.ts +454 -0
  244. package/src/lib/paint-scope.ts +461 -0
  245. package/src/lib/placement-drag-release.ts +23 -0
  246. package/src/lib/placement-metadata.ts +29 -0
  247. package/src/lib/planar-cursor-placement.test.ts +100 -0
  248. package/src/lib/planar-cursor-placement.ts +42 -0
  249. package/src/lib/plugin-panels.test.ts +19 -0
  250. package/src/lib/plugin-panels.ts +91 -0
  251. package/src/lib/quick-measurement.test.ts +77 -0
  252. package/src/lib/quick-measurement.ts +109 -0
  253. package/src/lib/roof-duplication.ts +7 -7
  254. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  255. package/src/lib/roof-wall-hit.ts +164 -0
  256. package/src/lib/scene-clipboard.test.ts +196 -0
  257. package/src/lib/scene-clipboard.ts +332 -0
  258. package/src/lib/scene.ts +40 -8
  259. package/src/lib/selection-routing.test.ts +298 -0
  260. package/src/lib/selection-routing.ts +175 -0
  261. package/src/lib/sfx/index.ts +1 -0
  262. package/src/lib/sfx/movement-tick.test.ts +65 -0
  263. package/src/lib/sfx/movement-tick.ts +18 -0
  264. package/src/lib/sfx-bus.ts +69 -10
  265. package/src/lib/sfx-player.test.ts +124 -0
  266. package/src/lib/sfx-player.ts +148 -55
  267. package/src/lib/site-boundary.ts +1 -0
  268. package/src/lib/slab-plan-snap.test.ts +93 -0
  269. package/src/lib/slab-plan-snap.ts +108 -0
  270. package/src/lib/snapping-mode.test.ts +138 -0
  271. package/src/lib/snapping-mode.ts +179 -0
  272. package/src/lib/stair-duplication.ts +4 -4
  273. package/src/lib/stair-levels.test.ts +146 -0
  274. package/src/lib/stair-levels.ts +177 -0
  275. package/src/lib/surface-plan-snap.test.ts +71 -0
  276. package/src/lib/surface-plan-snap.ts +256 -0
  277. package/src/lib/use-linear-display.ts +40 -0
  278. package/src/lib/world-grid-snap.ts +231 -0
  279. package/src/lib/zone-content.ts +130 -0
  280. package/src/store/use-alignment-guides.ts +21 -0
  281. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  282. package/src/store/use-editor.tsx +767 -114
  283. package/src/store/use-facing-pose.ts +44 -0
  284. package/src/store/use-fence-curve-draft.ts +21 -0
  285. package/src/store/use-floorplan-draft-preview.ts +101 -0
  286. package/src/store/use-floorplan-marquee.ts +50 -0
  287. package/src/store/use-interaction-scope.test.ts +186 -0
  288. package/src/store/use-interaction-scope.ts +132 -0
  289. package/src/store/use-measurement-draft.test.ts +530 -0
  290. package/src/store/use-measurement-draft.ts +543 -0
  291. package/src/store/use-opening-guides.ts +41 -0
  292. package/src/store/use-placement-preview.ts +39 -0
  293. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  294. package/src/store/use-quick-measurement-hud.ts +77 -0
  295. package/src/store/use-segment-draft-chain.ts +28 -0
  296. package/src/store/use-stair-build-preview.ts +43 -0
  297. package/src/store/use-wall-move-ghosts.ts +36 -0
  298. package/src/store/use-wall-snap-indicator.ts +34 -0
  299. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  300. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  301. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  302. package/src/components/tools/building/move-building-tool.tsx +0 -157
  303. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  304. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  305. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  306. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  307. package/src/components/tools/column/column-tool.tsx +0 -97
  308. package/src/components/tools/column/move-column-tool.tsx +0 -105
  309. package/src/components/tools/door/door-math.ts +0 -110
  310. package/src/components/tools/door/door-tool.tsx +0 -324
  311. package/src/components/tools/door/move-door-tool.tsx +0 -412
  312. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  313. package/src/components/tools/fence/fence-tool.tsx +0 -346
  314. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  315. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  316. package/src/components/tools/item/item-tool.tsx +0 -26
  317. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  318. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  319. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  320. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  321. package/src/components/tools/slab/slab-tool.tsx +0 -322
  322. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  323. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  324. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  325. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  326. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  327. package/src/components/tools/wall/wall-tool.tsx +0 -332
  328. package/src/components/tools/window/move-window-tool.tsx +0 -447
  329. package/src/components/tools/window/window-math.ts +0 -117
  330. package/src/components/tools/window/window-tool.tsx +0 -332
  331. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  332. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  333. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  334. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  335. package/src/components/ui/panels/column-panel.tsx +0 -759
  336. package/src/components/ui/panels/door-panel.tsx +0 -1299
  337. package/src/components/ui/panels/fence-panel.tsx +0 -222
  338. package/src/components/ui/panels/item-panel.tsx +0 -306
  339. package/src/components/ui/panels/paint-panel.tsx +0 -163
  340. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  341. package/src/components/ui/panels/roof-panel.tsx +0 -280
  342. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  343. package/src/components/ui/panels/slab-panel.tsx +0 -250
  344. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  345. package/src/components/ui/panels/stair-panel.tsx +0 -577
  346. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  347. package/src/components/ui/panels/wall-panel.tsx +0 -273
  348. package/src/components/ui/panels/window-panel.tsx +0 -968
  349. package/src/contexts/presets-context.tsx +0 -121
  350. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -1,29 +1,54 @@
1
1
  'use client'
2
2
 
3
3
  import {
4
+ type AnyNode,
5
+ type AnyNodeId,
4
6
  type CeilingNode,
7
+ type ChimneyMaterialRole,
8
+ type ChimneyNode,
5
9
  type ColumnNode,
10
+ type DormerSurfaceMaterialRole,
6
11
  type FenceNode,
7
12
  getCatalogMaterialById,
8
13
  getEffectiveRoofSurfaceMaterial,
14
+ getEffectiveSegmentSurfaceMaterial,
9
15
  getEffectiveStairSurfaceMaterial,
10
- getEffectiveWallSurfaceMaterial,
11
16
  getLibraryMaterialIdFromRef,
12
17
  type MaterialSchema,
13
18
  type MaterialTarget,
19
+ nodeRegistry,
14
20
  type RoofNode,
21
+ type RoofSegmentNode,
22
+ type RoofSegmentSurfaceMaterialRole,
15
23
  type RoofSurfaceMaterialRole,
24
+ type ShelfNode,
16
25
  type SlabNode,
17
26
  type StairNode,
18
27
  type StairSurfaceMaterialRole,
19
- type WallNode,
20
28
  type WallSurfaceSide,
21
29
  } from '@pascal-app/core'
22
30
 
23
- export type PaintableMaterialTarget = Extract<
24
- MaterialTarget,
25
- 'wall' | 'roof' | 'stair' | 'fence' | 'column' | 'slab' | 'ceiling'
26
- >
31
+ export type PaintableMaterialTarget =
32
+ | Extract<
33
+ MaterialTarget,
34
+ | 'wall'
35
+ | 'roof'
36
+ | 'stair'
37
+ | 'fence'
38
+ | 'column'
39
+ | 'slab'
40
+ | 'ceiling'
41
+ | 'shelf'
42
+ | 'cabinet'
43
+ | 'chimney'
44
+ | 'dormer'
45
+ | 'box-vent'
46
+ | 'ridge-vent'
47
+ | 'turbine-vent'
48
+ | 'cupola'
49
+ | 'eyebrow-vent'
50
+ >
51
+ | 'item'
27
52
 
28
53
  export type SingleSurfaceMaterialRole = 'surface'
29
54
 
@@ -52,45 +77,51 @@ export function getActivePaintMaterialLabel(material: ActivePaintMaterial | null
52
77
  return getCatalogEntryForActivePaintMaterial(material)?.label ?? 'Custom'
53
78
  }
54
79
 
55
- export function buildWallSurfaceMaterialPatch(
56
- node: WallNode,
57
- targetSide: WallSurfaceSide,
80
+ export function buildRoofSurfaceMaterialPatch(
81
+ node: RoofNode,
82
+ targetRole: RoofSurfaceMaterialRole,
58
83
  material: MaterialSchema | undefined,
59
84
  materialPreset: string | undefined,
60
- ): Partial<WallNode> {
85
+ ): Partial<RoofNode> {
61
86
  const nextSurfaceMaterial = { material, materialPreset }
62
- const nextInterior =
63
- targetSide === 'interior'
64
- ? nextSurfaceMaterial
65
- : getEffectiveWallSurfaceMaterial(node, 'interior')
66
- const nextExterior =
67
- targetSide === 'exterior'
68
- ? nextSurfaceMaterial
69
- : getEffectiveWallSurfaceMaterial(node, 'exterior')
87
+ const nextTop =
88
+ targetRole === 'top' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'top')
89
+ const nextEdge =
90
+ targetRole === 'edge' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'edge')
91
+ const nextWall =
92
+ targetRole === 'wall' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'wall')
70
93
 
71
94
  return {
72
- interiorMaterial: nextInterior.material,
73
- interiorMaterialPreset: nextInterior.materialPreset,
74
- exteriorMaterial: nextExterior.material,
75
- exteriorMaterialPreset: nextExterior.materialPreset,
95
+ topMaterial: nextTop.material,
96
+ topMaterialPreset: nextTop.materialPreset,
97
+ edgeMaterial: nextEdge.material,
98
+ edgeMaterialPreset: nextEdge.materialPreset,
99
+ wallMaterial: nextWall.material,
100
+ wallMaterialPreset: nextWall.materialPreset,
76
101
  material: undefined,
77
102
  materialPreset: undefined,
78
103
  }
79
104
  }
80
105
 
81
- export function buildRoofSurfaceMaterialPatch(
82
- node: RoofNode,
83
- targetRole: RoofSurfaceMaterialRole,
106
+ /**
107
+ * Build a per-segment paint patch for one of the three surface roles. The
108
+ * segment ends up with role-specific fields set (and the legacy catch-all
109
+ * `material` cleared) so subsequent reads pick the role override over any
110
+ * parent-roof fallback.
111
+ */
112
+ export function buildRoofSegmentSurfaceMaterialPatch(
113
+ node: RoofSegmentNode,
114
+ targetRole: RoofSegmentSurfaceMaterialRole,
84
115
  material: MaterialSchema | undefined,
85
116
  materialPreset: string | undefined,
86
- ): Partial<RoofNode> {
117
+ ): Partial<RoofSegmentNode> {
87
118
  const nextSurfaceMaterial = { material, materialPreset }
88
119
  const nextTop =
89
- targetRole === 'top' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'top')
120
+ targetRole === 'top' ? nextSurfaceMaterial : getEffectiveSegmentSurfaceMaterial(node, 'top')
90
121
  const nextEdge =
91
- targetRole === 'edge' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'edge')
122
+ targetRole === 'edge' ? nextSurfaceMaterial : getEffectiveSegmentSurfaceMaterial(node, 'edge')
92
123
  const nextWall =
93
- targetRole === 'wall' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'wall')
124
+ targetRole === 'wall' ? nextSurfaceMaterial : getEffectiveSegmentSurfaceMaterial(node, 'wall')
94
125
 
95
126
  return {
96
127
  topMaterial: nextTop.material,
@@ -104,6 +135,51 @@ export function buildRoofSurfaceMaterialPatch(
104
135
  }
105
136
  }
106
137
 
138
+ /**
139
+ * Clear every painted material on a node back to its default. Works for any
140
+ * kind without per-type knowledge: it nulls the catch-all `material` /
141
+ * `materialPreset` plus any role field (`*Material` / `*MaterialPreset`) that
142
+ * the node actually carries. For a roof it also resets every child segment, so
143
+ * a single call defaults the whole roof system. `updateNode` merges patches
144
+ * shallowly without re-validation, so the `undefined` values land as cleared
145
+ * fields and the renderer falls back to the theme defaults.
146
+ */
147
+ export function buildResetSurfaceMaterialUpdates(
148
+ nodes: Record<string, AnyNode>,
149
+ node: AnyNode,
150
+ ): { id: AnyNodeId; data: Partial<AnyNode> }[] {
151
+ const clearPatch = (target: AnyNode): Partial<AnyNode> => {
152
+ const patch: Record<string, undefined> = {}
153
+ for (const key of Object.keys(target)) {
154
+ if (
155
+ key === 'material' ||
156
+ key === 'materialPreset' ||
157
+ key === 'slots' ||
158
+ key.endsWith('Material') ||
159
+ key.endsWith('MaterialPreset')
160
+ ) {
161
+ patch[key] = undefined
162
+ }
163
+ }
164
+ return patch as Partial<AnyNode>
165
+ }
166
+
167
+ const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = [
168
+ { id: node.id as AnyNodeId, data: clearPatch(node) },
169
+ ]
170
+
171
+ if (node.type === 'roof') {
172
+ for (const segmentId of (node as RoofNode).children ?? []) {
173
+ const segment = nodes[segmentId as AnyNodeId]
174
+ if (segment?.type === 'roof-segment') {
175
+ updates.push({ id: segment.id as AnyNodeId, data: clearPatch(segment) })
176
+ }
177
+ }
178
+ }
179
+
180
+ return updates
181
+ }
182
+
107
183
  export function buildStairSurfaceMaterialPatch(
108
184
  node: StairNode,
109
185
  targetRole: StairSurfaceMaterialRole,
@@ -133,7 +209,7 @@ export function buildStairSurfaceMaterialPatch(
133
209
  }
134
210
 
135
211
  export function buildSingleSurfaceMaterialPatch<
136
- TNode extends FenceNode | ColumnNode | SlabNode | CeilingNode,
212
+ TNode extends FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode,
137
213
  >(material: MaterialSchema | undefined, materialPreset: string | undefined): Partial<TNode> {
138
214
  return {
139
215
  material,
@@ -141,6 +217,29 @@ export function buildSingleSurfaceMaterialPatch<
141
217
  } as Partial<TNode>
142
218
  }
143
219
 
220
+ // Chimney / dormer patch builders moved to
221
+ // `@pascal-app/nodes/<kind>/paint.ts` and are wired into the kind's
222
+ // `capabilities.paint.buildPatch`. The selection-manager invokes them
223
+ // through the registry; no editor-side helper needed here.
224
+ //
225
+ // `getEffectiveChimneyMaterial` below stays because
226
+ // `resolveActivePaintMaterialFromSelection` (also in this file) still
227
+ // has wall / roof / stair arms that follow the same shape — they all
228
+ // migrate together in a follow-up.
229
+
230
+ export function getEffectiveChimneyMaterial(
231
+ node: ChimneyNode,
232
+ role: ChimneyMaterialRole,
233
+ ): { material: MaterialSchema | undefined; materialPreset: string | undefined } {
234
+ if (role === 'top') {
235
+ const hasTop = node.topMaterial !== undefined || node.topMaterialPreset !== undefined
236
+ if (hasTop) {
237
+ return { material: node.topMaterial, materialPreset: node.topMaterialPreset }
238
+ }
239
+ }
240
+ return { material: node.material, materialPreset: node.materialPreset }
241
+ }
242
+
144
243
  export function resolveActivePaintMaterialFromSelection(params: {
145
244
  nodes: Record<string, any>
146
245
  selectedId: string | null
@@ -150,7 +249,10 @@ export function resolveActivePaintMaterialFromSelection(params: {
150
249
  | WallSurfaceSide
151
250
  | StairSurfaceMaterialRole
152
251
  | RoofSurfaceMaterialRole
252
+ | ChimneyMaterialRole
253
+ | DormerSurfaceMaterialRole
153
254
  | SingleSurfaceMaterialRole
255
+ | string
154
256
  } | null
155
257
  }): ActivePaintMaterial | null {
156
258
  const { nodes, selectedId, selectedMaterialTarget } = params
@@ -160,22 +262,31 @@ export function resolveActivePaintMaterialFromSelection(params: {
160
262
  const selectedNode = nodes[selectedId]
161
263
  if (!selectedNode) return null
162
264
 
163
- if (
164
- selectedNode.type === 'wall' &&
165
- (selectedMaterialTarget.role === 'interior' || selectedMaterialTarget.role === 'exterior')
166
- ) {
167
- const surface = getEffectiveWallSurfaceMaterial(selectedNode, selectedMaterialTarget.role)
168
- return hasActivePaintMaterial({
169
- material: surface.material,
170
- materialPreset: surface.materialPreset,
171
- sourceTarget: 'wall',
265
+ // Registry-driven path. Kinds that declare
266
+ // `capabilities.paint.getEffectiveMaterial` resolve their effective
267
+ // material here without an editor-side per-kind arm. Wall,
268
+ // chimney, dormer use this; roof / stair stay legacy below.
269
+ const paintCap = nodeRegistry.get(selectedNode.type)?.capabilities?.paint
270
+ if (paintCap?.getEffectiveMaterial) {
271
+ const surface = paintCap.getEffectiveMaterial({
272
+ node: selectedNode,
273
+ role: selectedMaterialTarget.role as string,
274
+ nodes,
172
275
  })
173
- ? {
174
- material: surface.material,
175
- materialPreset: surface.materialPreset,
176
- sourceTarget: 'wall',
177
- }
178
- : null
276
+ if (surface) {
277
+ const sourceTarget = (paintCap.materialTarget ?? selectedNode.type) as PaintableMaterialTarget
278
+ return hasActivePaintMaterial({
279
+ material: surface.material,
280
+ materialPreset: surface.materialPreset,
281
+ sourceTarget,
282
+ })
283
+ ? {
284
+ material: surface.material,
285
+ materialPreset: surface.materialPreset,
286
+ sourceTarget,
287
+ }
288
+ : null
289
+ }
179
290
  }
180
291
 
181
292
  if (
@@ -184,7 +295,27 @@ export function resolveActivePaintMaterialFromSelection(params: {
184
295
  selectedMaterialTarget.role === 'edge' ||
185
296
  selectedMaterialTarget.role === 'wall')
186
297
  ) {
187
- const surface = getEffectiveRoofSurfaceMaterial(selectedNode, selectedMaterialTarget.role)
298
+ let surface = getEffectiveRoofSurfaceMaterial(selectedNode, selectedMaterialTarget.role)
299
+ if (
300
+ selectedMaterialTarget.role === 'top' &&
301
+ surface.material === undefined &&
302
+ surface.materialPreset === undefined
303
+ ) {
304
+ const roofNode = selectedNode as RoofNode
305
+ const fallbackSegment = (roofNode.children ?? [])
306
+ .map((id: AnyNodeId) => nodes[id as AnyNodeId] as RoofSegmentNode | undefined)
307
+ .find(
308
+ (segment: RoofSegmentNode | undefined) =>
309
+ segment?.type === 'roof-segment' &&
310
+ (segment.material !== undefined || segment.materialPreset !== undefined),
311
+ )
312
+ if (fallbackSegment) {
313
+ surface = {
314
+ material: fallbackSegment.material,
315
+ materialPreset: fallbackSegment.materialPreset,
316
+ }
317
+ }
318
+ }
188
319
  return hasActivePaintMaterial({
189
320
  material: surface.material,
190
321
  materialPreset: surface.materialPreset,
@@ -218,13 +349,21 @@ export function resolveActivePaintMaterialFromSelection(params: {
218
349
  : null
219
350
  }
220
351
 
352
+ // Wall / chimney / dormer flow through the registry-driven path
353
+ // at the top of this function.
354
+
355
+ // Slot-backed kinds resolve via the registry-driven `getEffectiveMaterial`
356
+ // path at the top of this function, including legacy inline-material
357
+ // fallbacks when their capability exposes one.
358
+
221
359
  if (
222
360
  (selectedNode.type === 'fence' ||
223
361
  selectedNode.type === 'column' ||
224
- selectedNode.type === 'slab' ||
225
- selectedNode.type === 'ceiling') &&
362
+ selectedNode.type === 'shelf') &&
226
363
  selectedMaterialTarget.role === 'surface'
227
364
  ) {
365
+ // Roof vents previously lived here too; they now resolve via the
366
+ // registry-driven `getEffectiveMaterial` path at the top of this function.
228
367
  const target = selectedNode.type
229
368
  return hasActivePaintMaterial({
230
369
  material: selectedNode.material,
@@ -252,6 +391,10 @@ export function resolvePaintTargetFromSelection(params: {
252
391
  const selectedNode = nodes[selectedId]
253
392
  if (!selectedNode) return null
254
393
 
394
+ const registryPaintTarget = nodeRegistry.get(selectedNode.type)?.capabilities?.paint
395
+ ?.materialTarget
396
+ if (registryPaintTarget) return registryPaintTarget as PaintableMaterialTarget
397
+
255
398
  if (selectedNode.type === 'wall') {
256
399
  return 'wall'
257
400
  }
@@ -280,5 +423,41 @@ export function resolvePaintTargetFromSelection(params: {
280
423
  return 'ceiling'
281
424
  }
282
425
 
426
+ if (selectedNode.type === 'shelf') {
427
+ return 'shelf'
428
+ }
429
+
430
+ if (selectedNode.type === 'item') {
431
+ return 'item'
432
+ }
433
+
434
+ if (selectedNode.type === 'chimney') {
435
+ return 'chimney'
436
+ }
437
+
438
+ if (selectedNode.type === 'dormer') {
439
+ return 'dormer'
440
+ }
441
+
442
+ if (selectedNode.type === 'box-vent') {
443
+ return 'box-vent'
444
+ }
445
+
446
+ if (selectedNode.type === 'ridge-vent') {
447
+ return 'ridge-vent'
448
+ }
449
+
450
+ if (selectedNode.type === 'turbine-vent') {
451
+ return 'turbine-vent'
452
+ }
453
+
454
+ if (selectedNode.type === 'cupola') {
455
+ return 'cupola'
456
+ }
457
+
458
+ if (selectedNode.type === 'eyebrow-vent') {
459
+ return 'eyebrow-vent'
460
+ }
461
+
283
462
  return null
284
463
  }
@@ -0,0 +1,30 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ CREATABLE_MEASUREMENT_KINDS,
4
+ DEFAULT_CREATABLE_MEASUREMENT_KIND,
5
+ normalizeCreatableMeasurementKind,
6
+ } from './measurement-kind'
7
+
8
+ describe('creatable measurement kinds', () => {
9
+ test('keeps the supported creation kinds', () => {
10
+ expect(CREATABLE_MEASUREMENT_KINDS).toEqual([
11
+ 'distance',
12
+ 'angle',
13
+ 'area',
14
+ 'perimeter',
15
+ 'volume',
16
+ ])
17
+ for (const kind of CREATABLE_MEASUREMENT_KINDS) {
18
+ expect(normalizeCreatableMeasurementKind(kind)).toBe(kind)
19
+ }
20
+ })
21
+
22
+ test.each([
23
+ 'smart',
24
+ 'unknown',
25
+ null,
26
+ undefined,
27
+ ])('falls back from a non-creatable persisted value: %s', (value) => {
28
+ expect(normalizeCreatableMeasurementKind(value)).toBe(DEFAULT_CREATABLE_MEASUREMENT_KIND)
29
+ })
30
+ })
@@ -0,0 +1,19 @@
1
+ export const CREATABLE_MEASUREMENT_KINDS = [
2
+ 'distance',
3
+ 'angle',
4
+ 'area',
5
+ 'perimeter',
6
+ 'volume',
7
+ ] as const
8
+
9
+ export type CreatableMeasurementKind = (typeof CREATABLE_MEASUREMENT_KINDS)[number]
10
+
11
+ export const DEFAULT_CREATABLE_MEASUREMENT_KIND: CreatableMeasurementKind = 'distance'
12
+
13
+ export function isCreatableMeasurementKind(value: unknown): value is CreatableMeasurementKind {
14
+ return CREATABLE_MEASUREMENT_KINDS.includes(value as CreatableMeasurementKind)
15
+ }
16
+
17
+ export function normalizeCreatableMeasurementKind(value: unknown): CreatableMeasurementKind {
18
+ return isCreatableMeasurementKind(value) ? value : DEFAULT_CREATABLE_MEASUREMENT_KIND
19
+ }
@@ -0,0 +1,47 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { measurementCentroid, pointInPolygon2D } from '@pascal-app/core'
3
+ import { measurementPolygonLabelAnchor, triangulateMeasurementPolygon } from './measurement-label'
4
+
5
+ describe('measurementPolygonLabelAnchor', () => {
6
+ test('keeps a concave polygon label inside its visible fill', () => {
7
+ const base = [
8
+ [0, 0, 0],
9
+ [4, 0, 0],
10
+ [4, 0, 1],
11
+ [1, 0, 1],
12
+ [1, 0, 4],
13
+ [0, 0, 4],
14
+ ] as [number, number, number][]
15
+ const centroid = measurementCentroid(base)
16
+ const anchor = measurementPolygonLabelAnchor(base)
17
+
18
+ expect(centroid).not.toBeNull()
19
+ expect(
20
+ pointInPolygon2D(
21
+ [centroid![0], centroid![2]],
22
+ base.map(([x, , z]) => [x, z]),
23
+ ),
24
+ ).toBe(false)
25
+ expect(anchor).not.toBeNull()
26
+ expect(
27
+ pointInPolygon2D(
28
+ [anchor![0], anchor![2]],
29
+ base.map(([x, , z]) => [x, z]),
30
+ ),
31
+ ).toBe(true)
32
+ })
33
+
34
+ test('returns an on-plane anchor for a sloped polygon', () => {
35
+ const base = [
36
+ [0, 0, 0],
37
+ [4, 2, 0],
38
+ [4, 2, 4],
39
+ [0, 0, 4],
40
+ ] as [number, number, number][]
41
+ const anchor = measurementPolygonLabelAnchor(base)
42
+
43
+ expect(triangulateMeasurementPolygon(base)).toHaveLength(2)
44
+ expect(anchor).not.toBeNull()
45
+ expect(anchor![1]).toBeCloseTo(anchor![0] / 2)
46
+ })
47
+ })
@@ -0,0 +1,68 @@
1
+ import { type MeasurementPoint, measurementCentroid, measurementNormal } from '@pascal-app/core'
2
+ import { ShapeUtils, Vector2, Vector3 } from 'three'
3
+
4
+ type MeasurementPolygonProjection = {
5
+ contour: Vector2[]
6
+ triangles: number[][]
7
+ }
8
+
9
+ function projectMeasurementPolygon(
10
+ points: readonly MeasurementPoint[],
11
+ ): MeasurementPolygonProjection | null {
12
+ const normalValue = measurementNormal(points)
13
+ const originValue = points[0]
14
+ if (!(normalValue && originValue)) return null
15
+
16
+ const normal = new Vector3(...normalValue)
17
+ const reference = Math.abs(normal.y) < 0.9 ? new Vector3(0, 1, 0) : new Vector3(1, 0, 0)
18
+ const tangent = new Vector3().crossVectors(reference, normal).normalize()
19
+ const bitangent = new Vector3().crossVectors(normal, tangent).normalize()
20
+ const origin = new Vector3(...originValue)
21
+ const contour = points.map((point) => {
22
+ const relative = new Vector3(...point).sub(origin)
23
+ return new Vector2(relative.dot(tangent), relative.dot(bitangent))
24
+ })
25
+
26
+ return { contour, triangles: ShapeUtils.triangulateShape(contour, []) }
27
+ }
28
+
29
+ export function triangulateMeasurementPolygon(points: readonly MeasurementPoint[]): number[][] {
30
+ return projectMeasurementPolygon(points)?.triangles ?? []
31
+ }
32
+
33
+ export function measurementPolygonLabelAnchor(
34
+ points: readonly MeasurementPoint[],
35
+ ): MeasurementPoint | null {
36
+ const projection = projectMeasurementPolygon(points)
37
+ if (!projection || projection.triangles.length === 0) return measurementCentroid(points)
38
+
39
+ let largestTriangle: number[] | null = null
40
+ let largestArea = Number.NEGATIVE_INFINITY
41
+ for (const triangle of projection.triangles) {
42
+ const [firstIndex, secondIndex, thirdIndex] = triangle
43
+ const first = firstIndex === undefined ? null : projection.contour[firstIndex]
44
+ const second = secondIndex === undefined ? null : projection.contour[secondIndex]
45
+ const third = thirdIndex === undefined ? null : projection.contour[thirdIndex]
46
+ if (!(first && second && third)) continue
47
+ const area = Math.abs(
48
+ (second.x - first.x) * (third.y - first.y) - (second.y - first.y) * (third.x - first.x),
49
+ )
50
+ if (area > largestArea) {
51
+ largestArea = area
52
+ largestTriangle = triangle
53
+ }
54
+ }
55
+
56
+ if (!largestTriangle) return measurementCentroid(points)
57
+ const [firstIndex, secondIndex, thirdIndex] = largestTriangle
58
+ const first = firstIndex === undefined ? null : points[firstIndex]
59
+ const second = secondIndex === undefined ? null : points[secondIndex]
60
+ const third = thirdIndex === undefined ? null : points[thirdIndex]
61
+ if (!(first && second && third)) return measurementCentroid(points)
62
+
63
+ return [
64
+ (first[0] + second[0] + third[0]) / 3,
65
+ (first[1] + second[1] + third[1]) / 3,
66
+ (first[2] + second[2] + third[2]) / 3,
67
+ ]
68
+ }
@@ -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
+ }