@pascal-app/editor 0.8.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (350) hide show
  1. package/package.json +15 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +168 -0
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +795 -28
  5. package/src/components/editor/editor-layout-mobile.tsx +8 -3
  6. package/src/components/editor/editor-layout-v2.tsx +76 -51
  7. package/src/components/editor/export-manager.tsx +69 -32
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +182 -0
  10. package/src/components/editor/first-person/build-collider-world.ts +228 -83
  11. package/src/components/editor/first-person-controls.tsx +943 -27
  12. package/src/components/editor/floating-action-menu.tsx +676 -208
  13. package/src/components/editor/floating-building-action-menu.tsx +2 -2
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +6916 -12137
  17. package/src/components/editor/grid.tsx +197 -36
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +420 -0
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +396 -0
  24. package/src/components/editor/group-transform-shared.ts +489 -0
  25. package/src/components/editor/handles/handle-arrow.tsx +581 -0
  26. package/src/components/editor/handles/use-handle-drag.ts +246 -0
  27. package/src/components/editor/index.tsx +412 -162
  28. package/src/components/editor/measurement-pill.tsx +115 -0
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +1646 -0
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +1106 -483
  36. package/src/components/editor/site-edge-labels.tsx +45 -21
  37. package/src/components/editor/slab-hole-highlights.tsx +474 -0
  38. package/src/components/editor/snapshot-capture-overlay.tsx +369 -155
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +140 -41
  41. package/src/components/editor/use-floorplan-background-placement.ts +190 -35
  42. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  43. package/src/components/editor/use-floorplan-scene-data.ts +19 -7
  44. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  45. package/src/components/editor/wall-measurement-label.tsx +14 -19
  46. package/src/components/editor/wall-move-side-handles.tsx +870 -0
  47. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  48. package/src/components/editor/wall-snap-beacon-layer.tsx +304 -0
  49. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +15 -6
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  52. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  53. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  54. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  56. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +392 -0
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +857 -0
  59. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  60. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +77 -0
  61. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  62. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  63. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  64. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  65. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  66. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +62 -0
  67. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  68. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +3325 -0
  69. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  70. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  71. package/src/components/editor-2d/svg-paths.ts +10 -2
  72. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +439 -57
  73. package/src/components/systems/ceiling/ceiling-system.tsx +109 -12
  74. package/src/components/systems/roof/roof-edit-system.tsx +1851 -22
  75. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  76. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  77. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  78. package/src/components/systems/zone/zone-system.tsx +42 -13
  79. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  80. package/src/components/tools/elevator/elevator-tool.tsx +375 -0
  81. package/src/components/tools/elevator/move-elevator-tool.tsx +263 -0
  82. package/src/components/tools/fence/fence-drafting.ts +141 -27
  83. package/src/components/tools/item/move-tool.tsx +45 -105
  84. package/src/components/tools/item/placement-math.test.ts +22 -0
  85. package/src/components/tools/item/placement-math.ts +59 -7
  86. package/src/components/tools/item/placement-strategies.ts +432 -16
  87. package/src/components/tools/item/placement-types.ts +22 -1
  88. package/src/components/tools/item/use-draft-node.ts +51 -2
  89. package/src/components/tools/item/use-placement-coordinator.tsx +1399 -256
  90. package/src/components/tools/registry/move-registry-node-tool.tsx +1064 -0
  91. package/src/components/tools/roof/roof-tool.tsx +350 -28
  92. package/src/components/tools/select/box-select-state.ts +88 -0
  93. package/src/components/tools/select/box-select-tool.tsx +442 -520
  94. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  95. package/src/components/tools/select/marquee-geometry.ts +155 -0
  96. package/src/components/tools/select/plane-box-select-tool.tsx +570 -0
  97. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  98. package/src/components/tools/select/select-candidates.test.ts +109 -0
  99. package/src/components/tools/select/select-candidates.ts +85 -0
  100. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  101. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  102. package/src/components/tools/shared/drag-bounding-box.tsx +195 -0
  103. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  104. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  105. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  106. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  107. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  108. package/src/components/tools/shared/placement-box.tsx +305 -0
  109. package/src/components/tools/shared/polygon-editor.tsx +866 -175
  110. package/src/components/tools/shared/segment-angle.ts +56 -0
  111. package/src/components/tools/site/site-boundary-editor.tsx +520 -16
  112. package/src/components/tools/site/site-flag-model.ts +18 -0
  113. package/src/components/tools/stair/stair-defaults.ts +2 -1
  114. package/src/components/tools/stair/stair-tool.tsx +342 -37
  115. package/src/components/tools/tool-manager.tsx +239 -62
  116. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  117. package/src/components/tools/wall/wall-drafting.ts +248 -171
  118. package/src/components/tools/wall/wall-snap-geometry.test.ts +123 -0
  119. package/src/components/tools/wall/wall-snap-geometry.ts +331 -0
  120. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  121. package/src/components/tools/zone/zone-tool.tsx +86 -82
  122. package/src/components/ui/action-menu/action-button.tsx +21 -1
  123. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  124. package/src/components/ui/action-menu/control-modes.tsx +40 -170
  125. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  126. package/src/components/ui/action-menu/index.tsx +3 -117
  127. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  128. package/src/components/ui/action-menu/structure-tools.tsx +27 -93
  129. package/src/components/ui/action-menu/view-toggles.tsx +279 -90
  130. package/src/components/ui/command-palette/editor-commands.tsx +20 -15
  131. package/src/components/ui/command-palette/index.tsx +5 -3
  132. package/src/components/ui/controls/material-paint-panel.tsx +152 -0
  133. package/src/components/ui/controls/material-picker.tsx +87 -177
  134. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  135. package/src/components/ui/controls/metric-control.tsx +142 -46
  136. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  137. package/src/components/ui/controls/slider-control.tsx +88 -38
  138. package/src/components/ui/floating-level-selector.tsx +47 -10
  139. package/src/components/ui/helpers/building-helper.tsx +10 -23
  140. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  141. package/src/components/ui/helpers/helper-manager.tsx +235 -23
  142. package/src/components/ui/helpers/item-helper.tsx +28 -32
  143. package/src/components/ui/helpers/registered-tool-helper.tsx +59 -0
  144. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  145. package/src/components/ui/icon-ref.tsx +47 -0
  146. package/src/components/ui/item-catalog/catalog-items.tsx +40 -1
  147. package/src/components/ui/item-catalog/item-catalog.tsx +16 -36
  148. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  149. package/src/components/ui/panels/node-display.ts +17 -16
  150. package/src/components/ui/panels/panel-manager.tsx +55 -58
  151. package/src/components/ui/panels/panel-wrapper.tsx +238 -12
  152. package/src/components/ui/panels/parametric-inspector.tsx +482 -0
  153. package/src/components/ui/panels/reference-panel.tsx +54 -19
  154. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  155. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  156. package/src/components/ui/primitives/sidebar.tsx +1 -0
  157. package/src/components/ui/scene-loader.tsx +1 -3
  158. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  159. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  160. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  161. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  162. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  163. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +122 -41
  164. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +66 -7
  165. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +267 -0
  166. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  167. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +9 -5
  168. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +86 -0
  169. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  170. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  171. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +92 -0
  172. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  173. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +3 -3
  174. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +86 -0
  175. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -21
  176. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  177. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  178. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +140 -0
  179. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +48 -8
  180. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +132 -0
  181. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -5
  182. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +86 -0
  183. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  184. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  185. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +112 -39
  186. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  187. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  188. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  189. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  190. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  191. package/src/components/ui/sidebar/tab-bar.tsx +75 -1
  192. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  193. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  194. package/src/components/ui/snap-target-badge.tsx +88 -0
  195. package/src/components/viewer-overlay.tsx +253 -61
  196. package/src/components/viewer-zone-system.tsx +7 -2
  197. package/src/hooks/use-auto-save.test.ts +14 -0
  198. package/src/hooks/use-auto-save.ts +64 -10
  199. package/src/hooks/use-ceiling-events.ts +176 -0
  200. package/src/hooks/use-drag-action.ts +127 -0
  201. package/src/hooks/use-keyboard.ts +493 -95
  202. package/src/hooks/use-selection.ts +64 -0
  203. package/src/index.tsx +451 -6
  204. package/src/lib/active-placement-surface.ts +35 -0
  205. package/src/lib/ceiling-plan-snap.ts +24 -0
  206. package/src/lib/constants.ts +6 -2
  207. package/src/lib/contextual-help.test.ts +112 -0
  208. package/src/lib/contextual-help.ts +144 -0
  209. package/src/lib/continuation.ts +64 -0
  210. package/src/lib/direct-manipulation.test.ts +192 -0
  211. package/src/lib/direct-manipulation.ts +109 -0
  212. package/src/lib/editor-api.ts +50 -0
  213. package/src/lib/elevator-support.ts +96 -0
  214. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  215. package/src/lib/floorplan/apply-alignment.ts +117 -0
  216. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  217. package/src/lib/floorplan/geometry.ts +53 -0
  218. package/src/lib/floorplan/index.ts +10 -0
  219. package/src/lib/floorplan/items.ts +5 -2
  220. package/src/lib/floorplan/plan-coords.ts +21 -0
  221. package/src/lib/floorplan/selection-tool.ts +40 -0
  222. package/src/lib/floorplan/stairs.ts +12 -1
  223. package/src/lib/fresh-planar-placement.test.ts +339 -0
  224. package/src/lib/fresh-planar-placement.ts +130 -0
  225. package/src/lib/glb-export.test.ts +441 -0
  226. package/src/lib/glb-export.ts +874 -0
  227. package/src/lib/history.ts +11 -1
  228. package/src/lib/interaction/hot-set.test.ts +133 -0
  229. package/src/lib/interaction/hot-set.ts +67 -0
  230. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  231. package/src/lib/interaction/overlay-policy.ts +59 -0
  232. package/src/lib/interaction/scope.ts +211 -0
  233. package/src/lib/level-duplication.ts +54 -42
  234. package/src/lib/level-selection.ts +2 -2
  235. package/src/lib/material-paint.ts +227 -48
  236. package/src/lib/measurement-kind.test.ts +30 -0
  237. package/src/lib/measurement-kind.ts +19 -0
  238. package/src/lib/measurement-label.test.ts +47 -0
  239. package/src/lib/measurement-label.ts +68 -0
  240. package/src/lib/measurement-parser.ts +116 -0
  241. package/src/lib/measurements.test.ts +183 -0
  242. package/src/lib/measurements.ts +199 -0
  243. package/src/lib/paint-scope.test.ts +454 -0
  244. package/src/lib/paint-scope.ts +461 -0
  245. package/src/lib/placement-drag-release.ts +23 -0
  246. package/src/lib/placement-metadata.ts +29 -0
  247. package/src/lib/planar-cursor-placement.test.ts +100 -0
  248. package/src/lib/planar-cursor-placement.ts +42 -0
  249. package/src/lib/plugin-panels.test.ts +19 -0
  250. package/src/lib/plugin-panels.ts +91 -0
  251. package/src/lib/quick-measurement.test.ts +77 -0
  252. package/src/lib/quick-measurement.ts +109 -0
  253. package/src/lib/roof-duplication.ts +7 -7
  254. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  255. package/src/lib/roof-wall-hit.ts +164 -0
  256. package/src/lib/scene-clipboard.test.ts +196 -0
  257. package/src/lib/scene-clipboard.ts +332 -0
  258. package/src/lib/scene.ts +40 -8
  259. package/src/lib/selection-routing.test.ts +298 -0
  260. package/src/lib/selection-routing.ts +175 -0
  261. package/src/lib/sfx/index.ts +1 -0
  262. package/src/lib/sfx/movement-tick.test.ts +65 -0
  263. package/src/lib/sfx/movement-tick.ts +18 -0
  264. package/src/lib/sfx-bus.ts +69 -10
  265. package/src/lib/sfx-player.test.ts +124 -0
  266. package/src/lib/sfx-player.ts +148 -55
  267. package/src/lib/site-boundary.ts +1 -0
  268. package/src/lib/slab-plan-snap.test.ts +93 -0
  269. package/src/lib/slab-plan-snap.ts +108 -0
  270. package/src/lib/snapping-mode.test.ts +138 -0
  271. package/src/lib/snapping-mode.ts +179 -0
  272. package/src/lib/stair-duplication.ts +4 -4
  273. package/src/lib/stair-levels.test.ts +146 -0
  274. package/src/lib/stair-levels.ts +177 -0
  275. package/src/lib/surface-plan-snap.test.ts +71 -0
  276. package/src/lib/surface-plan-snap.ts +256 -0
  277. package/src/lib/use-linear-display.ts +40 -0
  278. package/src/lib/world-grid-snap.ts +231 -0
  279. package/src/lib/zone-content.ts +130 -0
  280. package/src/store/use-alignment-guides.ts +21 -0
  281. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  282. package/src/store/use-editor.tsx +767 -114
  283. package/src/store/use-facing-pose.ts +44 -0
  284. package/src/store/use-fence-curve-draft.ts +21 -0
  285. package/src/store/use-floorplan-draft-preview.ts +101 -0
  286. package/src/store/use-floorplan-marquee.ts +50 -0
  287. package/src/store/use-interaction-scope.test.ts +186 -0
  288. package/src/store/use-interaction-scope.ts +132 -0
  289. package/src/store/use-measurement-draft.test.ts +530 -0
  290. package/src/store/use-measurement-draft.ts +543 -0
  291. package/src/store/use-opening-guides.ts +41 -0
  292. package/src/store/use-placement-preview.ts +39 -0
  293. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  294. package/src/store/use-quick-measurement-hud.ts +77 -0
  295. package/src/store/use-segment-draft-chain.ts +28 -0
  296. package/src/store/use-stair-build-preview.ts +43 -0
  297. package/src/store/use-wall-move-ghosts.ts +36 -0
  298. package/src/store/use-wall-snap-indicator.ts +34 -0
  299. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  300. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  301. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  302. package/src/components/tools/building/move-building-tool.tsx +0 -157
  303. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  304. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  305. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  306. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  307. package/src/components/tools/column/column-tool.tsx +0 -97
  308. package/src/components/tools/column/move-column-tool.tsx +0 -105
  309. package/src/components/tools/door/door-math.ts +0 -110
  310. package/src/components/tools/door/door-tool.tsx +0 -324
  311. package/src/components/tools/door/move-door-tool.tsx +0 -412
  312. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  313. package/src/components/tools/fence/fence-tool.tsx +0 -346
  314. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  315. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  316. package/src/components/tools/item/item-tool.tsx +0 -26
  317. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  318. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  319. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  320. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  321. package/src/components/tools/slab/slab-tool.tsx +0 -322
  322. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  323. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  324. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  325. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  326. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  327. package/src/components/tools/wall/wall-tool.tsx +0 -332
  328. package/src/components/tools/window/move-window-tool.tsx +0 -447
  329. package/src/components/tools/window/window-math.ts +0 -117
  330. package/src/components/tools/window/window-tool.tsx +0 -332
  331. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  332. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  333. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  334. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  335. package/src/components/ui/panels/column-panel.tsx +0 -759
  336. package/src/components/ui/panels/door-panel.tsx +0 -1299
  337. package/src/components/ui/panels/fence-panel.tsx +0 -222
  338. package/src/components/ui/panels/item-panel.tsx +0 -306
  339. package/src/components/ui/panels/paint-panel.tsx +0 -163
  340. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  341. package/src/components/ui/panels/roof-panel.tsx +0 -280
  342. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  343. package/src/components/ui/panels/slab-panel.tsx +0 -250
  344. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  345. package/src/components/ui/panels/stair-panel.tsx +0 -577
  346. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  347. package/src/components/ui/panels/wall-panel.tsx +0 -273
  348. package/src/components/ui/panels/window-panel.tsx +0 -968
  349. package/src/contexts/presets-context.tsx +0 -121
  350. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -1,621 +1,543 @@
