@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,897 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
type AnyNodeId,
|
|
4
|
+
type BuildingNode,
|
|
5
|
+
emitter,
|
|
6
|
+
type ItemNode,
|
|
7
|
+
type NodeEvent,
|
|
8
|
+
resolveLevelId,
|
|
9
|
+
sceneRegistry,
|
|
10
|
+
useScene,
|
|
11
|
+
} from '@pascal-app/core'
|
|
12
|
+
|
|
13
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
14
|
+
import { useCallback, useEffect, useRef } from 'react'
|
|
15
|
+
import { Color, type Material, type Mesh, type Object3D } from 'three'
|
|
16
|
+
import { sfxEmitter } from '../../lib/sfx-bus'
|
|
17
|
+
import useEditor, { type Phase, type StructureLayer } from './../../store/use-editor'
|
|
18
|
+
import { boxSelectHandled } from '../tools/select/box-select-tool'
|
|
19
|
+
|
|
20
|
+
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
|
|
21
|
+
const currentLevelId = useViewer.getState().selection.levelId
|
|
22
|
+
if (!currentLevelId) return true // No level selected, allow all
|
|
23
|
+
const nodeLevelId = resolveLevelId(node, useScene.getState().nodes)
|
|
24
|
+
return nodeLevelId === currentLevelId
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type SelectableNodeType =
|
|
28
|
+
| 'wall'
|
|
29
|
+
| 'item'
|
|
30
|
+
| 'building'
|
|
31
|
+
| 'zone'
|
|
32
|
+
| 'slab'
|
|
33
|
+
| 'ceiling'
|
|
34
|
+
| 'roof'
|
|
35
|
+
| 'roof-segment'
|
|
36
|
+
| 'stair'
|
|
37
|
+
| 'stair-segment'
|
|
38
|
+
| 'window'
|
|
39
|
+
| 'door'
|
|
40
|
+
|
|
41
|
+
type ModifierKeys = {
|
|
42
|
+
meta: boolean
|
|
43
|
+
ctrl: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface SelectionStrategy {
|
|
47
|
+
types: SelectableNodeType[]
|
|
48
|
+
handleSelect: (node: AnyNode, nativeEvent?: any, modifierKeys?: ModifierKeys) => void
|
|
49
|
+
handleDeselect: () => void
|
|
50
|
+
isValid: (node: AnyNode) => boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type SelectionTarget = {
|
|
54
|
+
phase: Phase
|
|
55
|
+
structureLayer?: StructureLayer
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const resolveBuildingId = (
|
|
59
|
+
levelId: string,
|
|
60
|
+
nodes: Record<string, AnyNode>,
|
|
61
|
+
): string | null => {
|
|
62
|
+
const level = nodes[levelId]
|
|
63
|
+
if (!level) return null
|
|
64
|
+
if (level.parentId && nodes[level.parentId]?.type === 'building') {
|
|
65
|
+
return level.parentId
|
|
66
|
+
}
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const HIGHLIGHT_PROFILES = {
|
|
71
|
+
delete: {
|
|
72
|
+
color: new Color('#dc2626'),
|
|
73
|
+
blend: 0.76,
|
|
74
|
+
emissiveBlend: 0.92,
|
|
75
|
+
emissiveIntensity: 0.46,
|
|
76
|
+
},
|
|
77
|
+
selection: {
|
|
78
|
+
color: new Color('#818cf8'),
|
|
79
|
+
blend: 0.32,
|
|
80
|
+
emissiveBlend: 0.7,
|
|
81
|
+
emissiveIntensity: 0.42,
|
|
82
|
+
},
|
|
83
|
+
} as const
|
|
84
|
+
|
|
85
|
+
type HighlightKind = keyof typeof HIGHLIGHT_PROFILES
|
|
86
|
+
|
|
87
|
+
type HighlightableMaterial = Material & {
|
|
88
|
+
color?: Color
|
|
89
|
+
emissive?: Color
|
|
90
|
+
emissiveIntensity?: number
|
|
91
|
+
opacity?: number
|
|
92
|
+
transparent?: boolean
|
|
93
|
+
needsUpdate?: boolean
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isHighlightableMesh(object: Object3D): object is Mesh {
|
|
97
|
+
return Boolean(
|
|
98
|
+
(object as Mesh).isMesh &&
|
|
99
|
+
(object as Mesh).material &&
|
|
100
|
+
object.visible &&
|
|
101
|
+
object.name !== 'collision-mesh',
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function createHighlightedMaterial(material: Material, kind: HighlightKind): Material {
|
|
106
|
+
const highlightedMaterial = material.clone() as HighlightableMaterial
|
|
107
|
+
const profile = HIGHLIGHT_PROFILES[kind]
|
|
108
|
+
|
|
109
|
+
if (highlightedMaterial.color instanceof Color) {
|
|
110
|
+
highlightedMaterial.color = highlightedMaterial.color.clone().lerp(profile.color, profile.blend)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (highlightedMaterial.emissive instanceof Color) {
|
|
114
|
+
highlightedMaterial.emissive = highlightedMaterial.emissive
|
|
115
|
+
.clone()
|
|
116
|
+
.lerp(profile.color, profile.emissiveBlend)
|
|
117
|
+
highlightedMaterial.emissiveIntensity = Math.max(
|
|
118
|
+
highlightedMaterial.emissiveIntensity ?? 0,
|
|
119
|
+
profile.emissiveIntensity,
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (typeof highlightedMaterial.opacity === 'number' && highlightedMaterial.opacity < 1) {
|
|
124
|
+
highlightedMaterial.transparent = true
|
|
125
|
+
highlightedMaterial.opacity = Math.min(1, highlightedMaterial.opacity + 0.08)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
highlightedMaterial.needsUpdate = true
|
|
129
|
+
return highlightedMaterial
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function createHighlightedMaterials(
|
|
133
|
+
material: Material | Material[],
|
|
134
|
+
kind: HighlightKind,
|
|
135
|
+
): Material | Material[] {
|
|
136
|
+
if (Array.isArray(material)) {
|
|
137
|
+
return material.map((entry) => createHighlightedMaterial(entry, kind))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return createHighlightedMaterial(material, kind)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function disposeHighlightedMaterials(material: Material | Material[]) {
|
|
144
|
+
if (Array.isArray(material)) {
|
|
145
|
+
material.forEach((entry) => entry.dispose())
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
material.dispose()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const computeNextIds = (
|
|
153
|
+
node: AnyNode,
|
|
154
|
+
selectedIds: string[],
|
|
155
|
+
event?: any,
|
|
156
|
+
modifierKeys?: ModifierKeys,
|
|
157
|
+
): string[] => {
|
|
158
|
+
const isMeta = event?.metaKey || event?.nativeEvent?.metaKey || modifierKeys?.meta
|
|
159
|
+
const isCtrl = event?.ctrlKey || event?.nativeEvent?.ctrlKey || modifierKeys?.ctrl
|
|
160
|
+
|
|
161
|
+
if (isMeta || isCtrl) {
|
|
162
|
+
if (selectedIds.includes(node.id)) {
|
|
163
|
+
return selectedIds.filter((id) => id !== node.id)
|
|
164
|
+
}
|
|
165
|
+
return [...selectedIds, node.id]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Not holding modifiers: select only this node
|
|
169
|
+
return [node.id]
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|
173
|
+
site: {
|
|
174
|
+
types: ['building'],
|
|
175
|
+
handleSelect: (node) => {
|
|
176
|
+
useViewer.getState().setSelection({ buildingId: (node as BuildingNode).id })
|
|
177
|
+
},
|
|
178
|
+
handleDeselect: () => {
|
|
179
|
+
useViewer.getState().setSelection({ buildingId: null })
|
|
180
|
+
},
|
|
181
|
+
isValid: (node) => node.type === 'building',
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
structure: {
|
|
185
|
+
types: [
|
|
186
|
+
'wall',
|
|
187
|
+
'item',
|
|
188
|
+
'zone',
|
|
189
|
+
'slab',
|
|
190
|
+
'ceiling',
|
|
191
|
+
'roof',
|
|
192
|
+
'roof-segment',
|
|
193
|
+
'stair',
|
|
194
|
+
'stair-segment',
|
|
195
|
+
'window',
|
|
196
|
+
'door',
|
|
197
|
+
],
|
|
198
|
+
handleSelect: (node, nativeEvent, modifierKeys) => {
|
|
199
|
+
const { selection, setSelection } = useViewer.getState()
|
|
200
|
+
const nodes = useScene.getState().nodes
|
|
201
|
+
const nodeLevelId = resolveLevelId(node, nodes)
|
|
202
|
+
const buildingId = resolveBuildingId(nodeLevelId, nodes)
|
|
203
|
+
|
|
204
|
+
const updates: any = {}
|
|
205
|
+
if (nodeLevelId !== 'default' && nodeLevelId !== selection.levelId) {
|
|
206
|
+
updates.levelId = nodeLevelId
|
|
207
|
+
}
|
|
208
|
+
if (buildingId && buildingId !== selection.buildingId) {
|
|
209
|
+
updates.buildingId = buildingId
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (node.type === 'zone') {
|
|
213
|
+
updates.zoneId = node.id
|
|
214
|
+
// Don't reset selectedIds in structure phase for zone, but if we changed level, it might reset them via hierarchy guard.
|
|
215
|
+
// Wait, the hierarchy guard resets zoneId if levelId changes. That's fine since we provide zoneId.
|
|
216
|
+
setSelection(updates)
|
|
217
|
+
} else {
|
|
218
|
+
updates.selectedIds = computeNextIds(node, selection.selectedIds, nativeEvent, modifierKeys)
|
|
219
|
+
setSelection(updates)
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
handleDeselect: () => {
|
|
223
|
+
const structureLayer = useEditor.getState().structureLayer
|
|
224
|
+
if (structureLayer === 'zones') {
|
|
225
|
+
useViewer.getState().setSelection({ zoneId: null })
|
|
226
|
+
} else {
|
|
227
|
+
useViewer.getState().setSelection({ selectedIds: [] })
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
isValid: (node) => {
|
|
231
|
+
if (!isNodeInCurrentLevel(node)) return false
|
|
232
|
+
const structureLayer = useEditor.getState().structureLayer
|
|
233
|
+
if (structureLayer === 'zones') {
|
|
234
|
+
if (node.type === 'zone') return true
|
|
235
|
+
return false
|
|
236
|
+
}
|
|
237
|
+
if (
|
|
238
|
+
node.type === 'wall' ||
|
|
239
|
+
node.type === 'slab' ||
|
|
240
|
+
node.type === 'ceiling' ||
|
|
241
|
+
node.type === 'roof' ||
|
|
242
|
+
node.type === 'roof-segment' ||
|
|
243
|
+
node.type === 'stair' ||
|
|
244
|
+
node.type === 'stair-segment'
|
|
245
|
+
)
|
|
246
|
+
return true
|
|
247
|
+
if (node.type === 'item') {
|
|
248
|
+
return (
|
|
249
|
+
(node as ItemNode).asset.category === 'door' ||
|
|
250
|
+
(node as ItemNode).asset.category === 'window'
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
if (node.type === 'window' || node.type === 'door') return true
|
|
254
|
+
|
|
255
|
+
return false
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
furnish: {
|
|
260
|
+
types: ['item'],
|
|
261
|
+
handleSelect: (node, nativeEvent, modifierKeys) => {
|
|
262
|
+
const { selection, setSelection } = useViewer.getState()
|
|
263
|
+
const nodes = useScene.getState().nodes
|
|
264
|
+
const nodeLevelId = resolveLevelId(node, nodes)
|
|
265
|
+
const buildingId = resolveBuildingId(nodeLevelId, nodes)
|
|
266
|
+
|
|
267
|
+
const updates: any = {}
|
|
268
|
+
if (nodeLevelId !== 'default' && nodeLevelId !== selection.levelId) {
|
|
269
|
+
updates.levelId = nodeLevelId
|
|
270
|
+
}
|
|
271
|
+
if (buildingId && buildingId !== selection.buildingId) {
|
|
272
|
+
updates.buildingId = buildingId
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
updates.selectedIds = computeNextIds(node, selection.selectedIds, nativeEvent, modifierKeys)
|
|
276
|
+
setSelection(updates)
|
|
277
|
+
},
|
|
278
|
+
handleDeselect: () => {
|
|
279
|
+
useViewer.getState().setSelection({ selectedIds: [] })
|
|
280
|
+
},
|
|
281
|
+
isValid: (node) => {
|
|
282
|
+
if (!isNodeInCurrentLevel(node)) return false
|
|
283
|
+
if (node.type !== 'item') return false
|
|
284
|
+
const item = node as ItemNode
|
|
285
|
+
return item.asset.category !== 'door' && item.asset.category !== 'window'
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
|
|
291
|
+
if (node.type === 'zone') {
|
|
292
|
+
return {
|
|
293
|
+
phase: 'structure',
|
|
294
|
+
structureLayer: 'zones',
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (
|
|
299
|
+
node.type === 'wall' ||
|
|
300
|
+
node.type === 'slab' ||
|
|
301
|
+
node.type === 'ceiling' ||
|
|
302
|
+
node.type === 'roof' ||
|
|
303
|
+
node.type === 'roof-segment' ||
|
|
304
|
+
node.type === 'stair' ||
|
|
305
|
+
node.type === 'stair-segment' ||
|
|
306
|
+
node.type === 'window' ||
|
|
307
|
+
node.type === 'door'
|
|
308
|
+
) {
|
|
309
|
+
return {
|
|
310
|
+
phase: 'structure',
|
|
311
|
+
structureLayer: 'elements',
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (node.type === 'item') {
|
|
316
|
+
const item = node as ItemNode
|
|
317
|
+
if (item.asset.category === 'door' || item.asset.category === 'window') {
|
|
318
|
+
return {
|
|
319
|
+
phase: 'structure',
|
|
320
|
+
structureLayer: 'elements',
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
phase: 'furnish',
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return null
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export const SelectionManager = () => {
|
|
333
|
+
const phase = useEditor((s) => s.phase)
|
|
334
|
+
const mode = useEditor((s) => s.mode)
|
|
335
|
+
const setHoverHighlightMode = useViewer((s) => s.setHoverHighlightMode)
|
|
336
|
+
const modifierKeysRef = useRef<ModifierKeys>({
|
|
337
|
+
meta: false,
|
|
338
|
+
ctrl: false,
|
|
339
|
+
})
|
|
340
|
+
const clickHandledRef = useRef(false)
|
|
341
|
+
|
|
342
|
+
const movingNode = useEditor((s) => s.movingNode)
|
|
343
|
+
|
|
344
|
+
useEffect(() => {
|
|
345
|
+
setHoverHighlightMode(mode === 'delete' ? 'delete' : 'default')
|
|
346
|
+
|
|
347
|
+
return () => {
|
|
348
|
+
setHoverHighlightMode('default')
|
|
349
|
+
}
|
|
350
|
+
}, [mode, setHoverHighlightMode])
|
|
351
|
+
|
|
352
|
+
useEffect(() => {
|
|
353
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
354
|
+
if (event.key === 'Meta') modifierKeysRef.current.meta = true
|
|
355
|
+
if (event.key === 'Control') modifierKeysRef.current.ctrl = true
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const onKeyUp = (event: KeyboardEvent) => {
|
|
359
|
+
if (event.key === 'Meta') modifierKeysRef.current.meta = false
|
|
360
|
+
if (event.key === 'Control') modifierKeysRef.current.ctrl = false
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const clearModifiers = () => {
|
|
364
|
+
modifierKeysRef.current.meta = false
|
|
365
|
+
modifierKeysRef.current.ctrl = false
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
window.addEventListener('keydown', onKeyDown)
|
|
369
|
+
window.addEventListener('keyup', onKeyUp)
|
|
370
|
+
window.addEventListener('blur', clearModifiers)
|
|
371
|
+
|
|
372
|
+
return () => {
|
|
373
|
+
window.removeEventListener('keydown', onKeyDown)
|
|
374
|
+
window.removeEventListener('keyup', onKeyUp)
|
|
375
|
+
window.removeEventListener('blur', clearModifiers)
|
|
376
|
+
}
|
|
377
|
+
}, [])
|
|
378
|
+
|
|
379
|
+
useEffect(() => {
|
|
380
|
+
if (mode !== 'select') return
|
|
381
|
+
if (movingNode) return
|
|
382
|
+
|
|
383
|
+
const onClick = (event: NodeEvent) => {
|
|
384
|
+
// Skip if box-select just completed (drag ended over a node)
|
|
385
|
+
if (boxSelectHandled) return
|
|
386
|
+
|
|
387
|
+
const node = event.node
|
|
388
|
+
let currentPhase = useEditor.getState().phase
|
|
389
|
+
let currentStructureLayer = useEditor.getState().structureLayer
|
|
390
|
+
|
|
391
|
+
// Auto-switch between zones, structure, and furnish when clicking elements on the same level.
|
|
392
|
+
if (currentPhase === 'structure' || currentPhase === 'furnish') {
|
|
393
|
+
if (isNodeInCurrentLevel(node)) {
|
|
394
|
+
const target = getSelectionTarget(node)
|
|
395
|
+
if (target) {
|
|
396
|
+
if (target.phase !== currentPhase) {
|
|
397
|
+
useEditor.getState().setPhase(target.phase)
|
|
398
|
+
currentPhase = target.phase
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (
|
|
402
|
+
target.phase === 'structure' &&
|
|
403
|
+
target.structureLayer &&
|
|
404
|
+
target.structureLayer !== currentStructureLayer
|
|
405
|
+
) {
|
|
406
|
+
useEditor.getState().setStructureLayer(target.structureLayer)
|
|
407
|
+
currentStructureLayer = target.structureLayer
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const activeStrategy = SELECTION_STRATEGIES[currentPhase]
|
|
414
|
+
if (activeStrategy?.isValid(node)) {
|
|
415
|
+
event.stopPropagation()
|
|
416
|
+
clickHandledRef.current = true
|
|
417
|
+
|
|
418
|
+
let nodeToSelect = node
|
|
419
|
+
if (node.type === 'roof-segment' && node.parentId) {
|
|
420
|
+
const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId]
|
|
421
|
+
if (parentNode && parentNode.type === 'roof') {
|
|
422
|
+
nodeToSelect = parentNode
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if (node.type === 'stair-segment' && node.parentId) {
|
|
426
|
+
const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId]
|
|
427
|
+
if (parentNode && parentNode.type === 'stair') {
|
|
428
|
+
nodeToSelect = parentNode
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current)
|
|
433
|
+
|
|
434
|
+
// Reset the handled flag after a short delay to allow grid:click to be ignored
|
|
435
|
+
setTimeout(() => {
|
|
436
|
+
clickHandledRef.current = false
|
|
437
|
+
}, 50)
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const allTypes = [
|
|
442
|
+
'wall',
|
|
443
|
+
'item',
|
|
444
|
+
'building',
|
|
445
|
+
'zone',
|
|
446
|
+
'slab',
|
|
447
|
+
'ceiling',
|
|
448
|
+
'roof',
|
|
449
|
+
'roof-segment',
|
|
450
|
+
'stair',
|
|
451
|
+
'stair-segment',
|
|
452
|
+
'window',
|
|
453
|
+
'door',
|
|
454
|
+
]
|
|
455
|
+
allTypes.forEach((type) => {
|
|
456
|
+
emitter.on(`${type}:click` as any, onClick as any)
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
const onGridClick = () => {
|
|
460
|
+
if (clickHandledRef.current) return
|
|
461
|
+
if (boxSelectHandled) return
|
|
462
|
+
const { phase, structureLayer } = useEditor.getState()
|
|
463
|
+
const activeStrategy = SELECTION_STRATEGIES[phase]
|
|
464
|
+
if (activeStrategy) activeStrategy.handleDeselect()
|
|
465
|
+
|
|
466
|
+
// When deselecting from zone mode, return to structure select
|
|
467
|
+
if (phase === 'structure' && structureLayer === 'zones') {
|
|
468
|
+
useEditor.getState().setStructureLayer('elements')
|
|
469
|
+
useEditor.getState().setMode('select')
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
emitter.on('grid:click', onGridClick)
|
|
473
|
+
|
|
474
|
+
return () => {
|
|
475
|
+
allTypes.forEach((type) => {
|
|
476
|
+
emitter.off(`${type}:click` as any, onClick as any)
|
|
477
|
+
})
|
|
478
|
+
emitter.off('grid:click', onGridClick)
|
|
479
|
+
}
|
|
480
|
+
}, [mode, movingNode])
|
|
481
|
+
|
|
482
|
+
// Global double-click handler for auto-switching phases and cross-phase hover
|
|
483
|
+
useEffect(() => {
|
|
484
|
+
if (mode !== 'select') return
|
|
485
|
+
if (movingNode) return
|
|
486
|
+
|
|
487
|
+
const onEnter = (event: NodeEvent) => {
|
|
488
|
+
const node = event.node
|
|
489
|
+
const currentPhase = useEditor.getState().phase
|
|
490
|
+
|
|
491
|
+
// Ignore site/building if we are already inside a building
|
|
492
|
+
if (node.type === 'building' || node.type === 'site') {
|
|
493
|
+
if (currentPhase === 'structure' || currentPhase === 'furnish') {
|
|
494
|
+
return
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Ignore zones unless specifically in zones layer
|
|
499
|
+
if (node.type === 'zone') {
|
|
500
|
+
if (currentPhase !== 'structure' || useEditor.getState().structureLayer !== 'zones') {
|
|
501
|
+
return
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Check level constraint for interior nodes
|
|
506
|
+
if (currentPhase === 'structure' || currentPhase === 'furnish') {
|
|
507
|
+
if (!isNodeInCurrentLevel(node)) return
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
event.stopPropagation()
|
|
511
|
+
useViewer.setState({ hoveredId: node.id })
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const onLeave = (event: NodeEvent) => {
|
|
515
|
+
const nodeId = event?.node?.id
|
|
516
|
+
if (nodeId && useViewer.getState().hoveredId === nodeId) {
|
|
517
|
+
useViewer.setState({ hoveredId: null })
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const onDoubleClick = (event: NodeEvent) => {
|
|
522
|
+
const node = event.node
|
|
523
|
+
const currentPhase = useEditor.getState().phase
|
|
524
|
+
|
|
525
|
+
let targetPhase: 'site' | 'structure' | 'furnish' | null = null
|
|
526
|
+
let forceSelect = false
|
|
527
|
+
|
|
528
|
+
if (node.type === 'building' || node.type === 'site') {
|
|
529
|
+
if (currentPhase === 'structure' || currentPhase === 'furnish') {
|
|
530
|
+
return // Ignore building/site double clicks if we are already inside a building
|
|
531
|
+
}
|
|
532
|
+
if (node.type === 'building') {
|
|
533
|
+
targetPhase = 'structure'
|
|
534
|
+
}
|
|
535
|
+
} else if (
|
|
536
|
+
node.type === 'wall' ||
|
|
537
|
+
node.type === 'slab' ||
|
|
538
|
+
node.type === 'ceiling' ||
|
|
539
|
+
node.type === 'roof' ||
|
|
540
|
+
node.type === 'roof-segment' ||
|
|
541
|
+
node.type === 'stair' ||
|
|
542
|
+
node.type === 'stair-segment' ||
|
|
543
|
+
node.type === 'window' ||
|
|
544
|
+
node.type === 'door'
|
|
545
|
+
) {
|
|
546
|
+
targetPhase = 'structure'
|
|
547
|
+
if (node.type === 'roof-segment' && currentPhase === 'structure') {
|
|
548
|
+
forceSelect = true // allow double click to dive into roof-segment even if already in structure phase
|
|
549
|
+
}
|
|
550
|
+
if (node.type === 'stair-segment' && currentPhase === 'structure') {
|
|
551
|
+
forceSelect = true // allow double click to dive into stair-segment even if already in structure phase
|
|
552
|
+
}
|
|
553
|
+
} else if (node.type === 'item') {
|
|
554
|
+
const item = node as ItemNode
|
|
555
|
+
if (item.asset.category === 'door' || item.asset.category === 'window') {
|
|
556
|
+
targetPhase = 'structure'
|
|
557
|
+
} else {
|
|
558
|
+
targetPhase = 'furnish'
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (node.type === 'zone') {
|
|
563
|
+
return
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if ((targetPhase && targetPhase !== useEditor.getState().phase) || forceSelect) {
|
|
567
|
+
event.stopPropagation()
|
|
568
|
+
|
|
569
|
+
if (targetPhase && targetPhase !== useEditor.getState().phase) {
|
|
570
|
+
useEditor.getState().setPhase(targetPhase)
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (targetPhase === 'structure' && useEditor.getState().structureLayer === 'zones') {
|
|
574
|
+
useEditor.getState().setStructureLayer('elements')
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const strategy = SELECTION_STRATEGIES[targetPhase || currentPhase]
|
|
578
|
+
if (strategy) {
|
|
579
|
+
strategy.handleSelect(node, event.nativeEvent, modifierKeysRef.current)
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const allTypes = [
|
|
585
|
+
'wall',
|
|
586
|
+
'item',
|
|
587
|
+
'building',
|
|
588
|
+
'slab',
|
|
589
|
+
'ceiling',
|
|
590
|
+
'roof',
|
|
591
|
+
'roof-segment',
|
|
592
|
+
'stair',
|
|
593
|
+
'stair-segment',
|
|
594
|
+
'window',
|
|
595
|
+
'door',
|
|
596
|
+
'zone',
|
|
597
|
+
'site',
|
|
598
|
+
]
|
|
599
|
+
allTypes.forEach((type) => {
|
|
600
|
+
emitter.on(`${type}:enter` as any, onEnter as any)
|
|
601
|
+
emitter.on(`${type}:leave` as any, onLeave as any)
|
|
602
|
+
emitter.on(`${type}:double-click` as any, onDoubleClick as any)
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
return () => {
|
|
606
|
+
allTypes.forEach((type) => {
|
|
607
|
+
emitter.off(`${type}:enter` as any, onEnter as any)
|
|
608
|
+
emitter.off(`${type}:leave` as any, onLeave as any)
|
|
609
|
+
emitter.off(`${type}:double-click` as any, onDoubleClick as any)
|
|
610
|
+
})
|
|
611
|
+
}
|
|
612
|
+
}, [mode, movingNode])
|
|
613
|
+
|
|
614
|
+
// Delete mode: click-to-delete (sledgehammer tool)
|
|
615
|
+
useEffect(() => {
|
|
616
|
+
if (mode !== 'delete') return
|
|
617
|
+
|
|
618
|
+
const onClick = (event: NodeEvent) => {
|
|
619
|
+
const node = event.node
|
|
620
|
+
if (!isNodeInCurrentLevel(node)) return
|
|
621
|
+
|
|
622
|
+
event.stopPropagation()
|
|
623
|
+
|
|
624
|
+
// Play appropriate SFX
|
|
625
|
+
if (node.type === 'item') {
|
|
626
|
+
sfxEmitter.emit('sfx:item-delete')
|
|
627
|
+
} else {
|
|
628
|
+
sfxEmitter.emit('sfx:structure-delete')
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
useScene.getState().deleteNode(node.id as AnyNodeId)
|
|
632
|
+
if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
|
|
633
|
+
|
|
634
|
+
// Clear hover since the node is gone
|
|
635
|
+
if (useViewer.getState().hoveredId === node.id) {
|
|
636
|
+
useViewer.setState({ hoveredId: null })
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const onEnter = (event: NodeEvent) => {
|
|
641
|
+
const node = event.node
|
|
642
|
+
if (!isNodeInCurrentLevel(node)) return
|
|
643
|
+
if (node.type === 'building' || node.type === 'site') return
|
|
644
|
+
event.stopPropagation()
|
|
645
|
+
useViewer.setState({ hoveredId: node.id })
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const onLeave = (event: NodeEvent) => {
|
|
649
|
+
const nodeId = event?.node?.id
|
|
650
|
+
if (nodeId && useViewer.getState().hoveredId === nodeId) {
|
|
651
|
+
useViewer.setState({ hoveredId: null })
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const allTypes = [
|
|
656
|
+
'wall',
|
|
657
|
+
'item',
|
|
658
|
+
'slab',
|
|
659
|
+
'ceiling',
|
|
660
|
+
'roof',
|
|
661
|
+
'roof-segment',
|
|
662
|
+
'stair',
|
|
663
|
+
'stair-segment',
|
|
664
|
+
'window',
|
|
665
|
+
'door',
|
|
666
|
+
'zone',
|
|
667
|
+
] as const
|
|
668
|
+
|
|
669
|
+
for (const type of allTypes) {
|
|
670
|
+
emitter.on(`${type}:click` as any, onClick as any)
|
|
671
|
+
emitter.on(`${type}:enter` as any, onEnter as any)
|
|
672
|
+
emitter.on(`${type}:leave` as any, onLeave as any)
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
return () => {
|
|
676
|
+
for (const type of allTypes) {
|
|
677
|
+
emitter.off(`${type}:click` as any, onClick as any)
|
|
678
|
+
emitter.off(`${type}:enter` as any, onEnter as any)
|
|
679
|
+
emitter.off(`${type}:leave` as any, onLeave as any)
|
|
680
|
+
}
|
|
681
|
+
useViewer.setState({ hoveredId: null })
|
|
682
|
+
}
|
|
683
|
+
}, [mode])
|
|
684
|
+
|
|
685
|
+
return (
|
|
686
|
+
<>
|
|
687
|
+
<SelectionStateSync />
|
|
688
|
+
<SelectionMaterialSync />
|
|
689
|
+
<EditorOutlinerSync />
|
|
690
|
+
</>
|
|
691
|
+
)
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const SelectionStateSync = () => {
|
|
695
|
+
useEffect(() => {
|
|
696
|
+
return useScene.subscribe((state) => {
|
|
697
|
+
const { buildingId, levelId, zoneId, selectedIds } = useViewer.getState().selection
|
|
698
|
+
|
|
699
|
+
if (buildingId && !state.nodes[buildingId as AnyNodeId]) {
|
|
700
|
+
useViewer.getState().setSelection({ buildingId: null })
|
|
701
|
+
return
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (levelId && !state.nodes[levelId as AnyNodeId]) {
|
|
705
|
+
useViewer.getState().setSelection({ levelId: null })
|
|
706
|
+
return
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (zoneId && !state.nodes[zoneId as AnyNodeId]) {
|
|
710
|
+
useViewer.getState().setSelection({ zoneId: null })
|
|
711
|
+
return
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (selectedIds.length === 0) return
|
|
715
|
+
|
|
716
|
+
const nextSelectedIds = selectedIds.filter((id) => state.nodes[id as AnyNodeId])
|
|
717
|
+
if (nextSelectedIds.length !== selectedIds.length) {
|
|
718
|
+
useViewer.getState().setSelection({ selectedIds: nextSelectedIds })
|
|
719
|
+
}
|
|
720
|
+
})
|
|
721
|
+
}, [])
|
|
722
|
+
|
|
723
|
+
return null
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const SelectionMaterialSync = () => {
|
|
727
|
+
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
728
|
+
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
|
|
729
|
+
const hoveredId = useViewer((s) => s.hoveredId)
|
|
730
|
+
const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode)
|
|
731
|
+
const activeHighlightKindsRef = useRef(new Map<string, HighlightKind>())
|
|
732
|
+
const highlightedMaterialsRef = useRef(
|
|
733
|
+
new Map<
|
|
734
|
+
Mesh,
|
|
735
|
+
{
|
|
736
|
+
originalMaterial: Material | Material[]
|
|
737
|
+
highlightedMaterial: Material | Material[]
|
|
738
|
+
kind: HighlightKind
|
|
739
|
+
}
|
|
740
|
+
>(),
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
const syncSelectionMaterials = useCallback(() => {
|
|
744
|
+
const activeMeshes = new Set<Mesh>()
|
|
745
|
+
|
|
746
|
+
for (const [id, kind] of activeHighlightKindsRef.current.entries()) {
|
|
747
|
+
const node = useScene.getState().nodes[id as AnyNodeId]
|
|
748
|
+
if (node?.type === 'wall') {
|
|
749
|
+
continue
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const rootObject = sceneRegistry.nodes.get(id)
|
|
753
|
+
if (!rootObject) {
|
|
754
|
+
continue
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
rootObject.traverse((child) => {
|
|
758
|
+
if (!isHighlightableMesh(child)) {
|
|
759
|
+
return
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
activeMeshes.add(child)
|
|
763
|
+
const existingEntry = highlightedMaterialsRef.current.get(child)
|
|
764
|
+
if (existingEntry) {
|
|
765
|
+
const materialWasOverwritten = child.material !== existingEntry.highlightedMaterial
|
|
766
|
+
if (materialWasOverwritten || existingEntry.kind !== kind) {
|
|
767
|
+
disposeHighlightedMaterials(existingEntry.highlightedMaterial)
|
|
768
|
+
const originalMaterial = materialWasOverwritten
|
|
769
|
+
? child.material
|
|
770
|
+
: existingEntry.originalMaterial
|
|
771
|
+
const highlightedMaterial = createHighlightedMaterials(originalMaterial, kind)
|
|
772
|
+
child.material = highlightedMaterial
|
|
773
|
+
highlightedMaterialsRef.current.set(child, {
|
|
774
|
+
originalMaterial,
|
|
775
|
+
highlightedMaterial,
|
|
776
|
+
kind,
|
|
777
|
+
})
|
|
778
|
+
}
|
|
779
|
+
return
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const originalMaterial = child.material
|
|
783
|
+
const highlightedMaterial = createHighlightedMaterials(originalMaterial, kind)
|
|
784
|
+
child.material = highlightedMaterial
|
|
785
|
+
highlightedMaterialsRef.current.set(child, {
|
|
786
|
+
originalMaterial,
|
|
787
|
+
highlightedMaterial,
|
|
788
|
+
kind,
|
|
789
|
+
})
|
|
790
|
+
})
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
for (const [mesh, entry] of highlightedMaterialsRef.current.entries()) {
|
|
794
|
+
if (activeMeshes.has(mesh)) {
|
|
795
|
+
continue
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
if (mesh.material === entry.highlightedMaterial) {
|
|
799
|
+
mesh.material = entry.originalMaterial
|
|
800
|
+
}
|
|
801
|
+
disposeHighlightedMaterials(entry.highlightedMaterial)
|
|
802
|
+
highlightedMaterialsRef.current.delete(mesh)
|
|
803
|
+
}
|
|
804
|
+
}, [])
|
|
805
|
+
|
|
806
|
+
useEffect(() => {
|
|
807
|
+
const nextHighlightKinds = new Map<string, HighlightKind>()
|
|
808
|
+
|
|
809
|
+
for (const id of new Set([...selectedIds, ...previewSelectedIds])) {
|
|
810
|
+
nextHighlightKinds.set(id, 'selection')
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
if (hoverHighlightMode === 'delete' && hoveredId) {
|
|
814
|
+
nextHighlightKinds.set(hoveredId, 'delete')
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
activeHighlightKindsRef.current = nextHighlightKinds
|
|
818
|
+
syncSelectionMaterials()
|
|
819
|
+
}, [hoverHighlightMode, hoveredId, previewSelectedIds, selectedIds, syncSelectionMaterials])
|
|
820
|
+
|
|
821
|
+
useEffect(() => {
|
|
822
|
+
return useScene.subscribe(() => {
|
|
823
|
+
syncSelectionMaterials()
|
|
824
|
+
})
|
|
825
|
+
}, [syncSelectionMaterials])
|
|
826
|
+
|
|
827
|
+
useEffect(() => {
|
|
828
|
+
return () => {
|
|
829
|
+
for (const [mesh, entry] of highlightedMaterialsRef.current.entries()) {
|
|
830
|
+
if (mesh.material === entry.highlightedMaterial) {
|
|
831
|
+
mesh.material = entry.originalMaterial
|
|
832
|
+
}
|
|
833
|
+
disposeHighlightedMaterials(entry.highlightedMaterial)
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
highlightedMaterialsRef.current.clear()
|
|
837
|
+
}
|
|
838
|
+
}, [])
|
|
839
|
+
|
|
840
|
+
return null
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const EditorOutlinerSync = () => {
|
|
844
|
+
const phase = useEditor((s) => s.phase)
|
|
845
|
+
const selection = useViewer((s) => s.selection)
|
|
846
|
+
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
|
|
847
|
+
const hoveredId = useViewer((s) => s.hoveredId)
|
|
848
|
+
const outliner = useViewer((s) => s.outliner)
|
|
849
|
+
|
|
850
|
+
useEffect(() => {
|
|
851
|
+
let idsToHighlight: string[] = []
|
|
852
|
+
|
|
853
|
+
// 1. Determine what should be highlighted based on Phase
|
|
854
|
+
switch (phase) {
|
|
855
|
+
case 'site':
|
|
856
|
+
// Only highlight the building if one is selected
|
|
857
|
+
if (selection.buildingId) idsToHighlight = [selection.buildingId]
|
|
858
|
+
break
|
|
859
|
+
|
|
860
|
+
case 'structure':
|
|
861
|
+
// Highlight selected items (walls/slabs)
|
|
862
|
+
// We IGNORE buildingId even if it's set in the store
|
|
863
|
+
idsToHighlight = Array.from(new Set([...selection.selectedIds, ...previewSelectedIds]))
|
|
864
|
+
break
|
|
865
|
+
|
|
866
|
+
case 'furnish':
|
|
867
|
+
// Highlight selected furniture/items
|
|
868
|
+
idsToHighlight = Array.from(new Set([...selection.selectedIds, ...previewSelectedIds]))
|
|
869
|
+
break
|
|
870
|
+
|
|
871
|
+
default:
|
|
872
|
+
// Pure Viewer mode: Highlight based on the "deepest" selection
|
|
873
|
+
if (selection.selectedIds.length > 0 || previewSelectedIds.length > 0) {
|
|
874
|
+
idsToHighlight = Array.from(new Set([...selection.selectedIds, ...previewSelectedIds]))
|
|
875
|
+
} else if (selection.levelId) {
|
|
876
|
+
idsToHighlight = [selection.levelId]
|
|
877
|
+
} else if (selection.buildingId) {
|
|
878
|
+
idsToHighlight = [selection.buildingId]
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// 2. Sync with the imperative outliner arrays (mutate in place to keep references)
|
|
883
|
+
outliner.selectedObjects.length = 0
|
|
884
|
+
for (const id of idsToHighlight) {
|
|
885
|
+
const obj = sceneRegistry.nodes.get(id)
|
|
886
|
+
if (obj) outliner.selectedObjects.push(obj)
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
outliner.hoveredObjects.length = 0
|
|
890
|
+
if (hoveredId) {
|
|
891
|
+
const obj = sceneRegistry.nodes.get(hoveredId)
|
|
892
|
+
if (obj) outliner.hoveredObjects.push(obj)
|
|
893
|
+
}
|
|
894
|
+
}, [phase, previewSelectedIds, selection, hoveredId, outliner])
|
|
895
|
+
|
|
896
|
+
return null
|
|
897
|
+
}
|