@pascal-app/editor 0.7.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. package/package.json +12 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +755 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +62 -54
  6. package/src/components/editor/export-manager.tsx +32 -3
  7. package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
  8. package/src/components/editor/first-person/build-collider-world.ts +203 -87
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
  10. package/src/components/editor/first-person-controls.tsx +836 -31
  11. package/src/components/editor/floating-action-menu.tsx +287 -196
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floorplan-panel.tsx +5837 -12006
  14. package/src/components/editor/grid.tsx +51 -17
  15. package/src/components/editor/group-move-handle.tsx +316 -0
  16. package/src/components/editor/group-rotate-handle.tsx +382 -0
  17. package/src/components/editor/group-transform-shared.test.ts +205 -0
  18. package/src/components/editor/group-transform-shared.ts +256 -0
  19. package/src/components/editor/handles/handle-arrow.tsx +523 -0
  20. package/src/components/editor/handles/use-handle-drag.ts +213 -0
  21. package/src/components/editor/index.tsx +234 -190
  22. package/src/components/editor/measurement-pill.tsx +75 -0
  23. package/src/components/editor/node-arrow-handles.tsx +1453 -0
  24. package/src/components/editor/selection-manager.tsx +435 -158
  25. package/src/components/editor/site-edge-labels.tsx +44 -20
  26. package/src/components/editor/slab-hole-highlights.tsx +466 -0
  27. package/src/components/editor/snapshot-capture-overlay.tsx +539 -0
  28. package/src/components/editor/thumbnail-generator.tsx +27 -64
  29. package/src/components/editor/use-floorplan-background-placement.ts +122 -35
  30. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  31. package/src/components/editor/use-floorplan-scene-data.ts +14 -2
  32. package/src/components/editor/wall-measurement-label.tsx +11 -6
  33. package/src/components/editor/wall-move-side-handles.tsx +855 -0
  34. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  35. package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
  36. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
  37. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  38. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  39. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
  41. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  42. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
  43. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  44. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
  45. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  46. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
  47. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  49. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
  50. package/src/components/editor-2d/svg-paths.ts +10 -2
  51. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
  52. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  53. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  54. package/src/components/systems/stair/stair-edit-system.tsx +2 -2
  55. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  56. package/src/components/systems/zone/zone-system.tsx +0 -0
  57. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  58. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  59. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  60. package/src/components/tools/fence/fence-drafting.ts +64 -22
  61. package/src/components/tools/item/move-tool.tsx +40 -107
  62. package/src/components/tools/item/placement-math.test.ts +10 -0
  63. package/src/components/tools/item/placement-math.ts +33 -9
  64. package/src/components/tools/item/placement-strategies.ts +201 -25
  65. package/src/components/tools/item/placement-types.ts +10 -1
  66. package/src/components/tools/item/use-draft-node.ts +20 -2
  67. package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
  68. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  69. package/src/components/tools/roof/roof-tool.tsx +108 -12
  70. package/src/components/tools/select/box-select-state.ts +77 -0
  71. package/src/components/tools/select/box-select-tool.tsx +324 -543
  72. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  73. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  74. package/src/components/tools/select/select-candidates.test.ts +109 -0
  75. package/src/components/tools/select/select-candidates.ts +85 -0
  76. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  77. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  78. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  79. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  80. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  81. package/src/components/tools/shared/placement-box.tsx +123 -0
  82. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  83. package/src/components/tools/shared/segment-angle.ts +57 -1
  84. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  85. package/src/components/tools/site/site-flag-model.ts +18 -0
  86. package/src/components/tools/stair/stair-defaults.ts +1 -1
  87. package/src/components/tools/stair/stair-tool.tsx +269 -35
  88. package/src/components/tools/tool-manager.tsx +172 -56
  89. package/src/components/tools/wall/wall-drafting.ts +98 -98
  90. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  91. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  92. package/src/components/tools/zone/zone-tool.tsx +36 -11
  93. package/src/components/ui/action-menu/action-button.tsx +21 -1
  94. package/src/components/ui/action-menu/camera-actions.tsx +0 -0
  95. package/src/components/ui/action-menu/control-modes.tsx +5 -76
  96. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  97. package/src/components/ui/action-menu/index.tsx +33 -196
  98. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  99. package/src/components/ui/action-menu/view-toggles.tsx +262 -37
  100. package/src/components/ui/command-palette/editor-commands.tsx +24 -17
  101. package/src/components/ui/command-palette/index.tsx +6 -256
  102. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  103. package/src/components/ui/controls/material-picker.tsx +17 -35
  104. package/src/components/ui/controls/metric-control.tsx +33 -11
  105. package/src/components/ui/floating-level-selector.tsx +48 -11
  106. package/src/components/ui/helpers/helper-manager.tsx +21 -17
  107. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  108. package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
  109. package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
  110. package/src/components/ui/level-duplicate-dialog.tsx +5 -6
  111. package/src/components/ui/panels/node-display.ts +1 -0
  112. package/src/components/ui/panels/paint-panel.tsx +66 -41
  113. package/src/components/ui/panels/panel-manager.tsx +33 -74
  114. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  115. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  116. package/src/components/ui/panels/reference-panel.tsx +28 -13
  117. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  118. package/src/components/ui/primitives/number-input.tsx +1 -1
  119. package/src/components/ui/primitives/sidebar.tsx +0 -0
  120. package/src/components/ui/scene-loader.tsx +1 -3
  121. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  122. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  123. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  124. package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  127. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  128. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  129. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  132. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  133. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  134. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  135. package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
  136. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  137. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  138. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  139. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  140. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  141. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  142. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  143. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
  144. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
  145. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
  146. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  147. package/src/components/ui/slider.tsx +1 -1
  148. package/src/components/viewer-overlay.tsx +169 -52
  149. package/src/components/viewer-zone-system.tsx +1 -1
  150. package/src/hooks/use-auto-save.ts +24 -3
  151. package/src/hooks/use-ceiling-events.ts +175 -0
  152. package/src/hooks/use-drag-action.ts +124 -0
  153. package/src/hooks/use-keyboard.ts +125 -62
  154. package/src/hooks/use-selection.ts +64 -0
  155. package/src/index.tsx +243 -4
  156. package/src/lib/constants.ts +6 -2
  157. package/src/lib/editor-api.ts +62 -0
  158. package/src/lib/elevator-support.ts +96 -0
  159. package/src/lib/floorplan/apply-alignment.ts +111 -0
  160. package/src/lib/floorplan/index.ts +7 -0
  161. package/src/lib/floorplan/selection-tool.ts +40 -0
  162. package/src/lib/floorplan/stairs.ts +12 -1
  163. package/src/lib/fresh-planar-placement.test.ts +102 -0
  164. package/src/lib/fresh-planar-placement.ts +63 -0
  165. package/src/lib/history.ts +2 -1
  166. package/src/lib/level-duplication.test.ts +0 -2
  167. package/src/lib/level-duplication.ts +55 -43
  168. package/src/lib/level-name.ts +11 -0
  169. package/src/lib/material-paint.ts +212 -20
  170. package/src/lib/placement-metadata.ts +29 -0
  171. package/src/lib/planar-cursor-placement.test.ts +43 -0
  172. package/src/lib/planar-cursor-placement.ts +42 -0
  173. package/src/lib/roof-duplication.ts +2 -2
  174. package/src/lib/scene-bounds.ts +1 -1
  175. package/src/lib/scene-clipboard.ts +267 -0
  176. package/src/lib/scene.ts +13 -4
  177. package/src/lib/sfx-bus.ts +25 -1
  178. package/src/lib/sfx-player.ts +75 -27
  179. package/src/lib/site-boundary.ts +1 -0
  180. package/src/lib/stair-duplication.ts +2 -2
  181. package/src/lib/stair-levels.test.ts +146 -0
  182. package/src/lib/stair-levels.ts +177 -0
  183. package/src/lib/world-grid-snap.ts +199 -0
  184. package/src/lib/zone-content.ts +130 -0
  185. package/src/store/use-alignment-guides.ts +21 -0
  186. package/src/store/use-editor.tsx +342 -91
  187. package/src/store/use-placement-preview.ts +31 -0
  188. package/src/store/use-wall-move-ghosts.ts +36 -0
  189. package/src/store/use-wall-snap-indicator.ts +32 -0
  190. package/tsconfig.json +2 -1
  191. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  192. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  193. package/src/components/feedback-dialog.tsx +0 -265
  194. package/src/components/pascal-radio.tsx +0 -280
  195. package/src/components/preview-button.tsx +0 -16
  196. package/src/components/tools/building/move-building-tool.tsx +0 -157
  197. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  198. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  199. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  200. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
  201. package/src/components/tools/column/column-tool.tsx +0 -97
  202. package/src/components/tools/column/move-column-tool.tsx +0 -105
  203. package/src/components/tools/door/door-math.ts +0 -110
  204. package/src/components/tools/door/door-tool.tsx +0 -324
  205. package/src/components/tools/door/move-door-tool.tsx +0 -412
  206. package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
  207. package/src/components/tools/fence/fence-tool.tsx +0 -346
  208. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
  209. package/src/components/tools/fence/move-fence-tool.tsx +0 -298
  210. package/src/components/tools/item/item-tool.tsx +0 -26
  211. package/src/components/tools/roof/move-roof-tool.tsx +0 -359
  212. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  213. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  214. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  215. package/src/components/tools/slab/slab-tool.tsx +0 -322
  216. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  217. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  218. package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
  219. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
  220. package/src/components/tools/wall/move-wall-tool.tsx +0 -356
  221. package/src/components/tools/wall/wall-tool.tsx +0 -332
  222. package/src/components/tools/window/move-window-tool.tsx +0 -447
  223. package/src/components/tools/window/window-math.ts +0 -117
  224. package/src/components/tools/window/window-tool.tsx +0 -332
  225. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  226. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  227. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  228. package/src/components/ui/panels/ceiling-panel.tsx +0 -250
  229. package/src/components/ui/panels/column-panel.tsx +0 -715
  230. package/src/components/ui/panels/door-panel.tsx +0 -1292
  231. package/src/components/ui/panels/fence-panel.tsx +0 -227
  232. package/src/components/ui/panels/item-panel.tsx +0 -306
  233. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  234. package/src/components/ui/panels/roof-panel.tsx +0 -230
  235. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  236. package/src/components/ui/panels/slab-panel.tsx +0 -250
  237. package/src/components/ui/panels/spawn-panel.tsx +0 -155
  238. package/src/components/ui/panels/stair-panel.tsx +0 -525
  239. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  240. package/src/components/ui/panels/wall-panel.tsx +0 -177
  241. package/src/components/ui/panels/window-panel.tsx +0 -960
  242. package/src/components/ui/viewer-toolbar.tsx +0 -436
  243. package/src/contexts/presets-context.tsx +0 -121
  244. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -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
+ }