@pascal-app/editor 0.4.0

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 (165) hide show
  1. package/package.json +62 -0
  2. package/src/components/editor/custom-camera-controls.tsx +387 -0
  3. package/src/components/editor/editor-layout-v2.tsx +220 -0
  4. package/src/components/editor/export-manager.tsx +78 -0
  5. package/src/components/editor/first-person-controls.tsx +249 -0
  6. package/src/components/editor/floating-action-menu.tsx +231 -0
  7. package/src/components/editor/floorplan-panel.tsx +9609 -0
  8. package/src/components/editor/grid.tsx +161 -0
  9. package/src/components/editor/index.tsx +928 -0
  10. package/src/components/editor/node-action-menu.tsx +66 -0
  11. package/src/components/editor/preset-thumbnail-generator.tsx +125 -0
  12. package/src/components/editor/selection-manager.tsx +897 -0
  13. package/src/components/editor/site-edge-labels.tsx +90 -0
  14. package/src/components/editor/thumbnail-generator.tsx +166 -0
  15. package/src/components/editor/wall-measurement-label.tsx +258 -0
  16. package/src/components/feedback-dialog.tsx +265 -0
  17. package/src/components/pascal-radio.tsx +280 -0
  18. package/src/components/preview-button.tsx +16 -0
  19. package/src/components/systems/ceiling/ceiling-system.tsx +77 -0
  20. package/src/components/systems/roof/roof-edit-system.tsx +69 -0
  21. package/src/components/systems/stair/stair-edit-system.tsx +69 -0
  22. package/src/components/systems/zone/zone-label-editor-system.tsx +320 -0
  23. package/src/components/systems/zone/zone-system.tsx +87 -0
  24. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +42 -0
  25. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +47 -0
  26. package/src/components/tools/ceiling/ceiling-tool.tsx +465 -0
  27. package/src/components/tools/door/door-math.ts +110 -0
  28. package/src/components/tools/door/door-tool.tsx +293 -0
  29. package/src/components/tools/door/move-door-tool.tsx +373 -0
  30. package/src/components/tools/item/item-tool.tsx +26 -0
  31. package/src/components/tools/item/move-tool.tsx +90 -0
  32. package/src/components/tools/item/placement-math.ts +85 -0
  33. package/src/components/tools/item/placement-strategies.ts +556 -0
  34. package/src/components/tools/item/placement-types.ts +117 -0
  35. package/src/components/tools/item/use-draft-node.ts +227 -0
  36. package/src/components/tools/item/use-placement-coordinator.tsx +877 -0
  37. package/src/components/tools/roof/move-roof-tool.tsx +288 -0
  38. package/src/components/tools/roof/roof-tool.tsx +318 -0
  39. package/src/components/tools/select/box-select-tool.tsx +626 -0
  40. package/src/components/tools/shared/cursor-sphere.tsx +119 -0
  41. package/src/components/tools/shared/polygon-editor.tsx +361 -0
  42. package/src/components/tools/site/site-boundary-editor.tsx +42 -0
  43. package/src/components/tools/slab/slab-boundary-editor.tsx +42 -0
  44. package/src/components/tools/slab/slab-hole-editor.tsx +47 -0
  45. package/src/components/tools/slab/slab-tool.tsx +322 -0
  46. package/src/components/tools/stair/stair-defaults.ts +7 -0
  47. package/src/components/tools/stair/stair-tool.tsx +194 -0
  48. package/src/components/tools/tool-manager.tsx +120 -0
  49. package/src/components/tools/wall/wall-drafting.ts +140 -0
  50. package/src/components/tools/wall/wall-tool.tsx +210 -0
  51. package/src/components/tools/window/move-window-tool.tsx +410 -0
  52. package/src/components/tools/window/window-math.ts +117 -0
  53. package/src/components/tools/window/window-tool.tsx +303 -0
  54. package/src/components/tools/zone/zone-boundary-editor.tsx +39 -0
  55. package/src/components/tools/zone/zone-tool.tsx +364 -0
  56. package/src/components/ui/action-menu/action-button.tsx +59 -0
  57. package/src/components/ui/action-menu/camera-actions.tsx +74 -0
  58. package/src/components/ui/action-menu/control-modes.tsx +240 -0
  59. package/src/components/ui/action-menu/furnish-tools.tsx +102 -0
  60. package/src/components/ui/action-menu/index.tsx +152 -0
  61. package/src/components/ui/action-menu/structure-tools.tsx +100 -0
  62. package/src/components/ui/action-menu/view-toggles.tsx +397 -0
  63. package/src/components/ui/command-palette/editor-commands.tsx +396 -0
  64. package/src/components/ui/command-palette/index.tsx +730 -0
  65. package/src/components/ui/controls/action-button.tsx +33 -0
  66. package/src/components/ui/controls/material-picker.tsx +194 -0
  67. package/src/components/ui/controls/metric-control.tsx +262 -0
  68. package/src/components/ui/controls/panel-section.tsx +65 -0
  69. package/src/components/ui/controls/segmented-control.tsx +45 -0
  70. package/src/components/ui/controls/slider-control.tsx +245 -0
  71. package/src/components/ui/controls/toggle-control.tsx +38 -0
  72. package/src/components/ui/floating-level-selector.tsx +355 -0
  73. package/src/components/ui/helpers/ceiling-helper.tsx +20 -0
  74. package/src/components/ui/helpers/helper-manager.tsx +33 -0
  75. package/src/components/ui/helpers/item-helper.tsx +40 -0
  76. package/src/components/ui/helpers/roof-helper.tsx +16 -0
  77. package/src/components/ui/helpers/slab-helper.tsx +20 -0
  78. package/src/components/ui/helpers/wall-helper.tsx +20 -0
  79. package/src/components/ui/item-catalog/catalog-items.tsx +1580 -0
  80. package/src/components/ui/item-catalog/item-catalog.tsx +219 -0
  81. package/src/components/ui/panels/ceiling-panel.tsx +230 -0
  82. package/src/components/ui/panels/collections/collections-popover.tsx +356 -0
  83. package/src/components/ui/panels/door-panel.tsx +600 -0
  84. package/src/components/ui/panels/item-panel.tsx +306 -0
  85. package/src/components/ui/panels/panel-manager.tsx +59 -0
  86. package/src/components/ui/panels/panel-wrapper.tsx +80 -0
  87. package/src/components/ui/panels/presets/presets-popover.tsx +511 -0
  88. package/src/components/ui/panels/reference-panel.tsx +177 -0
  89. package/src/components/ui/panels/roof-panel.tsx +262 -0
  90. package/src/components/ui/panels/roof-segment-panel.tsx +326 -0
  91. package/src/components/ui/panels/slab-panel.tsx +228 -0
  92. package/src/components/ui/panels/stair-panel.tsx +304 -0
  93. package/src/components/ui/panels/stair-segment-panel.tsx +339 -0
  94. package/src/components/ui/panels/wall-panel.tsx +123 -0
  95. package/src/components/ui/panels/window-panel.tsx +441 -0
  96. package/src/components/ui/primitives/button.tsx +69 -0
  97. package/src/components/ui/primitives/card.tsx +75 -0
  98. package/src/components/ui/primitives/color-dot.tsx +61 -0
  99. package/src/components/ui/primitives/context-menu.tsx +227 -0
  100. package/src/components/ui/primitives/dialog.tsx +129 -0
  101. package/src/components/ui/primitives/dropdown-menu.tsx +228 -0
  102. package/src/components/ui/primitives/error-boundary.tsx +52 -0
  103. package/src/components/ui/primitives/input.tsx +21 -0
  104. package/src/components/ui/primitives/number-input.tsx +187 -0
  105. package/src/components/ui/primitives/opacity-control.tsx +79 -0
  106. package/src/components/ui/primitives/popover.tsx +42 -0
  107. package/src/components/ui/primitives/separator.tsx +28 -0
  108. package/src/components/ui/primitives/sheet.tsx +130 -0
  109. package/src/components/ui/primitives/shortcut-token.tsx +64 -0
  110. package/src/components/ui/primitives/sidebar.tsx +855 -0
  111. package/src/components/ui/primitives/skeleton.tsx +13 -0
  112. package/src/components/ui/primitives/slider.tsx +58 -0
  113. package/src/components/ui/primitives/switch.tsx +29 -0
  114. package/src/components/ui/primitives/tooltip.tsx +57 -0
  115. package/src/components/ui/scene-loader.tsx +40 -0
  116. package/src/components/ui/sidebar/app-sidebar.tsx +103 -0
  117. package/src/components/ui/sidebar/icon-rail.tsx +147 -0
  118. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +100 -0
  119. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +438 -0
  120. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +188 -0
  121. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +80 -0
  122. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +126 -0
  123. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +64 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +1543 -0
  125. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +98 -0
  126. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +117 -0
  127. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +65 -0
  128. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +214 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +96 -0
  130. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +216 -0
  131. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +115 -0
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +342 -0
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +271 -0
  134. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +106 -0
  135. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +64 -0
  136. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +87 -0
  137. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +167 -0
  138. package/src/components/ui/sidebar/tab-bar.tsx +39 -0
  139. package/src/components/ui/slider-demo.tsx +36 -0
  140. package/src/components/ui/slider.tsx +81 -0
  141. package/src/components/ui/viewer-toolbar.tsx +342 -0
  142. package/src/components/viewer-overlay.tsx +499 -0
  143. package/src/components/viewer-zone-system.tsx +48 -0
  144. package/src/contexts/presets-context.tsx +121 -0
  145. package/src/hooks/use-auto-save.ts +194 -0
  146. package/src/hooks/use-contextual-tools.ts +52 -0
  147. package/src/hooks/use-grid-events.ts +106 -0
  148. package/src/hooks/use-keyboard.ts +214 -0
  149. package/src/hooks/use-mobile.ts +19 -0
  150. package/src/hooks/use-reduced-motion.ts +20 -0
  151. package/src/index.tsx +33 -0
  152. package/src/lib/constants.ts +3 -0
  153. package/src/lib/level-selection.ts +31 -0
  154. package/src/lib/scene.ts +394 -0
  155. package/src/lib/sfx/index.ts +2 -0
  156. package/src/lib/sfx-bus.ts +49 -0
  157. package/src/lib/sfx-player.ts +60 -0
  158. package/src/lib/utils.ts +43 -0
  159. package/src/store/use-audio.tsx +45 -0
  160. package/src/store/use-command-registry.ts +36 -0
  161. package/src/store/use-editor.tsx +522 -0
  162. package/src/store/use-palette-view-registry.ts +45 -0
  163. package/src/store/use-upload.ts +90 -0
  164. package/src/three-types.ts +3 -0
  165. package/tsconfig.json +9 -0