1
- import { Icon } from '@iconify/react'
2
- import {
3
- type AnyNodeId,
4
- type CeilingNode,
5
- emitter,
6
- type GridEvent,
7
- type ItemNode,
8
- type LevelNode,
9
- type SlabNode,
10
- sceneRegistry,
11
- useScene,
12
- type WallNode,
13
- type ZoneNode,
14
- } from '@pascal-app/core'
1
+ import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
15
2
  import { useViewer } from '@pascal-app/viewer'
16
3
  import { useThree } from '@react-three/fiber'
17
- import { useEffect, useRef } from 'react'
4
+ import { useCallback, useEffect, useRef } from 'react'
18
5
  import {
19
6
  Box3,
20
- BufferAttribute,
21
- BufferGeometry,
22
- DoubleSide,
23
- type Group,
24
- LineBasicMaterial,
25
- LineSegments,
26
- type Mesh,
7
+ type Camera,
8
+ Matrix4,
9
+ type Object3D,
27
10
  Plane,
28
11
  Raycaster,
29
12
  Vector2,
30
13
  Vector3,
31
14
  } from 'three'
32
- import { EDITOR_LAYER } from '../../../lib/constants'
33
- import { sfxEmitter } from '../../../lib/sfx-bus'
34
15
  import useEditor from '../../../store/use-editor'
