@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
@@ -1,4 +1,4 @@
1
- import { Howl } from 'howler'
1
+ import { Howl, Howler } from 'howler'
2
2
  import useAudio from '../store/use-audio'
3
3
 
4
4
  // Per-sound variation config. Playback rate also shifts pitch (one semitone ≈ 1.0595×),
@@ -14,13 +14,18 @@ type SFXConfig = {
14
14
  // Minimum gap between two plays of this SFX. Triggers within this window
15
15
  // are silently dropped so bursty sequences don't phase-stack into noise.
16
16
  minIntervalMs?: number
17
- // Random stereo pan per play — max absolute offset (0 = center, 1 = hard
18
- // right). A small value like 0.15 keeps things centred but adds just enough
19
- // spread to stop repeats from stacking on the same point in the field.
20
- panJitter?: number
17
+ }
18
+
19
+ type LoopSFXConfig = {
20
+ src: string
21
+ volumeMultiplier: number
22
+ fadeInMs?: number
23
+ fadeOutMs?: number
21
24
  }
22
25
 
23
26
  const DEFAULT_MIN_INTERVAL_MS = 30
27
+ const SFX_FAILURE_BACKOFF_MS = 5_000
28
+ const DEFAULT_LOOP_FADE_MS = 90
24
29
 
25
30
  // SFX sound definitions
26
31
  export const SFX: Record<string, SFXConfig> = {
@@ -32,52 +37,53 @@ export const SFX: Record<string, SFXConfig> = {
32
37
  ],
33
38
  rateRange: [0.98, 1.02],
34
39
  volumeRange: [0.5, 0.6],
35
- panJitter: 0.15,
36
40
  minIntervalMs: 50,
37
41
  },
38
42
  itemDelete: {
39
43
  src: '/audios/sfx/item_delete.mp3',
40
44
  rateRange: [0.9, 1.1],
41
45
  volumeRange: [0.9, 1.0],
42
- panJitter: 0.05,
43
46
  },
44
47
  itemPick: {
45
48
  src: '/audios/sfx/item_pick.mp3',
46
49
  rateRange: [0.95, 1.05],
47
50
  volumeRange: [0.92, 1.0],
48
- panJitter: 0.15,
49
51
  },
50
52
  itemPlace: {
51
53
  src: '/audios/sfx/item_place.mp3',
52
54
  rateRange: [0.98, 1.02],
53
55
  volumeRange: [0.9, 1.0],
54
- panJitter: 0.15,
55
56
  },
56
57
  itemRotate: {
57
58
  src: '/audios/sfx/item_rotate.mp3',
58
59
  rateRange: [0.94, 1.06],
59
60
  volumeRange: [0.92, 1.0],
60
- panJitter: 0.15,
61
+ },
62
+ // Ticks as a resize handle is dragged across snap steps. Fires in rapid
63
+ // succession, so it mirrors gridSnap: three variations cycled round-robin
64
+ // with pitch jitter and a gap so the run reads as texture, not a tone.
65
+ resize: {
66
+ src: ['/audios/sfx/resize_0.mp3', '/audios/sfx/resize_1.mp3', '/audios/sfx/resize_2.mp3'],
67
+ rateRange: [0.98, 1.02],
68
+ volumeRange: [0.26, 0.34],
69
+ minIntervalMs: 80,
61
70
  },
62
71
  // Fired when a structure draft begins (first click of a wall/slab/etc).
63
72
  structureBuildStart: {
64
73
  src: '/audios/sfx/structure_build_start.mp3',
65
74
  rateRange: [0.95, 1.05],
66
75
  volumeRange: [0.88, 1.0],
67
- panJitter: 0.15,
68
76
  },
69
77
  // Fired when a structure is committed (segment placed / polygon closed).
70
78
  structureBuildEnd: {
71
79
  src: '/audios/sfx/structure_build_end.mp3',
72
80
  rateRange: [0.95, 1.05],
73
81
  volumeRange: [0.88, 1.0],
74
- panJitter: 0.15,
75
82
  },
