@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
@@ -0,0 +1,577 @@
1
+ import '../../../three-types'
2
+
3
+ import { Icon } from '@iconify/react'
4
+ import {
5
+ type AnyNodeId,
6
+ emitter,
7
+ type GridEvent,
8
+ sceneRegistry,
9
+ useScene,
10
+ type ZoneNode,
11
+ } from '@pascal-app/core'
12
+ import { useViewer } from '@pascal-app/viewer'
13
+ import type { ThreeElements } from '@react-three/fiber'
14
+ import { useThree } from '@react-three/fiber'
15
+ import { useCallback, useEffect, useRef } from 'react'
16
+ import {
17
+ Box3,
18
+ BufferAttribute,
19
+ BufferGeometry,
20
+ DoubleSide,
21
+ type Group,
22
+ LineBasicMaterial,
23
+ LineSegments,
24
+ type Mesh,
25
+ Plane,
26
+ Raycaster,
27
+ Vector2,
28
+ Vector3,
29
+ } from 'three'
30
+ import { EDITOR_LAYER } from '../../../lib/constants'
31
+ import { sfxEmitter } from '../../../lib/sfx-bus'
32
+ import useEditor from '../../../store/use-editor'
33
+ import { CursorSphere } from '../shared/cursor-sphere'
34
+ import { isBoxSelectPointerSuppressed, markBoxSelectHandled } from './box-select-state'
35
+ import { collectSelectableCandidateIds } from './select-candidates'
36
+
37
+ declare module 'react/jsx-runtime' {
38
+ namespace JSX {
39
+ interface IntrinsicElements extends ThreeElements {}
40
+ }
41
+ }
42
+
43
+ type Bounds = { minX: number; maxX: number; minZ: number; maxZ: number }
44
+
45
+ const BOX_SELECT_ACCENT_COLOR = '#818cf8'
46
+ const DRAG_THRESHOLD_PX = 4
47
+ const tempVec = new Vector3()
48
+ const tempBox = new Box3()
49
+
50
+ function pointInBounds(x: number, z: number, b: Bounds): boolean {
51
+ return x >= b.minX && x <= b.maxX && z >= b.minZ && z <= b.maxZ
52
+ }
53
+
54
+ function segmentsIntersect(
55
+ ax1: number,
56
+ az1: number,
57
+ ax2: number,
58
+ az2: number,
59
+ bx1: number,
60
+ bz1: number,
61
+ bx2: number,
62
+ bz2: number,
63
+ ): boolean {
64
+ const d1 = cross(bx1, bz1, bx2, bz2, ax1, az1)
65
+ const d2 = cross(bx1, bz1, bx2, bz2, ax2, az2)
66
+ const d3 = cross(ax1, az1, ax2, az2, bx1, bz1)
67
+ const d4 = cross(ax1, az1, ax2, az2, bx2, bz2)
68
+
69
+ if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) {
70
+ return true
71
+ }
72
+
73
+ if (d1 === 0 && onSegment(bx1, bz1, bx2, bz2, ax1, az1)) return true
74
+ if (d2 === 0 && onSegment(bx1, bz1, bx2, bz2, ax2, az2)) return true
75
+ if (d3 === 0 && onSegment(ax1, az1, ax2, az2, bx1, bz1)) return true
76
+ if (d4 === 0 && onSegment(ax1, az1, ax2, az2, bx2, bz2)) return true
77
+
78
+ return false
79
+ }
80
+
81
+ function cross(ax: number, az: number, bx: number, bz: number, cx: number, cz: number): number {
82
+ return (bx - ax) * (cz - az) - (bz - az) * (cx - ax)
83
+ }
84
+
85
+ function onSegment(
86
+ ax: number,
87
+ az: number,
88
+ bx: number,
89
+ bz: number,
90
+ cx: number,
91
+ cz: number,
92
+ ): boolean {
93
+ return (
94
+ Math.min(ax, bx) <= cx &&
95
+ cx <= Math.max(ax, bx) &&
96
+ Math.min(az, bz) <= cz &&
97
+ cz <= Math.max(az, bz)
98
+ )
99
+ }
100
+
101
+ function segmentIntersectsBounds(
102
+ x1: number,
103
+ z1: number,
104
+ x2: number,
105
+ z2: number,
106
+ b: Bounds,
107
+ ): boolean {
108
+ if (pointInBounds(x1, z1, b) || pointInBounds(x2, z2, b)) return true
109
+
110
+ const edges: [number, number, number, number][] = [
111
+ [b.minX, b.minZ, b.maxX, b.minZ],
112
+ [b.maxX, b.minZ, b.maxX, b.maxZ],
113
+ [b.maxX, b.maxZ, b.minX, b.maxZ],
114
+ [b.minX, b.maxZ, b.minX, b.minZ],
115
+ ]
116
+ for (const [ex1, ez1, ex2, ez2] of edges) {
117
+ if (segmentsIntersect(x1, z1, x2, z2, ex1, ez1, ex2, ez2)) return true
118
+ }
119
+ return false
120
+ }
121
+
122
+ function polygonIntersectsBounds(polygon: [number, number][], b: Bounds): boolean {
123
+ if (polygon.some(([x, z]) => pointInBounds(x, z, b))) return true
124
+
125
+ const corners: [number, number][] = [
126
+ [b.minX, b.minZ],
127
+ [b.maxX, b.minZ],
128
+ [b.maxX, b.maxZ],
129
+ [b.minX, b.maxZ],
130
+ ]
131
+ if (corners.some(([cx, cz]) => pointInPolygon(cx, cz, polygon))) return true
132
+
133
+ const edges: [number, number, number, number][] = [
134
+ [b.minX, b.minZ, b.maxX, b.minZ],
135
+ [b.maxX, b.minZ, b.maxX, b.maxZ],
136
+ [b.maxX, b.maxZ, b.minX, b.maxZ],
137
+ [b.minX, b.maxZ, b.minX, b.minZ],
138
+ ]
139
+ for (let i = 0; i < polygon.length; i++) {
140
+ const [px1, pz1] = polygon[i]!
141
+ const [px2, pz2] = polygon[(i + 1) % polygon.length]!
142
+ for (const [ex1, ez1, ex2, ez2] of edges) {
143
+ if (segmentsIntersect(px1, pz1, px2, pz2, ex1, ez1, ex2, ez2)) return true
144
+ }
145
+ }
146
+
147
+ return false
148
+ }
149
+
150
+ function pointInPolygon(x: number, z: number, polygon: [number, number][]): boolean {
151
+ let inside = false
152
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
153
+ const [xi, zi] = polygon[i]!
154
+ const [xj, zj] = polygon[j]!
155
+ if (zi > z !== zj > z && x < ((xj - xi) * (z - zi)) / (zj - zi) + xi) {
156
+ inside = !inside
157
+ }
158
+ }
159
+ return inside
160
+ }
161
+
162
+ function getNodeWorldXZ(nodeId: string): [number, number] | null {
163
+ const obj = sceneRegistry.nodes.get(nodeId)
164
+ if (!obj) return null
165
+ obj.getWorldPosition(tempVec)
166
+ return [tempVec.x, tempVec.z]
167
+ }
168
+
169
+ function objectBoundsIntersectsBounds(nodeId: string, bounds: Bounds): boolean {
170
+ const obj = sceneRegistry.nodes.get(nodeId)
171
+ if (!obj) return false
172
+
173
+ obj.updateWorldMatrix(true, true)
174
+ tempBox.setFromObject(obj)
175
+
176
+ if (tempBox.isEmpty()) {
177
+ const xz = getNodeWorldXZ(nodeId)
178
+ return Boolean(xz && pointInBounds(xz[0], xz[1], bounds))
179
+ }
180
+
181
+ return !(
182
+ tempBox.max.x < bounds.minX ||
183
+ tempBox.min.x > bounds.maxX ||
184
+ tempBox.max.z < bounds.minZ ||
185
+ tempBox.min.z > bounds.maxZ
186
+ )
187
+ }
188
+
189
+ function collectNodeIdsInPlaneBounds(bounds: Bounds | null): string[] {
190
+ const candidateIds = collectSelectableCandidateIds()
191
+ if (!bounds) return candidateIds
192
+
193
+ const { nodes } = useScene.getState()
194
+ return candidateIds.filter((id) => {
195
+ const node = nodes[id as AnyNodeId]
196
+ if (!node) return false
197
+
198
+ if (node.type === 'wall' || node.type === 'fence') {
199
+ return segmentIntersectsBounds(node.start[0], node.start[1], node.end[0], node.end[1], bounds)
200
+ }
201
+
202
+ if (node.type === 'slab' || node.type === 'ceiling' || node.type === 'zone') {
203
+ return polygonIntersectsBounds(node.polygon, bounds)
204
+ }
205
+
206
+ return objectBoundsIntersectsBounds(id, bounds)
207
+ })
208
+ }
209
+
210
+ function haveSameIds(currentIds: string[], nextIds: string[]): boolean {
211
+ return (
212
+ currentIds.length === nextIds.length &&
213
+ currentIds.every((currentId, index) => currentId === nextIds[index])
214
+ )
215
+ }
216
+
217
+ function updateRectVisuals(
218
+ fillMesh: Mesh,
219
+ outline: LineSegments,
220
+ start: Vector3,
221
+ end: Vector3,
222
+ y: number,
223
+ ) {
224
+ const cx = (start.x + end.x) / 2
225
+ const cz = (start.z + end.z) / 2
226
+ const w = Math.abs(end.x - start.x)
227
+ const h = Math.abs(end.z - start.z)
228
+
229
+ if (w < 0.01 && h < 0.01) {
230
+ fillMesh.visible = false
231
+ outline.visible = false
232
+ return
233
+ }
234
+
235
+ fillMesh.visible = true
236
+ fillMesh.position.set(cx, y + 0.02, cz)
237
+ fillMesh.scale.set(w, h, 1)
238
+
239
+ outline.visible = true
240
+ const oy = y + 0.03
241
+ const x0 = cx - w / 2
242
+ const x1 = cx + w / 2
243
+ const z0 = cz - h / 2
244
+ const z1 = cz + h / 2
245
+ const pos = outline.geometry.attributes.position as BufferAttribute
246
+ pos.setXYZ(0, x0, oy, z0)
247
+ pos.setXYZ(1, x1, oy, z0)
248
+ pos.setXYZ(2, x1, oy, z0)
249
+ pos.setXYZ(3, x1, oy, z1)
250
+ pos.setXYZ(4, x1, oy, z1)
251
+ pos.setXYZ(5, x0, oy, z1)
252
+ pos.setXYZ(6, x0, oy, z1)
253
+ pos.setXYZ(7, x0, oy, z0)
254
+ pos.needsUpdate = true
255
+ }
256
+
257
+ function createOutlineSegments(): LineSegments {
258
+ const geometry = new BufferGeometry()
259
+ geometry.setAttribute('position', new BufferAttribute(new Float32Array(8 * 3), 3))
260
+
261
+ const material = new LineBasicMaterial({
262
+ color: BOX_SELECT_ACCENT_COLOR,
263
+ depthTest: false,
264
+ depthWrite: false,
265
+ transparent: true,
266
+ opacity: 0.85,
267
+ })
268
+
269
+ const segments = new LineSegments(geometry, material)
270
+ segments.layers.set(EDITOR_LAYER)
271
+ segments.renderOrder = 2
272
+ segments.visible = false
273
+ segments.frustumCulled = false
274
+
275
+ return segments
276
+ }
277
+
278
+ function getSnappedGridPosition(x: number, z: number): [number, number] {
279
+ return [Math.round(x * 2) / 2, Math.round(z * 2) / 2]
280
+ }
281
+
282
+ function setSnappedPoint(target: Vector3, x: number, y: number, z: number) {
283
+ const [snappedX, snappedZ] = getSnappedGridPosition(x, z)
284
+ target.set(snappedX, y, snappedZ)
285
+ }
286
+
287
+ const BOX_SELECT_TOOLTIP = (
288
+ <Icon
289
+ color="currentColor"
290
+ height={24}
291
+ icon="mdi:select-drag"
292
+ style={{ filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))' }}
293
+ width={24}
294
+ />
295
+ )
296
+
297
+ export const PlaneBoxSelectTool: React.FC = () => {
298
+ const { camera, gl } = useThree()
299
+ const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds)
300
+ const cursorRef = useRef<Group>(null)
301
+ const rectFillRef = useRef<Mesh>(null!)
302
+ const outlineRef = useRef(createOutlineSegments())
303
+ const startPoint = useRef(new Vector3())
304
+ const currentPoint = useRef(new Vector3())
305
+ const pointerDown = useRef(false)
306
+ const isDragging = useRef(false)
307
+ const startClientX = useRef(0)
308
+ const startClientY = useRef(0)
309
+ const gridY = useRef(0)
310
+ const previousGridPosition = useRef<[number, number] | null>(null)
311
+ const previewSelectedIdsRef = useRef<string[]>([])
312
+ const spaceDownRef = useRef(false)
313
+ const raycasterRef = useRef(new Raycaster())
314
+ const pointerNDC = useRef(new Vector2())
315
+ const groundPlane = useRef(new Plane(new Vector3(0, 1, 0), 0))
316
+ const hitPoint = useRef(new Vector3())
317
+
318
+ const syncPreviewSelectedIds = useCallback(
319
+ (nextIds: string[]) => {
320
+ if (haveSameIds(previewSelectedIdsRef.current, nextIds)) return
321
+ previewSelectedIdsRef.current = nextIds
322
+ setPreviewSelectedIds(nextIds)
323
+ },
324
+ [setPreviewSelectedIds],
325
+ )
326
+
327
+ const resetDrag = useCallback(() => {
328
+ pointerDown.current = false
329
+ isDragging.current = false
330
+ rectFillRef.current.visible = false
331
+ outlineRef.current.visible = false
332
+ syncPreviewSelectedIds([])
333
+ }, [syncPreviewSelectedIds])
334
+
335
+ const raycastToGround = useCallback(
336
+ (event: PointerEvent): Vector3 | null => {
337
+ const rect = gl.domElement.getBoundingClientRect()
338
+ pointerNDC.current.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
339
+ pointerNDC.current.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
340
+ raycasterRef.current.setFromCamera(pointerNDC.current, camera)
341
+ if (raycasterRef.current.ray.intersectPlane(groundPlane.current, hitPoint.current)) {
342
+ return hitPoint.current
343
+ }
344
+ return null
345
+ },
346
+ [camera, gl],
347
+ )
348
+
349
+ useEffect(() => {
350
+ const outline = outlineRef.current
351
+ return () => {
352
+ previewSelectedIdsRef.current = []
353
+ setPreviewSelectedIds([])
354
+ outline.geometry.dispose()
355
+ ;(outline.material as LineBasicMaterial).dispose()
356
+ }
357
+ }, [setPreviewSelectedIds])
358
+
359
+ useEffect(() => {
360
+ const unsubscribe = useViewer.subscribe((state) => {
361
+ const levelId = state.selection.levelId
362
+ if (!levelId) return
363
+ const obj = sceneRegistry.nodes.get(levelId)
364
+ if (obj) groundPlane.current.constant = -obj.position.y
365
+ })
366
+ const levelId = useViewer.getState().selection.levelId
367
+ if (levelId) {
368
+ const obj = sceneRegistry.nodes.get(levelId)
369
+ if (obj) groundPlane.current.constant = -obj.position.y
370
+ }
371
+ return unsubscribe
372
+ }, [])
373
+
374
+ useEffect(() => {
375
+ const onKeyDown = (event: KeyboardEvent) => {
376
+ if (event.code !== 'Space') return
377
+ spaceDownRef.current = true
378
+ if (pointerDown.current) {
379
+ resetDrag()
380
+ }
381
+ }
382
+
383
+ const onKeyUp = (event: KeyboardEvent) => {
384
+ if (event.code !== 'Space') return
385
+ spaceDownRef.current = false
386
+ }
387
+
388
+ const onBlur = () => {
389
+ spaceDownRef.current = false
390
+ resetDrag()
391
+ }
392
+
393
+ window.addEventListener('keydown', onKeyDown)
394
+ window.addEventListener('keyup', onKeyUp)
395
+ window.addEventListener('blur', onBlur)
396
+
397
+ return () => {
398
+ window.removeEventListener('keydown', onKeyDown)
399
+ window.removeEventListener('keyup', onKeyUp)
400
+ window.removeEventListener('blur', onBlur)
401
+ }
402
+ }, [resetDrag])
403
+
404
+ useEffect(() => {
405
+ const canvas = gl.domElement
406
+
407
+ const onCanvasPointerDown = (event: PointerEvent) => {
408
+ if (event.button !== 0) return
409
+ if (spaceDownRef.current) return
410
+ if (isBoxSelectPointerSuppressed(event)) return
411
+ if (useViewer.getState().cameraDragging) return
412
+ if (useViewer.getState().inputDragging) return
413
+
414
+ const point = raycastToGround(event)
415
+ if (!point) return
416
+
417
+ setSnappedPoint(startPoint.current, point.x, point.y, point.z)
418
+ setSnappedPoint(currentPoint.current, point.x, point.y, point.z)
419
+ gridY.current = point.y
420
+ pointerDown.current = true
421
+ isDragging.current = false
422
+ previousGridPosition.current = getSnappedGridPosition(point.x, point.z)
423
+ startClientX.current = event.clientX
424
+ startClientY.current = event.clientY
425
+ syncPreviewSelectedIds([])
426
+ }
427
+
428
+ const onCanvasPointerUp = (event: PointerEvent) => {
429
+ if (event.button !== 0) return
430
+ if (isBoxSelectPointerSuppressed(event) || useViewer.getState().inputDragging) {
431
+ markBoxSelectHandled()
432
+ resetDrag()
433
+ return
434
+ }
435
+ if (!pointerDown.current) return
436
+
437
+ if (isDragging.current) {
438
+ const point = raycastToGround(event)
439
+ if (point) setSnappedPoint(currentPoint.current, point.x, point.y, point.z)
440
+
441
+ const bounds: Bounds = {
442
+ minX: Math.min(startPoint.current.x, currentPoint.current.x),
443
+ maxX: Math.max(startPoint.current.x, currentPoint.current.x),
444
+ minZ: Math.min(startPoint.current.z, currentPoint.current.z),
445
+ maxZ: Math.max(startPoint.current.z, currentPoint.current.z),
446
+ }
447
+
448
+ const ids = collectNodeIdsInPlaneBounds(bounds)
449
+ const shouldAppend = event.metaKey || event.ctrlKey
450
+ const { phase, structureLayer } = useEditor.getState()
451
+
452
+ if (phase === 'structure' && structureLayer === 'zones') {
453
+ if (ids.length > 0) {
454
+ useViewer.getState().setSelection({ zoneId: ids[0] as ZoneNode['id'] })
455
+ } else if (!shouldAppend) {
456
+ useViewer.getState().setSelection({ zoneId: null })
457
+ }
458
+ } else if (shouldAppend) {
459
+ const currentIds = useViewer.getState().selection.selectedIds
460
+ useViewer.getState().setSelection({
461
+ selectedIds: Array.from(new Set([...currentIds, ...ids])),
462
+ })
463
+ } else {
464
+ const allOnLevel = collectNodeIdsInPlaneBounds(null)
465
+ const { buildingId } = useViewer.getState().selection
466
+ const selectedEntireLevel = allOnLevel.length > 0 && ids.length === allOnLevel.length
467
+
468
+ if (selectedEntireLevel && buildingId) {
469
+ useViewer.getState().setSelection({ buildingId })
470
+ } else {
471
+ useViewer.getState().setSelection({ selectedIds: ids })
472
+ }
473
+ }
474
+
475
+ markBoxSelectHandled()
476
+ }
477
+
478
+ resetDrag()
479
+ }
480
+
481
+ canvas.addEventListener('pointerdown', onCanvasPointerDown)
482
+ canvas.addEventListener('pointerup', onCanvasPointerUp)
483
+
484
+ return () => {
485
+ canvas.removeEventListener('pointerdown', onCanvasPointerDown)
486
+ canvas.removeEventListener('pointerup', onCanvasPointerUp)
487
+ }
488
+ }, [gl, raycastToGround, resetDrag, syncPreviewSelectedIds])
489
+
490
+ useEffect(() => {
491
+ const onMove = (event: GridEvent) => {
492
+ const [snappedX, snappedZ] = getSnappedGridPosition(event.position[0], event.position[2])
493
+
494
+ if (cursorRef.current) {
495
+ cursorRef.current.position.set(snappedX, event.position[1], snappedZ)
496
+ }
497
+
498
+ if (!pointerDown.current) return
499
+ if (isBoxSelectPointerSuppressed(event.nativeEvent)) {
500
+ markBoxSelectHandled()
501
+ resetDrag()
502
+ return
503
+ }
504
+ if (spaceDownRef.current || useViewer.getState().inputDragging) {
505
+ markBoxSelectHandled()
506
+ resetDrag()
507
+ return
508
+ }
509
+
510
+ currentPoint.current.set(snappedX, event.position[1], snappedZ)
511
+
512
+ const nativeEvent = event.nativeEvent as unknown as PointerEvent
513
+ const dx = nativeEvent.clientX - startClientX.current
514
+ const dy = nativeEvent.clientY - startClientY.current
515
+ if (!isDragging.current && Math.hypot(dx, dy) >= DRAG_THRESHOLD_PX) {
516
+ isDragging.current = true
517
+ }
518
+
519
+ if (isDragging.current && rectFillRef.current && outlineRef.current) {
520
+ updateRectVisuals(
521
+ rectFillRef.current,
522
+ outlineRef.current,
523
+ startPoint.current,
524
+ currentPoint.current,
525
+ gridY.current,
526
+ )
527
+
528
+ const nextGridPosition: [number, number] = [snappedX, snappedZ]
529
+ if (
530
+ previousGridPosition.current &&
531
+ (nextGridPosition[0] !== previousGridPosition.current[0] ||
532
+ nextGridPosition[1] !== previousGridPosition.current[1])
533
+ ) {
534
+ sfxEmitter.emit('sfx:grid-snap')
535
+ }
536
+ previousGridPosition.current = nextGridPosition
537
+
538
+ const bounds: Bounds = {
539
+ minX: Math.min(startPoint.current.x, currentPoint.current.x),
540
+ maxX: Math.max(startPoint.current.x, currentPoint.current.x),
541
+ minZ: Math.min(startPoint.current.z, currentPoint.current.z),
542
+ maxZ: Math.max(startPoint.current.z, currentPoint.current.z),
543
+ }
544
+ syncPreviewSelectedIds(collectNodeIdsInPlaneBounds(bounds))
545
+ }
546
+ }
547
+
548
+ emitter.on('grid:move', onMove)
549
+ return () => {
550
+ emitter.off('grid:move', onMove)
551
+ }
552
+ }, [resetDrag, syncPreviewSelectedIds])
553
+
554
+ return (
555
+ <group>
556
+ <CursorSphere ref={cursorRef} tooltipContent={BOX_SELECT_TOOLTIP} />
557
+ <mesh
558
+ layers={EDITOR_LAYER}
559
+ ref={rectFillRef}
560
+ renderOrder={1}
561
+ rotation={[-Math.PI / 2, 0, 0]}
562
+ visible={false}
563
+ >
564
+ <planeGeometry args={[1, 1]} />
565
+ <meshBasicMaterial
566
+ color={BOX_SELECT_ACCENT_COLOR}
567
+ depthTest={false}
568
+ depthWrite={false}
569
+ opacity={0.14}
570
+ side={DoubleSide}
571
+ transparent
572
+ />
573
+ </mesh>
574
+ <primitive object={outlineRef.current} />
575
+ </group>
576
+ )
577
+ }
@@ -0,0 +1,84 @@
1
+ export type ScreenRect = {
2
+ minX: number
3
+ minY: number
4
+ maxX: number
5
+ maxY: number
6
+ }
7
+
8
+ export const SCREEN_RECTANGLE_SELECTION_DRAG_THRESHOLD_PX = 4
9
+
10
+ const SCREEN_RECTANGLE_SELECTION_FILL_COLOR = 'rgba(129, 140, 248, 0.14)'
11
+ const SCREEN_RECTANGLE_SELECTION_BORDER_COLOR = 'rgba(129, 140, 248, 0.9)'
12
+ const SCREEN_RECTANGLE_SELECTION_SHADOW_COLOR = 'rgba(129, 140, 248, 0.28)'
13
+
14
+ export function createScreenRectangleSelectionElement(): HTMLDivElement {
15
+ const element = document.createElement('div')
16
+ element.style.position = 'fixed'
17
+ element.style.display = 'none'
18
+ element.style.pointerEvents = 'none'
19
+ element.style.zIndex = '2147483647'
20
+ element.style.border = `1px solid ${SCREEN_RECTANGLE_SELECTION_BORDER_COLOR}`
21
+ element.style.background = SCREEN_RECTANGLE_SELECTION_FILL_COLOR
22
+ element.style.boxShadow = `0 0 0 1px ${SCREEN_RECTANGLE_SELECTION_SHADOW_COLOR} inset`
23
+ element.style.contain = 'layout paint style'
24
+ return element
25
+ }
26
+
27
+ export function normalizeScreenRect(
28
+ startX: number,
29
+ startY: number,
30
+ endX: number,
31
+ endY: number,
32
+ ): ScreenRect {
33
+ return {
34
+ minX: Math.min(startX, endX),
35
+ minY: Math.min(startY, endY),
36
+ maxX: Math.max(startX, endX),
37
+ maxY: Math.max(startY, endY),
38
+ }
39
+ }
40
+
41
+ export function screenRectFromDomRect(rect: DOMRect | DOMRectReadOnly): ScreenRect {
42
+ return {
43
+ minX: rect.left,
44
+ minY: rect.top,
45
+ maxX: rect.right,
46
+ maxY: rect.bottom,
47
+ }
48
+ }
49
+
50
+ export function screenRectsIntersect(a: ScreenRect, b: ScreenRect): boolean {
51
+ return !(b.maxX < a.minX || b.minX > a.maxX || b.maxY < a.minY || b.minY > a.maxY)
52
+ }
53
+
54
+ export function intersectScreenRects(a: ScreenRect, b: ScreenRect): ScreenRect | null {
55
+ const rect = {
56
+ minX: Math.max(a.minX, b.minX),
57
+ minY: Math.max(a.minY, b.minY),
58
+ maxX: Math.min(a.maxX, b.maxX),
59
+ maxY: Math.min(a.maxY, b.maxY),
60
+ }
61
+
62
+ if (rect.maxX <= rect.minX || rect.maxY <= rect.minY) {
63
+ return null
64
+ }
65
+
66
+ return rect
67
+ }
68
+
69
+ export function updateScreenRectangleSelectionElement(element: HTMLDivElement, rect: ScreenRect) {
70
+ element.style.display = 'block'
71
+ element.style.left = `${rect.minX}px`
72
+ element.style.top = `${rect.minY}px`
73
+ element.style.width = `${Math.max(0, rect.maxX - rect.minX)}px`
74
+ element.style.height = `${Math.max(0, rect.maxY - rect.minY)}px`
75
+ }
76
+
77
+ export function hideScreenRectangleSelectionElement(element: HTMLDivElement | null) {
78
+ if (!element) {
79
+ return
80
+ }
81
+ element.style.display = 'none'
82
+ element.style.width = '0px'
83
+ element.style.height = '0px'
84
+ }