35
- import { CursorSphere } from '../shared/cursor-sphere'
36
-
37
- /**
38
- * Module-level flag to prevent the SelectionManager from deselecting
39
- * on the grid:click that fires right after a box-select drag completes.
40
- */
41
- export let boxSelectHandled = false
42
-
43
- // ── Geometry helpers ────────────────────────────────────────────────────────
44
-
45
- type Bounds = { minX: number; maxX: number; minZ: number; maxZ: number }
46
-
47
- function pointInBounds(x: number, z: number, b: Bounds): boolean {
48
- return x >= b.minX && x <= b.maxX && z >= b.minZ && z <= b.maxZ
49
- }
50
-
51
- function segmentsIntersect(
52
- ax1: number,
53
- az1: number,
54
- ax2: number,
55
- az2: number,
56
- bx1: number,
57
- bz1: number,
58
- bx2: number,
59
- bz2: number,
60
- ): boolean {
61
- const d1 = cross(bx1, bz1, bx2, bz2, ax1, az1)
62
- const d2 = cross(bx1, bz1, bx2, bz2, ax2, az2)
63
- const d3 = cross(ax1, az1, ax2, az2, bx1, bz1)
64
- const d4 = cross(ax1, az1, ax2, az2, bx2, bz2)
65
-
66
- if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) {
67
- return true
68
- }
69
-
70
- if (d1 === 0 && onSeg(bx1, bz1, bx2, bz2, ax1, az1)) return true
71
- if (d2 === 0 && onSeg(bx1, bz1, bx2, bz2, ax2, az2)) return true
72
- if (d3 === 0 && onSeg(ax1, az1, ax2, az2, bx1, bz1)) return true
73
- if (d4 === 0 && onSeg(ax1, az1, ax2, az2, bx2, bz2)) return true
74
-
75
- return false
76
- }
77
-
78
- function cross(ax: number, az: number, bx: number, bz: number, cx: number, cz: number): number {
79
- return (bx - ax) * (cz - az) - (bz - az) * (cx - ax)
80
- }
16
+ import useInteractionScope from '../../../store/use-interaction-scope'
17
+ import {
18
+ clearBoxSelectHandled,
19
+ isBoxSelectPointerSuppressed,
20
+ markBoxSelectHandled,
21
+ } from './box-select-state'
22
+ import {
23
+ convexHull2D,
24
+ type Point2,
25
+ polygonsIntersect,
26
+ rectIntersectsHull,
27
+ segmentIntersectsPolygon,
28
+ } from './marquee-geometry'
29
+ import { PlaneBoxSelectTool } from './plane-box-select-tool'
30
+ import {
31
+ createScreenRectangleSelectionElement,
32
+ hideScreenRectangleSelectionElement,
33
+ intersectScreenRects,
34
+ normalizeScreenRect,
35
+ SCREEN_RECTANGLE_SELECTION_DRAG_THRESHOLD_PX,
36
+ type ScreenRect,
37
+ screenRectFromDomRect,
38
+ updateScreenRectangleSelectionElement,
39
+ } from './screen-rectangle-selection'
40
+ import { collectSelectableCandidateIds } from './select-candidates'
41
+
42
+ const tempBox = new Box3()
43
+ const tempChildBox = new Box3()
44
+ const tempInvWorld = new Matrix4()
45
+ const tempRelMatrix = new Matrix4()
46
+ const tempWorldPoint = new Vector3()
47
+ const tempScreenPoint = new Vector3()
48
+ const tempNDC = new Vector2()
49
+ const tempPlane = new Plane()
50
+ const tempRaycaster = new Raycaster()
51
+ const UP = new Vector3(0, 1, 0)
52
+ const boxCorners = [
53
+ new Vector3(),
54
+ new Vector3(),
55
+ new Vector3(),
56
+ new Vector3(),
57
+ new Vector3(),
58
+ new Vector3(),
59
+ new Vector3(),
60
+ new Vector3(),
61
+ ]
81
62
 
