@pascal-app/editor 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/package.json +62 -0
  2. package/src/components/editor/custom-camera-controls.tsx +387 -0
  3. package/src/components/editor/editor-layout-v2.tsx +220 -0
  4. package/src/components/editor/export-manager.tsx +78 -0
  5. package/src/components/editor/first-person-controls.tsx +249 -0
  6. package/src/components/editor/floating-action-menu.tsx +231 -0
  7. package/src/components/editor/floorplan-panel.tsx +9609 -0
  8. package/src/components/editor/grid.tsx +161 -0
  9. package/src/components/editor/index.tsx +928 -0
  10. package/src/components/editor/node-action-menu.tsx +66 -0
  11. package/src/components/editor/preset-thumbnail-generator.tsx +125 -0
  12. package/src/components/editor/selection-manager.tsx +897 -0
  13. package/src/components/editor/site-edge-labels.tsx +90 -0
  14. package/src/components/editor/thumbnail-generator.tsx +166 -0
  15. package/src/components/editor/wall-measurement-label.tsx +258 -0
  16. package/src/components/feedback-dialog.tsx +265 -0
  17. package/src/components/pascal-radio.tsx +280 -0
  18. package/src/components/preview-button.tsx +16 -0
  19. package/src/components/systems/ceiling/ceiling-system.tsx +77 -0
  20. package/src/components/systems/roof/roof-edit-system.tsx +69 -0
  21. package/src/components/systems/stair/stair-edit-system.tsx +69 -0
  22. package/src/components/systems/zone/zone-label-editor-system.tsx +320 -0
  23. package/src/components/systems/zone/zone-system.tsx +87 -0
  24. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +42 -0
  25. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +47 -0
  26. package/src/components/tools/ceiling/ceiling-tool.tsx +465 -0
  27. package/src/components/tools/door/door-math.ts +110 -0
  28. package/src/components/tools/door/door-tool.tsx +293 -0
  29. package/src/components/tools/door/move-door-tool.tsx +373 -0
  30. package/src/components/tools/item/item-tool.tsx +26 -0
  31. package/src/components/tools/item/move-tool.tsx +90 -0
  32. package/src/components/tools/item/placement-math.ts +85 -0
  33. package/src/components/tools/item/placement-strategies.ts +556 -0
  34. package/src/components/tools/item/placement-types.ts +117 -0
  35. package/src/components/tools/item/use-draft-node.ts +227 -0
  36. package/src/components/tools/item/use-placement-coordinator.tsx +877 -0
  37. package/src/components/tools/roof/move-roof-tool.tsx +288 -0
  38. package/src/components/tools/roof/roof-tool.tsx +318 -0
  39. package/src/components/tools/select/box-select-tool.tsx +626 -0
  40. package/src/components/tools/shared/cursor-sphere.tsx +119 -0
  41. package/src/components/tools/shared/polygon-editor.tsx +361 -0
  42. package/src/components/tools/site/site-boundary-editor.tsx +42 -0
  43. package/src/components/tools/slab/slab-boundary-editor.tsx +42 -0
  44. package/src/components/tools/slab/slab-hole-editor.tsx +47 -0
  45. package/src/components/tools/slab/slab-tool.tsx +322 -0
  46. package/src/components/tools/stair/stair-defaults.ts +7 -0
  47. package/src/components/tools/stair/stair-tool.tsx +194 -0
  48. package/src/components/tools/tool-manager.tsx +120 -0
  49. package/src/components/tools/wall/wall-drafting.ts +140 -0
  50. package/src/components/tools/wall/wall-tool.tsx +210 -0
  51. package/src/components/tools/window/move-window-tool.tsx +410 -0
  52. package/src/components/tools/window/window-math.ts +117 -0
  53. package/src/components/tools/window/window-tool.tsx +303 -0
  54. package/src/components/tools/zone/zone-boundary-editor.tsx +39 -0
  55. package/src/components/tools/zone/zone-tool.tsx +364 -0
  56. package/src/components/ui/action-menu/action-button.tsx +59 -0
  57. package/src/components/ui/action-menu/camera-actions.tsx +74 -0
  58. package/src/components/ui/action-menu/control-modes.tsx +240 -0
  59. package/src/components/ui/action-menu/furnish-tools.tsx +102 -0
  60. package/src/components/ui/action-menu/index.tsx +152 -0
  61. package/src/components/ui/action-menu/structure-tools.tsx +100 -0
  62. package/src/components/ui/action-menu/view-toggles.tsx +397 -0
  63. package/src/components/ui/command-palette/editor-commands.tsx +396 -0
  64. package/src/components/ui/command-palette/index.tsx +730 -0
  65. package/src/components/ui/controls/action-button.tsx +33 -0
  66. package/src/components/ui/controls/material-picker.tsx +194 -0
  67. package/src/components/ui/controls/metric-control.tsx +262 -0
  68. package/src/components/ui/controls/panel-section.tsx +65 -0
  69. package/src/components/ui/controls/segmented-control.tsx +45 -0
  70. package/src/components/ui/controls/slider-control.tsx +245 -0
  71. package/src/components/ui/controls/toggle-control.tsx +38 -0
  72. package/src/components/ui/floating-level-selector.tsx +355 -0
  73. package/src/components/ui/helpers/ceiling-helper.tsx +20 -0
  74. package/src/components/ui/helpers/helper-manager.tsx +33 -0
  75. package/src/components/ui/helpers/item-helper.tsx +40 -0
  76. package/src/components/ui/helpers/roof-helper.tsx +16 -0
  77. package/src/components/ui/helpers/slab-helper.tsx +20 -0
  78. package/src/components/ui/helpers/wall-helper.tsx +20 -0
  79. package/src/components/ui/item-catalog/catalog-items.tsx +1580 -0
  80. package/src/components/ui/item-catalog/item-catalog.tsx +219 -0
  81. package/src/components/ui/panels/ceiling-panel.tsx +230 -0
  82. package/src/components/ui/panels/collections/collections-popover.tsx +356 -0
  83. package/src/components/ui/panels/door-panel.tsx +600 -0
  84. package/src/components/ui/panels/item-panel.tsx +306 -0
  85. package/src/components/ui/panels/panel-manager.tsx +59 -0
  86. package/src/components/ui/panels/panel-wrapper.tsx +80 -0
  87. package/src/components/ui/panels/presets/presets-popover.tsx +511 -0
  88. package/src/components/ui/panels/reference-panel.tsx +177 -0
  89. package/src/components/ui/panels/roof-panel.tsx +262 -0
  90. package/src/components/ui/panels/roof-segment-panel.tsx +326 -0
  91. package/src/components/ui/panels/slab-panel.tsx +228 -0
  92. package/src/components/ui/panels/stair-panel.tsx +304 -0
  93. package/src/components/ui/panels/stair-segment-panel.tsx +339 -0
  94. package/src/components/ui/panels/wall-panel.tsx +123 -0
  95. package/src/components/ui/panels/window-panel.tsx +441 -0
  96. package/src/components/ui/primitives/button.tsx +69 -0
  97. package/src/components/ui/primitives/card.tsx +75 -0
  98. package/src/components/ui/primitives/color-dot.tsx +61 -0
  99. package/src/components/ui/primitives/context-menu.tsx +227 -0
  100. package/src/components/ui/primitives/dialog.tsx +129 -0
  101. package/src/components/ui/primitives/dropdown-menu.tsx +228 -0
  102. package/src/components/ui/primitives/error-boundary.tsx +52 -0
  103. package/src/components/ui/primitives/input.tsx +21 -0
  104. package/src/components/ui/primitives/number-input.tsx +187 -0
  105. package/src/components/ui/primitives/opacity-control.tsx +79 -0
  106. package/src/components/ui/primitives/popover.tsx +42 -0
  107. package/src/components/ui/primitives/separator.tsx +28 -0
  108. package/src/components/ui/primitives/sheet.tsx +130 -0
  109. package/src/components/ui/primitives/shortcut-token.tsx +64 -0
  110. package/src/components/ui/primitives/sidebar.tsx +855 -0
  111. package/src/components/ui/primitives/skeleton.tsx +13 -0
  112. package/src/components/ui/primitives/slider.tsx +58 -0
  113. package/src/components/ui/primitives/switch.tsx +29 -0
  114. package/src/components/ui/primitives/tooltip.tsx +57 -0
  115. package/src/components/ui/scene-loader.tsx +40 -0
  116. package/src/components/ui/sidebar/app-sidebar.tsx +103 -0
  117. package/src/components/ui/sidebar/icon-rail.tsx +147 -0
  118. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +100 -0
  119. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +438 -0
  120. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +188 -0
  121. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +80 -0
  122. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +126 -0
  123. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +64 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +1543 -0
  125. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +98 -0
  126. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +117 -0
  127. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +65 -0
  128. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +214 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +96 -0
  130. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +216 -0
  131. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +115 -0
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +342 -0
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +271 -0
  134. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +106 -0
  135. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +64 -0
  136. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +87 -0
  137. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +167 -0
  138. package/src/components/ui/sidebar/tab-bar.tsx +39 -0
  139. package/src/components/ui/slider-demo.tsx +36 -0
  140. package/src/components/ui/slider.tsx +81 -0
  141. package/src/components/ui/viewer-toolbar.tsx +342 -0
  142. package/src/components/viewer-overlay.tsx +499 -0
  143. package/src/components/viewer-zone-system.tsx +48 -0
  144. package/src/contexts/presets-context.tsx +121 -0
  145. package/src/hooks/use-auto-save.ts +194 -0
  146. package/src/hooks/use-contextual-tools.ts +52 -0
  147. package/src/hooks/use-grid-events.ts +106 -0
  148. package/src/hooks/use-keyboard.ts +214 -0
  149. package/src/hooks/use-mobile.ts +19 -0
  150. package/src/hooks/use-reduced-motion.ts +20 -0
  151. package/src/index.tsx +33 -0
  152. package/src/lib/constants.ts +3 -0
  153. package/src/lib/level-selection.ts +31 -0
  154. package/src/lib/scene.ts +394 -0
  155. package/src/lib/sfx/index.ts +2 -0
  156. package/src/lib/sfx-bus.ts +49 -0
  157. package/src/lib/sfx-player.ts +60 -0
  158. package/src/lib/utils.ts +43 -0
  159. package/src/store/use-audio.tsx +45 -0
  160. package/src/store/use-command-registry.ts +36 -0
  161. package/src/store/use-editor.tsx +522 -0
  162. package/src/store/use-palette-view-registry.ts +45 -0
  163. package/src/store/use-upload.ts +90 -0
  164. package/src/three-types.ts +3 -0
  165. package/tsconfig.json +9 -0
@@ -0,0 +1,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
+ }