@pascal-app/editor 0.8.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 (223) hide show
  1. package/package.json +8 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +754 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +58 -51
  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 +201 -83
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +8 -8
  10. package/src/components/editor/first-person-controls.tsx +825 -20
  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 +5829 -12005
  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 +178 -149
  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 +433 -156
  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 +136 -62
  28. package/src/components/editor/thumbnail-generator.tsx +9 -3
  29. package/src/components/editor/use-floorplan-background-placement.ts +119 -32
  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-geometry-renderer.tsx +266 -0
  45. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  46. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  47. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  48. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  49. package/src/components/editor-2d/svg-paths.ts +10 -2
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +26 -3
  51. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  52. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  53. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  54. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  55. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  56. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  57. package/src/components/tools/fence/fence-drafting.ts +64 -22
  58. package/src/components/tools/item/move-tool.tsx +41 -105
  59. package/src/components/tools/item/placement-math.test.ts +10 -0
  60. package/src/components/tools/item/placement-math.ts +31 -5
  61. package/src/components/tools/item/placement-strategies.ts +190 -15
  62. package/src/components/tools/item/placement-types.ts +10 -1
  63. package/src/components/tools/item/use-draft-node.ts +20 -1
  64. package/src/components/tools/item/use-placement-coordinator.tsx +694 -191
  65. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  66. package/src/components/tools/roof/roof-tool.tsx +108 -12
  67. package/src/components/tools/select/box-select-state.ts +77 -0
  68. package/src/components/tools/select/box-select-tool.tsx +324 -538
  69. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  70. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  71. package/src/components/tools/select/select-candidates.test.ts +109 -0
  72. package/src/components/tools/select/select-candidates.ts +85 -0
  73. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  74. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  75. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  76. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  77. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  78. package/src/components/tools/shared/placement-box.tsx +123 -0
  79. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  80. package/src/components/tools/shared/segment-angle.ts +56 -0
  81. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  82. package/src/components/tools/site/site-flag-model.ts +18 -0
  83. package/src/components/tools/stair/stair-defaults.ts +1 -1
  84. package/src/components/tools/stair/stair-tool.tsx +269 -35
  85. package/src/components/tools/tool-manager.tsx +172 -56
  86. package/src/components/tools/wall/wall-drafting.ts +98 -98
  87. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  88. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  89. package/src/components/tools/zone/zone-tool.tsx +16 -6
  90. package/src/components/ui/action-menu/action-button.tsx +21 -1
  91. package/src/components/ui/action-menu/control-modes.tsx +5 -82
  92. package/src/components/ui/action-menu/index.tsx +1 -113
  93. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  94. package/src/components/ui/action-menu/view-toggles.tsx +243 -6
  95. package/src/components/ui/command-palette/editor-commands.tsx +18 -13
  96. package/src/components/ui/command-palette/index.tsx +2 -1
  97. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  98. package/src/components/ui/controls/material-picker.tsx +11 -32
  99. package/src/components/ui/controls/metric-control.tsx +33 -11
  100. package/src/components/ui/floating-level-selector.tsx +47 -10
  101. package/src/components/ui/helpers/helper-manager.tsx +16 -17
  102. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  103. package/src/components/ui/item-catalog/catalog-items.tsx +1 -0
  104. package/src/components/ui/item-catalog/item-catalog.tsx +3 -0
  105. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  106. package/src/components/ui/panels/node-display.ts +1 -0
  107. package/src/components/ui/panels/panel-manager.tsx +35 -47
  108. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  109. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  110. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  111. package/src/components/ui/scene-loader.tsx +1 -3
  112. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  113. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  114. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  115. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  116. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  117. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  118. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  119. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  120. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  121. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  122. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  123. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
  125. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  126. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  127. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  128. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -36
  133. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  134. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  135. package/src/components/viewer-overlay.tsx +169 -52
  136. package/src/components/viewer-zone-system.tsx +1 -1
  137. package/src/hooks/use-auto-save.ts +10 -3
  138. package/src/hooks/use-ceiling-events.ts +175 -0
  139. package/src/hooks/use-drag-action.ts +124 -0
  140. package/src/hooks/use-keyboard.ts +115 -62
  141. package/src/hooks/use-selection.ts +64 -0
  142. package/src/index.tsx +237 -5
  143. package/src/lib/constants.ts +6 -2
  144. package/src/lib/editor-api.ts +62 -0
  145. package/src/lib/elevator-support.ts +96 -0
  146. package/src/lib/floorplan/apply-alignment.ts +111 -0
  147. package/src/lib/floorplan/index.ts +7 -0
  148. package/src/lib/floorplan/selection-tool.ts +40 -0
  149. package/src/lib/floorplan/stairs.ts +12 -1
  150. package/src/lib/fresh-planar-placement.test.ts +102 -0
  151. package/src/lib/fresh-planar-placement.ts +63 -0
  152. package/src/lib/history.ts +2 -1
  153. package/src/lib/level-duplication.ts +54 -42
  154. package/src/lib/level-name.ts +11 -0
  155. package/src/lib/material-paint.ts +211 -19
  156. package/src/lib/placement-metadata.ts +29 -0
  157. package/src/lib/planar-cursor-placement.test.ts +43 -0
  158. package/src/lib/planar-cursor-placement.ts +42 -0
  159. package/src/lib/roof-duplication.ts +1 -1
  160. package/src/lib/scene-clipboard.ts +267 -0
  161. package/src/lib/scene.ts +13 -4
  162. package/src/lib/sfx-bus.ts +23 -1
  163. package/src/lib/sfx-player.ts +70 -22
  164. package/src/lib/site-boundary.ts +1 -0
  165. package/src/lib/stair-levels.test.ts +146 -0
  166. package/src/lib/stair-levels.ts +177 -0
  167. package/src/lib/world-grid-snap.ts +199 -0
  168. package/src/lib/zone-content.ts +130 -0
  169. package/src/store/use-alignment-guides.ts +21 -0
  170. package/src/store/use-editor.tsx +320 -37
  171. package/src/store/use-placement-preview.ts +31 -0
  172. package/src/store/use-wall-move-ghosts.ts +36 -0
  173. package/src/store/use-wall-snap-indicator.ts +32 -0
  174. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  175. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  176. package/src/components/tools/building/move-building-tool.tsx +0 -157
  177. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  178. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  179. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  180. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  181. package/src/components/tools/column/column-tool.tsx +0 -97
  182. package/src/components/tools/column/move-column-tool.tsx +0 -105
  183. package/src/components/tools/door/door-math.ts +0 -110
  184. package/src/components/tools/door/door-tool.tsx +0 -324
  185. package/src/components/tools/door/move-door-tool.tsx +0 -412
  186. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  187. package/src/components/tools/fence/fence-tool.tsx +0 -346
  188. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  189. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  190. package/src/components/tools/item/item-tool.tsx +0 -26
  191. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  192. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  193. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  194. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  195. package/src/components/tools/slab/slab-tool.tsx +0 -322
  196. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  197. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  198. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  199. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  200. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  201. package/src/components/tools/wall/wall-tool.tsx +0 -332
  202. package/src/components/tools/window/move-window-tool.tsx +0 -447
  203. package/src/components/tools/window/window-math.ts +0 -117
  204. package/src/components/tools/window/window-tool.tsx +0 -332
  205. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  206. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  207. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  208. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  209. package/src/components/ui/panels/column-panel.tsx +0 -759
  210. package/src/components/ui/panels/door-panel.tsx +0 -1299
  211. package/src/components/ui/panels/fence-panel.tsx +0 -222
  212. package/src/components/ui/panels/item-panel.tsx +0 -306
  213. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  214. package/src/components/ui/panels/roof-panel.tsx +0 -280
  215. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  216. package/src/components/ui/panels/slab-panel.tsx +0 -250
  217. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  218. package/src/components/ui/panels/stair-panel.tsx +0 -577
  219. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  220. package/src/components/ui/panels/wall-panel.tsx +0 -273
  221. package/src/components/ui/panels/window-panel.tsx +0 -968
  222. package/src/contexts/presets-context.tsx +0 -121
  223. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -1,621 +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 === '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)
