@pascal-app/editor 0.7.0 → 0.9.1

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