@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,243 @@
1
+ // The authoritative description of "what the user is currently doing".
2
+ //
3
+ // Before this, that question was answered by re-deriving from 7+ independent
4
+ // `useEditor` flags (`mode`, `tool`, `movingNode`, `placementDragMode`,
5
+ // `activeHandleDrag`, `curvingWall`, `curvingFence`, `editingHole`,
6
+ // `movingWallEndpoint`, `movingFenceEndpoint`, …). Every overlay and pick site
7
+ // re-derived its behaviour from a different subset, so the flags could drift
8
+ // into illegal combinations (moving + curving at once; a stale `movingNode`
9
+ // after a drag ended). Collapsing them into one discriminated union makes those
10
+ // combinations unrepresentable: a scope is exactly one interaction at a time,
11
+ // and `idle` carries no interaction payload at all.
12
+
13
+ import type { AnyNode } from '@pascal-app/core'
14
+
15
+ export type InteractionView = '2d' | '3d'
16
+ export type ReshapeDriver = 'tool' | 'floorplan'
17
+
18
+ // Endpoint/curve/hole/boundary edits are all "reshape the selected node" — one
19
+ // node, one in-flight reshape. Grouping them as sub-states of `reshaping`
20
+ // (rather than four sibling scopes) keeps the union small while still making
21
+ // "curving and hole-editing at once" unrepresentable.
22
+ export type ReshapeKind = 'curve' | 'hole' | 'endpoint' | 'boundary' | 'control-point' | 'tangent'
23
+
24
+ export type InteractionScope =
25
+ | { kind: 'idle' }
26
+ // Placing a fresh node (catalog/preset/build tool). `pressDrag` is the
27
+ // gizmo press-drag flavour (commit on release) vs click-to-place.
28
+ | {
29
+ kind: 'placing'
30
+ // The node being placed, carried inline: a fresh-placement / duplicate
31
+ // draft is not in the scene yet, so it cannot be recovered by id. Set once
32
+ // at `begin` and never mutated, so it is a stable reference for the gesture.
33
+ node: AnyNode
34
+ nodeId: string
35
+ nodeType: string
36
+ view: InteractionView
37
+ pressDrag: boolean
38
+ }
39
+ // Moving an existing node.
40
+ | { kind: 'moving'; node: AnyNode; nodeId: string; nodeType: string; view: InteractionView }
41
+ // Dragging a resize/translate/rotate handle of a selected node.
42
+ | { kind: 'handle-drag'; nodeId: string; handle: string }
43
+ // Click-to-click drafting of a polyline/polygon kind (wall/fence/slab/…).
44
+ | { kind: 'drafting'; tool: string }
45
+ // Reshaping a selected node's geometry (see ReshapeKind). `holeIndex` is set
46
+ // only for `reshape: 'hole'`; `endpoint` only for `reshape: 'endpoint'`.
47
+ | {
48
+ kind: 'reshaping'
49
+ nodeId: string
50
+ reshape: ReshapeKind
51
+ // Floorplan affordances own their commit and must not mount a second 3D reshape tool.
52
+ driver: ReshapeDriver
53
+ holeIndex?: number
54
+ endpoint?: 'start' | 'end'
55
+ index?: number
56
+ side?: 'in' | 'out'
57
+ }
58
+ // Marquee selection drag.
59
+ | { kind: 'box-select' }
60
+ // Material paint application.
61
+ | { kind: 'painting' }
62
+ // Terrain sculpting. Held for the whole time the sculpt tool is armed, not
63
+ // just while the pointer is down — the same lifetime as `painting`, and for
64
+ // the same reason: an *active* scope is what makes every other object's
65
+ // handles, the floating action menu, and the zone labels step back
66
+ // (`resolveOverlayPolicy`). A brush that only claimed the scope between
67
+ // pointer-down and -up would flicker all of that back on between dabs.
68
+ | { kind: 'sculpting' }
69
+
70
+ export type InteractionKind = InteractionScope['kind']
71
+
72
+ export type ActiveInteractionScope = Exclude<InteractionScope, { kind: 'idle' }>
73
+
74
+ export const IDLE_SCOPE: InteractionScope = { kind: 'idle' }
75
+
76
+ export function isIdle(scope: InteractionScope): scope is { kind: 'idle' } {
77
+ return scope.kind === 'idle'
78
+ }
79
+
80
+ export function isActive(scope: InteractionScope): scope is ActiveInteractionScope {
81
+ return scope.kind !== 'idle'
82
+ }
83
+
84
+ // The node a scope is acting on, if any. Drafting/box-select/painting/idle
85
+ // target no single existing node.
86
+ export function scopeNodeId(scope: InteractionScope): string | null {
87
+ switch (scope.kind) {
88
+ case 'placing':
89
+ case 'moving':
90
+ case 'handle-drag':
91
+ case 'reshaping':
92
+ return scope.nodeId
93
+ default:
94
+ return null
95
+ }
96
+ }
97
+
98
+ // The node a placing/moving scope is acting on, carried inline (see the
99
+ // `placing` variant comment). Null for every other scope. Replaces the legacy
100
+ // `useEditor.movingNode` flag: the node lives inside the discriminated union, so
101
+ // it cannot survive past the interaction's `end()`.
102
+ export function movingNodeOf(scope: InteractionScope): AnyNode | null {
103
+ return scope.kind === 'placing' || scope.kind === 'moving' ? scope.node : null
104
+ }
105
+
106
+ // Selection/hover picking is only meaningful while idle. During any active
107
+ // interaction the pointer belongs to that interaction's body, not to selecting
108
+ // a different object — the picking choke point should not route a hover/click
109
+ // to selection while this is false.
110
+ export function selectionEnabled(scope: InteractionScope): boolean {
111
+ return scope.kind === 'idle'
112
+ }
113
+
114
+ // Derived views of the scope that mirror the legacy `useEditor` flags they
115
+ // replaced. Each returns null unless that exact interaction is active, so a
116
+ // stale payload is unrepresentable: the value is a pure function of the single
117
+ // authoritative scope, not an independent flag that can drift out of sync.
118
+
119
+ // The legacy `activeHandleDrag` flag. `label` keeps the legacy field name so
120
+ // downstream `=== ROTATE_HANDLE_DRAG_LABEL` / `=== 'height'` checks are unchanged.
121
+ export function handleDragInfo(scope: InteractionScope): { nodeId: string; label: string } | null {
122
+ return scope.kind === 'handle-drag' ? { nodeId: scope.nodeId, label: scope.handle } : null
123
+ }
124
+
125
+ // The legacy `editingHole` flag (`SurfaceHoleTarget`).
126
+ export function editingHoleInfo(
127
+ scope: InteractionScope,
128
+ ): { nodeId: string; holeIndex: number } | null {
129
+ return scope.kind === 'reshaping' && scope.reshape === 'hole' && scope.holeIndex !== undefined
130
+ ? { nodeId: scope.nodeId, holeIndex: scope.holeIndex }
131
+ : null
132
+ }
133
+
134
+ // Build the scope payload for a hole reshape, so producers don't re-spell the
135
+ // discriminator at every call site.
136
+ export function holeEditScope(target: {
137
+ nodeId: string
138
+ holeIndex: number
139
+ driver?: ReshapeDriver
140
+ }): ActiveInteractionScope {
141
+ return {
142
+ kind: 'reshaping',
143
+ nodeId: target.nodeId,
144
+ reshape: 'hole',
145
+ holeIndex: target.holeIndex,
146
+ driver: target.driver ?? 'tool',
147
+ }
148
+ }
149
+
150
+ // True while the selected node's geometry is being curved (legacy
151
+ // `curvingWall` / `curvingFence` — now one scope; the wall-vs-fence kind is
152
+ // recovered from the reshaped node's type, looked up from the scene by nodeId).
153
+ export function isCurveReshape(scope: InteractionScope): boolean {
154
+ return scope.kind === 'reshaping' && scope.reshape === 'curve'
155
+ }
156
+
157
+ export function isToolDrivenReshape(scope: InteractionScope): boolean {
158
+ return scope.kind === 'reshaping' && scope.driver === 'tool'
159
+ }
160
+
161
+ export function isFloorplanDrivenReshape(scope: InteractionScope): boolean {
162
+ return scope.kind === 'reshaping' && scope.driver === 'floorplan'
163
+ }
164
+
165
+ // The legacy `movingWallEndpoint` / `movingFenceEndpoint` flags minus the node
166
+ // itself (consumers fetch the node from the scene by `nodeId`; it is stable for
167
+ // the duration of the drag).
168
+ export function endpointReshapeInfo(
169
+ scope: InteractionScope,
170
+ ): { nodeId: string; endpoint: 'start' | 'end' } | null {
171
+ return scope.kind === 'reshaping' && scope.reshape === 'endpoint' && scope.endpoint !== undefined
172
+ ? { nodeId: scope.nodeId, endpoint: scope.endpoint }
173
+ : null
174
+ }
175
+
176
+ export function controlPointReshapeInfo(
177
+ scope: InteractionScope,
178
+ ): { nodeId: string; index: number } | null {
179
+ return scope.kind === 'reshaping' &&
180
+ scope.reshape === 'control-point' &&
181
+ scope.index !== undefined
182
+ ? { nodeId: scope.nodeId, index: scope.index }
183
+ : null
184
+ }
185
+
186
+ export function tangentReshapeInfo(
187
+ scope: InteractionScope,
188
+ ): { nodeId: string; index: number; side: 'in' | 'out' } | null {
189
+ return scope.kind === 'reshaping' &&
190
+ scope.reshape === 'tangent' &&
191
+ scope.index !== undefined &&
192
+ scope.side !== undefined
193
+ ? { nodeId: scope.nodeId, index: scope.index, side: scope.side }
194
+ : null
195
+ }
196
+
197
+ // The id of the node being reshaped (any reshape kind), for the scene lookup
198
+ // that recovers the full node payload a few consumers still need.
199
+ export function reshapingNodeId(scope: InteractionScope): string | null {
200
+ return scope.kind === 'reshaping' ? scope.nodeId : null
201
+ }
202
+
203
+ // Builders so producers don't re-spell the discriminator at every call site.
204
+ export function curveReshapeScope(
205
+ nodeId: string,
206
+ driver: ReshapeDriver = 'tool',
207
+ ): ActiveInteractionScope {
208
+ return { kind: 'reshaping', nodeId, reshape: 'curve', driver }
209
+ }
210
+
211
+ export function endpointReshapeScope(
212
+ nodeId: string,
213
+ endpoint: 'start' | 'end',
214
+ driver: ReshapeDriver = 'tool',
215
+ ): ActiveInteractionScope {
216
+ return { kind: 'reshaping', nodeId, reshape: 'endpoint', endpoint, driver }
217
+ }
218
+
219
+ export function controlPointReshapeScope(
220
+ nodeId: string,
221
+ index: number,
222
+ driver: ReshapeDriver = 'tool',
223
+ ): ActiveInteractionScope {
224
+ return { kind: 'reshaping', nodeId, reshape: 'control-point', index, driver }
225
+ }
226
+
227
+ export function tangentReshapeScope(
228
+ nodeId: string,
229
+ index: number,
230
+ side: 'in' | 'out',
231
+ driver: ReshapeDriver = 'tool',
232
+ ): ActiveInteractionScope {
233
+ return { kind: 'reshaping', nodeId, reshape: 'tangent', index, side, driver }
234
+ }
235
+
236
+ // Dragging a polygon vertex/edge (slab / ceiling boundary). Drives the snapping
237
+ // HUD (no-angle 'polygon' set) and keeps the idle select hints off-screen.
238
+ export function boundaryReshapeScope(
239
+ nodeId: string,
240
+ driver: ReshapeDriver = 'tool',
241
+ ): ActiveInteractionScope {
242
+ return { kind: 'reshaping', nodeId, reshape: 'boundary', driver }
243
+ }
@@ -11,7 +11,7 @@ import { buildLevelDuplicateCreateOps } from './level-duplication'
11
11
 
