@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,597 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ bboxCornerAnchors,
5
+ collectAlignmentAnchors,
6
+ emitter,
7
+ pauseSceneHistory,
8
+ pauseSpaceDetection,
9
+ resolveAlignment,
10
+ resumeSceneHistory,
11
+ resumeSpaceDetection,
12
+ type SceneMaterialId,
13
+ useLiveNodeOverrides,
14
+ useLiveTransforms,
15
+ useScene,
16
+ } from '@pascal-app/core'
17
+ import { useViewer } from '@pascal-app/viewer'
18
+ import { Plane, Vector2, Vector3 } from 'three'
19
+ import { GROUP_MOVE_DRAG_LABEL } from '../../lib/contextual-help'
20
+ import { clientToPlan } from '../../lib/floorplan/plan-coords'
21
+ import {
22
+ copySelectedNodesToEditorClipboard,
23
+ duplicateNodesToLevel,
24
+ getEditorClipboardSnapshot,
25
+ pasteSystemEditorClipboardToLevel,
26
+ } from '../../lib/scene-clipboard'
27
+ import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
28
+ import useAlignmentGuides from '../../store/use-alignment-guides'
29
+ import useDeleteConfirmation from '../../store/use-delete-confirmation'
30
+ import useEditor, {
31
+ isAlignmentGuideActive,
32
+ isBrushMode,
33
+ isGridSnapActive,
34
+ isMagneticSnapActive,
35
+ } from '../../store/use-editor'
36
+ import useInteractionScope from '../../store/use-interaction-scope'
37
+ import { useFloorplanGroupDrag } from '../editor-2d/floorplan-group-move'
38
+ import {
39
+ classifyParticipant,
40
+ collectParticipants,
41
+ computeGroupBox,
42
+ expandToComponent,
43
+ levelFrame,
44
+ participantExtents,
45
+ rotateGroupSnapshots,
46
+ translateGroupPatches,
47
+ type Vec2,
48
+ } from './group-transform-shared'
49
+ import { swallowNextClick } from './handles/use-handle-drag'
50
+ import { getEditorThreeContext } from './three-context-bridge'
51
+
52
+ // Whole-selection actions behind the group action menu (Move / Duplicate /
53
+ // Delete), shared by the 2D and 3D menus. The Move flow is a "pick-up": the
54
+ // selection rides the cursor (relative to where tracking starts, so nothing
55
+ // teleports) across BOTH surfaces — floor plan via the scene CTM, 3D via a
56
+ // ground-plane raycast through the bridged camera — until a click commits it
57
+ // as one undo step. Escape / right-click cancels.
58
+
59
+ const ALIGNMENT_THRESHOLD_M = 0.08
60
+ // Matches the keyboard Delete arm's accidental-bulk-delete guard.
61
+ const BULK_DELETE_THRESHOLD = 10
62
+
63
+ // Any non-empty selection can be picked up (the menus themselves gate on a
64
+ // multi-selection; Duplicate can legitimately land on a single cloned root).
65
+ function groupParticipantIds(): string[] {
66
+ const { selectedIds, levelId } = useViewer.getState().selection
67
+ if (selectedIds.length === 0) return []
68
+ const nodes = useScene.getState().nodes
69
+ return selectedIds.filter(
70
+ (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
71
+ )
72
+ }
73
+
74
+ /** True when the current selection is a group the pick-up move can carry. */
75
+ export function canGroupPickUp(): boolean {
76
+ return groupParticipantIds().length > 0
77
+ }
78
+
79
+ /**
80
+ * Pick up the current multi-selection: it follows the cursor (delta-relative)
81
+ * until a click commits, mirroring the single-node `movingNode` flow. Returns
82
+ * false when the selection holds no transformable participants.
83
+ *
84
+ * `scopeToSelection` limits the moving set to the selected participants —
85
+ * no connected-component expansion and no welded-neighbor endpoints. The
86
+ * Duplicate flow needs this: its clones sit EXACTLY on the originals, so
87
+ * junction coincidence would otherwise weld the originals into the pick-up
88
+ * and drag them along with the copies.
89
+ */
90
+ export function startGroupPickUp(
91
+ opts: { onCancel?: () => void; positionAtCursor?: boolean; scopeToSelection?: boolean } = {},
92
+ ): boolean {
93
+ const { selectedIds, levelId } = useViewer.getState().selection
94
+ const participantIds = groupParticipantIds()
95
+ if (participantIds.length === 0) return false
96
+ const nodes = useScene.getState().nodes
97
+ const fullIds = opts.scopeToSelection
98
+ ? participantIds
99
+ : expandToComponent(participantIds, nodes, levelId)
100
+ const collected = collectParticipants(fullIds, nodes, levelId)
101
+ // Mutable: mid-carry R/T rotates these snapshots in place.
102
+ let starts = collected.starts
103
+ let links = opts.scopeToSelection ? [] : collected.links
104
+ if (starts.length === 0) return false
105
+ const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
106
+
107
+ // Rest bounds in the level frame. Prefer the mounted meshes' world box
108
+ // (footprint-accurate), but fall back to the participant DATA when the
109
+ // meshes aren't up yet — Duplicate starts the pick-up synchronously after
110
+ // `createNodes`, one frame before the clones' renderers mount.
111
+ const { inverse: frameInv } = levelFrame(levelId)
112
+ const restBox = computeGroupBox(fullIds)
113
+ let minX = Number.POSITIVE_INFINITY
114
+ let minZ = Number.POSITIVE_INFINITY
115
+ let maxX = Number.NEGATIVE_INFINITY
116
+ let maxZ = Number.NEGATIVE_INFINITY
117
+ if (restBox) {
118
+ const boxMin = restBox.min.clone().applyMatrix4(frameInv)
119
+ const boxMax = restBox.max.clone().applyMatrix4(frameInv)
120
+ minX = Math.min(boxMin.x, boxMax.x)
121
+ minZ = Math.min(boxMin.z, boxMax.z)
122
+ maxX = Math.max(boxMin.x, boxMax.x)
123
+ maxZ = Math.max(boxMin.z, boxMax.z)
124
+ } else {
125
+ const reach = (x: number, z: number) => {
126
+ minX = Math.min(minX, x)
127
+ minZ = Math.min(minZ, z)
128
+ maxX = Math.max(maxX, x)
129
+ maxZ = Math.max(maxZ, z)
130
+ }
131
+ for (const s of starts) {
132
+ if (s.kind === 'endpoint') {
133
+ reach(s.start[0], s.start[1])
134
+ reach(s.end[0], s.end[1])
135
+ } else if (s.kind === 'polygon') {
136
+ for (const [x, z] of s.polygon) {
137
+ reach(x, z)
138
+ }
139
+ } else {
140
+ reach(s.position[0], s.position[2])
141
+ }
142
+ }
143
+ }
144
+ if (!Number.isFinite(minX)) return false
145
+ // Rotation pivot for mid-carry R/T; stable across the whole pick-up.
146
+ const restCenter: [number, number] = [(minX + maxX) / 2, (minZ + maxZ) / 2]
147
+ // Ground plane for the 3D surface: the meshes' base when available, floor
148
+ // level otherwise. Placements live in the level frame, so both surfaces
149
+ // resolve into it before measuring.
150
+ const plane = new Plane(new Vector3(0, 1, 0), -(restBox?.min.y ?? 0))
151
+
152
+ // Alignment candidates — everything outside the moving set; the group
153
+ // aligns as one rigid footprint via its bbox corners + center.
154
+ const movingIdSet = new Set<string>(affectedIds)
155
+ const staticNodes: Record<string, AnyNode> = {}
156
+ for (const [nid, n] of Object.entries(nodes)) {
157
+ if (n && !movingIdSet.has(nid)) staticNodes[nid] = n
158
+ }
159
+ const candidates = collectAlignmentAnchors(staticNodes, '', levelId)
160
+ let restAnchors = bboxCornerAnchors('group-move', minX, minZ, maxX, maxZ)
161
+
162
+ // Cursor → level-frame plan point, whichever surface the pointer is over.
163
+ const ndc = new Vector2()
164
+ const resolvePlanPoint = (e: PointerEvent): Vec2 | null => {
165
+ const three = getEditorThreeContext()
166
+ if (three && e.target === three.domElement) {
167
+ const rect = three.domElement.getBoundingClientRect()
168
+ ndc.set(
169
+ ((e.clientX - rect.left) / rect.width) * 2 - 1,
170
+ -((e.clientY - rect.top) / rect.height) * 2 + 1,
171
+ )
172
+ three.raycaster.setFromCamera(ndc, three.camera)
173
+ const hit = new Vector3()
174
+ if (!three.raycaster.ray.intersectPlane(plane, hit)) return null
175
+ const local = hit.applyMatrix4(frameInv)
176
+ return [local.x, local.z]
177
+ }
178
+ // Anywhere inside the floor-plan viewport counts (the scene `<g>` only
179
+ // covers painted elements, so bounds-test the owning SVG).
180
+ const sceneEl = document.querySelector('g[data-floorplan-scene]') as SVGGElement | null
181
+ const svg = sceneEl?.ownerSVGElement
182
+ if (!svg) return null
183
+ const rect = svg.getBoundingClientRect()
184
+ if (
185
+ e.clientX < rect.left ||
186
+ e.clientX > rect.right ||
187
+ e.clientY < rect.top ||
188
+ e.clientY > rect.bottom
189
+ ) {
190
+ return null
191
+ }
192
+ const plan = clientToPlan(e.clientX, e.clientY)
193
+ return plan ? [plan[0], plan[1]] : null
194
+ }
195
+
196
+ let startPlan: Vec2 | null = null
197
+ let lastDelta: Vec2 | null = null
198
+
199
+ for (const id of affectedIds) {
200
+ useLiveTransforms.getState().clear(id)
201
+ }
202
+ sfxEmitter.emit('sfx:item-pick')
203
+ pauseSceneHistory(useScene)
204
+ useInteractionScope.getState().begin({
205
+ kind: 'handle-drag',
206
+ nodeId: (participantIds[0] ?? '') as AnyNodeId,
207
+ handle: GROUP_MOVE_DRAG_LABEL,
208
+ })
209
+ document.body.style.cursor = 'grabbing'
210
+
211
+ const applyMove = (e: PointerEvent) => {
212
+ const plan = resolvePlanPoint(e)
213
+ if (!plan) return
214
+ // Ordinary moves are delta-relative so the group never teleports. A
215
+ // pasted selection instead arrives centered under the cursor.
216
+ if (!opts.positionAtCursor && !startPlan) {
217
+ startPlan = plan
218
+ return
219
+ }
220
+ const step = useEditor.getState().gridSnapStep
221
+ const snap = isGridSnapActive() && step > 0
222
+ const rawDx = opts.positionAtCursor ? plan[0] - restCenter[0] : plan[0] - startPlan![0]
223
+ const rawDz = opts.positionAtCursor ? plan[1] - restCenter[1] : plan[1] - startPlan![1]
224
+ let dx = snap ? Math.round(rawDx / step) * step : rawDx
225
+ let dz = snap ? Math.round(rawDz / step) * step : rawDz
226
+
227
+ if (isAlignmentGuideActive() && candidates.length > 0 && restAnchors.length > 0) {
228
+ const result = resolveAlignment({
229
+ moving: restAnchors.map((a) => ({ ...a, x: a.x + dx, z: a.z + dz })),
230
+ candidates,
231
+ threshold: ALIGNMENT_THRESHOLD_M,
232
+ })
233
+ if (result.snap && isMagneticSnapActive()) {
234
+ dx += result.snap.dx
235
+ dz += result.snap.dz
236
+ }
237
+ useAlignmentGuides.getState().set(result.guides)
238
+ } else {
239
+ useAlignmentGuides.getState().clear()
240
+ }
241
+
242
+ applyDelta(dx, dz)
243
+ }
244
+
245
+ const applyDelta = (dx: number, dz: number) => {
246
+ if (!lastDelta || lastDelta[0] !== dx || lastDelta[1] !== dz) {
247
+ sfxEmitter.emit('sfx:grid-snap')
248
+ lastDelta = [dx, dz]
249
+ }
250
+
251
+ const entries = translateGroupPatches(starts, links, dx, dz)
252
+ const patchById = new Map(entries)
253
+ const liveTransforms = useLiveTransforms.getState()
254
+ for (const start of starts) {
255
+ if (start.kind === 'scalar') {
256
+ const patch = patchById.get(start.id)
257
+ if (patch) {
258
+ liveTransforms.set(start.id, {
259
+ position: patch.position as [number, number, number],
260
+ rotation: start.rotation,
261
+ })
262
+ }
263
+ }
264
+ useScene.getState().markDirty(start.id)
265
+ }
266
+ for (const l of links) {
267
+ useScene.getState().markDirty(l.id)
268
+ }
269
+ useLiveNodeOverrides.getState().setMany(entries)
270
+ useFloorplanGroupDrag.getState().set([dx, dz])
271
+ }
272
+
273
+ // Mid-carry R/T: rotate the SNAPSHOTS around the rest pivot and re-apply
274
+ // the current delta — the carried group turns exactly like the idle
275
+ // keyboard rotate, and the placement stays a single updateNodes.
276
+ const rotateCarried = (direction: 1 | -1) => {
277
+ const rotated = rotateGroupSnapshots(
278
+ starts,
279
+ links,
280
+ { x: restCenter[0], z: restCenter[1] },
281
+ -direction * (Math.PI / 4),
282
+ )
283
+ starts = rotated.starts
284
+ links = rotated.links
285
+ const ext = participantExtents(rotated.starts)
286
+ if (ext) {
287
+ restAnchors = bboxCornerAnchors('group-move', ext.minX, ext.minZ, ext.maxX, ext.maxZ)
288
+ }
289
+ sfxEmitter.emit('sfx:item-rotate')
290
+ applyDelta(lastDelta?.[0] ?? 0, lastDelta?.[1] ?? 0)
291
+ }
292
+
293
+ const clearLivePreviews = () => {
294
+ const overrides = useLiveNodeOverrides.getState()
295
+ const liveTransforms = useLiveTransforms.getState()
296
+ for (const id of affectedIds) {
297
+ overrides.clear(id)
298
+ liveTransforms.clear(id)
299
+ useScene.getState().markDirty(id)
300
+ }
301
+ }
302
+
303
+ const removeListeners = () => {
304
+ window.removeEventListener('pointermove', onMove)
305
+ window.removeEventListener('pointerdown', onPointerDown, true)
306
+ window.removeEventListener('pointerup', onPointerUp, true)
307
+ window.removeEventListener('keydown', onKeyDown, true)
308
+ window.removeEventListener('contextmenu', onContextMenu, true)
309
+ }
310
+
311
+ // History resume pairs one-to-one with the pause above, on the commit and
312
+ // cancel paths only.
313
+ const teardown = () => {
314
+ removeListeners()
315
+ if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
316
+ useAlignmentGuides.getState().clear()
317
+ useInteractionScope
318
+ .getState()
319
+ .endIf((sc) => sc.kind === 'handle-drag' && sc.handle === GROUP_MOVE_DRAG_LABEL)
320
+ useFloorplanGroupDrag.getState().set(null)
321
+ }
322
+
323
+ const commit = () => {
324
+ swallowNextClick()
325
+ sfxEmitter.emit('sfx:item-place')
326
+ const overrides = useLiveNodeOverrides.getState()
327
+ const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
328
+ for (const id of affectedIds) {
329
+ const patch = overrides.get(id)
330
+ if (patch) updates.push({ id, data: patch as Partial<AnyNode> })
331
+ }
332
+ // Group transforms move existing structure rigidly — the wall-driven room
333
+ // auto-detection must not re-create floors/ceilings for the walls' new
334
+ // positions (that belongs to wall building/editing). Paused around the
335
+ // commit; the sync rolls its baseline forward for paused changes.
336
+ pauseSpaceDetection()
337
+ resumeSceneHistory(useScene)
338
+ if (updates.length > 0) useScene.getState().updateNodes(updates)
339
+ resumeSpaceDetection()
340
+ clearLivePreviews()
341
+ teardown()
342
+ }
343
+
344
+ const cancel = () => {
345
+ clearLivePreviews()
346
+ resumeSceneHistory(useScene)
347
+ teardown()
348
+ opts.onCancel?.()
349
+ }
350
+
351
+ const onMove = (e: PointerEvent) => {
352
+ applyMove(e)
353
+ }
354
+
355
+ // Capture phase: commit before the press reaches selection / tools, so the
356
+ // drop click can't also select whatever lands under the cursor.
357
+ // The placement gesture: the press over a tracked surface is claimed in
358
+ // capture phase and the commit runs on ITS pointerup, also claimed — the
359
+ // canvas never sees either, so `use-node-events` cannot synthesize a
360
+ // selection click from the release (which would re-select whatever sits
361
+ // under the cursor and break the multi-selection).
362
+ let commitPointerId: number | null = null
363
+
364
+ const onPointerDown = (e: PointerEvent) => {
365
+ if (e.button === 2) {
366
+ e.preventDefault()
367
+ e.stopPropagation()
368
+ cancel()
369
+ return
370
+ }
371
+ if (e.button !== 0) return
372
+ // Only a press over a tracked surface commits; a click on side panels or
373
+ // the toolbar keeps the pick-up alive.
374
+ if (!resolvePlanPoint(e)) return
375
+ if (opts.positionAtCursor && !lastDelta) applyMove(e)
376
+ e.preventDefault()
377
+ e.stopPropagation()
378
+ commitPointerId = e.pointerId
379
+ }
380
+
381
+ const onPointerUp = (e: PointerEvent) => {
382
+ if (commitPointerId === null || e.pointerId !== commitPointerId) return
383
+ commitPointerId = null
384
+ // Raise `inputDragging` through this event's dispatch so the canvas's
385
+ // use-node-events suppresses the selection click it synthesizes on every
386
+ // pointerup (stopPropagation would also break the window bubble
387
+ // listeners that clear the box-select pointer suppression).
388
+ useViewer.getState().setInputDragging(true)
389
+ setTimeout(() => useViewer.getState().setInputDragging(false), 0)
390
+ commit()
391
+ }
392
+
393
+ const onKeyDown = (e: KeyboardEvent) => {
394
+ const key = e.key.toLowerCase()
395
+ if ((e.metaKey || e.ctrlKey) && (key === 'c' || key === 'v' || key === 'x')) {
396
+ // A clipboard chord replaces the current carry. Let the global keyboard
397
+ // arm receive the same event after this cancellation. Capture C/X first:
398
+ // pasted or duplicated carries delete their transient selection while
399
+ // cancelling, so the global arm would otherwise see nothing.
400
+ if (key === 'c' || key === 'x') {
401
+ copySelectedNodesToEditorClipboard()
402
+ }
403
+ cancel()
404
+ return
405
+ }
406
+ if ((key === 'r' || key === 't') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
407
+ e.preventDefault()
408
+ e.stopPropagation()
409
+ rotateCarried(key === 'r' ? 1 : -1)
410
+ return
411
+ }
412
+ if (e.key === 'Delete' || e.key === 'Backspace') {
413
+ // Deleting mid-carry: put the group down first (revert), then let the
414
+ // global Delete arm remove the selection — no dangling carry. The
415
+ // duplicate flow's onCancel already discards the clones instead.
416
+ cancel()
417
+ return
418
+ }
419
+ if (e.key !== 'Escape') return
420
+ e.preventDefault()
421
+ e.stopPropagation()
422
+ cancel()
423
+ }
424
+
425
+ const onContextMenu = (e: Event) => {
426
+ e.preventDefault()
427
+ e.stopPropagation()
428
+ }
429
+
430
+ window.addEventListener('pointermove', onMove)
431
+ window.addEventListener('pointerdown', onPointerDown, true)
432
+ window.addEventListener('pointerup', onPointerUp, true)
433
+ window.addEventListener('keydown', onKeyDown, true)
434
+ window.addEventListener('contextmenu', onContextMenu, true)
435
+ return true
436
+ }
437
+
438
+ /**
439
+ * Duplicate the whole selection (subtrees + id remap via the clipboard
440
+ * pipeline, without touching the clipboard), select the clones, and pick
441
+ * them up so the next click places them. Cancelling the pick-up removes the
442
+ * clones again.
443
+ */
444
+ export function duplicateSelectionAndPickUp(): boolean {
445
+ const { selectedIds, levelId } = useViewer.getState().selection
446
+ if (selectedIds.length < 2) return false
447
+ const result = duplicateNodesToLevel(
448
+ selectedIds as AnyNodeId[],
449
+ (levelId ?? undefined) as AnyNodeId | undefined,
450
+ )
451
+ if (!result || result.pastedIds.length === 0) return false
452
+ sfxEmitter.emit('sfx:item-pick')
453
+ startGroupPickUp({
454
+ scopeToSelection: true,
455
+ onCancel: () => {
456
+ useScene.getState().deleteNodes(result.pastedIds)
457
+ useViewer.getState().setSelection({ selectedIds: [] })
458
+ },
459
+ })
460
+ return true
461
+ }
462
+
463
+ function sceneReferencesMaterial(materialId: string) {
464
+ const reference = `scene:${materialId}`
465
+ const containsReference = (value: unknown): boolean => {
466
+ if (value === reference) return true
467
+ if (Array.isArray(value)) return value.some(containsReference)
468
+ if (value && typeof value === 'object') {
469
+ return Object.values(value).some(containsReference)
470
+ }
471
+ return false
472
+ }
473
+ return Object.values(useScene.getState().nodes).some(containsReference)
474
+ }
475
+
476
+ function removeUnusedPasteMaterials(materialIds: SceneMaterialId[]) {
477
+ for (const materialId of materialIds) {
478
+ if (!sceneReferencesMaterial(materialId)) {
479
+ useScene.getState().removeSceneMaterial(materialId)
480
+ }
481
+ }
482
+ }
483
+
484
+ /**
485
+ * Paste the Pascal scene payload from the browser clipboard onto the active
486
+ * level, then carry the clones under the cursor until click-to-place. Escape
487
+ * removes the uncommitted clones and any scene materials imported with them.
488
+ */
489
+ export async function pasteSelectionAndPickUp(targetLevelId?: AnyNodeId): Promise<boolean> {
490
+ const activeScope = useInteractionScope.getState().scope
491
+ if (activeScope.kind === 'placing' || activeScope.kind === 'moving') {
492
+ emitter.emit('tool:cancel')
493
+ }
494
+ // Paint and sculpt hold their scope for the whole *mode*, so a pick-up would
495
+ // `begin({kind: 'handle-drag'})` over it — scopes are single-owner, so the brush
496
+ // would stay armed and painting while the editor believed a group move was
497
+ // running, and the mode's own release would then end someone else's scope.
498
+ // ⌘V is reachable from either mode (it reads the clipboard, not the selection,
499
+ // which is why clearing the selection on mode entry does not cover it), so drop
500
+ // the brush first: carrying clones is the newer intent, and unlike the cancels
501
+ // above there is nothing to abandon.
502
+ if (isBrushMode(useEditor.getState().mode)) useEditor.getState().setMode('select')
503
+
504
+ const result = await pasteSystemEditorClipboardToLevel(targetLevelId)
505
+ if (!result || result.pastedIds.length === 0) return false
506
+
507
+ const discardPaste = () => {
508
+ useScene.getState().deleteNodes(result.pastedIds)
509
+ removeUnusedPasteMaterials(result.createdMaterialIds)
510
+ useViewer.getState().setSelection({ selectedIds: [] })
511
+ }
512
+ if (result.pastedIds.length === 1) {
513
+ const rootId = result.pastedIds[0]!
514
+ const root = useScene.getState().nodes[rootId]
515
+ if (root?.type === 'door' || root?.type === 'window') {
516
+ const metadata =
517
+ root.metadata && typeof root.metadata === 'object' && !Array.isArray(root.metadata)
518
+ ? (root.metadata as Record<string, unknown>)
519
+ : {}
520
+ const draft = { ...root, metadata: { ...metadata, isNew: true } }
521
+ useScene.getState().updateNode(rootId, { metadata: draft.metadata })
522
+ useViewer.getState().setSelection({ selectedIds: [] })
523
+ const unsubscribe = useInteractionScope.subscribe((state, previous) => {
524
+ const previousOwnsDraft =
525
+ (previous.scope.kind === 'placing' || previous.scope.kind === 'moving') &&
526
+ previous.scope.nodeId === rootId
527
+ const currentOwnsDraft =
528
+ (state.scope.kind === 'placing' || state.scope.kind === 'moving') &&
529
+ state.scope.nodeId === rootId
530
+ if (!previousOwnsDraft || currentOwnsDraft) return
531
+ unsubscribe()
532
+ removeUnusedPasteMaterials(result.createdMaterialIds)
533
+ })
534
+ useEditor.getState().setMovingNode(draft)
535
+ sfxEmitter.emit('sfx:item-pick')
536
+ return true
537
+ }
538
+ }
539
+
540
+ const started = startGroupPickUp({
541
+ positionAtCursor: true,
542
+ scopeToSelection: true,
543
+ onCancel: discardPaste,
544
+ })
545
+ if (!started) sfxEmitter.emit('sfx:item-place')
546
+ return true
547
+ }
548
+
549
+ /**
550
+ * Cut uses the same cross-tab clipboard payload as Copy, then removes exactly
551
+ * the copied roots. Promoted subtree selections (such as all modules in one
552
+ * cabinet run) therefore remove the same root that Paste will recreate.
553
+ */
554
+ export function cutSelectionToEditorClipboard(): boolean {
555
+ if (!copySelectedNodesToEditorClipboard()) return false
556
+ const payload = getEditorClipboardSnapshot()
557
+ if (!payload || payload.rootIds.length === 0) return false
558
+
559
+ if (payload.rootIds.length === 1) {
560
+ emitDeleteSFX(useScene.getState().nodes[payload.rootIds[0]!]?.type)
561
+ } else {
562
+ sfxEmitter.emit('sfx:structure-delete')
563
+ }
564
+ useScene.getState().deleteNodes(payload.rootIds)
565
+ useViewer.getState().setSelection({ selectedIds: [] })
566
+ return true
567
+ }
568
+
569
+ /**
570
+ * Delete every selected node — same semantics as the keyboard Delete arm,
571
+ * including the accidental-bulk-delete confirm.
572
+ */
573
+ export function deleteSelection(): boolean {
574
+ const selectedIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
575
+ if (selectedIds.length === 0) return false
576
+
577
+ const commitDelete = () => {
578
+ if (selectedIds.length === 1) {
579
+ emitDeleteSFX(useScene.getState().nodes[selectedIds[0]!]?.type)
580
+ } else {
581
+ sfxEmitter.emit('sfx:structure-delete')
582
+ }
583
+ useScene.getState().deleteNodes(selectedIds)
584
+ useViewer.getState().setSelection({ selectedIds: [] })
585
+ }
586
+
587
+ if (selectedIds.length >= BULK_DELETE_THRESHOLD) {
588
+ useDeleteConfirmation.getState().requestConfirmation({
589
+ count: selectedIds.length,
590
+ onConfirm: commitDelete,
591
+ })
592
+ return true
593
+ }
594
+
595
+ commitDelete()
596
+ return true
597
+ }