76
83
  structureDelete: {
77
84
  src: '/audios/sfx/structure_delete.mp3',
78
85
  rateRange: [0.9, 1.1],
79
86
  volumeRange: [0.9, 1.0],
80
- panJitter: 0.08,
81
87
  },
82
88
  snapshotCapture: {
83
89
  // Shutter should sound consistent — no variation.
@@ -90,7 +96,6 @@ export const SFX: Record<string, SFXConfig> = {
90
96
  src: '/audios/sfx/menu_hover.mp3',
91
97
  rateRange: [0.98, 1.02],
92
98
  volumeRange: [0.2, 0.3],
93
- panJitter: 0.1,
94
99
  minIntervalMs: 0,
95
100
  },
96
101
  // Fired when a main category in the Build / Items panels is clicked.
@@ -98,81 +103,254 @@ export const SFX: Record<string, SFXConfig> = {
98
103
  src: '/audios/sfx/menu_click.mp3',
99
104
  rateRange: [0.98, 1.02],
100
105
  volumeRange: [0.5, 0.6],
101
- panJitter: 0.1,
106
+ },
107
+ // Fired when a material is applied to a surface in paint mode. Painting can
108
+ // fire in quick succession across faces, so keep variation + a small gap.
109
+ paintApply: {
110
+ src: '/audios/sfx/paint_apply.mp3',
111
+ rateRange: [0.95, 1.05],
112
+ volumeRange: [0.85, 1.0],
113
+ minIntervalMs: 60,
102
114
  },
103
115
  } as const
104
116
 
105
117
  export type SFXName = keyof typeof SFX
106
118
 
119
+ export const LOOP_SFX = {
120
+ terrainRaise: {
121
+ src: '/audios/sfx/terrain_raise.mp3',
122
+ volumeMultiplier: 0.2,
123
+ },
124
+ terrainLower: {
125
+ src: '/audios/sfx/terrain_lower.mp3',
126
+ volumeMultiplier: 0.2,
127
+ },
128
+ terrainFlatten: {
129
+ src: '/audios/sfx/terrain_flatten.mp3',
130
+ volumeMultiplier: 0.2,
131
+ },
132
+ terrainSmooth: {
133
+ src: '/audios/sfx/terrain_smooth.mp3',
134
+ volumeMultiplier: 0.2,
135
+ },
136
+ } as const satisfies Record<string, LoopSFXConfig>
137
+
138
+ export type LoopSFXName = keyof typeof LOOP_SFX
139
+ type CachedSFXName = SFXName | LoopSFXName
140
+
141
+ function loopConfig(name: LoopSFXName): LoopSFXConfig {
142
+ return LOOP_SFX[name]
143
+ }
144
+
145
+ export type SFXPlaybackOptions = {
146
+ source?: 'local' | 'remote'
147
+ stereo?: number
148
+ volumeMultiplier?: number
149
+ }
150
+
107
151
  function randomInRange([min, max]: [number, number]): number {
108
152
  return min + Math.random() * (max - min)
109
153
  }
110
154
 
111
- // Preload all SFX sounds. Each variation gets its own Howl so they can overlap
112
- // and be cycled round-robin.
113
- const sfxCache = new Map<SFXName, Howl[]>()
155
+ let sfxCache = new Map<CachedSFXName, Howl[]>()
156
+ let sfxAudioContext: AudioContext | null = null
157
+ let sfxRetryAfter = 0
114
158
  const lastPlayedAt = new Map<SFXName, number>()
115
159
  const lastVariation = new Map<SFXName, number>()