216
- }
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)
259
- }
260
- }
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)
261
135
  }
262
136
  }
263
137
 
264
138
  return result
265
139
  }
266
140
 
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
- }
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()
273
145
 
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
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
+ }
291
152
  return
292
153
  }
293
154
 
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
- }
155
+ if (shouldAppend) {
156
+ viewer.setSelection({
157
+ selectedIds: Array.from(new Set([...viewer.selection.selectedIds, ...ids])),
158
+ })
159
+ return
160
+ }
355
161
 
356
- function setSnappedPoint(target: Vector3, x: number, y: number, z: number) {
357
- const [snappedX, snappedZ] = getSnappedGridPosition(x, z)
358
- target.set(snappedX, y, snappedZ)
162
+ viewer.setSelection({ selectedIds: ids })
359
163
  }
360
164
 
361
- // ── Component ───────────────────────────────────────────────────────────────
362
-
363
165
  export const BoxSelectTool: React.FC = () => {
166
+ const phase = useEditor((s) => s.phase)
364
167
  const mode = useEditor((s) => s.mode)
365
168
  const selectionTool = useEditor((s) => s.floorplanSelectionTool)
366
- const isActive = mode === 'select' && selectionTool === 'marquee'
169
+ const isActive = mode === 'select' && (phase === 'structure' || phase === 'furnish')
367
170
 
368
171
  if (!isActive) return null
369
172
 
370
- return <BoxSelectToolInner />
173
+ if (selectionTool === 'marquee') {
174
+ return <PlaneBoxSelectTool />
175
+ }
176
+
177
+ return <ScreenRectangleSelectTool />
371
178
  }