12
12
  describe('buildLevelDuplicateCreateOps', () => {
13
13
  test('parents a duplicated bootstrap level back to its building', () => {
14
- const level = LevelNode.parse({ level: 0, children: [] })
14
+ const level = LevelNode.parse({ level: 0, height: 3.25, children: [] })
15
15
  const building = BuildingNode.parse({ children: [level.id] })
16
16
  const wall = WallNode.parse({
17
17
  parentId: level.id,
@@ -36,6 +36,7 @@ describe('buildLevelDuplicateCreateOps', () => {
36
36
 
37
37
  expect(sourceLevel.parentId).toBeNull()
38
38
  expect(levelCreateOp?.parentId).toBe(building.id)
39
+ expect(levelCreateOp?.node.type === 'level' ? levelCreateOp.node.height : undefined).toBe(3.25)
39
40
  })
40
41
 
41
42
  test('does not copy spawn points from the source level', () => {
@@ -5,10 +5,10 @@ import { useViewer } from '@pascal-app/viewer'
5
5
  function getAdjacentLevelIdForDeletion(levelId: AnyNodeId): LevelNode['id'] | null {
6
6
  const { nodes } = useScene.getState()
7
7
  const level = nodes[levelId]
8
- if (!level || level.type !== 'level' || !level.parentId) return null
8
+ if (level?.type !== 'level' || !level.parentId) return null
9
9
 
10
10
  const building = nodes[level.parentId as AnyNodeId]
11
- if (!building || building.type !== 'building') return null
11
+ if (building?.type !== 'building') return null
12
12
 
13
13
  const siblingLevelIds = (building as BuildingNode).children.filter(
14
14
  (childId): childId is LevelNode['id'] => nodes[childId as AnyNodeId]?.type === 'level',
@@ -13,7 +13,6 @@ import {
13
13
  getEffectiveRoofSurfaceMaterial,
14
14
  getEffectiveSegmentSurfaceMaterial,
15
15
  getEffectiveStairSurfaceMaterial,
16
- getEffectiveWallSurfaceMaterial,
17
16
  getLibraryMaterialIdFromRef,
18
17
  type MaterialSchema,
19
18
  type MaterialTarget,
@@ -26,28 +25,30 @@ import {
26
25
  type SlabNode,
27
26
  type StairNode,
28
27
  type StairSurfaceMaterialRole,
29
- type WallNode,
30
28
  type WallSurfaceSide,
31
29
  } from '@pascal-app/core'
32
30
 
33
- export type PaintableMaterialTarget = Extract<
34
- MaterialTarget,
35
- | 'wall'
36
- | 'roof'
37
- | 'stair'
38
- | 'fence'
39
- | 'column'
40
- | 'slab'
41
- | 'ceiling'
42
- | 'shelf'
43
- | 'chimney'
44
- | 'dormer'
45
- | 'box-vent'
46
- | 'ridge-vent'
47
- | 'turbine-vent'
48
- | 'cupola'
49
- | 'eyebrow-vent'
50
- >
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'
51
52
 
52
53
  export type SingleSurfaceMaterialRole = 'surface'
53
54
 
@@ -76,32 +77,6 @@ export function getActivePaintMaterialLabel(material: ActivePaintMaterial | null
76
77
  return getCatalogEntryForActivePaintMaterial(material)?.label ?? 'Custom'
77
78
  }
78
79
 
79
- export function buildWallSurfaceMaterialPatch(
80
- node: WallNode,
81
- targetSide: WallSurfaceSide,
82
- material: MaterialSchema | undefined,
83
- materialPreset: string | undefined,
84
- ): Partial<WallNode> {
85
- const nextSurfaceMaterial = { material, materialPreset }
86
- const nextInterior =
87
- targetSide === 'interior'
88
- ? nextSurfaceMaterial
89
- : getEffectiveWallSurfaceMaterial(node, 'interior')
90
- const nextExterior =
91
- targetSide === 'exterior'
92
- ? nextSurfaceMaterial
93
- : getEffectiveWallSurfaceMaterial(node, 'exterior')
94
-
95
- return {
96
- interiorMaterial: nextInterior.material,
97
- interiorMaterialPreset: nextInterior.materialPreset,
98
- exteriorMaterial: nextExterior.material,
99
- exteriorMaterialPreset: nextExterior.materialPreset,
100
- material: undefined,
101
- materialPreset: undefined,
102
- }
103
- }
104
-
105
80
  export function buildRoofSurfaceMaterialPatch(
106
81
  node: RoofNode,
107
82
  targetRole: RoofSurfaceMaterialRole,
@@ -179,6 +154,7 @@ export function buildResetSurfaceMaterialUpdates(
179
154
  if (
180
155
  key === 'material' ||
181
156
  key === 'materialPreset' ||
157
+ key === 'slots' ||
182
158
  key.endsWith('Material') ||
183
159
  key.endsWith('MaterialPreset')
184
160
  ) {
@@ -276,6 +252,7 @@ export function resolveActivePaintMaterialFromSelection(params: {
276
252
  | ChimneyMaterialRole
277
253
  | DormerSurfaceMaterialRole
278
254
  | SingleSurfaceMaterialRole
255
+ | string
279
256
  } | null
280
257
  }): ActivePaintMaterial | null {
281
258
  const { nodes, selectedId, selectedMaterialTarget } = params
@@ -297,7 +274,7 @@ export function resolveActivePaintMaterialFromSelection(params: {
297
274
  nodes,
298
275
  })
299
276
  if (surface) {
300
- const sourceTarget = selectedNode.type as PaintableMaterialTarget
277
+ const sourceTarget = (paintCap.materialTarget ?? selectedNode.type) as PaintableMaterialTarget
301
278
  return hasActivePaintMaterial({
302
279
  material: surface.material,
303
280
  materialPreset: surface.materialPreset,
@@ -375,11 +352,13 @@ export function resolveActivePaintMaterialFromSelection(params: {
375
352
  // Wall / chimney / dormer flow through the registry-driven path
376
353
  // at the top of this function.
377
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
+
378
359
  if (
379
360
  (selectedNode.type === 'fence' ||
380
361
  selectedNode.type === 'column' ||
381
- selectedNode.type === 'slab' ||
382
- selectedNode.type === 'ceiling' ||
383
362
  selectedNode.type === 'shelf') &&
384
363
  selectedMaterialTarget.role === 'surface'
385
364
  ) {
@@ -412,6 +391,10 @@ export function resolvePaintTargetFromSelection(params: {
412
391
  const selectedNode = nodes[selectedId]
413
392
  if (!selectedNode) return null
414
393
 
394
+ const registryPaintTarget = nodeRegistry.get(selectedNode.type)?.capabilities?.paint
395
+ ?.materialTarget
396
+ if (registryPaintTarget) return registryPaintTarget as PaintableMaterialTarget
397
+
415
398
  if (selectedNode.type === 'wall') {
416
399
  return 'wall'
417
400
  }
@@ -444,6 +427,10 @@ export function resolvePaintTargetFromSelection(params: {
444
427
  return 'shelf'
445
428
  }
446
429
 
430
+ if (selectedNode.type === 'item') {
431
+ return 'item'
432
+ }
433
+
447
434
  if (selectedNode.type === 'chimney') {
448
435
  return 'chimney'
449
436
  }
@@ -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
+ }