160
+ let activeLoop: {
161
+ id: number
162
+ name: LoopSFXName
163
+ sound: Howl
164
+ volume: number
165
+ } | null = null
166
+ let pendingLoop: { name: LoopSFXName; sound: Howl } | null = null
167
+ let requestedLoop: LoopSFXName | null = null
168
+
169
+ function loopVolume(name: LoopSFXName): number {
170
+ const { masterVolume, sfxVolume } = useAudio.getState()
171
+ return (masterVolume / 100) * (sfxVolume / 100) * LOOP_SFX[name].volumeMultiplier
172
+ }
173
+
174
+ function stopActiveLoop(fadeMs: number) {
175
+ const active = activeLoop
176
+ if (!active) return
177
+ activeLoop = null
178
+
179
+ if (fadeMs <= 0) {
180
+ active.sound.stop(active.id)
181
+ return
182
+ }
183
+
184
+ active.sound.once('fade', () => active.sound.stop(active.id), active.id)
185
+ active.sound.fade(active.volume, 0, fadeMs, active.id)
186
+ }
187
+
188
+ function unloadCachedSounds(resetPlaybackState: boolean) {
189
+ requestedLoop = null
190
+ pendingLoop = null
191
+ stopActiveLoop(0)
192
+ for (const sounds of sfxCache.values()) {
193
+ for (const sound of sounds) {
194
+ try {
195
+ sound.unload()
196
+ } catch {}
197
+ }
198
+ }
199
+ sfxCache.clear()
200
+ sfxAudioContext = null
201
+ if (resetPlaybackState) {
202
+ sfxRetryAfter = 0
203
+ lastPlayedAt.clear()
204
+ lastVariation.clear()
205
+ }
206
+ }
207
+
208
+ function cacheNeedsRebuild(): boolean {
209
+ if (sfxCache.size === 0) return true
210
+ if (sfxAudioContext !== Howler.ctx) return true
211
+ for (const sounds of sfxCache.values()) {
212
+ if (sounds.some((sound) => sound.state() === 'unloaded')) return true
213
+ }
214
+ return false
215
+ }
116
216
 
117
- // Initialize all sounds
118
- Object.entries(SFX).forEach(([name, config]) => {
119
- const sources = Array.isArray(config.src) ? config.src : [config.src]
120
- const sounds = sources.map(
121
- (src) =>
122
- new Howl({
123
- src: [src],
124
- preload: true,
125
- volume: 0.5, // Will be adjusted by the bus
126
- }),
127
- )
128
- sfxCache.set(name as SFXName, sounds)
129
- })
217
+ export function preloadSFX() {
218
+ if (!cacheNeedsRebuild()) return
219
+ unloadCachedSounds(false)
220
+
221
+ const definitions = { ...SFX, ...LOOP_SFX }
222
+ for (const [name, config] of Object.entries(definitions)) {
223
+ const sources = Array.isArray(config.src) ? config.src : [config.src]
224
+ sfxCache.set(
225
+ name as CachedSFXName,
226
+ sources.map(
227
+ (src) =>
228
+ new Howl({
229
+ loop: name in LOOP_SFX,
230
+ src: [src],
231
+ preload: true,
232
+ volume: 0.5,
233
+ }),
234
+ ),
235
+ )
236
+ }
237
+ sfxAudioContext = Howler.ctx ?? null
238
+ }
239
+
240
+ export function disposeSFX() {
241
+ unloadCachedSounds(true)
242
+ }
243
+
244
+ export function startLoopSFX(name: LoopSFXName) {
245
+ const { muted } = useAudio.getState()
246
+ if (muted) {
247
+ stopLoopSFX()
248
+ return
249
+ }
250
+
251
+ requestedLoop = name
252
+ const current = activeLoop
253
+ if (current?.name === name && current.sound.playing(current.id)) return
254
+ if (current) stopActiveLoop(loopConfig(current.name).fadeOutMs ?? DEFAULT_LOOP_FADE_MS)
255
+
256
+ const now = performance.now()
257
+ if (now < sfxRetryAfter) return
258
+
259
+ try {
260
+ preloadSFX()
261
+ // A fresh Howler context rebuilds the cache and clears stale playback
262
+ // state. Restore this current press after that cleanup so a first-use loop
263
+ // can still begin when its asset finishes loading.
264
+ requestedLoop = name
265
+ const sound = sfxCache.get(name)?.[0]
266
+ if (!sound) return
267
+ if (sound.state() !== 'loaded') {
268
+ if (pendingLoop?.name === name && pendingLoop.sound === sound) return
269
+ pendingLoop = { name, sound }
270
+ sound.once('load', () => {
271
+ if (pendingLoop?.sound === sound) pendingLoop = null
272
+ if (requestedLoop === name) startLoopSFX(name)
273
+ })
274
+ return
275
+ }
276
+
277
+ const config = loopConfig(name)
278
+ const volume = loopVolume(name)
279
+ const id = sound.play()
280
+ sound.volume(0, id)
281
+ activeLoop = { id, name, sound, volume }
282
+ sound.fade(0, volume, config.fadeInMs ?? DEFAULT_LOOP_FADE_MS, id)
283
+ } catch {
284
+ unloadCachedSounds(false)
285
+ sfxRetryAfter = now + SFX_FAILURE_BACKOFF_MS
286
+ }
287
+ }
288
+
289
+ export function stopLoopSFX() {
290
+ requestedLoop = null
291
+ pendingLoop = null
292
+ const fadeMs = activeLoop ? (loopConfig(activeLoop.name).fadeOutMs ?? DEFAULT_LOOP_FADE_MS) : 0
293
+ stopActiveLoop(fadeMs)
294
+ }
130
295
 