372
179
 
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 = () => {
180
+ const ScreenRectangleSelectTool: React.FC = () => {
384
181
  const { camera, gl } = useThree()
385
182
  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)
183
+ const elementRef = useRef<HTMLDivElement | null>(null)
397
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
+ )
398
204
 
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())
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])
404
217
 
405
- // Cleanup outline geometry on unmount
406
218
  useEffect(() => {
407
- const outline = outlineRef.current
219
+ const element = createScreenRectangleSelectionElement()
220
+ document.body.appendChild(element)
221
+ elementRef.current = element
222
+
408
223
  return () => {
409
- previewSelectedIdsRef.current = []
410
- setPreviewSelectedIds([])
411
- outline.geometry.dispose()
412
- ;(outline.material as LineBasicMaterial).dispose()
224
+ element.remove()
225
+ elementRef.current = null
413
226
  }
414
- }, [setPreviewSelectedIds])
227
+ }, [])
415
228
 
416
- const syncPreviewSelectedIds = (nextIds: string[]) => {
417
- if (haveSameIds(previewSelectedIdsRef.current, nextIds)) {
418
- return
229
+ useEffect(() => {
230
+ const cancelForSpace = () => {
231
+ if (!pointerDownRef.current) return
232
+ markBoxSelectHandled()
233
+ resetDrag()
419
234
  }
420
235
 
421
- previewSelectedIdsRef.current = nextIds
422
- setPreviewSelectedIds(nextIds)
423
- }
236
+ const onKeyDown = (event: KeyboardEvent) => {
237
+ if (event.code !== 'Space') return
238
+ spaceDownRef.current = true
239
+ cancelForSpace()
240
+ }
424
241
 
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
242
+ const onKeyUp = (event: KeyboardEvent) => {
243
+ if (event.code !== 'Space') return
244
+ spaceDownRef.current = false
438
245
  }
439
- return unsubscribe
440
- }, [])
441
246
 
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
247
+ const onBlur = () => {
248
+ spaceDownRef.current = false
249
+ resetDrag()
449
250
  }
450
- return null
451
- }
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])
452
262
 
453
263
  useEffect(() => {
454
264
  const canvas = gl.domElement
455
265
 
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([])
472
- }
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
+ }
473
281
 
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)
481
-
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
- }
488
-
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 })
506
- }
507
-
508
- // Prevent the subsequent grid:click from deselecting
509
- boxSelectHandled = true
510
- setTimeout(() => {
511
- boxSelectHandled = false
512
- }, 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 {}
513
298
  }
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
299
 
517
- // Hide visuals
518
- if (rectFillRef.current) rectFillRef.current.visible = false
519
- if (outlineRef.current) outlineRef.current.visible = false
520
- 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
+ }
521
318
 
522
- // Reset
523
- pointerDown.current = false
524
- isDragging.current = false
319
+ updateScreenRectangleSelectionElement(elementRef.current!, clampedRect)
320
+ syncPreviewSelectedIds(collectNodeIdsInScreenRect(clampedRect, camera, canvas))
525
321
  }
526
322
 
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])
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
+ }
535
335
 
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])
336
+ if (isDraggingRef.current) {
337
+ event.preventDefault()
338
+ event.stopPropagation()
339
+ markBoxSelectHandled()
540
340
 
541
- // Always update cursor position
542
- if (cursorRef.current) {
543
- 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)
544
353
  }
545
354
 
546
- if (!pointerDown.current) return
355
+ try {
356
+ canvas.releasePointerCapture(event.pointerId)
357
+ } catch {}
547
358
 
548
- currentPoint.current.set(snappedX, event.position[1], snappedZ)
359
+ resetDrag()
360
+ }
549
361
 
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
- }
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
+ }
557
379
 
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
- )
380
+ const onPointerCancel = (event: PointerEvent) => {
381
+ if (pointerIdRef.current !== null && event.pointerId !== pointerIdRef.current) return
382
+ resetDrag()
383
+ }
566
384
 
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
576
-
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
- }
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()
585
396
  }
397
+ }, [camera, gl, resetDrag, syncPreviewSelectedIds])
586
398
 
587
- emitter.on('grid:move', onMove)
399
+ useEffect(() => {
588
400
  return () => {
589
- emitter.off('grid:move', onMove)
401
+ clearBoxSelectHandled()
402
+ resetDrag()
590
403
  }
591
- }, [])
404
+ }, [resetDrag])
592
405
 
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
- )
406
+ return null
621
407
  }