@pascal-app/editor 0.8.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (350) hide show
  1. package/package.json +15 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +168 -0
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +795 -28
  5. package/src/components/editor/editor-layout-mobile.tsx +8 -3
  6. package/src/components/editor/editor-layout-v2.tsx +76 -51
  7. package/src/components/editor/export-manager.tsx +69 -32
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +182 -0
  10. package/src/components/editor/first-person/build-collider-world.ts +228 -83
  11. package/src/components/editor/first-person-controls.tsx +943 -27
  12. package/src/components/editor/floating-action-menu.tsx +676 -208
  13. package/src/components/editor/floating-building-action-menu.tsx +2 -2
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +6916 -12137
  17. package/src/components/editor/grid.tsx +197 -36
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +420 -0
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +396 -0
  24. package/src/components/editor/group-transform-shared.ts +489 -0
  25. package/src/components/editor/handles/handle-arrow.tsx +581 -0
  26. package/src/components/editor/handles/use-handle-drag.ts +246 -0
  27. package/src/components/editor/index.tsx +412 -162
  28. package/src/components/editor/measurement-pill.tsx +115 -0
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +1646 -0
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +1106 -483
  36. package/src/components/editor/site-edge-labels.tsx +45 -21
  37. package/src/components/editor/slab-hole-highlights.tsx +474 -0
  38. package/src/components/editor/snapshot-capture-overlay.tsx +369 -155
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +140 -41
  41. package/src/components/editor/use-floorplan-background-placement.ts +190 -35
  42. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  43. package/src/components/editor/use-floorplan-scene-data.ts +19 -7
  44. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  45. package/src/components/editor/wall-measurement-label.tsx +14 -19
  46. package/src/components/editor/wall-move-side-handles.tsx +870 -0
  47. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  48. package/src/components/editor/wall-snap-beacon-layer.tsx +304 -0
  49. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +15 -6
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  52. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  53. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  54. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  56. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +392 -0
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +857 -0
  59. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  60. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +77 -0
  61. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  62. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  63. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  64. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  65. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  66. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +62 -0
  67. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  68. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +3325 -0
  69. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  70. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  71. package/src/components/editor-2d/svg-paths.ts +10 -2
  72. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +439 -57
  73. package/src/components/systems/ceiling/ceiling-system.tsx +109 -12
  74. package/src/components/systems/roof/roof-edit-system.tsx +1851 -22
  75. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  76. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  77. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  78. package/src/components/systems/zone/zone-system.tsx +42 -13
  79. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  80. package/src/components/tools/elevator/elevator-tool.tsx +375 -0
  81. package/src/components/tools/elevator/move-elevator-tool.tsx +263 -0
  82. package/src/components/tools/fence/fence-drafting.ts +141 -27
  83. package/src/components/tools/item/move-tool.tsx +45 -105
  84. package/src/components/tools/item/placement-math.test.ts +22 -0
  85. package/src/components/tools/item/placement-math.ts +59 -7
  86. package/src/components/tools/item/placement-strategies.ts +432 -16
  87. package/src/components/tools/item/placement-types.ts +22 -1
  88. package/src/components/tools/item/use-draft-node.ts +51 -2
  89. package/src/components/tools/item/use-placement-coordinator.tsx +1399 -256
  90. package/src/components/tools/registry/move-registry-node-tool.tsx +1064 -0
  91. package/src/components/tools/roof/roof-tool.tsx +350 -28
  92. package/src/components/tools/select/box-select-state.ts +88 -0
  93. package/src/components/tools/select/box-select-tool.tsx +442 -520
  94. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  95. package/src/components/tools/select/marquee-geometry.ts +155 -0
  96. package/src/components/tools/select/plane-box-select-tool.tsx +570 -0
  97. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  98. package/src/components/tools/select/select-candidates.test.ts +109 -0
  99. package/src/components/tools/select/select-candidates.ts +85 -0
  100. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  101. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  102. package/src/components/tools/shared/drag-bounding-box.tsx +195 -0
  103. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  104. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  105. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  106. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  107. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  108. package/src/components/tools/shared/placement-box.tsx +305 -0
  109. package/src/components/tools/shared/polygon-editor.tsx +866 -175
  110. package/src/components/tools/shared/segment-angle.ts +56 -0
  111. package/src/components/tools/site/site-boundary-editor.tsx +520 -16
  112. package/src/components/tools/site/site-flag-model.ts +18 -0
  113. package/src/components/tools/stair/stair-defaults.ts +2 -1
  114. package/src/components/tools/stair/stair-tool.tsx +342 -37
  115. package/src/components/tools/tool-manager.tsx +239 -62
  116. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  117. package/src/components/tools/wall/wall-drafting.ts +248 -171
  118. package/src/components/tools/wall/wall-snap-geometry.test.ts +123 -0
  119. package/src/components/tools/wall/wall-snap-geometry.ts +331 -0
  120. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  121. package/src/components/tools/zone/zone-tool.tsx +86 -82
  122. package/src/components/ui/action-menu/action-button.tsx +21 -1
  123. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  124. package/src/components/ui/action-menu/control-modes.tsx +40 -170
  125. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  126. package/src/components/ui/action-menu/index.tsx +3 -117
  127. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  128. package/src/components/ui/action-menu/structure-tools.tsx +27 -93
  129. package/src/components/ui/action-menu/view-toggles.tsx +279 -90
  130. package/src/components/ui/command-palette/editor-commands.tsx +20 -15
  131. package/src/components/ui/command-palette/index.tsx +5 -3
  132. package/src/components/ui/controls/material-paint-panel.tsx +152 -0
  133. package/src/components/ui/controls/material-picker.tsx +87 -177
  134. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  135. package/src/components/ui/controls/metric-control.tsx +142 -46
  136. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  137. package/src/components/ui/controls/slider-control.tsx +88 -38
  138. package/src/components/ui/floating-level-selector.tsx +47 -10
  139. package/src/components/ui/helpers/building-helper.tsx +10 -23
  140. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  141. package/src/components/ui/helpers/helper-manager.tsx +235 -23
  142. package/src/components/ui/helpers/item-helper.tsx +28 -32
  143. package/src/components/ui/helpers/registered-tool-helper.tsx +59 -0
  144. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  145. package/src/components/ui/icon-ref.tsx +47 -0
  146. package/src/components/ui/item-catalog/catalog-items.tsx +40 -1
  147. package/src/components/ui/item-catalog/item-catalog.tsx +16 -36
  148. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  149. package/src/components/ui/panels/node-display.ts +17 -16
  150. package/src/components/ui/panels/panel-manager.tsx +55 -58
  151. package/src/components/ui/panels/panel-wrapper.tsx +238 -12
  152. package/src/components/ui/panels/parametric-inspector.tsx +482 -0
  153. package/src/components/ui/panels/reference-panel.tsx +54 -19
  154. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  155. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  156. package/src/components/ui/primitives/sidebar.tsx +1 -0
  157. package/src/components/ui/scene-loader.tsx +1 -3
  158. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  159. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  160. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  161. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  162. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  163. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +122 -41
  164. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +66 -7
  165. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +267 -0
  166. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  167. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +9 -5
  168. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +86 -0
  169. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  170. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  171. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +92 -0
  172. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  173. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +3 -3
  174. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +86 -0
  175. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -21
  176. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  177. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  178. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +140 -0
  179. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +48 -8
  180. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +132 -0
  181. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -5
  182. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +86 -0
  183. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  184. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  185. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +112 -39
  186. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  187. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  188. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  189. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  190. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  191. package/src/components/ui/sidebar/tab-bar.tsx +75 -1
  192. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  193. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  194. package/src/components/ui/snap-target-badge.tsx +88 -0
  195. package/src/components/viewer-overlay.tsx +253 -61
  196. package/src/components/viewer-zone-system.tsx +7 -2
  197. package/src/hooks/use-auto-save.test.ts +14 -0
  198. package/src/hooks/use-auto-save.ts +64 -10
  199. package/src/hooks/use-ceiling-events.ts +176 -0
  200. package/src/hooks/use-drag-action.ts +127 -0
  201. package/src/hooks/use-keyboard.ts +493 -95
  202. package/src/hooks/use-selection.ts +64 -0
  203. package/src/index.tsx +451 -6
  204. package/src/lib/active-placement-surface.ts +35 -0
  205. package/src/lib/ceiling-plan-snap.ts +24 -0
  206. package/src/lib/constants.ts +6 -2
  207. package/src/lib/contextual-help.test.ts +112 -0
  208. package/src/lib/contextual-help.ts +144 -0
  209. package/src/lib/continuation.ts +64 -0
  210. package/src/lib/direct-manipulation.test.ts +192 -0
  211. package/src/lib/direct-manipulation.ts +109 -0
  212. package/src/lib/editor-api.ts +50 -0
  213. package/src/lib/elevator-support.ts +96 -0
  214. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  215. package/src/lib/floorplan/apply-alignment.ts +117 -0
  216. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  217. package/src/lib/floorplan/geometry.ts +53 -0
  218. package/src/lib/floorplan/index.ts +10 -0
  219. package/src/lib/floorplan/items.ts +5 -2
  220. package/src/lib/floorplan/plan-coords.ts +21 -0
  221. package/src/lib/floorplan/selection-tool.ts +40 -0
  222. package/src/lib/floorplan/stairs.ts +12 -1
  223. package/src/lib/fresh-planar-placement.test.ts +339 -0
  224. package/src/lib/fresh-planar-placement.ts +130 -0
  225. package/src/lib/glb-export.test.ts +441 -0
  226. package/src/lib/glb-export.ts +874 -0
  227. package/src/lib/history.ts +11 -1
  228. package/src/lib/interaction/hot-set.test.ts +133 -0
  229. package/src/lib/interaction/hot-set.ts +67 -0
  230. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  231. package/src/lib/interaction/overlay-policy.ts +59 -0
  232. package/src/lib/interaction/scope.ts +211 -0
  233. package/src/lib/level-duplication.ts +54 -42
  234. package/src/lib/level-selection.ts +2 -2
  235. package/src/lib/material-paint.ts +227 -48
  236. package/src/lib/measurement-kind.test.ts +30 -0
  237. package/src/lib/measurement-kind.ts +19 -0
  238. package/src/lib/measurement-label.test.ts +47 -0
  239. package/src/lib/measurement-label.ts +68 -0
  240. package/src/lib/measurement-parser.ts +116 -0
  241. package/src/lib/measurements.test.ts +183 -0
  242. package/src/lib/measurements.ts +199 -0
  243. package/src/lib/paint-scope.test.ts +454 -0
  244. package/src/lib/paint-scope.ts +461 -0
  245. package/src/lib/placement-drag-release.ts +23 -0
  246. package/src/lib/placement-metadata.ts +29 -0
  247. package/src/lib/planar-cursor-placement.test.ts +100 -0
  248. package/src/lib/planar-cursor-placement.ts +42 -0
  249. package/src/lib/plugin-panels.test.ts +19 -0
  250. package/src/lib/plugin-panels.ts +91 -0
  251. package/src/lib/quick-measurement.test.ts +77 -0
  252. package/src/lib/quick-measurement.ts +109 -0
  253. package/src/lib/roof-duplication.ts +7 -7
  254. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  255. package/src/lib/roof-wall-hit.ts +164 -0
  256. package/src/lib/scene-clipboard.test.ts +196 -0
  257. package/src/lib/scene-clipboard.ts +332 -0
  258. package/src/lib/scene.ts +40 -8
  259. package/src/lib/selection-routing.test.ts +298 -0
  260. package/src/lib/selection-routing.ts +175 -0
  261. package/src/lib/sfx/index.ts +1 -0
  262. package/src/lib/sfx/movement-tick.test.ts +65 -0
  263. package/src/lib/sfx/movement-tick.ts +18 -0
  264. package/src/lib/sfx-bus.ts +69 -10
  265. package/src/lib/sfx-player.test.ts +124 -0
  266. package/src/lib/sfx-player.ts +148 -55
  267. package/src/lib/site-boundary.ts +1 -0
  268. package/src/lib/slab-plan-snap.test.ts +93 -0
  269. package/src/lib/slab-plan-snap.ts +108 -0
  270. package/src/lib/snapping-mode.test.ts +138 -0
  271. package/src/lib/snapping-mode.ts +179 -0
  272. package/src/lib/stair-duplication.ts +4 -4
  273. package/src/lib/stair-levels.test.ts +146 -0
  274. package/src/lib/stair-levels.ts +177 -0
  275. package/src/lib/surface-plan-snap.test.ts +71 -0
  276. package/src/lib/surface-plan-snap.ts +256 -0
  277. package/src/lib/use-linear-display.ts +40 -0
  278. package/src/lib/world-grid-snap.ts +231 -0
  279. package/src/lib/zone-content.ts +130 -0
  280. package/src/store/use-alignment-guides.ts +21 -0
  281. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  282. package/src/store/use-editor.tsx +767 -114
  283. package/src/store/use-facing-pose.ts +44 -0
  284. package/src/store/use-fence-curve-draft.ts +21 -0
  285. package/src/store/use-floorplan-draft-preview.ts +101 -0
  286. package/src/store/use-floorplan-marquee.ts +50 -0
  287. package/src/store/use-interaction-scope.test.ts +186 -0
  288. package/src/store/use-interaction-scope.ts +132 -0
  289. package/src/store/use-measurement-draft.test.ts +530 -0
  290. package/src/store/use-measurement-draft.ts +543 -0
  291. package/src/store/use-opening-guides.ts +41 -0
  292. package/src/store/use-placement-preview.ts +39 -0
  293. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  294. package/src/store/use-quick-measurement-hud.ts +77 -0
  295. package/src/store/use-segment-draft-chain.ts +28 -0
  296. package/src/store/use-stair-build-preview.ts +43 -0
  297. package/src/store/use-wall-move-ghosts.ts +36 -0
  298. package/src/store/use-wall-snap-indicator.ts +34 -0
  299. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  300. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  301. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  302. package/src/components/tools/building/move-building-tool.tsx +0 -157
  303. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  304. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  305. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  306. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  307. package/src/components/tools/column/column-tool.tsx +0 -97
  308. package/src/components/tools/column/move-column-tool.tsx +0 -105
  309. package/src/components/tools/door/door-math.ts +0 -110
  310. package/src/components/tools/door/door-tool.tsx +0 -324
  311. package/src/components/tools/door/move-door-tool.tsx +0 -412
  312. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  313. package/src/components/tools/fence/fence-tool.tsx +0 -346
  314. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  315. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  316. package/src/components/tools/item/item-tool.tsx +0 -26
  317. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  318. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  319. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  320. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  321. package/src/components/tools/slab/slab-tool.tsx +0 -322
  322. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  323. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  324. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  325. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  326. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  327. package/src/components/tools/wall/wall-tool.tsx +0 -332
  328. package/src/components/tools/window/move-window-tool.tsx +0 -447
  329. package/src/components/tools/window/window-math.ts +0 -117
  330. package/src/components/tools/window/window-tool.tsx +0 -332
  331. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  332. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  333. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  334. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  335. package/src/components/ui/panels/column-panel.tsx +0 -759
  336. package/src/components/ui/panels/door-panel.tsx +0 -1299
  337. package/src/components/ui/panels/fence-panel.tsx +0 -222
  338. package/src/components/ui/panels/item-panel.tsx +0 -306
  339. package/src/components/ui/panels/paint-panel.tsx +0 -163
  340. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  341. package/src/components/ui/panels/roof-panel.tsx +0 -280
  342. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  343. package/src/components/ui/panels/slab-panel.tsx +0 -250
  344. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  345. package/src/components/ui/panels/stair-panel.tsx +0 -577
  346. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  347. package/src/components/ui/panels/wall-panel.tsx +0 -273
  348. package/src/components/ui/panels/window-panel.tsx +0 -968
  349. package/src/contexts/presets-context.tsx +0 -121
  350. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -0,0 +1,420 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ DEFAULT_ANGLE_STEP,
