@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.
- package/package.json +62 -0
- package/src/components/editor/custom-camera-controls.tsx +387 -0
- package/src/components/editor/editor-layout-v2.tsx +220 -0
- package/src/components/editor/export-manager.tsx +78 -0
- package/src/components/editor/first-person-controls.tsx +249 -0
- package/src/components/editor/floating-action-menu.tsx +231 -0
- package/src/components/editor/floorplan-panel.tsx +9609 -0
- package/src/components/editor/grid.tsx +161 -0
- package/src/components/editor/index.tsx +928 -0
- package/src/components/editor/node-action-menu.tsx +66 -0
- package/src/components/editor/preset-thumbnail-generator.tsx +125 -0
- package/src/components/editor/selection-manager.tsx +897 -0
- package/src/components/editor/site-edge-labels.tsx +90 -0
- package/src/components/editor/thumbnail-generator.tsx +166 -0
- package/src/components/editor/wall-measurement-label.tsx +258 -0
- package/src/components/feedback-dialog.tsx +265 -0
- package/src/components/pascal-radio.tsx +280 -0
- package/src/components/preview-button.tsx +16 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +77 -0
- package/src/components/systems/roof/roof-edit-system.tsx +69 -0
- package/src/components/systems/stair/stair-edit-system.tsx +69 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +320 -0
- package/src/components/systems/zone/zone-system.tsx +87 -0
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +42 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +47 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +465 -0
- package/src/components/tools/door/door-math.ts +110 -0
- package/src/components/tools/door/door-tool.tsx +293 -0
- package/src/components/tools/door/move-door-tool.tsx +373 -0
- package/src/components/tools/item/item-tool.tsx +26 -0
- package/src/components/tools/item/move-tool.tsx +90 -0
- package/src/components/tools/item/placement-math.ts +85 -0
- package/src/components/tools/item/placement-strategies.ts +556 -0
- package/src/components/tools/item/placement-types.ts +117 -0
- package/src/components/tools/item/use-draft-node.ts +227 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +877 -0
- package/src/components/tools/roof/move-roof-tool.tsx +288 -0
- package/src/components/tools/roof/roof-tool.tsx +318 -0
- package/src/components/tools/select/box-select-tool.tsx +626 -0
- package/src/components/tools/shared/cursor-sphere.tsx +119 -0
- package/src/components/tools/shared/polygon-editor.tsx +361 -0
- package/src/components/tools/site/site-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +47 -0
- package/src/components/tools/slab/slab-tool.tsx +322 -0
- package/src/components/tools/stair/stair-defaults.ts +7 -0
- package/src/components/tools/stair/stair-tool.tsx +194 -0
- package/src/components/tools/tool-manager.tsx +120 -0
- package/src/components/tools/wall/wall-drafting.ts +140 -0
- package/src/components/tools/wall/wall-tool.tsx +210 -0
- package/src/components/tools/window/move-window-tool.tsx +410 -0
- package/src/components/tools/window/window-math.ts +117 -0
- package/src/components/tools/window/window-tool.tsx +303 -0
- package/src/components/tools/zone/zone-boundary-editor.tsx +39 -0
- package/src/components/tools/zone/zone-tool.tsx +364 -0
- package/src/components/ui/action-menu/action-button.tsx +59 -0
- package/src/components/ui/action-menu/camera-actions.tsx +74 -0
- package/src/components/ui/action-menu/control-modes.tsx +240 -0
- package/src/components/ui/action-menu/furnish-tools.tsx +102 -0
- package/src/components/ui/action-menu/index.tsx +152 -0
- package/src/components/ui/action-menu/structure-tools.tsx +100 -0
- package/src/components/ui/action-menu/view-toggles.tsx +397 -0
- package/src/components/ui/command-palette/editor-commands.tsx +396 -0
- package/src/components/ui/command-palette/index.tsx +730 -0
- package/src/components/ui/controls/action-button.tsx +33 -0
- package/src/components/ui/controls/material-picker.tsx +194 -0
- package/src/components/ui/controls/metric-control.tsx +262 -0
- package/src/components/ui/controls/panel-section.tsx +65 -0
- package/src/components/ui/controls/segmented-control.tsx +45 -0
- package/src/components/ui/controls/slider-control.tsx +245 -0
- package/src/components/ui/controls/toggle-control.tsx +38 -0
- package/src/components/ui/floating-level-selector.tsx +355 -0
- package/src/components/ui/helpers/ceiling-helper.tsx +20 -0
- package/src/components/ui/helpers/helper-manager.tsx +33 -0
- package/src/components/ui/helpers/item-helper.tsx +40 -0
- package/src/components/ui/helpers/roof-helper.tsx +16 -0
- package/src/components/ui/helpers/slab-helper.tsx +20 -0
- package/src/components/ui/helpers/wall-helper.tsx +20 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1580 -0
- package/src/components/ui/item-catalog/item-catalog.tsx +219 -0
- package/src/components/ui/panels/ceiling-panel.tsx +230 -0
- package/src/components/ui/panels/collections/collections-popover.tsx +356 -0
- package/src/components/ui/panels/door-panel.tsx +600 -0
- package/src/components/ui/panels/item-panel.tsx +306 -0
- package/src/components/ui/panels/panel-manager.tsx +59 -0
- package/src/components/ui/panels/panel-wrapper.tsx +80 -0
- package/src/components/ui/panels/presets/presets-popover.tsx +511 -0
- package/src/components/ui/panels/reference-panel.tsx +177 -0
- package/src/components/ui/panels/roof-panel.tsx +262 -0
- package/src/components/ui/panels/roof-segment-panel.tsx +326 -0
- package/src/components/ui/panels/slab-panel.tsx +228 -0
- package/src/components/ui/panels/stair-panel.tsx +304 -0
- package/src/components/ui/panels/stair-segment-panel.tsx +339 -0
- package/src/components/ui/panels/wall-panel.tsx +123 -0
- package/src/components/ui/panels/window-panel.tsx +441 -0
- package/src/components/ui/primitives/button.tsx +69 -0
- package/src/components/ui/primitives/card.tsx +75 -0
- package/src/components/ui/primitives/color-dot.tsx +61 -0
- package/src/components/ui/primitives/context-menu.tsx +227 -0
- package/src/components/ui/primitives/dialog.tsx +129 -0
- package/src/components/ui/primitives/dropdown-menu.tsx +228 -0
- package/src/components/ui/primitives/error-boundary.tsx +52 -0
- package/src/components/ui/primitives/input.tsx +21 -0
- package/src/components/ui/primitives/number-input.tsx +187 -0
- package/src/components/ui/primitives/opacity-control.tsx +79 -0
- package/src/components/ui/primitives/popover.tsx +42 -0
- package/src/components/ui/primitives/separator.tsx +28 -0
- package/src/components/ui/primitives/sheet.tsx +130 -0
- package/src/components/ui/primitives/shortcut-token.tsx +64 -0
- package/src/components/ui/primitives/sidebar.tsx +855 -0
- package/src/components/ui/primitives/skeleton.tsx +13 -0
- package/src/components/ui/primitives/slider.tsx +58 -0
- package/src/components/ui/primitives/switch.tsx +29 -0
- package/src/components/ui/primitives/tooltip.tsx +57 -0
- package/src/components/ui/scene-loader.tsx +40 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +103 -0
- package/src/components/ui/sidebar/icon-rail.tsx +147 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +100 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +438 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +188 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +80 -0
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +126 -0
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +1543 -0
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +98 -0
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +117 -0
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +65 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +214 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +96 -0
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +216 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +115 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +342 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +271 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +106 -0
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +87 -0
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +167 -0
- package/src/components/ui/sidebar/tab-bar.tsx +39 -0
- package/src/components/ui/slider-demo.tsx +36 -0
- package/src/components/ui/slider.tsx +81 -0
- package/src/components/ui/viewer-toolbar.tsx +342 -0
- package/src/components/viewer-overlay.tsx +499 -0
- package/src/components/viewer-zone-system.tsx +48 -0
- package/src/contexts/presets-context.tsx +121 -0
- package/src/hooks/use-auto-save.ts +194 -0
- package/src/hooks/use-contextual-tools.ts +52 -0
- package/src/hooks/use-grid-events.ts +106 -0
- package/src/hooks/use-keyboard.ts +214 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/hooks/use-reduced-motion.ts +20 -0
- package/src/index.tsx +33 -0
- package/src/lib/constants.ts +3 -0
- package/src/lib/level-selection.ts +31 -0
- package/src/lib/scene.ts +394 -0
- package/src/lib/sfx/index.ts +2 -0
- package/src/lib/sfx-bus.ts +49 -0
- package/src/lib/sfx-player.ts +60 -0
- package/src/lib/utils.ts +43 -0
- package/src/store/use-audio.tsx +45 -0
- package/src/store/use-command-registry.ts +36 -0
- package/src/store/use-editor.tsx +522 -0
- package/src/store/use-palette-view-registry.ts +45 -0
- package/src/store/use-upload.ts +90 -0
- package/src/three-types.ts +3 -0
- 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
|
+
}
|