131
296
  /**
132
297
  * Play a sound effect with volume based on audio settings
133
298
  */
134
- export function playSFX(name: SFXName) {
135
- const sounds = sfxCache.get(name)
136
- if (!sounds || sounds.length === 0) {
137
- console.warn(`SFX not found: ${name}`)
138
- return
139
- }
299
+ export function playSFX(name: SFXName, options: SFXPlaybackOptions = {}) {
140
300
  const config = SFX[name]!
301
+ const { masterVolume, sfxVolume, muted } = useAudio.getState()
302
+
303
+ if (muted) return
141
304
 
142
305
  // Drop rapid repeats — two plays of the same SFX within minIntervalMs just
143
306
  // smear into noise, they don't add useful information.
144
307
  const now = performance.now()
308
+ if (now < sfxRetryAfter) return
145
309
  const minInterval = config.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS
146
- const last = lastPlayedAt.get(name)
310
+ const source = options.source ?? 'local'
311
+ const playbackKey = `${source}:${name}`
312
+ const last = lastPlayedAt.get(playbackKey)
147
313
  if (last !== undefined && now - last < minInterval) return
148
- lastPlayedAt.set(name, now)
314
+ // Local feedback stays legible when a collaborator makes the same kind of
315
+ // change at nearly the same time; the quieter remote cue yields instead.
316
+ const lastLocal = lastPlayedAt.get(`local:${name}`)
317
+ if (source === 'remote' && lastLocal !== undefined && now - lastLocal < 120) return
318
+ lastPlayedAt.set(playbackKey, now)
149
319
 
150
- // Pick a random variation, avoiding an immediate repeat of the last one so
151
- // consecutive plays don't land on the same file.
152
- let index = Math.floor(Math.random() * sounds.length)
153
- if (sounds.length > 1 && index === lastVariation.get(name)) {
154
- index = (index + 1) % sounds.length
155
- }
156
- lastVariation.set(name, index)
157
- const sound = sounds[index]!
320
+ try {
321
+ preloadSFX()
322
+ const sounds = sfxCache.get(name)
323
+ if (!sounds || sounds.length === 0) return
158
324
 
159
- const { masterVolume, sfxVolume, muted } = useAudio.getState()
160
-
161
- if (muted) return
162
-
163
- // Calculate final volume (masterVolume and sfxVolume are 0-100)
164
- const baseVolume = (masterVolume / 100) * (sfxVolume / 100)
165
- const volumeJitter = config.volumeRange ? randomInRange(config.volumeRange) : 1
166
- const rate = config.rateRange ? randomInRange(config.rateRange) : 1
167
-
168
- // Apply per-play variation using the returned sound id so overlapping plays
169
- // don't fight over shared properties on the Howl.
170
- const id = sound.play()
171
- sound.volume(baseVolume * volumeJitter, id)
172
- if (rate !== 1) sound.rate(rate, id)
173
- if (config.panJitter) {
174
- const pan = (Math.random() * 2 - 1) * config.panJitter
175
- sound.stereo(pan, id)
325
+ // Pick a random variation, avoiding an immediate repeat of the last one so
326
+ // consecutive plays don't land on the same file.
327
+ let index = Math.floor(Math.random() * sounds.length)
328
+ if (sounds.length > 1 && index === lastVariation.get(name)) {
329
+ index = (index + 1) % sounds.length
330
+ }
331
+ lastVariation.set(name, index)
332
+ const sound = sounds[index]!
333
+ // Howler queues per-play mutations while a sound is loading. If its global
334
+ // AudioContext is replaced before that queue drains, stereo setup can try
335
+ // to connect nodes from different contexts and throw asynchronously.
336
+ if (sound.state() !== 'loaded') return
337
+ const baseVolume = (masterVolume / 100) * (sfxVolume / 100)
338
+ const volumeJitter = config.volumeRange ? randomInRange(config.volumeRange) : 1
339
+ const volumeMultiplier = Number.isFinite(options.volumeMultiplier)
340
+ ? Math.max(0, Math.min(2, options.volumeMultiplier!))
341
+ : 1
342
+ const rate = config.rateRange ? randomInRange(config.rateRange) : 1
343
+ const id = sound.play()
344
+ sound.volume(baseVolume * volumeJitter * volumeMultiplier, id)
345
+ if (Number.isFinite(options.stereo)) {
346
+ sound.stereo(Math.max(-1, Math.min(1, options.stereo!)), id)
347
+ }
348
+ if (rate !== 1) sound.rate(rate, id)
349
+ } catch {
350
+ // Optional audio must never abort an editor input callback. Rebuild from
351
+ // the current Howler context after a backoff instead of retrying every pointer cue.
352
+ unloadCachedSounds(false)
353
+ sfxRetryAfter = now + SFX_FAILURE_BACKOFF_MS
176
354
  }
177
355
  }
178
356
 
@@ -180,12 +358,27 @@ export function playSFX(name: SFXName) {
180
358
  * Update all cached SFX volumes (useful when settings change)
181
359
  */
182
360
  export function updateSFXVolumes() {
183
- const { masterVolume, sfxVolume } = useAudio.getState()
361
+ const { masterVolume, muted, sfxVolume } = useAudio.getState()
362
+ if (muted) {
363
+ stopLoopSFX()
364
+ return
365
+ }
184
366
  const finalVolume = (masterVolume / 100) * (sfxVolume / 100)
185
367
 
186
- sfxCache.forEach((sounds) => {
187
- sounds.forEach((sound) => {
188
- sound.volume(finalVolume)
368
+ try {
369
+ if (performance.now() < sfxRetryAfter) return
370
+ preloadSFX()
371
+ sfxCache.forEach((sounds) => {
372
+ sounds.forEach((sound) => {
373
+ sound.volume(finalVolume)
374
+ })
189
375
  })
190
- })
376
+ if (activeLoop) {
377
+ activeLoop.volume = loopVolume(activeLoop.name)
378
+ activeLoop.sound.volume(activeLoop.volume, activeLoop.id)
379
+ }
380
+ } catch {
381
+ unloadCachedSounds(false)
382
+ sfxRetryAfter = performance.now() + SFX_FAILURE_BACKOFF_MS
383
+ }
191
384
  }
@@ -0,0 +1,53 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { Mode, Phase } from '../store/use-editor'
3
+ import { siteBoundaryHandlesEnabled } from './site-boundary'
4
+
5
+ /**
6
+ * The site handles are the one affordance both views draw from the same state, so
7
+ * they are also the one place a 2D/3D parity break hides: each pane used to derive
8
+ * this rule locally, and the sculpt exclusion existed in the 3D copy only.
9
+ */
10
+
11
+ const MODES: Mode[] = ['select', 'edit', 'delete', 'build', 'material-paint', 'terrain-sculpt']
12
+ const PHASES: Phase[] = ['site', 'structure', 'furnish']
13
+
14
+ describe('siteBoundaryHandlesEnabled', () => {
15
+ test('sculpt hides the handles in every phase', () => {
16
+ // Including `site` — which is the case that matters, because arming sculpt
17
+ // *promotes* the phase to `site`, so this is the only combination the brush is
18
+ // ever actually in.
19
+ for (const phase of PHASES) {
20
+ expect(siteBoundaryHandlesEnabled({ mode: 'terrain-sculpt', phase })).toBe(false)
21
+ }
22
+ })
23
+
24
+ test('the site phase offers them in every other mode', () => {
25
+ for (const mode of MODES.filter((m) => m !== 'terrain-sculpt')) {
26
+ expect(siteBoundaryHandlesEnabled({ mode, phase: 'site' })).toBe(true)
27
+ }
28
+ })
29
+
30
+ test('select mode offers them outside the site phase', () => {
31
+ // The flags double as the affordance that *enters* site editing, so select mode
32
+ // has to show them from structure/furnish or the lot becomes unreachable.
33
+ expect(siteBoundaryHandlesEnabled({ mode: 'select', phase: 'structure' })).toBe(true)
34
+ expect(siteBoundaryHandlesEnabled({ mode: 'select', phase: 'furnish' })).toBe(true)
35
+ })
36
+
37
+ test('a drafting or painting mode outside the site phase hides them', () => {
38
+ for (const mode of ['edit', 'delete', 'build', 'material-paint'] as Mode[]) {
39
+ expect(siteBoundaryHandlesEnabled({ mode, phase: 'structure' })).toBe(false)
40
+ expect(siteBoundaryHandlesEnabled({ mode, phase: 'furnish' })).toBe(false)
41
+ }
42
+ })
43
+
44
+ test('sculpt is excluded before the phase term, not alongside it', () => {
45
+ // Ordering is the whole fix: `phase === 'site' || mode === 'select'` alone is
46
+ // *true* while sculpting, so an implementation that ORs the sculpt check in
47
+ // rather than early-returning would still show the handles.
48
+ const sculptInSite = siteBoundaryHandlesEnabled({ mode: 'terrain-sculpt', phase: 'site' })
49
+ const selectInSite = siteBoundaryHandlesEnabled({ mode: 'select', phase: 'site' })
50
+ expect(sculptInSite).toBe(false)
51
+ expect(selectInSite).toBe(true)
52
+ })
53
+ })
@@ -1 +1,28 @@
1
+ import type { Mode, Phase } from '../store/use-editor'
2
+
1
3
  export const SITE_BOUNDARY_DRAG_LABEL = 'site-boundary'
4
+
5
+ /**
6
+ * Whether the site's vertex/midpoint handles are grabbable right now.
7
+ *
8
+ * Shared by the 3D boundary editor (`tool-manager.tsx`) and the floorplan's SVG
9
+ * handles (`floorplan-panel.tsx`) because they must agree, and until this existed
10
+ * they did not: each derived the rule from `phase`/`mode` locally, and the sculpt
11
+ * clause was added to only one of them.
12
+ *
13
+ * The disagreement was reachable in exactly one configuration, which is why it was
14
+ * easy to miss: arming sculpt from the 2D view promotes `viewMode` to `split` *and*
15
+ * the phase to `site`, so the floorplan's `phase === 'site'` term went true at the
16
+ * same moment the 3D editor's `!sculpting` term went false — handles gone in one
17
+ * pane, live in the other. Grabbing one then calls `begin({kind: 'handle-drag'})`,
18
+ * which atomically replaces the held `sculpting` scope: the brush stays armed and
19
+ * still paints, while the rest of the editor believes a boundary drag is running.
20
+ *
21
+ * Sculpt is excluded rather than merely deprioritised because the flags sit *on* the
22
+ * ground the brush is aimed at — leaving them mounted puts a grabbable handle under
23
+ * every dab near the lot line, in either view.
24
+ */
25
+ export function siteBoundaryHandlesEnabled(args: { mode: Mode; phase: Phase }): boolean {
26
+ if (args.mode === 'terrain-sculpt') return false
27
+ return args.phase === 'site' || args.mode === 'select'
28
+ }
@@ -0,0 +1,93 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { type AnyNode, WallNode } from '@pascal-app/core'
3
+ import useWallSnapIndicator from '../store/use-wall-snap-indicator'
4
+ import { resolveSlabEdgeBandSnap } from './slab-plan-snap'
5
+
6
+ function sceneWithWall(thickness = 0.1) {
7
+ const wall = WallNode.parse({ start: [0, 0], end: [4, 0], thickness })
8
+ const nodes: Record<string, AnyNode> = { [wall.id]: wall }
9
+ return { wall, nodes }
10
+ }
11
+
12
+ describe('resolveSlabEdgeBandSnap', () => {
13
+ test('magnetic: an edge inside the wall band sticks to the centerline and shows the beacon', () => {
14
+ const { wall, nodes } = sceneWithWall()
15
+ useWallSnapIndicator.getState().clear()
16
+
17
+ const snap = resolveSlabEdgeBandSnap({
18
+ edge: [
19
+ [0.5, 0.08],
20
+ [3.5, 0.08],
21
+ ],
22
+ nodes,
23
+ referencePoint: [1, 0.08],
24
+ magnetic: true,
25
+ })
26
+
27
+ expect(snap).not.toBeNull()
28
+ expect(snap!.wallId).toBe(wall.id)
29
+ expect(snap!.edge[0][1]).toBeCloseTo(0)
30
+ expect(snap!.edge[1][1]).toBeCloseTo(0)
31
+
32
+ const beacon = useWallSnapIndicator.getState().point
33
+ expect(beacon).not.toBeNull()
34
+ expect(beacon!.kind).toBe('wall')
35
+ expect(beacon!.wallIds).toEqual([wall.id])
36
+ // Beacon hugs the reference point projected onto the snapped edge.
37
+ expect(beacon!.x).toBeCloseTo(1)
38
+ expect(beacon!.z).toBeCloseTo(0)
39
+ })
40
+
41
+ test('non-magnetic: only the tight connect stick remains', () => {
42
+ const { nodes } = sceneWithWall()
43
+
44
+ // 8cm off the centerline: inside the band but beyond the connect radius.
45
+ expect(
46
+ resolveSlabEdgeBandSnap({
47
+ edge: [
48
+ [0.5, 0.08],
49
+ [3.5, 0.08],
50
+ ],
51
+ nodes,
52
+ magnetic: false,
53
+ }),
54
+ ).toBeNull()
55
+ expect(useWallSnapIndicator.getState().point).toBeNull()
56
+
57
+ // 4cm: genuinely dropped on the wall — still sticks.
58
+ expect(
59
+ resolveSlabEdgeBandSnap({
60
+ edge: [
61
+ [0.5, 0.04],
62
+ [3.5, 0.04],
63
+ ],
64
+ nodes,
65
+ magnetic: false,
66
+ }),
67
+ ).not.toBeNull()
68
+ })
69
+
70
+ test('clears the beacon when the edge leaves every band', () => {
71
+ const { nodes } = sceneWithWall()
72
+
73
+ resolveSlabEdgeBandSnap({
74
+ edge: [
75
+ [0.5, 0.05],
76
+ [3.5, 0.05],
77
+ ],
78
+ nodes,
79
+ magnetic: true,
80
+ })
81
+ expect(useWallSnapIndicator.getState().point).not.toBeNull()
82
+
83
+ resolveSlabEdgeBandSnap({
84
+ edge: [
85
+ [0.5, 1.5],
86
+ [3.5, 1.5],
87
+ ],
88
+ nodes,
89
+ magnetic: true,
90
+ })
91
+ expect(useWallSnapIndicator.getState().point).toBeNull()
92
+ })
93
+ })