7
+ pauseSpaceDetection,
8
+ resumeSpaceDetection,
9
+ useLiveNodeOverrides,
10
+ useLiveTransforms,
11
+ useScene,
12
+ } from '@pascal-app/core'
13
+ import { useViewer } from '@pascal-app/viewer'
14
+ import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
15
+ import { useEffect, useMemo, useRef, useState } from 'react'
16
+ import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three'
17
+ import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL } from '../../lib/contextual-help'
18
+ import { isHistoryShortcut } from '../../lib/history'
19
+ import { sfxEmitter } from '../../lib/sfx-bus'
20
+ import useEditor from '../../store/use-editor'
21
+ import useInteractionScope, {
22
+ useActiveHandleDrag,
23
+ useMovingNode,
24
+ } from '../../store/use-interaction-scope'
25
+ import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
26
+ import {
27
+ CORNER_OFFSET,
28
+ classifyParticipant,
29
+ collectParticipants,
30
+ computeGroupBox,
31
+ expandToComponent,
32
+ levelFrame,
33
+ rotateGroupPatches,
34
+ type Vec3,
35
+ } from './group-transform-shared'
36
+ import {
37
+ ARROW_COLOR,
38
+ ARROW_HOVER_COLOR,
39
+ ARROW_SCALE,
40
+ createRotateArrowHandleGeometry,
41
+ createRotateArrowHitAreaGeometry,
42
+ GuideRing,
43
+ RotationGuide,
44
+ type RotationGuideData,
45
+ swallowNextClick,
46
+ useArrowMaterial,
47
+ useInvisibleHitAreaMaterial,
48
+ } from './node-arrow-handles'
49
+ import { useMeshSettleEpoch } from './use-mesh-settle-epoch'
50
+
51
+ /**
52
+ * Group-rotate gizmo. When 2+ transformable nodes in the active level frame are
53
+ * selected, a single rotation handle appears at the selection's bounding-box
54
+ * center. Dragging it spins every selected node rigidly around that shared
55
+ * center — orbiting each node's position AND turning its yaw by the same delta,
56
+ * so the group rotates as one piece.
57
+ *
58
+ * The single-selection case is handled by `NodeArrowHandles`; a full-level
59
+ * box-select promotes to a building selection, so neither reaches this gizmo.
60
+ */
61
+ export function GroupRotateHandle() {
62
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
63
+ const levelId = useViewer((s) => s.selection.levelId)
64
+ const mode = useEditor((s) => s.mode)
65
+ const movingNode = useMovingNode()
66
+ const activeHandleDrag = useActiveHandleDrag()
67
+ const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
68
+ // Re-derive participants whenever the scene mutates (e.g. after a commit).
69
+ // Drags only touch `useLiveNodeOverrides`, so this does not fire mid-drag.
70
+ const nodes = useScene((s) => s.nodes)
71
+ // Re-measure the pivot/corner once the meshes settle after a scene change.
72
+ const meshEpoch = useMeshSettleEpoch(nodes)
73
+
74
+ const participantIds = useMemo(
75
+ () =>
76
+ selectedIds.filter(
77
+ (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
78
+ ),
79
+ [selectedIds, levelId, nodes],
80
+ )
81
+
82
+ // Gate on the explicit selection (so a single connected wall still gets the
83
+ // per-node handles), but transform the full connected wall/fence component so
84
+ // attached structure rotates rigidly as one piece.
85
+ const fullIds = useMemo(
86
+ () => expandToComponent(participantIds, nodes, levelId),
87
+ [participantIds, levelId, nodes],
88
+ )
89
+
90
+ const shouldRender =
91
+ participantIds.length >= 2 &&
92
+ mode !== 'delete' &&
93
+ !movingNode &&
94
+ !isFloorplanHovered &&
95
+ // Hide while the sibling move gizmo drags the group — the frozen corner
96
+ // this handle would sit at goes stale as the group slides under it.
97
+ activeHandleDrag?.label !== GROUP_MOVE_DRAG_LABEL
98
+
99
+ if (!shouldRender) return null
100
+ // Remount when the moving set changes so the rest pivot re-seeds cleanly.
101
+ return <GroupRotateHandleInner ids={fullIds} key={fullIds.join(',')} meshEpoch={meshEpoch} />
102
+ }
103
+
104
+ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: number }) {
105
+ const { camera, raycaster, gl, scene } = useThree()
106
+ const arrowGeometry = useMemo(() => createRotateArrowHandleGeometry(), [])
107
+ const hitGeometry = useMemo(() => createRotateArrowHitAreaGeometry(), [])
108
+ const arrowMaterial = useArrowMaterial()
109
+ const hitMaterial = useInvisibleHitAreaMaterial()
110
+ const [isHovered, setIsHovered] = useState(false)
111
+ const [isDragging, setIsDragging] = useState(false)
112
+ const [guide, setGuide] = useState<RotationGuideData | null>(null)
113
+ const dragCleanupRef = useRef<(() => void) | null>(null)
114
+ const frozenRest = useRef<{ pivot: Vector3; corner: Vector3 } | null>(null)
115
+
116
+ useEffect(() => {
117
+ arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
118
+ }, [arrowMaterial, isHovered])
119
+ useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
120
+ useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
121
+ useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
122
+ useEffect(() => () => dragCleanupRef.current?.(), [])
123
+
124
+ const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
125
+ const baseScale = zoom * ARROW_SCALE * 1.05
126
+ const scale = (isHovered ? 1.12 : 1) * baseScale
127
+
128
+ // World-space bounding box of the selected meshes. Levels are axis-aligned in
129
+ // XZ, so world XZ coincides with each node's level-local placement — letting
130
+ // us rotate `position` / `start` / `end` directly against the pivot without
131
+ // per-node frame conversion.
132
+ // - `pivot` = bbox center (XZ), Y at the group's base → the rotation origin
133
+ // - `corner` = front-right bbox corner at mid-height → where the gizmo sits
134
+ const rest = useMemo(() => {
135
+ const box = computeGroupBox(ids)
136
+ if (!box) return null
137
+ const pivot = new Vector3((box.min.x + box.max.x) / 2, box.min.y, (box.min.z + box.max.z) / 2)
138
+ const corner = new Vector3(
139
+ box.max.x + CORNER_OFFSET,
140
+ (box.min.y + box.max.y) / 2,
141
+ box.max.z + CORNER_OFFSET,
142
+ )
143
+ return { pivot, corner }
144
+ // biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes
145
+ }, [ids, meshEpoch])
146
+
147
+ if (!rest) return null
148
+ const active = isDragging && frozenRest.current ? frozenRest.current : rest
149
+ const corner = active.corner
150
+
151
+ const onHoverEnter = (event: ThreeEvent<PointerEvent>) => {
152
+ event.stopPropagation()
153
+ setIsHovered(true)
154
+ if (document.body.style.cursor !== 'grabbing') document.body.style.cursor = 'grab'
155
+ }
156
+ const onHoverLeave = (event: ThreeEvent<PointerEvent>) => {
157
+ event.stopPropagation()
158
+ setIsHovered(false)
159
+ if (document.body.style.cursor === 'grab') document.body.style.cursor = ''
160
+ }
161
+
162
+ const activate = (event: ThreeEvent<PointerEvent>) => {
163
+ if (event.button !== 0) return
164
+ event.stopPropagation()
165
+ suppressBoxSelectForPointer(event)
166
+
167
+ frozenRest.current = { pivot: rest.pivot.clone(), corner: rest.corner.clone() }
168
+ const center = rest.pivot.clone()
169
+
170
+ // Snapshot the selected participants + connected wall/fence neighbours whose
171
+ // shared endpoints must follow the rotation (so junctions stay welded).
172
+ const levelId = useViewer.getState().selection.levelId
173
+ const { starts, links } = collectParticipants(ids, useScene.getState().nodes, levelId)
174
+ if (starts.length === 0) return
175
+
176
+ // Placements live in the level frame; the world pivot must be converted into
177
+ // it before orbiting positions, or a rotated building displaces the centre.
178
+ // The swept angle is frame-invariant (both frames differ by a constant yaw,
179
+ // which cancels in `angleOf(move) - angleOf(start)`), so it's still measured
180
+ // in world against `center` — keeping the world-space guide overlay correct.
181
+ const localCenter = center.clone().applyMatrix4(levelFrame(levelId).inverse)
182
+
183
+ // Horizontal drag plane at the pivot; bearing measured around the pivot.
184
+ const plane = new Plane(new Vector3(0, 1, 0), -center.y)
185
+ const angleOf = (p: Vector3) => Math.atan2(p.z - center.z, p.x - center.x)
186
+
187
+ // Wedge radius tracks how far the group spreads from the pivot — sample each
188
+ // participant's anchor point(s).
189
+ let spread = 0
190
+ const reach = (x: number, z: number) => {
191
+ spread = Math.max(spread, Math.hypot(x - localCenter.x, z - localCenter.z))
192
+ }
193
+ for (const s of starts) {
194
+ if (s.kind === 'endpoint') {
195
+ reach(s.start[0], s.start[1])
196
+ reach(s.end[0], s.end[1])
197
+ } else if (s.kind === 'polygon') {
198
+ for (const [x, z] of s.polygon) {
199
+ reach(x, z)
200
+ }
201
+ } else {
202
+ reach(s.position[0], s.position[2])
203
+ }
204
+ }
205
+ const guideRadius = Math.min(Math.max(spread * 0.6, 0.3), 3)
206
+
207
+ const ndc = new Vector2()
208
+ const setNDC = (clientX: number, clientY: number) => {
209
+ const rect = gl.domElement.getBoundingClientRect()
210
+ ndc.set(
211
+ ((clientX - rect.left) / rect.width) * 2 - 1,
212
+ -((clientY - rect.top) / rect.height) * 2 + 1,
213
+ )
214
+ }
215
+
216
+ setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY)
217
+ raycaster.setFromCamera(ndc, camera)
218
+ const hit = new Vector3()
219
+ if (!raycaster.ray.intersectPlane(plane, hit)) return
220
+ const initialAngle = angleOf(hit)
221
+
222
+ document.body.style.cursor = 'grabbing'
223
+ sfxEmitter.emit('sfx:item-pick')
224
+ useViewer.getState().setInputDragging(true)
225
+ useScene.temporal.getState().pause()
226
+ useInteractionScope.getState().begin({
227
+ kind: 'handle-drag',
228
+ nodeId: ids[0] ?? '',
229
+ handle: GROUP_ROTATE_DRAG_LABEL,
230
+ })
231
+ setIsDragging(true)
232
+
233
+ const onMove = (e: PointerEvent) => {
234
+ setNDC(e.clientX, e.clientY)
235
+ raycaster.setFromCamera(ndc, camera)
236
+ const moveHit = new Vector3()
237
+ if (!raycaster.ray.intersectPlane(plane, moveHit)) return
238
+ let delta = angleOf(moveHit) - initialAngle
239
+ while (delta > Math.PI) delta -= 2 * Math.PI
240
+ while (delta < -Math.PI) delta += 2 * Math.PI
241
+ if (!e.shiftKey) delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
242
+
243
+ // Shared rigid-rotation math (also used by the keyboard group R/T);
244
+ // see `rotateGroupPatches` for the orbit/yaw handedness contract.
245
+ const overrideEntries = rotateGroupPatches(
246
+ starts,
247
+ links,
248
+ { x: localCenter.x, z: localCenter.z },
249
+ delta,
250
+ )
251
+ const patchById = new Map(overrideEntries)
252
+ const liveTransforms = useLiveTransforms.getState()
253
+ for (const s of starts) {
254
+ if (s.kind === 'scalar') {
255
+ const patch = patchById.get(s.id)
256
+ if (patch) {
257
+ liveTransforms.set(s.id, {
258
+ position: patch.position as Vec3,
259
+ rotation: patch.rotation as number,
260
+ })
261
+ }
262
+ }
263
+ useScene.getState().markDirty(s.id)
264
+ }
265
+ for (const l of links) {
266
+ useScene.getState().markDirty(l.id)
267
+ }
268
+ useLiveNodeOverrides.getState().setMany(overrideEntries)
269
+
270
+ if (Math.abs(delta) < 0.0087) {
271
+ setGuide(null)
272
+ } else {
273
+ const midAngle = initialAngle + delta / 2
274
+ const labelRadius = guideRadius + 0.22
275
+ setGuide({
276
+ center: [center.x, center.y, center.z],
277
+ startAngle: initialAngle,
278
+ endAngle: initialAngle + delta,
279
+ radius: guideRadius,
280
+ labelPos: [
281
+ center.x + Math.cos(midAngle) * labelRadius,
282
+ center.y + 0.02,
283
+ center.z + Math.sin(midAngle) * labelRadius,
284
+ ],
285
+ sweep: Math.abs(delta),
286
+ })
287
+ }
288
+ }
289
+
290
+ const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
291
+ const clearLivePreviews = () => {
292
+ const overrides = useLiveNodeOverrides.getState()
293
+ const liveTransforms = useLiveTransforms.getState()
294
+ for (const id of affectedIds) {
295
+ overrides.clear(id)
296
+ liveTransforms.clear(id)
297
+ useScene.getState().markDirty(id)
298
+ }
299
+ }
300
+
301
+ const cleanup = () => {
302
+ window.removeEventListener('pointermove', onMove)
303
+ window.removeEventListener('pointerup', onUp)
304
+ window.removeEventListener('pointercancel', onCancel)
305
+ window.removeEventListener('keydown', onKeyDown, true)
306
+ if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
307
+ useScene.temporal.getState().resume()
308
+ useViewer.getState().setInputDragging(false)
309
+ useInteractionScope
310
+ .getState()
311
+ .endIf((s) => s.kind === 'handle-drag' && s.handle === GROUP_ROTATE_DRAG_LABEL)
312
+ setIsDragging(false)
313
+ setGuide(null)
314
+ frozenRest.current = null
315
+ dragCleanupRef.current = null
316
+ }
317
+
318
+ const commitFromOverrides = () => {
319
+ const overrides = useLiveNodeOverrides.getState()
320
+ const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
321
+ for (const id of affectedIds) {
322
+ const patch = overrides.get(id)
323
+ if (patch) updates.push({ id, data: patch as Partial<AnyNode> })
324
+ }
325
+ return updates
326
+ }
327
+
328
+ const onUp = () => {
329
+ // Eat the click that follows pointer-up so the selection manager doesn't
330
+ // treat it as a canvas click and clear the multi-selection.
331
+ swallowNextClick()
332
+ sfxEmitter.emit('sfx:item-place')
333
+ const updates = commitFromOverrides()
334
+ // Resume before the commit so the single batched `updateNodes` is the
335
+ // one tracked set — collapsing the whole group rotation into one undo.
336
+ // Space detection stays out: a rigid rotation of existing walls must
337
+ // not re-create the room's auto floors/ceilings at the new bearing.
338
+ pauseSpaceDetection()
339
+ useScene.temporal.getState().resume()
340
+ if (updates.length > 0) useScene.getState().updateNodes(updates)
341
+ resumeSpaceDetection()
342
+ clearLivePreviews()
343
+ cleanup()
344
+ }
345
+
346
+ const onCancel = () => {
347
+ // Revert: drop overrides + mark dirty so renderers rebuild from the store.
348
+ clearLivePreviews()
349
+ cleanup()
350
+ }
351
+
352
+ // Escape / ⌘Z abort the rotate — capture phase so they win over the global
353
+ // use-keyboard arms (⌘Z must never history-jump under a live pointer).
354
+ const onKeyDown = (e: KeyboardEvent) => {
355
+ if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
356
+ e.preventDefault()
357
+ e.stopPropagation()
358
+ swallowNextClick()
359
+ onCancel()
360
+ }
361
+
362
+ dragCleanupRef.current = () => {
363
+ clearLivePreviews()
364
+ cleanup()
365
+ }
366
+ for (const id of affectedIds) {
367
+ useLiveTransforms.getState().clear(id)
368
+ }
369
+ window.addEventListener('pointermove', onMove)
370
+ window.addEventListener('pointerup', onUp)
371
+ window.addEventListener('pointercancel', onCancel)
372
+ window.addEventListener('keydown', onKeyDown, true)
373
+ }
374
+
375
+ return createPortal(
376
+ <>
377
+ {(isHovered || isDragging) && (
378
+ <group position={[corner.x, corner.y, corner.z]}>
379
+ <GuideRing radius={0.2 * scale} y={0} />
380
+ </group>
381
+ )}
382
+ <group position={[corner.x, corner.y, corner.z]}>
383
+ {/* Fat invisible grab target (torus wrapping the arrow). A plain
384
+ default-layer mesh with the standard raycast — the shared
385
+ `InvisibleHandleHitArea` (EDITOR_LAYER + custom raycast) never
386
+ received pointer events in this portalled context: R3F's
387
+ `createPortal` gives the portal root its own fresh `Raycaster`
388
+ (default mask = layer 0 only), so the EDITOR_LAYER enable applied
389
+ to the root raycaster in `custom-camera-controls` never reaches
390
+ it. Harmless render-wise — the material is colorWrite:false /
391
+ depthWrite:false, so nothing leaks into thumbnails or the ink
392
+ pass. A proper fix would enable EDITOR_LAYER on the portal
393
+ raycaster from inside the portal. */}
394
+ <mesh
395
+ frustumCulled={false}
396
+ geometry={hitGeometry}
397
+ material={hitMaterial}
398
+ onPointerDown={activate}
399
+ onPointerEnter={onHoverEnter}
400
+ onPointerLeave={onHoverLeave}
401
+ scale={baseScale}
402
+ />
403
+ <mesh
404
+ frustumCulled={false}
405
+ geometry={arrowGeometry}
406
+ material={arrowMaterial}
407
+ onPointerDown={activate}
408
+ onPointerEnter={onHoverEnter}
409
+ onPointerLeave={onHoverLeave}
410
+ renderOrder={1010}
411
+ scale={scale}
412
+ />
413
+ </group>
414
+ {guide ? <RotationGuide data={guide} /> : null}
415
+ </>,
416
+ scene,
417
+ )
418
+ }
419
+
420
+ export default GroupRotateHandle
@@ -0,0 +1,173 @@
1
+ 'use client'
2
+
3
+ import { type AnyNodeId, useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { type ThreeEvent, useThree } from '@react-three/fiber'
6
+ import { useEffect, useMemo } from 'react'
7
+ import * as THREE from 'three'
8
+ import useEditor from '../../store/use-editor'
9
+ import { useMovingNode } from '../../store/use-interaction-scope'
10
+ import { useFloorplanGroupDrag } from '../editor-2d/floorplan-group-move'
11
+ import { armGroupMove3d } from './group-move-3d'
12
+ import {
13
+ classifyParticipant,
14
+ computeGroupBox,
15
+ expandToComponent,
16
+ levelFrame,
17
+ } from './group-transform-shared'
18
+ import { useMeshSettleEpoch } from './use-mesh-settle-epoch'
19
+
20
+ // Matches the 2D dashed selection box's stroke.
21
+ const BOX_COLOR = '#3b82f6'
22
+ // Small clearance so the dashes don't z-fight the selection's outer faces.
23
+ const BOX_PAD = 0.06
24
+
25
+ /**
26
+ * 3D sibling of the 2D dashed group selection box: a dashed wireframe around
27
+ * the multi-selection's transformable participants (expanded to the welded
28
+ * wall/fence component) that doubles as the group's whole-volume drag
29
+ * handle — move cursor across it, press-drag anywhere on it slides the group,
30
+ * a plain click picks it up. Holding a selection modifier passes the press
31
+ * through so members inside can still be toggled. Rides the live drag delta
32
+ * so it tracks the group mid-gesture.
33
+ */
34
+ export function GroupSelectionBox3D() {
35
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
36
+ const levelId = useViewer((s) => s.selection.levelId)
37
+ const nodes = useScene((s) => s.nodes)
38
+ const mode = useEditor((s) => s.mode)
39
+ const movingNode = useMovingNode()
40
+ const delta = useFloorplanGroupDrag((s) => s.delta)
41
+ const { camera, raycaster, gl } = useThree()
42
+
43
+ const participantIds = useMemo(
44
+ () =>
45
+ selectedIds.length > 1
46
+ ? selectedIds.filter(
47
+ (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
48
+ )
49
+ : [],
50
+ [selectedIds, levelId, nodes],
51
+ )
52
+
53
+ // Re-measure once the meshes settle after a scene change (undo included).
54
+ const meshEpoch = useMeshSettleEpoch(nodes)
55
+ const box = useMemo(() => {
56
+ if (participantIds.length === 0) return null
57
+ const fullIds = expandToComponent(participantIds, nodes, levelId)
58
+ const world = computeGroupBox(fullIds)
59
+ if (!world) return null
60
+ const size = new THREE.Vector3()
61
+ world.getSize(size)
62
+ const center = new THREE.Vector3()
63
+ world.getCenter(center)
64
+ return {
65
+ size: [size.x + 2 * BOX_PAD, size.y + 2 * BOX_PAD, size.z + 2 * BOX_PAD] as [
66
+ number,
67
+ number,
68
+ number,
69
+ ],
70
+ center,
71
+ }
72
+ // biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes
73
+ }, [participantIds, nodes, levelId, meshEpoch])
74
+
75
+ // Dashed wireframe. Built per box size (rare — selection / commit changes)
76
+ // because LineDashedMaterial measures dashes along the line, so scaling a
77
+ // unit box would stretch them per axis.
78
+ const edges = useMemo(() => {
79
+ if (!box) return null
80
+ const geometry = new THREE.EdgesGeometry(new THREE.BoxGeometry(...box.size))
81
+ const line = new THREE.LineSegments(
82
+ geometry,
83
+ new THREE.LineDashedMaterial({
84
+ color: BOX_COLOR,
85
+ dashSize: 0.18,
86
+ gapSize: 0.12,
87
+ opacity: 0.9,
88
+ transparent: true,
89
+ }),
90
+ )
91
+ line.computeLineDistances()
92
+ return line
93
+ }, [box])
94
+ useEffect(
95
+ () => () => {
96
+ if (edges) {
97
+ edges.geometry.dispose()
98
+ ;(edges.material as THREE.Material).dispose()
99
+ }
100
+ },
101
+ [edges],
102
+ )
103
+
104
+ // Mid-drag the sessions publish a level-frame delta; map it to world so the
105
+ // box rides the group (shared with the 2D box's store).
106
+ const worldOffset = useMemo(() => {
107
+ if (!delta) return null
108
+ const { matrix } = levelFrame(levelId)
109
+ const origin = new THREE.Vector3().applyMatrix4(matrix)
110
+ return new THREE.Vector3(delta[0], 0, delta[1]).applyMatrix4(matrix).sub(origin)
111
+ }, [delta, levelId])
112
+
113
+ if (!box || !edges || movingNode || mode !== 'select') return null
114
+
115
+ const handlePointerDown = (event: ThreeEvent<PointerEvent>) => {
116
+ const native = event.nativeEvent
117
+ if (native.button !== 0) return
118
+ // A selection modifier passes through so members inside the box can
119
+ // still be toggled in and out of the selection.
120
+ if (native.metaKey || native.ctrlKey || native.shiftKey || native.altKey) return
121
+ const { selectedIds: currentIds, levelId: currentLevelId } = useViewer.getState().selection
122
+ const sceneNodes = useScene.getState().nodes
123
+ const anchor = currentIds.find(
124
+ (id) => classifyParticipant(sceneNodes[id as AnyNodeId], currentLevelId, sceneNodes) !== null,
125
+ )
126
+ if (!anchor) return
127
+ const armed = armGroupMove3d({
128
+ nodeId: anchor as AnyNodeId,
129
+ clientX: native.clientX,
130
+ clientY: native.clientY,
131
+ pointerId: native.pointerId,
132
+ nativeEvent: native,
133
+ camera,
134
+ raycaster,
135
+ domElement: gl.domElement,
136
+ })
137
+ // Own the press: deeper hits (members, ground) must not also select.
138
+ if (armed) event.stopPropagation()
139
+ }
140
+
141
+ // Set on move (not just enter): the canvas may carry an app default
142
+ // cursor, and member hovers inside the box write theirs — keep 'move'
143
+ // asserted across the whole volume, except while a drag shows 'grabbing'.
144
+ const applyMoveCursor = () => {
145
+ const cursor = gl.domElement.style.cursor
146
+ if (cursor !== 'move' && cursor !== 'grabbing') gl.domElement.style.cursor = 'move'
147
+ }
148
+ const handlePointerLeave = () => {
149
+ if (gl.domElement.style.cursor === 'move') gl.domElement.style.cursor = ''
150
+ }
151
+
152
+ const position: [number, number, number] = [
153
+ box.center.x + (worldOffset?.x ?? 0),
154
+ box.center.y + (worldOffset?.y ?? 0),
155
+ box.center.z + (worldOffset?.z ?? 0),
156
+ ]
157
+
158
+ return (
159
+ <group position={position}>
160
+ <primitive object={edges} />
161
+ {/* Invisible whole-volume hit target — the box IS the drag handle. */}
162
+ <mesh
163
+ onPointerDown={handlePointerDown}
164
+ onPointerEnter={applyMoveCursor}
165
+ onPointerLeave={handlePointerLeave}
166
+ onPointerMove={applyMoveCursor}
167
+ >
168
+ <boxGeometry args={box.size} />
169
+ <meshBasicMaterial depthWrite={false} opacity={0} transparent />
170
+ </mesh>
171
+ </group>
172
+ )
173
+ }