82
- function onSeg(ax: number, az: number, bx: number, bz: number, cx: number, cz: number): boolean {
63
+ function haveSameIds(currentIds: string[], nextIds: string[]): boolean {
83
64
  return (
84
- Math.min(ax, bx) <= cx &&
85
- cx <= Math.max(ax, bx) &&
86
- Math.min(az, bz) <= cz &&
87
- cz <= Math.max(az, bz)
65
+ currentIds.length === nextIds.length &&
66
+ currentIds.every((currentId, index) => currentId === nextIds[index])
88
67
  )
89
68
  }
90
69
 
91
- function segmentIntersectsBounds(
92
- x1: number,
93
- z1: number,
94
- x2: number,
95
- z2: number,
96
- b: Bounds,
97
- ): boolean {
98
- if (pointInBounds(x1, z1, b) || pointInBounds(x2, z2, b)) return true
99
-
100
- const edges: [number, number, number, number][] = [
101
- [b.minX, b.minZ, b.maxX, b.minZ],
102
- [b.maxX, b.minZ, b.maxX, b.maxZ],
103
- [b.maxX, b.maxZ, b.minX, b.maxZ],
104
- [b.minX, b.maxZ, b.minX, b.minZ],
70
+ function projectWorldPointToScreen(
71
+ point: Vector3,
72
+ camera: Camera,
73
+ canvasRect: DOMRect,
74
+ ): [number, number] | null {
75
+ tempScreenPoint.copy(point).project(camera)
76
+ if (tempScreenPoint.z < -1 || tempScreenPoint.z > 1) return null
77
+
78
+ return [
79
+ canvasRect.left + (tempScreenPoint.x * 0.5 + 0.5) * canvasRect.width,
80
+ canvasRect.top + (-tempScreenPoint.y * 0.5 + 0.5) * canvasRect.height,
105
81
  ]
106
- for (const [ex1, ez1, ex2, ez2] of edges) {
107
- if (segmentsIntersect(x1, z1, x2, z2, ex1, ez1, ex2, ez2)) return true
108
- }
109
- return false
110
82
  }
111
83
 
112
- function polygonIntersectsBounds(polygon: [number, number][], b: Bounds): boolean {
113
- if (polygon.some(([x, z]) => pointInBounds(x, z, b))) return true
114
-
115
- const corners: [number, number][] = [
116
- [b.minX, b.minZ],
117
- [b.maxX, b.minZ],
118
- [b.maxX, b.maxZ],
119
- [b.minX, b.maxZ],
120
- ]
121
- if (corners.some(([cx, cz]) => pointInPolygon(cx, cz, polygon))) return true
122
-
123
- const edges: [number, number, number, number][] = [
124
- [b.minX, b.minZ, b.maxX, b.minZ],
125
- [b.maxX, b.minZ, b.maxX, b.maxZ],
126
- [b.maxX, b.maxZ, b.minX, b.maxZ],
127
- [b.minX, b.maxZ, b.minX, b.minZ],
128
- ]
129
- for (let i = 0; i < polygon.length; i++) {
130
- const [px1, pz1] = polygon[i]!
131
- const [px2, pz2] = polygon[(i + 1) % polygon.length]!
132
- for (const [ex1, ez1, ex2, ez2] of edges) {
133
- if (segmentsIntersect(px1, pz1, px2, pz2, ex1, ez1, ex2, ez2)) return true
84
+ /**
85
+ * Union bounding box of the object's mesh geometry in the OBJECT's OWN frame
86
+ * (an oriented box). The world AABB the previous implementation used inflates
87
+ * around rotated geometry — a diagonal wall's world AABB spans a whole square
88
+ * — and projecting THAT to a screen AABB inflates again, which made the
89
+ * marquee select objects visually far from the cursor.
90
+ */
91
+ function computeLocalBox(object: Object3D): Box3 | null {
92
+ tempBox.makeEmpty()
93
+ tempInvWorld.copy(object.matrixWorld).invert()
94
+ object.traverse((child) => {
95
+ const mesh = child as {
96
+ isMesh?: boolean
97
+ geometry?: { boundingBox: Box3 | null; computeBoundingBox: () => void }
98
+ matrixWorld: import('three').Matrix4
134
99
  }
135
- }
136
-
137
- return false
100
+ if (!mesh.isMesh || !mesh.geometry) return
101
+ if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox()
102
+ const bounds = mesh.geometry.boundingBox
103
+ if (!bounds || bounds.isEmpty()) return
104
+ tempChildBox.copy(bounds)
105
+ tempRelMatrix.multiplyMatrices(tempInvWorld, mesh.matrixWorld)
106
+ tempChildBox.applyMatrix4(tempRelMatrix)
107
+ tempBox.union(tempChildBox)
108
+ })
109
+ return tempBox.isEmpty() ? null : tempBox
138
110
  }
139
111
 
140
- function pointInPolygon(x: number, z: number, polygon: [number, number][]): boolean {
141
- let inside = false
142
- for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
143
- const [xi, zi] = polygon[i]!
144
- const [xj, zj] = polygon[j]!
145
- if (zi > z !== zj > z && x < ((xj - xi) * (z - zi)) / (zj - zi) + xi) {
146
- inside = !inside
147
- }
112
+ /** Screen-space convex hull of the object's oriented bounding box. */
113
+ function getObjectScreenHull(
114
+ object: Object3D,
115
+ camera: Camera,
116
+ canvasRect: DOMRect,
117
+ ): Point2[] | null {
118
+ object.updateWorldMatrix(true, true)
119
+ const localBox = computeLocalBox(object)
120
+
121
+ if (!localBox) {
122
+ object.getWorldPosition(tempWorldPoint)
123
+ const projected = projectWorldPointToScreen(tempWorldPoint, camera, canvasRect)
124
+ return projected ? [projected] : null
148
125
  }
149
- return inside
150
- }
151
126
 
152
- // ── Node-in-bounds checks ───────────────────────────────────────────────────
127
+ boxCorners[0]!.set(localBox.min.x, localBox.min.y, localBox.min.z)
128
+ boxCorners[1]!.set(localBox.min.x, localBox.min.y, localBox.max.z)
129
+ boxCorners[2]!.set(localBox.min.x, localBox.max.y, localBox.min.z)
130
+ boxCorners[3]!.set(localBox.min.x, localBox.max.y, localBox.max.z)
131
+ boxCorners[4]!.set(localBox.max.x, localBox.min.y, localBox.min.z)
132
+ boxCorners[5]!.set(localBox.max.x, localBox.min.y, localBox.max.z)
133
+ boxCorners[6]!.set(localBox.max.x, localBox.max.y, localBox.min.z)
134
+ boxCorners[7]!.set(localBox.max.x, localBox.max.y, localBox.max.z)
135
+
136
+ const projectedPoints: Point2[] = []
137
+ for (const corner of boxCorners) {
138
+ corner.applyMatrix4(object.matrixWorld)
139
+ const projected = projectWorldPointToScreen(corner, camera, canvasRect)
140
+ if (projected) projectedPoints.push(projected)
141
+ }
153
142
 
154
- const _tempVec = new Vector3()
155
- const _tempBox = new Box3()
143
+ if (projectedPoints.length === 0) {
144
+ object.getWorldPosition(tempWorldPoint)
145
+ const projected = projectWorldPointToScreen(tempWorldPoint, camera, canvasRect)
146
+ return projected ? [projected] : null
147
+ }
156
148
 
157
- function getNodeWorldXZ(nodeId: string): [number, number] | null {
158
- const obj = sceneRegistry.nodes.get(nodeId)
159
- if (!obj) return null
160
- obj.getWorldPosition(_tempVec)
161
- return [_tempVec.x, _tempVec.z]
149
+ return convexHull2D(projectedPoints)
162
150
  }
163
151
 
164
- function objectBoundsIntersectsBounds(nodeId: string, bounds: Bounds): boolean {
165
- const obj = sceneRegistry.nodes.get(nodeId)
166
- if (!obj) return false
167
-
168
- obj.updateWorldMatrix(true, true)
169
- _tempBox.setFromObject(obj)
170
-
171
- if (_tempBox.isEmpty()) {
172
- const xz = getNodeWorldXZ(nodeId)
173
- return Boolean(xz && pointInBounds(xz[0], xz[1], bounds))
152
+ function isObjectVisible(object: Object3D): boolean {
153
+ let current: Object3D | null = object
154
+ while (current) {
155
+ if (!current.visible) return false
156
+ current = current.parent
174
157
  }
175
-
176
- return !(
177
- _tempBox.max.x < bounds.minX ||
178
- _tempBox.min.x > bounds.maxX ||
179
- _tempBox.max.z < bounds.minZ ||
180
- _tempBox.min.z > bounds.maxZ
181
- )
158
+ return true
182
159
  }
183
160
 
184
- function collectNodeIdsInBounds(bounds: Bounds): string[] {
185
- const { levelId } = useViewer.getState().selection
186
- const { nodes } = useScene.getState()
187
- const { phase, structureLayer } = useEditor.getState()
161
+ const isVec2 = (v: unknown): v is [number, number] =>
162
+ Array.isArray(v) && v.length === 2 && v.every((n) => typeof n === 'number')
163
+ const isVec2Array = (v: unknown): v is [number, number][] =>
164
+ Array.isArray(v) && v.length > 0 && v.every(isVec2)
188
165
 
189
- if (!levelId) return []
190
- const levelNode = nodes[levelId] as LevelNode | undefined
191
- if (!levelNode || levelNode.type !== 'level') return []
166
+ /**
167
+ * The marquee rect projected onto the active level's floor plane, in the
168
+ * LEVEL frame — a convex quad (perspective keeps rect convexity). Null when
169
+ * any corner ray misses the plane (camera near the horizon); callers fall
170
+ * back to the screen-hull test then.
171
+ */
172
+ function marqueeGroundQuad(rect: ScreenRect, camera: Camera, canvasRect: DOMRect): Point2[] | null {
173
+ const levelId = useViewer.getState().selection.levelId
174
+ const levelObject = levelId ? sceneRegistry.nodes.get(levelId) : null
175
+ if (!levelObject) return null
176
+ levelObject.updateWorldMatrix(true, false)
177
+ tempInvWorld.copy(levelObject.matrixWorld).invert()
178
+ levelObject.getWorldPosition(tempWorldPoint)
179
+ tempPlane.set(UP, -tempWorldPoint.y)
192
180
 
181
+ const corners: [number, number][] = [
182
+ [rect.minX, rect.minY],
183
+ [rect.maxX, rect.minY],
184
+ [rect.maxX, rect.maxY],
185
+ [rect.minX, rect.maxY],
186
+ ]
187
+ const quad: Point2[] = []
188
+ for (const [cx, cy] of corners) {
189
+ tempNDC.set(
190
+ ((cx - canvasRect.left) / canvasRect.width) * 2 - 1,
191
+ -((cy - canvasRect.top) / canvasRect.height) * 2 + 1,
192
+ )
193
+ tempRaycaster.setFromCamera(tempNDC, camera)
194
+ if (!tempRaycaster.ray.intersectPlane(tempPlane, tempWorldPoint)) return null
195
+ tempWorldPoint.applyMatrix4(tempInvWorld)
196
+ quad.push([tempWorldPoint.x, tempWorldPoint.z])
197
+ }
198
+ return quad
199
+ }
200
+
201
+ function collectNodeIdsInScreenRect(
202
+ rect: ScreenRect,
203
+ camera: Camera,
204
+ canvas: HTMLCanvasElement,
205
+ ): string[] {
206
+ const canvasRect = canvas.getBoundingClientRect()
193
207
  const result: string[] = []
194
208
 
195
- if (phase === 'structure' && structureLayer === 'zones') {
196
- for (const childId of levelNode.children) {
197
- const node = nodes[childId as AnyNodeId]
198
- if (!node || node.type !== 'zone') continue
199
- const zone = node as ZoneNode
200
- if (polygonIntersectsBounds(zone.polygon, bounds)) {
201
- result.push(zone.id)
202
- }
203
- }
204
- } else {
205
- // structure (elements) and furnish: collect all node types
206
- for (const childId of levelNode.children) {
207
- const node = nodes[childId as AnyNodeId]
208
- if (!node) continue
209
-
210
- if (node.type === 'wall' || node.type === 'fence') {
211
- const wall = node as WallNode
212
- if (
213
- segmentIntersectsBounds(wall.start[0], wall.start[1], wall.end[0], wall.end[1], bounds)
214
- ) {
215
- result.push(wall.id)
209
+ // Plan-footprint membership for the data kinds (walls / fences by their
210
+ // segment, slabs / ceilings / zones by their polygon) — exact under any
211
+ // rotation, matching the plane-marquee tool's semantics. Kinds whose
212
+ // placement lives in mesh transforms (items, columns, …) intersect the
213
+ // marquee with their oriented bbox projected to a screen hull instead.
214
+ const quad = marqueeGroundQuad(rect, camera, canvasRect)
215
+ const nodes = useScene.getState().nodes
216
+
217
+ for (const id of collectSelectableCandidateIds()) {
218
+ const object = sceneRegistry.nodes.get(id)
219
+ if (!object || !isObjectVisible(object)) continue
220
+
221
+ if (quad) {
222
+ const node = nodes[id as keyof typeof nodes] as
223
+ | { start?: unknown; end?: unknown; polygon?: unknown }
224
+ | undefined
225
+ if (node) {
226
+ const { start, end, polygon } = node
227
+ if (isVec2(start) && isVec2(end)) {
228
+ if (segmentIntersectsPolygon(start, end, quad)) result.push(id)
229
+ continue
216
230
  }
217
- // Check wall children (doors/windows)
218
- for (const itemId of Array.isArray(wall.children) ? wall.children : []) {
219
- const child = nodes[itemId as AnyNodeId]
220
- if (!child) continue
221
- if (
222
- child.type === 'window' ||
223
- child.type === 'door' ||
224
- (child.type === 'item' &&
225
- ((child as ItemNode).asset.category === 'door' ||
226
- (child as ItemNode).asset.category === 'window'))
227
- ) {
228
- const xz = getNodeWorldXZ(child.id)
229
- if (xz && pointInBounds(xz[0], xz[1], bounds)) {
230
- result.push(child.id)
231
- }
232
- }
233
- }
234
- } else if (node.type === 'slab') {
235
- const slab = node as SlabNode
236
- if (polygonIntersectsBounds(slab.polygon, bounds)) {
237
- result.push(slab.id)
238
- }
239
- } else if (node.type === 'ceiling') {
240
- const ceiling = node as CeilingNode
241
- if (polygonIntersectsBounds(ceiling.polygon, bounds)) {
242
- result.push(ceiling.id)
243
- }
244
- } else if (node.type === 'roof') {
245
- const xz = getNodeWorldXZ(node.id)
246
- if (xz && pointInBounds(xz[0], xz[1], bounds)) {
247
- result.push(node.id)
248
- }
249
- } else if (node.type === 'stair') {
250
- if (objectBoundsIntersectsBounds(node.id, bounds)) {
251
- result.push(node.id)
252
- }
253
- } else if (node.type === 'item') {
254
- const item = node as ItemNode
255
- if (item.asset.category === 'door' || item.asset.category === 'window') continue
256
- const xz = getNodeWorldXZ(item.id)
257
- if (xz && pointInBounds(xz[0], xz[1], bounds)) {
258
- result.push(item.id)
231
+ if (isVec2Array(polygon)) {
232
+ if (polygonsIntersect(polygon, quad)) result.push(id)
233
+ continue
259
234
  }
260
235
  }
261
236
  }
237
+
238
+ const hull = getObjectScreenHull(object, camera, canvasRect)
239
+ if (hull && rectIntersectsHull(rect, hull)) {
240
+ result.push(id)
241
+ }
262
242
  }
263
243
 
264
244
  return result
265
245
  }
266
246
 
267
- function haveSameIds(currentIds: string[], nextIds: string[]): boolean {
268
- return (
269
- currentIds.length === nextIds.length &&
270
- currentIds.every((currentId, index) => currentId === nextIds[index])
271
- )
272
- }
247
+ function commitBoxSelection(ids: string[], event: PointerEvent) {
248
+ const shouldAppend = event.metaKey || event.ctrlKey || event.shiftKey
249
+ const { phase, structureLayer } = useEditor.getState()
250
+ const viewer = useViewer.getState()
273
251
 
274
- // ── Visual helpers ──────────────────────────────────────────────────────────
275
-
276
- function updateRectVisuals(
277
- fillMesh: Mesh,
278
- outline: LineSegments,
279
- start: Vector3,
280
- end: Vector3,
281
- y: number,
282
- ) {
283
- const cx = (start.x + end.x) / 2
284
- const cz = (start.z + end.z) / 2
285
- const w = Math.abs(end.x - start.x)
286
- const h = Math.abs(end.z - start.z)
287
-
288
- if (w < 0.01 && h < 0.01) {
289
- fillMesh.visible = false
290
- outline.visible = false
252
+ if (phase === 'structure' && structureLayer === 'zones') {
253
+ if (ids.length > 0) {
254
+ viewer.setSelection({ zoneId: ids[0] as ZoneNode['id'] })
255
+ } else if (!shouldAppend) {
256
+ viewer.setSelection({ zoneId: null })
257
+ }
291
258
  return
292
259
  }
293
260
 
294
- // Fill rect (unit plane scaled)
295
- fillMesh.visible = true
296
- fillMesh.position.set(cx, y + 0.02, cz)
297
- fillMesh.scale.set(w, h, 1)
298
-
299
- // Outline — 4 edges as line segment pairs (8 vertices)
300
- outline.visible = true
301
- const oy = y + 0.03
302
- const x0 = cx - w / 2
303
- const x1 = cx + w / 2
304
- const z0 = cz - h / 2
305
- const z1 = cz + h / 2
306
- const pos = outline.geometry.attributes.position as BufferAttribute
307
- // bottom: (x0,z0)→(x1,z0)
308
- pos.setXYZ(0, x0, oy, z0)
309
- pos.setXYZ(1, x1, oy, z0)
310
- // right: (x1,z0)→(x1,z1)
311
- pos.setXYZ(2, x1, oy, z0)
312
- pos.setXYZ(3, x1, oy, z1)
313
- // top: (x1,z1)→(x0,z1)
314
- pos.setXYZ(4, x1, oy, z1)
315
- pos.setXYZ(5, x0, oy, z1)
316
- // left: (x0,z1)→(x0,z0)
317
- pos.setXYZ(6, x0, oy, z1)
318
- pos.setXYZ(7, x0, oy, z0)
319
- pos.needsUpdate = true
320
- }
321
-
322
- // ── Outline geometry (allocated once, reused) ───────────────────────────────
323
-
324
- function createOutlineSegments(): LineSegments {
325
- const geo = new BufferGeometry()
326
- // 4 edges × 2 vertices each = 8 vertices
327
- const positions = new Float32Array(8 * 3)
328
- geo.setAttribute('position', new BufferAttribute(positions, 3))
329
-
330
- const mat = new LineBasicMaterial({
331
- color: BOX_SELECT_ACCENT_COLOR,
332
- depthTest: false,
333
- depthWrite: false,
334
- transparent: true,
335
- opacity: 0.85,
336
- })
337
-
338
- const segments = new LineSegments(geo, mat)
339
- segments.layers.set(EDITOR_LAYER)
340
- segments.renderOrder = 2
341
- segments.visible = false
342
- segments.frustumCulled = false
343
-
344
- return segments
345
- }
346
-
347
- // ── Drag threshold (pixels) ─────────────────────────────────────────────────
348
-
349
- const BOX_SELECT_ACCENT_COLOR = '#818cf8'
350
- const DRAG_THRESHOLD_PX = 4
351
-
352
- function getSnappedGridPosition(x: number, z: number): [number, number] {
353
- return [Math.round(x * 2) / 2, Math.round(z * 2) / 2]
354
- }
261
+ if (shouldAppend) {
262
+ viewer.setSelection({
263
+ selectedIds: Array.from(new Set([...viewer.selection.selectedIds, ...ids])),
264
+ })
265
+ return
266
+ }
355
267
 
356
- function setSnappedPoint(target: Vector3, x: number, y: number, z: number) {
357
- const [snappedX, snappedZ] = getSnappedGridPosition(x, z)
358
- target.set(snappedX, y, snappedZ)
268
+ viewer.setSelection({ selectedIds: ids })
359
269
  }
360
270
 
361
- // ── Component ───────────────────────────────────────────────────────────────
362
-
363
271
  export const BoxSelectTool: React.FC = () => {
272
+ const phase = useEditor((s) => s.phase)
364
273
  const mode = useEditor((s) => s.mode)
365
274
  const selectionTool = useEditor((s) => s.floorplanSelectionTool)
366
- const isActive = mode === 'select' && selectionTool === 'marquee'
275
+ const isActive = mode === 'select' && (phase === 'structure' || phase === 'furnish')
367
276
 
368
277
  if (!isActive) return null
369
278
 
370
- return <BoxSelectToolInner />
279
+ if (selectionTool === 'marquee') {
280
+ return <PlaneBoxSelectTool />
281
+ }
282
+
283
+ return <ScreenRectangleSelectTool />
371
284
  }
372
285
 
373
- const BOX_SELECT_TOOLTIP = (
374
- <Icon
375
- color="currentColor"
376
- height={24}
377
- icon="mdi:select-drag"
378
- style={{ filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))' }}
379
- width={24}
380
- />
381
- )
382
-
383
- const BoxSelectToolInner: React.FC = () => {
286
+ const ScreenRectangleSelectTool: React.FC = () => {
384
287
  const { camera, gl } = useThree()
385
288
  const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds)
386
- const cursorRef = useRef<Group>(null)
387
- const rectFillRef = useRef<Mesh>(null!)
388
- const outlineRef = useRef(createOutlineSegments())
389
- const startPoint = useRef(new Vector3())
390
- const currentPoint = useRef(new Vector3())
391
- const pointerDown = useRef(false)
392
- const isDragging = useRef(false)
393
- const startClientX = useRef(0)
394
- const startClientY = useRef(0)
395
- const gridY = useRef(0)
396
- const previousGridPosition = useRef<[number, number] | null>(null)
289
+ const elementRef = useRef<HTMLDivElement | null>(null)
397
290
  const previewSelectedIdsRef = useRef<string[]>([])
291
+ const pointerDownRef = useRef(false)
292
+ const isDraggingRef = useRef(false)
293
+ const ownsInputDraggingRef = useRef(false)
294
+ const pointerIdRef = useRef<number | null>(null)
295
+ const startClientXRef = useRef(0)
296
+ const startClientYRef = useRef(0)
297
+ const currentClientXRef = useRef(0)
298
+ const currentClientYRef = useRef(0)
299
+ const spaceDownRef = useRef(false)
300
+ // rAF throttle for the expensive marquee preview pass. pointermove can fire
301
+ // several times per animation frame; the per-node AABB projection in
302
+ // `collectNodeIdsInScreenRect` only needs to run once per frame. We stash the
303
+ // latest clamped rect and process it inside the rAF callback.
304
+ const previewRafRef = useRef<number | null>(null)
305
+ const pendingPreviewRectRef = useRef<ScreenRect | null>(null)
306
+
307
+ const syncPreviewSelectedIds = useCallback(
308
+ (nextIds: string[]) => {
309
+ if (haveSameIds(previewSelectedIdsRef.current, nextIds)) return
310
+
311
+ previewSelectedIdsRef.current = nextIds
312
+ setPreviewSelectedIds(nextIds)
313
+ },
314
+ [setPreviewSelectedIds],
315
+ )
398
316
 
399
- // Raycasting helpers (same technique as useGridEvents)
400
- const raycasterRef = useRef(new Raycaster())
401
- const pointerNDC = useRef(new Vector2())
402
- const groundPlane = useRef(new Plane(new Vector3(0, 1, 0), 0))
403
- const hitPoint = useRef(new Vector3())
317
+ const resetDrag = useCallback(() => {
318
+ pointerDownRef.current = false
319
+ isDraggingRef.current = false
320
+ pointerIdRef.current = null
321
+ if (previewRafRef.current !== null) {
322
+ cancelAnimationFrame(previewRafRef.current)
323
+ previewRafRef.current = null
324
+ }
325
+ pendingPreviewRectRef.current = null
326
+ hideScreenRectangleSelectionElement(elementRef.current)
327
+ syncPreviewSelectedIds([])
328
+
329
+ if (ownsInputDraggingRef.current) {
330
+ useViewer.getState().setInputDragging(false)
331
+ ownsInputDraggingRef.current = false
332
+ }
333
+ useInteractionScope.getState().endIf((s) => s.kind === 'box-select')
334
+ }, [syncPreviewSelectedIds])
404
335
 
405
- // Cleanup outline geometry on unmount
406
336
  useEffect(() => {
407
- const outline = outlineRef.current
337
+ const element = createScreenRectangleSelectionElement()
338
+ document.body.appendChild(element)
339
+ elementRef.current = element
340
+
408
341
  return () => {
409
- previewSelectedIdsRef.current = []
410
- setPreviewSelectedIds([])
411
- outline.geometry.dispose()
412
- ;(outline.material as LineBasicMaterial).dispose()
342
+ element.remove()
343
+ elementRef.current = null
413
344
  }
414
- }, [setPreviewSelectedIds])
345
+ }, [])
415
346
 
416
- const syncPreviewSelectedIds = (nextIds: string[]) => {
417
- if (haveSameIds(previewSelectedIdsRef.current, nextIds)) {
418
- return
347
+ useEffect(() => {
348
+ const cancelForSpace = () => {
349
+ if (!pointerDownRef.current) return
350
+ markBoxSelectHandled()
351
+ resetDrag()
419
352
  }
420
353
 
421
- previewSelectedIdsRef.current = nextIds
422
- setPreviewSelectedIds(nextIds)
423
- }
354
+ const onKeyDown = (event: KeyboardEvent) => {
355
+ if (event.code !== 'Space') return
356
+ spaceDownRef.current = true
357
+ cancelForSpace()
358
+ }
424
359
 
425
- // Sync ground plane Y with the current level
426
- useEffect(() => {
427
- const unsubscribe = useViewer.subscribe((state) => {
428
- const levelId = state.selection.levelId
429
- if (!levelId) return
430
- const obj = sceneRegistry.nodes.get(levelId)
431
- if (obj) groundPlane.current.constant = -obj.position.y
432
- })
433
- // Set initial value
434
- const levelId = useViewer.getState().selection.levelId
435
- if (levelId) {
436
- const obj = sceneRegistry.nodes.get(levelId)
437
- if (obj) groundPlane.current.constant = -obj.position.y
360
+ const onKeyUp = (event: KeyboardEvent) => {
361
+ if (event.code !== 'Space') return
362
+ spaceDownRef.current = false
438
363
  }
439
- return unsubscribe
440
- }, [])
441
364
 
442
- const raycastToGround = (e: PointerEvent): Vector3 | null => {
443
- const rect = gl.domElement.getBoundingClientRect()
444
- pointerNDC.current.x = ((e.clientX - rect.left) / rect.width) * 2 - 1
445
- pointerNDC.current.y = -((e.clientY - rect.top) / rect.height) * 2 + 1
446
- raycasterRef.current.setFromCamera(pointerNDC.current, camera)
447
- if (raycasterRef.current.ray.intersectPlane(groundPlane.current, hitPoint.current)) {
448
- return hitPoint.current
365
+ const onBlur = () => {
366
+ spaceDownRef.current = false
367
+ resetDrag()
449
368
  }
450
- return null
451
- }
369
+
370
+ window.addEventListener('keydown', onKeyDown)
371
+ window.addEventListener('keyup', onKeyUp)
372
+ window.addEventListener('blur', onBlur)
373
+
374
+ return () => {
375
+ window.removeEventListener('keydown', onKeyDown)
376
+ window.removeEventListener('keyup', onKeyUp)
377
+ window.removeEventListener('blur', onBlur)
378
+ }
379
+ }, [resetDrag])
452
380
 
453
381
  useEffect(() => {
454
382
  const canvas = gl.domElement
455
383
 
456
- const onCanvasPointerDown = (e: PointerEvent) => {
457
- if (e.button !== 0) return
458
- if (useViewer.getState().cameraDragging) return
459
-
460
- const point = raycastToGround(e)
461
- if (!point) return
462
-
463
- setSnappedPoint(startPoint.current, point.x, point.y, point.z)
464
- setSnappedPoint(currentPoint.current, point.x, point.y, point.z)
465
- gridY.current = point.y
466
- pointerDown.current = true
467
- isDragging.current = false
468
- previousGridPosition.current = getSnappedGridPosition(point.x, point.z)
469
- startClientX.current = e.clientX
470
- startClientY.current = e.clientY
471
- syncPreviewSelectedIds([])
384
+ const flushPreview = () => {
385
+ previewRafRef.current = null
386
+ const rect = pendingPreviewRectRef.current
387
+ if (!rect) return
388
+ pendingPreviewRectRef.current = null
389
+ syncPreviewSelectedIds(collectNodeIdsInScreenRect(rect, camera, canvas))
472
390
  }
473
391
 
474
- const onCanvasPointerUp = (e: PointerEvent) => {
475
- if (e.button !== 0) return
476
- if (!pointerDown.current) return
477
-
478
- if (isDragging.current) {
479
- const point = raycastToGround(e)
480
- if (point) setSnappedPoint(currentPoint.current, point.x, point.y, point.z)
392
+ const updateDrag = (event: PointerEvent) => {
393
+ if (!pointerDownRef.current) return
394
+ if (pointerIdRef.current !== null && event.pointerId !== pointerIdRef.current) return
395
+
396
+ const viewer = useViewer.getState()
397
+ if (
398
+ isBoxSelectPointerSuppressed(event) ||
399
+ spaceDownRef.current ||
400
+ viewer.cameraDragging ||
401
+ (viewer.inputDragging && !ownsInputDraggingRef.current)
402
+ ) {
403
+ markBoxSelectHandled()
404
+ resetDrag()
405
+ return
406
+ }
481
407
 
482
- const bounds: Bounds = {
483
- minX: Math.min(startPoint.current.x, currentPoint.current.x),
484
- maxX: Math.max(startPoint.current.x, currentPoint.current.x),
485
- minZ: Math.min(startPoint.current.z, currentPoint.current.z),
486
- maxZ: Math.max(startPoint.current.z, currentPoint.current.z),
487
- }
408
+ currentClientXRef.current = event.clientX
409
+ currentClientYRef.current = event.clientY
410
+
411
+ const dragDistance = Math.hypot(
412
+ currentClientXRef.current - startClientXRef.current,
413
+ currentClientYRef.current - startClientYRef.current,
414
+ )
415
+
416
+ if (!isDraggingRef.current && dragDistance >= SCREEN_RECTANGLE_SELECTION_DRAG_THRESHOLD_PX) {
417
+ isDraggingRef.current = true
418
+ ownsInputDraggingRef.current = true
419
+ useViewer.getState().setInputDragging(true)
420
+ useInteractionScope.getState().begin({ kind: 'box-select' })
421
+ markBoxSelectHandled()
422
+ try {
423
+ canvas.setPointerCapture(event.pointerId)
424
+ } catch {}
425
+ }
488
426
 
489
- const ids = collectNodeIdsInBounds(bounds)
490
-
491
- const shouldAppend = e.metaKey || e.ctrlKey
492
- const { phase, structureLayer } = useEditor.getState()
493
-
494
- if (phase === 'structure' && structureLayer === 'zones') {
495
- if (ids.length > 0) {
496
- useViewer.getState().setSelection({ zoneId: ids[0] as ZoneNode['id'] })
497
- } else if (!shouldAppend) {
498
- useViewer.getState().setSelection({ zoneId: null })
499
- }
500
- } else if (shouldAppend) {
501
- const currentIds = useViewer.getState().selection.selectedIds
502
- const merged = Array.from(new Set([...currentIds, ...ids]))
503
- useViewer.getState().setSelection({ selectedIds: merged })
504
- } else {
505
- useViewer.getState().setSelection({ selectedIds: ids })
427
+ if (!isDraggingRef.current) return
428
+
429
+ event.preventDefault()
430
+ const rect = normalizeScreenRect(
431
+ startClientXRef.current,
432
+ startClientYRef.current,
433
+ currentClientXRef.current,
434
+ currentClientYRef.current,
435
+ )
436
+ const clampedRect = intersectScreenRects(
437
+ rect,
438
+ screenRectFromDomRect(canvas.getBoundingClientRect()),
439
+ )
440
+ if (!clampedRect) {
441
+ if (previewRafRef.current !== null) {
442
+ cancelAnimationFrame(previewRafRef.current)
443
+ previewRafRef.current = null
506
444
  }
507
-
508
- // Prevent the subsequent grid:click from deselecting
509
- boxSelectHandled = true
510
- setTimeout(() => {
511
- boxSelectHandled = false
512
- }, 50)
445
+ pendingPreviewRectRef.current = null
446
+ hideScreenRectangleSelectionElement(elementRef.current)
447
+ syncPreviewSelectedIds([])
448
+ return
513
449
  }
514
- // NOTE: Short clicks (no drag) fall through to the SelectionManager's
515
- // existing grid:click / node:click handlers — no extra logic needed here.
516
-
517
- // Hide visuals
518
- if (rectFillRef.current) rectFillRef.current.visible = false
519
- if (outlineRef.current) outlineRef.current.visible = false
520
- syncPreviewSelectedIds([])
521
450
 
522
- // Reset
523
- pointerDown.current = false
524
- isDragging.current = false
451
+ updateScreenRectangleSelectionElement(elementRef.current!, clampedRect)
452
+ // Coalesce the per-node AABB projection to one run per animation frame.
453
+ pendingPreviewRectRef.current = clampedRect
454
+ if (previewRafRef.current === null) {
455
+ previewRafRef.current = requestAnimationFrame(flushPreview)
456
+ }
525
457
  }
526
458
 
527
- canvas.addEventListener('pointerdown', onCanvasPointerDown)
528
- canvas.addEventListener('pointerup', onCanvasPointerUp)
529
-
530
- return () => {
531
- canvas.removeEventListener('pointerdown', onCanvasPointerDown)
532
- canvas.removeEventListener('pointerup', onCanvasPointerUp)
533
- }
534
- }, [camera, gl])
459
+ const finishDrag = (event: PointerEvent) => {
460
+ if (!pointerDownRef.current) return
461
+ if (pointerIdRef.current !== null && event.pointerId !== pointerIdRef.current) return
462
+
463
+ if (
464
+ isBoxSelectPointerSuppressed(event) ||
465
+ (useViewer.getState().inputDragging && !ownsInputDraggingRef.current)
466
+ ) {
467
+ markBoxSelectHandled()
468
+ resetDrag()
469
+ return
470
+ }
535
471
 
536
- // grid:move for cursor tracking + rectangle update during drag
537
- useEffect(() => {
538
- const onMove = (event: GridEvent) => {
539
- const [snappedX, snappedZ] = getSnappedGridPosition(event.position[0], event.position[2])
472
+ if (isDraggingRef.current) {
473
+ event.preventDefault()
474
+ event.stopPropagation()
475
+ markBoxSelectHandled()
540
476
 
541
- // Always update cursor position
542
- if (cursorRef.current) {
543
- cursorRef.current.position.set(snappedX, event.position[1], snappedZ)
477
+ const rect = normalizeScreenRect(
478
+ startClientXRef.current,
479
+ startClientYRef.current,
480
+ event.clientX,
481
+ event.clientY,
482
+ )
483
+ const clampedRect = intersectScreenRects(
484
+ rect,
485
+ screenRectFromDomRect(canvas.getBoundingClientRect()),
486
+ )
487
+ const ids = clampedRect ? collectNodeIdsInScreenRect(clampedRect, camera, canvas) : []
488
+ commitBoxSelection(ids, event)
544
489
  }
545
490
 
546
- if (!pointerDown.current) return
491
+ try {
492
+ canvas.releasePointerCapture(event.pointerId)
493
+ } catch {}
547
494
 
548
- currentPoint.current.set(snappedX, event.position[1], snappedZ)
495
+ resetDrag()
496
+ }
549
497
 
550
- // Check drag threshold (screen pixels)
551
- const nativeEvent = event.nativeEvent as unknown as PointerEvent
552
- const dx = nativeEvent.clientX - startClientX.current
553
- const dy = nativeEvent.clientY - startClientY.current
554
- if (!isDragging.current && Math.hypot(dx, dy) >= DRAG_THRESHOLD_PX) {
555
- isDragging.current = true
556
- }
498
+ const onCanvasPointerDown = (event: PointerEvent) => {
499
+ if (event.button !== 0) return
500
+ if (spaceDownRef.current) return
501
+ if (isBoxSelectPointerSuppressed(event)) return
502
+
503
+ const viewer = useViewer.getState()
504
+ if (viewer.cameraDragging || viewer.inputDragging) return
505
+
506
+ pointerDownRef.current = true
507
+ isDraggingRef.current = false
508
+ pointerIdRef.current = event.pointerId
509
+ startClientXRef.current = event.clientX
510
+ startClientYRef.current = event.clientY
511
+ currentClientXRef.current = event.clientX
512
+ currentClientYRef.current = event.clientY
513
+ syncPreviewSelectedIds([])
514
+ }
557
515
 
558
- if (isDragging.current && rectFillRef.current && outlineRef.current) {
559
- updateRectVisuals(
560
- rectFillRef.current,
561
- outlineRef.current,
562
- startPoint.current,
563
- currentPoint.current,
564
- gridY.current,
565
- )
516
+ const onPointerCancel = (event: PointerEvent) => {
517
+ if (pointerIdRef.current !== null && event.pointerId !== pointerIdRef.current) return
518
+ resetDrag()
519
+ }
566
520
 
567
- const nextGridPosition: [number, number] = [snappedX, snappedZ]
568
- if (
569
- previousGridPosition.current &&
570
- (nextGridPosition[0] !== previousGridPosition.current[0] ||
571
- nextGridPosition[1] !== previousGridPosition.current[1])
572
- ) {
573
- sfxEmitter.emit('sfx:grid-snap')
574
- }
575
- previousGridPosition.current = nextGridPosition
521
+ canvas.addEventListener('pointerdown', onCanvasPointerDown)
522
+ window.addEventListener('pointermove', updateDrag, { passive: false })
523
+ window.addEventListener('pointerup', finishDrag)
524
+ window.addEventListener('pointercancel', onPointerCancel)
576
525
 
577
- const bounds: Bounds = {
578
- minX: Math.min(startPoint.current.x, currentPoint.current.x),
579
- maxX: Math.max(startPoint.current.x, currentPoint.current.x),
580
- minZ: Math.min(startPoint.current.z, currentPoint.current.z),
581
- maxZ: Math.max(startPoint.current.z, currentPoint.current.z),
582
- }
583
- syncPreviewSelectedIds(collectNodeIdsInBounds(bounds))
584
- }
526
+ return () => {
527
+ canvas.removeEventListener('pointerdown', onCanvasPointerDown)
528
+ window.removeEventListener('pointermove', updateDrag)
529
+ window.removeEventListener('pointerup', finishDrag)
530
+ window.removeEventListener('pointercancel', onPointerCancel)
531
+ resetDrag()
585
532
  }
533
+ }, [camera, gl, resetDrag, syncPreviewSelectedIds])
586
534
 
587
- emitter.on('grid:move', onMove)
535
+ useEffect(() => {
588
536
  return () => {
589
- emitter.off('grid:move', onMove)
537
+ clearBoxSelectHandled()
538
+ resetDrag()
590
539
  }
591
- }, [])
540
+ }, [resetDrag])
592
541
 
593
- return (
594
- <group>
595
- {/* Cursor indicator */}
596
- <CursorSphere ref={cursorRef} tooltipContent={BOX_SELECT_TOOLTIP} />
597
-
598
- {/* Selection rectangle fill */}
599
- <mesh
600
- layers={EDITOR_LAYER}
601
- ref={rectFillRef}
602
- renderOrder={1}
603
- rotation={[-Math.PI / 2, 0, 0]}
604
- visible={false}
605
- >
606
- <planeGeometry args={[1, 1]} />
607
- <meshBasicMaterial
608
- color={BOX_SELECT_ACCENT_COLOR}
609
- depthTest={false}
610
- depthWrite={false}
611
- opacity={0.14}
612
- side={DoubleSide}
613
- transparent
614
- />
615
- </mesh>
616
-
617
- {/* Outline (LineLoop added as primitive — allocated once in ref) */}
618
- <primitive object={outlineRef.current} />
619
- </group>
620
- )
542
+ return null
621
543
  }