@@ -0,0 +1,626 @@
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'
15
+ import { useViewer } from '@pascal-app/viewer'
16
+ 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'
34
+ 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
+ }
81
+
82
+ function onSeg(ax: number, az: number, bx: number, bz: number, cx: number, cz: number): boolean {
83
+ 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)
88
+ )
89
+ }
90
+
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],
105
+ ]
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
+ }
111
+
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
+ }
135
+ }
136
+
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
+ }
148
+ }
149
+ return inside
150
+ }
151
+
152
+ // ── Node-in-bounds checks ───────────────────────────────────────────────────
153
+
154
+ const _tempVec = new Vector3()
155
+ const _tempBox = new Box3()
156
+
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]
162
+ }
163
+
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))
174
+ }
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
+ )
182
+ }
183
+
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
+
193
+ const result: string[] = []
194
+
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') {
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 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
+ }
266
+ }
267
+ }
268
+
269
+ return result
270
+ }
271
+
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
+ }
278
+
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
296
+ return
297
+ }
298
+
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
+ }
360
+
361
+ function setSnappedPoint(target: Vector3, x: number, y: number, z: number) {
362
+ const [snappedX, snappedZ] = getSnappedGridPosition(x, z)
363
+ target.set(snappedX, y, snappedZ)
364
+ }
365
+
366
+ // ── Component ───────────────────────────────────────────────────────────────
367
+
368
+ export const BoxSelectTool: React.FC = () => {
369
+ const mode = useEditor((s) => s.mode)
370
+ const selectionTool = useEditor((s) => s.floorplanSelectionTool)
371
+ const isActive = mode === 'select' && selectionTool === 'marquee'
372
+
373
+ if (!isActive) return null
374
+
375
+ return <BoxSelectToolInner />
376
+ }
377
+
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 = () => {
389
+ const { camera, gl } = useThree()
390
+ 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)
402
+ const previewSelectedIdsRef = useRef<string[]>([])
403
+
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())
409
+
410
+ // Cleanup outline geometry on unmount
411
+ useEffect(() => {
412
+ const outline = outlineRef.current
413
+ return () => {
414
+ previewSelectedIdsRef.current = []
415
+ setPreviewSelectedIds([])
416
+ outline.geometry.dispose()
417
+ ;(outline.material as LineBasicMaterial).dispose()
418
+ }
419
+ }, [setPreviewSelectedIds])
420
+
421
+ const syncPreviewSelectedIds = (nextIds: string[]) => {
422
+ if (haveSameIds(previewSelectedIdsRef.current, nextIds)) {
423
+ return
424
+ }
425
+
426
+ previewSelectedIdsRef.current = nextIds
427
+ setPreviewSelectedIds(nextIds)
428
+ }
429
+
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
443
+ }
444
+ return unsubscribe
445
+ }, [])
446
+
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
454
+ }
455
+ return null
456
+ }
457
+
458
+ useEffect(() => {
459
+ const canvas = gl.domElement
460
+
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
+ }
478
+
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)
518
+ }
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
+
522
+ // Hide visuals
523
+ if (rectFillRef.current) rectFillRef.current.visible = false
524
+ if (outlineRef.current) outlineRef.current.visible = false
525
+ syncPreviewSelectedIds([])
526
+
527
+ // Reset
528
+ pointerDown.current = false
529
+ isDragging.current = false
530
+ }
531
+
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])
540
+
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])
545
+
546
+ // Always update cursor position
547
+ if (cursorRef.current) {
548
+ cursorRef.current.position.set(snappedX, event.position[1], snappedZ)
549
+ }
550
+
551
+ if (!pointerDown.current) return
552
+
553
+ currentPoint.current.set(snappedX, event.position[1], snappedZ)
554
+
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
+ }
562
+
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
+ )
571
+
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
+ }
590
+ }
591
+
592
+ emitter.on('grid:move', onMove)
593
+ return () => {
594
+ emitter.off('grid:move', onMove)
595
+ }
596
+ }, [])
597
+
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
+ )
626
+ }