@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,877 @@
|
|
|
1
|
+
import type { AssetInput } from '@pascal-app/core'
|
|
2
|
+
import {
|
|
3
|
+
type AnyNodeId,
|
|
4
|
+
type CeilingEvent,
|
|
5
|
+
emitter,
|
|
6
|
+
type GridEvent,
|
|
7
|
+
getScaledDimensions,
|
|
8
|
+
type ItemEvent,
|
|
9
|
+
resolveLevelId,
|
|
10
|
+
sceneRegistry,
|
|
11
|
+
spatialGridManager,
|
|
12
|
+
useLiveTransforms,
|
|
13
|
+
useScene,
|
|
14
|
+
useSpatialQuery,
|
|
15
|
+
type WallEvent,
|
|
16
|
+
type WallNode,
|
|
17
|
+
} from '@pascal-app/core'
|
|
18
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
19
|
+
import { useFrame } from '@react-three/fiber'
|
|
20
|
+
import { useEffect, useRef } from 'react'
|
|
21
|
+
import {
|
|
22
|
+
BoxGeometry,
|
|
23
|
+
EdgesGeometry,
|
|
24
|
+
Euler,
|
|
25
|
+
type Group,
|
|
26
|
+
type LineSegments,
|
|
27
|
+
type Mesh,
|
|
28
|
+
PlaneGeometry,
|
|
29
|
+
Quaternion,
|
|
30
|
+
Vector3,
|
|
31
|
+
} from 'three'
|
|
32
|
+
import { distance, smoothstep, uv, vec2 } from 'three/tsl'
|
|
33
|
+
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
|
34
|
+
import { EDITOR_LAYER } from '../../../lib/constants'
|
|
35
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
36
|
+
import {
|
|
37
|
+
ceilingStrategy,
|
|
38
|
+
checkCanPlace,
|
|
39
|
+
floorStrategy,
|
|
40
|
+
itemSurfaceStrategy,
|
|
41
|
+
wallStrategy,
|
|
42
|
+
} from './placement-strategies'
|
|
43
|
+
import type { PlacementState, TransitionResult } from './placement-types'
|
|
44
|
+
import type { DraftNodeHandle } from './use-draft-node'
|
|
45
|
+
|
|
46
|
+
const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
|
|
47
|
+
|
|
48
|
+
// Shared materials for placement cursor - we just change colors, not swap materials
|
|
49
|
+
// Note: EdgesGeometry doesn't work with dashed lines, so using solid lines
|
|
50
|
+
const edgeMaterial = new LineBasicNodeMaterial({
|
|
51
|
+
color: 0xef_44_44, // red-500 (invalid)
|
|
52
|
+
linewidth: 3,
|
|
53
|
+
depthTest: false,
|
|
54
|
+
depthWrite: false,
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const basePlaneMaterial = new MeshBasicNodeMaterial({
|
|
58
|
+
color: 0xef_44_44, // red-500 (invalid)
|
|
59
|
+
transparent: true,
|
|
60
|
+
depthTest: false,
|
|
61
|
+
depthWrite: false,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// Create radial opacity: transparent in center, opaque at edges
|
|
65
|
+
const center = vec2(0.5, 0.5)
|
|
66
|
+
const dist = distance(uv(), center)
|
|
67
|
+
const radialOpacity = smoothstep(0, 0.7, dist).mul(0.6)
|
|
68
|
+
basePlaneMaterial.opacityNode = radialOpacity
|
|
69
|
+
|
|
70
|
+
export interface PlacementCoordinatorConfig {
|
|
71
|
+
asset: AssetInput
|
|
72
|
+
draftNode: DraftNodeHandle
|
|
73
|
+
initDraft: (gridPosition: Vector3) => void
|
|
74
|
+
onCommitted: () => boolean
|
|
75
|
+
onCancel?: () => void
|
|
76
|
+
initialState?: PlacementState
|
|
77
|
+
/** Scale to use when lazily creating a draft (e.g. for wall/ceiling duplicates). Defaults to [1,1,1]. */
|
|
78
|
+
defaultScale?: [number, number, number]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
|
|
82
|
+
const cursorGroupRef = useRef<Group>(null!)
|
|
83
|
+
const edgesRef = useRef<LineSegments>(null!)
|
|
84
|
+
const basePlaneRef = useRef<Mesh>(null!)
|
|
85
|
+
const gridPosition = useRef(new Vector3(0, 0, 0))
|
|
86
|
+
const placementState = useRef<PlacementState>(
|
|
87
|
+
config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null },
|
|
88
|
+
)
|
|
89
|
+
const shiftFreeRef = useRef(false)
|
|
90
|
+
|
|
91
|
+
// Store config callbacks in refs to avoid re-running effect when they change
|
|
92
|
+
const configRef = useRef(config)
|
|
93
|
+
configRef.current = config
|
|
94
|
+
|
|
95
|
+
const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery()
|
|
96
|
+
const { asset, draftNode } = config
|
|
97
|
+
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
useScene.temporal.getState().pause()
|
|
100
|
+
|
|
101
|
+
const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
|
|
102
|
+
|
|
103
|
+
// Reset placement state
|
|
104
|
+
placementState.current = configRef.current.initialState ?? {
|
|
105
|
+
surface: 'floor',
|
|
106
|
+
wallId: null,
|
|
107
|
+
ceilingId: null,
|
|
108
|
+
surfaceItemId: null,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---- Helpers ----
|
|
112
|
+
|
|
113
|
+
const getContext = () => ({
|
|
114
|
+
asset,
|
|
115
|
+
levelId: useViewer.getState().selection.levelId,
|
|
116
|
+
draftItem: draftNode.current,
|
|
117
|
+
gridPosition: gridPosition.current,
|
|
118
|
+
state: { ...placementState.current },
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const getActiveValidators = () =>
|
|
122
|
+
shiftFreeRef.current
|
|
123
|
+
? {
|
|
124
|
+
canPlaceOnFloor: () => ({ valid: true }),
|
|
125
|
+
canPlaceOnWall: () => ({ valid: true }),
|
|
126
|
+
canPlaceOnCeiling: () => ({ valid: true }),
|
|
127
|
+
}
|
|
128
|
+
: validators
|
|
129
|
+
|
|
130
|
+
const revalidate = (): boolean => {
|
|
131
|
+
const placeable = shiftFreeRef.current || checkCanPlace(getContext(), validators)
|
|
132
|
+
const color = placeable ? 0x22_c5_5e : 0xef_44_44 // green-500 : red-500
|
|
133
|
+
edgeMaterial.color.setHex(color)
|
|
134
|
+
basePlaneMaterial.color.setHex(color)
|
|
135
|
+
return placeable
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const applyTransition = (result: TransitionResult) => {
|
|
139
|
+
Object.assign(placementState.current, result.stateUpdate)
|
|
140
|
+
gridPosition.current.set(...result.gridPosition)
|
|
141
|
+
|
|
142
|
+
cursorGroupRef.current.position.set(...result.cursorPosition)
|
|
143
|
+
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
|
144
|
+
|
|
145
|
+
const draft = draftNode.current
|
|
146
|
+
if (draft) {
|
|
147
|
+
// Update ref for validation — no store update during drag
|
|
148
|
+
Object.assign(draft, result.nodeUpdate)
|
|
149
|
+
}
|
|
150
|
+
revalidate()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const ensureDraft = (result: TransitionResult) => {
|
|
154
|
+
gridPosition.current.set(...result.gridPosition)
|
|
155
|
+
cursorGroupRef.current.position.set(...result.cursorPosition)
|
|
156
|
+
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
|
157
|
+
|
|
158
|
+
draftNode.create(
|
|
159
|
+
gridPosition.current,
|
|
160
|
+
asset,
|
|
161
|
+
[0, result.cursorRotationY, 0],
|
|
162
|
+
configRef.current.defaultScale,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
const draft = draftNode.current
|
|
166
|
+
if (draft) {
|
|
167
|
+
Object.assign(draft, result.nodeUpdate)
|
|
168
|
+
// One-time setup: put node in the right parent so it renders correctly
|
|
169
|
+
useScene.getState().updateNode(draft.id, result.nodeUpdate)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!revalidate()) {
|
|
173
|
+
draftNode.destroy()
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---- Init draft ----
|
|
178
|
+
configRef.current.initDraft(gridPosition.current)
|
|
179
|
+
|
|
180
|
+
// Sync cursor to the draft mesh's world position and rotation
|
|
181
|
+
if (draftNode.current) {
|
|
182
|
+
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
|
|
183
|
+
if (mesh) {
|
|
184
|
+
mesh.getWorldPosition(cursorGroupRef.current.position)
|
|
185
|
+
// Extract world Y rotation (handles wall-parented items correctly)
|
|
186
|
+
const q = new Quaternion()
|
|
187
|
+
mesh.getWorldQuaternion(q)
|
|
188
|
+
cursorGroupRef.current.rotation.y = new Euler().setFromQuaternion(q, 'YXZ').y
|
|
189
|
+
} else {
|
|
190
|
+
cursorGroupRef.current.position.copy(gridPosition.current)
|
|
191
|
+
cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
revalidate()
|
|
196
|
+
|
|
197
|
+
// ---- Floor Handlers ----
|
|
198
|
+
|
|
199
|
+
let previousGridPos: [number, number, number] | null = null
|
|
200
|
+
|
|
201
|
+
const onGridMove = (event: GridEvent) => {
|
|
202
|
+
const result = floorStrategy.move(getContext(), event)
|
|
203
|
+
if (!result) return
|
|
204
|
+
|
|
205
|
+
// Play snap sound when grid position changes
|
|
206
|
+
if (
|
|
207
|
+
previousGridPos &&
|
|
208
|
+
(result.gridPosition[0] !== previousGridPos[0] ||
|
|
209
|
+
result.gridPosition[2] !== previousGridPos[2])
|
|
210
|
+
) {
|
|
211
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
previousGridPos = [...result.gridPosition]
|
|
215
|
+
gridPosition.current.set(...result.gridPosition)
|
|
216
|
+
// Only update X and Z for cursor - useFrame will handle Y (slab elevation)
|
|
217
|
+
cursorGroupRef.current.position.x = result.cursorPosition[0]
|
|
218
|
+
cursorGroupRef.current.position.z = result.cursorPosition[2]
|
|
219
|
+
|
|
220
|
+
const draft = draftNode.current
|
|
221
|
+
if (draft) draft.position = result.gridPosition
|
|
222
|
+
|
|
223
|
+
// Publish live transform for 2D floorplan
|
|
224
|
+
if (draft) {
|
|
225
|
+
useLiveTransforms.getState().set(draft.id, {
|
|
226
|
+
position: result.gridPosition,
|
|
227
|
+
rotation: cursorGroupRef.current.rotation.y,
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
revalidate()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const onGridClick = (event: GridEvent) => {
|
|
235
|
+
const result = floorStrategy.click(getContext(), event, getActiveValidators())
|
|
236
|
+
if (!result) return
|
|
237
|
+
|
|
238
|
+
// Preserve cursor rotation for the next draft
|
|
239
|
+
const currentRotation: [number, number, number] = [0, cursorGroupRef.current.rotation.y, 0]
|
|
240
|
+
|
|
241
|
+
// Clear live transform before commit
|
|
242
|
+
if (draftNode.current) {
|
|
243
|
+
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
draftNode.commit(result.nodeUpdate)
|
|
247
|
+
if (configRef.current.onCommitted()) {
|
|
248
|
+
draftNode.create(gridPosition.current, asset, currentRotation)
|
|
249
|
+
revalidate()
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---- Wall Handlers ----
|
|
254
|
+
|
|
255
|
+
const onWallEnter = (event: WallEvent) => {
|
|
256
|
+
const nodes = useScene.getState().nodes
|
|
257
|
+
const result = wallStrategy.enter(
|
|
258
|
+
getContext(),
|
|
259
|
+
event,
|
|
260
|
+
resolveLevelId,
|
|
261
|
+
nodes,
|
|
262
|
+
getActiveValidators(),
|
|
263
|
+
)
|
|
264
|
+
if (!result) return
|
|
265
|
+
|
|
266
|
+
event.stopPropagation()
|
|
267
|
+
applyTransition(result)
|
|
268
|
+
|
|
269
|
+
if (!draftNode.current) {
|
|
270
|
+
ensureDraft(result)
|
|
271
|
+
} else if (result.nodeUpdate.parentId) {
|
|
272
|
+
// Existing draft (move mode): reparent to new wall
|
|
273
|
+
useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
|
|
274
|
+
if (result.stateUpdate.wallId) {
|
|
275
|
+
useScene.getState().dirtyNodes.add(result.stateUpdate.wallId as AnyNodeId)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const onWallMove = (event: WallEvent) => {
|
|
281
|
+
const ctx = getContext()
|
|
282
|
+
|
|
283
|
+
if (ctx.state.surface !== 'wall') {
|
|
284
|
+
const nodes = useScene.getState().nodes
|
|
285
|
+
const enterResult = wallStrategy.enter(
|
|
286
|
+
ctx,
|
|
287
|
+
event,
|
|
288
|
+
resolveLevelId,
|
|
289
|
+
nodes,
|
|
290
|
+
getActiveValidators(),
|
|
291
|
+
)
|
|
292
|
+
if (!enterResult) return
|
|
293
|
+
|
|
294
|
+
event.stopPropagation()
|
|
295
|
+
applyTransition(enterResult)
|
|
296
|
+
if (draftNode.current && enterResult.nodeUpdate.parentId) {
|
|
297
|
+
useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
|
|
298
|
+
if (enterResult.stateUpdate.wallId) {
|
|
299
|
+
useScene.getState().dirtyNodes.add(enterResult.stateUpdate.wallId as AnyNodeId)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (!draftNode.current) {
|
|
306
|
+
const nodes = useScene.getState().nodes
|
|
307
|
+
const setup = wallStrategy.enter(
|
|
308
|
+
getContext(),
|
|
309
|
+
event,
|
|
310
|
+
resolveLevelId,
|
|
311
|
+
nodes,
|
|
312
|
+
getActiveValidators(),
|
|
313
|
+
)
|
|
314
|
+
if (!setup) return
|
|
315
|
+
|
|
316
|
+
event.stopPropagation()
|
|
317
|
+
ensureDraft(setup)
|
|
318
|
+
return
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const result = wallStrategy.move(ctx, event, getActiveValidators())
|
|
322
|
+
if (!result) return
|
|
323
|
+
|
|
324
|
+
event.stopPropagation()
|
|
325
|
+
|
|
326
|
+
const posChanged =
|
|
327
|
+
gridPosition.current.x !== result.gridPosition[0] ||
|
|
328
|
+
gridPosition.current.y !== result.gridPosition[1] ||
|
|
329
|
+
gridPosition.current.z !== result.gridPosition[2]
|
|
330
|
+
|
|
331
|
+
// Play snap sound when grid position changes
|
|
332
|
+
if (posChanged) {
|
|
333
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
gridPosition.current.set(...result.gridPosition)
|
|
337
|
+
cursorGroupRef.current.position.set(...result.cursorPosition)
|
|
338
|
+
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
|
339
|
+
|
|
340
|
+
const draft = draftNode.current
|
|
341
|
+
if (draft && result.nodeUpdate) {
|
|
342
|
+
if ('side' in result.nodeUpdate) draft.side = result.nodeUpdate.side
|
|
343
|
+
if ('rotation' in result.nodeUpdate)
|
|
344
|
+
draft.rotation = result.nodeUpdate.rotation as [number, number, number]
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const placeable = revalidate()
|
|
348
|
+
|
|
349
|
+
if (draft && placeable) {
|
|
350
|
+
draft.position = result.gridPosition
|
|
351
|
+
const mesh = sceneRegistry.nodes.get(draft.id)
|
|
352
|
+
if (mesh) {
|
|
353
|
+
mesh.position.copy(gridPosition.current)
|
|
354
|
+
const rot = result.nodeUpdate?.rotation
|
|
355
|
+
if (rot) mesh.rotation.y = rot[1]
|
|
356
|
+
|
|
357
|
+
// Push wall-side items out by half the parent wall's thickness
|
|
358
|
+
if (asset.attachTo === 'wall-side' && placementState.current.wallId) {
|
|
359
|
+
const parentWall = useScene.getState().nodes[placementState.current.wallId as AnyNodeId]
|
|
360
|
+
if (parentWall?.type === 'wall') {
|
|
361
|
+
const wallThickness = (parentWall as WallNode).thickness ?? 0.1
|
|
362
|
+
mesh.position.z = (wallThickness / 2) * (draft.side === 'front' ? 1 : -1)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// Mark parent wall dirty so it rebuilds geometry — only when position changed
|
|
367
|
+
if (result.dirtyNodeId && posChanged) {
|
|
368
|
+
useScene.getState().dirtyNodes.add(result.dirtyNodeId)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Publish live transform for 2D floorplan
|
|
372
|
+
useLiveTransforms.getState().set(draft.id, {
|
|
373
|
+
position: result.cursorPosition,
|
|
374
|
+
rotation: result.cursorRotationY,
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const onWallClick = (event: WallEvent) => {
|
|
380
|
+
const result = wallStrategy.click(getContext(), event, getActiveValidators())
|
|
381
|
+
if (!result) return
|
|
382
|
+
|
|
383
|
+
event.stopPropagation()
|
|
384
|
+
// Clear live transform before commit
|
|
385
|
+
if (draftNode.current) {
|
|
386
|
+
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
387
|
+
}
|
|
388
|
+
draftNode.commit(result.nodeUpdate)
|
|
389
|
+
if (result.dirtyNodeId) {
|
|
390
|
+
useScene.getState().dirtyNodes.add(result.dirtyNodeId)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (configRef.current.onCommitted()) {
|
|
394
|
+
const nodes = useScene.getState().nodes
|
|
395
|
+
const enterResult = wallStrategy.enter(
|
|
396
|
+
getContext(),
|
|
397
|
+
event,
|
|
398
|
+
resolveLevelId,
|
|
399
|
+
nodes,
|
|
400
|
+
validators,
|
|
401
|
+
)
|
|
402
|
+
if (enterResult) {
|
|
403
|
+
applyTransition(enterResult)
|
|
404
|
+
} else {
|
|
405
|
+
revalidate()
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const onWallLeave = (event: WallEvent) => {
|
|
411
|
+
const result = wallStrategy.leave(getContext())
|
|
412
|
+
if (!result) return
|
|
413
|
+
|
|
414
|
+
event.stopPropagation()
|
|
415
|
+
|
|
416
|
+
if (asset.attachTo) {
|
|
417
|
+
if (draftNode.isAdopted) {
|
|
418
|
+
// Move mode: keep draft alive, reparent to level
|
|
419
|
+
const oldWallId = placementState.current.wallId
|
|
420
|
+
applyTransition(result)
|
|
421
|
+
const draft = draftNode.current
|
|
422
|
+
if (draft) {
|
|
423
|
+
useScene
|
|
424
|
+
.getState()
|
|
425
|
+
.updateNode(draft.id, { parentId: result.nodeUpdate.parentId as string })
|
|
426
|
+
}
|
|
427
|
+
if (oldWallId) {
|
|
428
|
+
useScene.getState().dirtyNodes.add(oldWallId as AnyNodeId)
|
|
429
|
+
}
|
|
430
|
+
} else {
|
|
431
|
+
// Create mode: destroy transient and reset state
|
|
432
|
+
draftNode.destroy()
|
|
433
|
+
Object.assign(placementState.current, result.stateUpdate)
|
|
434
|
+
}
|
|
435
|
+
} else {
|
|
436
|
+
applyTransition(result)
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ---- Item Surface Handlers ----
|
|
441
|
+
|
|
442
|
+
const onItemEnter = (event: ItemEvent) => {
|
|
443
|
+
if (event.node.id === draftNode.current?.id) return
|
|
444
|
+
const result = itemSurfaceStrategy.enter(getContext(), event)
|
|
445
|
+
if (!result) return
|
|
446
|
+
|
|
447
|
+
event.stopPropagation()
|
|
448
|
+
applyTransition(result)
|
|
449
|
+
|
|
450
|
+
if (!draftNode.current) {
|
|
451
|
+
ensureDraft(result)
|
|
452
|
+
} else if (result.nodeUpdate.parentId) {
|
|
453
|
+
// Existing draft (move mode): reparent to surface item
|
|
454
|
+
useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const onItemMove = (event: ItemEvent) => {
|
|
459
|
+
if (event.node.id === draftNode.current?.id) return
|
|
460
|
+
const ctx = getContext()
|
|
461
|
+
|
|
462
|
+
if (ctx.state.surface !== 'item-surface') {
|
|
463
|
+
// Try entering surface mode
|
|
464
|
+
const enterResult = itemSurfaceStrategy.enter(ctx, event)
|
|
465
|
+
if (!enterResult) return
|
|
466
|
+
|
|
467
|
+
event.stopPropagation()
|
|
468
|
+
applyTransition(enterResult)
|
|
469
|
+
if (draftNode.current && enterResult.nodeUpdate.parentId) {
|
|
470
|
+
useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
|
|
471
|
+
}
|
|
472
|
+
return
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (!draftNode.current) {
|
|
476
|
+
const enterResult = itemSurfaceStrategy.enter(getContext(), event)
|
|
477
|
+
if (!enterResult) return
|
|
478
|
+
event.stopPropagation()
|
|
479
|
+
ensureDraft(enterResult)
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const result = itemSurfaceStrategy.move(ctx, event)
|
|
484
|
+
if (!result) return
|
|
485
|
+
|
|
486
|
+
event.stopPropagation()
|
|
487
|
+
|
|
488
|
+
gridPosition.current.set(...result.gridPosition)
|
|
489
|
+
cursorGroupRef.current.position.set(...result.cursorPosition)
|
|
490
|
+
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
|
491
|
+
|
|
492
|
+
const draft = draftNode.current
|
|
493
|
+
if (draft) {
|
|
494
|
+
draft.position = result.gridPosition
|
|
495
|
+
const mesh = sceneRegistry.nodes.get(draft.id)
|
|
496
|
+
if (mesh) mesh.position.set(...result.gridPosition)
|
|
497
|
+
|
|
498
|
+
// Publish live transform for 2D floorplan
|
|
499
|
+
useLiveTransforms.getState().set(draft.id, {
|
|
500
|
+
position: result.cursorPosition,
|
|
501
|
+
rotation: result.cursorRotationY,
|
|
502
|
+
})
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
revalidate()
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const onItemLeave = (event: ItemEvent) => {
|
|
509
|
+
if (event.node.id === draftNode.current?.id) return
|
|
510
|
+
if (placementState.current.surface !== 'item-surface') return
|
|
511
|
+
|
|
512
|
+
event.stopPropagation()
|
|
513
|
+
|
|
514
|
+
// Transition back to floor using event world position
|
|
515
|
+
const wx = Math.round(event.position[0] * 2) / 2
|
|
516
|
+
const wz = Math.round(event.position[2] * 2) / 2
|
|
517
|
+
const floorPos: [number, number, number] = [wx, 0, wz]
|
|
518
|
+
|
|
519
|
+
Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null })
|
|
520
|
+
gridPosition.current.set(wx, 0, wz)
|
|
521
|
+
cursorGroupRef.current.position.set(wx, event.position[1], wz)
|
|
522
|
+
|
|
523
|
+
const draft = draftNode.current
|
|
524
|
+
if (draft) {
|
|
525
|
+
draft.position = floorPos
|
|
526
|
+
useScene.getState().updateNode(draft.id, {
|
|
527
|
+
parentId: useViewer.getState().selection.levelId as string,
|
|
528
|
+
position: floorPos,
|
|
529
|
+
})
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
revalidate()
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const onItemClick = (event: ItemEvent) => {
|
|
536
|
+
if (event.node.id === draftNode.current?.id) return
|
|
537
|
+
const result = itemSurfaceStrategy.click(getContext(), event)
|
|
538
|
+
if (!result) return
|
|
539
|
+
|
|
540
|
+
event.stopPropagation()
|
|
541
|
+
// Clear live transform before commit
|
|
542
|
+
if (draftNode.current) {
|
|
543
|
+
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
544
|
+
}
|
|
545
|
+
draftNode.commit(result.nodeUpdate)
|
|
546
|
+
|
|
547
|
+
if (configRef.current.onCommitted()) {
|
|
548
|
+
// Try to set up next draft on the same surface
|
|
549
|
+
const enterResult = itemSurfaceStrategy.enter(getContext(), event)
|
|
550
|
+
if (enterResult) {
|
|
551
|
+
applyTransition(enterResult)
|
|
552
|
+
} else {
|
|
553
|
+
revalidate()
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// ---- Ceiling Handlers ----
|
|
559
|
+
|
|
560
|
+
const onCeilingEnter = (event: CeilingEvent) => {
|
|
561
|
+
const nodes = useScene.getState().nodes
|
|
562
|
+
const result = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
|
|
563
|
+
if (!result) return
|
|
564
|
+
|
|
565
|
+
event.stopPropagation()
|
|
566
|
+
applyTransition(result)
|
|
567
|
+
|
|
568
|
+
if (!draftNode.current) {
|
|
569
|
+
ensureDraft(result)
|
|
570
|
+
} else if (result.nodeUpdate.parentId) {
|
|
571
|
+
// Existing draft (move mode): reparent to new ceiling
|
|
572
|
+
useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
|
|
573
|
+
if (result.stateUpdate.ceilingId) {
|
|
574
|
+
useScene.getState().dirtyNodes.add(result.stateUpdate.ceilingId as AnyNodeId)
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const onCeilingMove = (event: CeilingEvent) => {
|
|
580
|
+
if (!draftNode.current && placementState.current.surface === 'ceiling') {
|
|
581
|
+
const nodes = useScene.getState().nodes
|
|
582
|
+
const setup = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
|
|
583
|
+
if (!setup) return
|
|
584
|
+
|
|
585
|
+
event.stopPropagation()
|
|
586
|
+
ensureDraft(setup)
|
|
587
|
+
return
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const result = ceilingStrategy.move(getContext(), event)
|
|
591
|
+
if (!result) return
|
|
592
|
+
|
|
593
|
+
event.stopPropagation()
|
|
594
|
+
|
|
595
|
+
// Play snap sound when grid position changes
|
|
596
|
+
const posChanged =
|
|
597
|
+
gridPosition.current.x !== result.gridPosition[0] ||
|
|
598
|
+
gridPosition.current.y !== result.gridPosition[1] ||
|
|
599
|
+
gridPosition.current.z !== result.gridPosition[2]
|
|
600
|
+
|
|
601
|
+
if (posChanged) {
|
|
602
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
gridPosition.current.set(...result.gridPosition)
|
|
606
|
+
cursorGroupRef.current.position.set(...result.cursorPosition)
|
|
607
|
+
|
|
608
|
+
revalidate()
|
|
609
|
+
|
|
610
|
+
const draft = draftNode.current
|
|
611
|
+
if (draft) {
|
|
612
|
+
draft.position = result.gridPosition
|
|
613
|
+
const mesh = sceneRegistry.nodes.get(draft.id)
|
|
614
|
+
if (mesh) mesh.position.copy(gridPosition.current)
|
|
615
|
+
|
|
616
|
+
// Publish live transform for 2D floorplan
|
|
617
|
+
useLiveTransforms.getState().set(draft.id, {
|
|
618
|
+
position: result.cursorPosition,
|
|
619
|
+
rotation: cursorGroupRef.current.rotation.y,
|
|
620
|
+
})
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const onCeilingClick = (event: CeilingEvent) => {
|
|
625
|
+
const result = ceilingStrategy.click(getContext(), event, getActiveValidators())
|
|
626
|
+
if (!result) return
|
|
627
|
+
|
|
628
|
+
event.stopPropagation()
|
|
629
|
+
// Clear live transform before commit
|
|
630
|
+
if (draftNode.current) {
|
|
631
|
+
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
632
|
+
}
|
|
633
|
+
draftNode.commit(result.nodeUpdate)
|
|
634
|
+
|
|
635
|
+
if (configRef.current.onCommitted()) {
|
|
636
|
+
const nodes = useScene.getState().nodes
|
|
637
|
+
const enterResult = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
|
|
638
|
+
if (enterResult) {
|
|
639
|
+
applyTransition(enterResult)
|
|
640
|
+
} else {
|
|
641
|
+
revalidate()
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const onCeilingLeave = (event: CeilingEvent) => {
|
|
647
|
+
const result = ceilingStrategy.leave(getContext())
|
|
648
|
+
if (!result) return
|
|
649
|
+
|
|
650
|
+
event.stopPropagation()
|
|
651
|
+
|
|
652
|
+
if (asset.attachTo) {
|
|
653
|
+
if (draftNode.isAdopted) {
|
|
654
|
+
// Move mode: keep draft alive, reparent to level
|
|
655
|
+
const oldCeilingId = placementState.current.ceilingId
|
|
656
|
+
applyTransition(result)
|
|
657
|
+
const draft = draftNode.current
|
|
658
|
+
if (draft) {
|
|
659
|
+
useScene
|
|
660
|
+
.getState()
|
|
661
|
+
.updateNode(draft.id, { parentId: result.nodeUpdate.parentId as string })
|
|
662
|
+
}
|
|
663
|
+
if (oldCeilingId) {
|
|
664
|
+
useScene.getState().dirtyNodes.add(oldCeilingId as AnyNodeId)
|
|
665
|
+
}
|
|
666
|
+
} else {
|
|
667
|
+
// Create mode: destroy transient and reset state
|
|
668
|
+
draftNode.destroy()
|
|
669
|
+
Object.assign(placementState.current, result.stateUpdate)
|
|
670
|
+
}
|
|
671
|
+
} else {
|
|
672
|
+
applyTransition(result)
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// ---- Keyboard rotation ----
|
|
677
|
+
|
|
678
|
+
const ROTATION_STEP = Math.PI / 2
|
|
679
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
680
|
+
if (event.key === 'Shift') {
|
|
681
|
+
shiftFreeRef.current = true
|
|
682
|
+
revalidate()
|
|
683
|
+
return
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const draft = draftNode.current
|
|
687
|
+
if (!draft) return
|
|
688
|
+
|
|
689
|
+
let rotationDelta = 0
|
|
690
|
+
if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
|
|
691
|
+
else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
|
|
692
|
+
|
|
693
|
+
if (rotationDelta !== 0) {
|
|
694
|
+
event.preventDefault()
|
|
695
|
+
sfxEmitter.emit('sfx:item-rotate')
|
|
696
|
+
const currentRotation = draft.rotation
|
|
697
|
+
const newRotationY = (currentRotation[1] ?? 0) + rotationDelta
|
|
698
|
+
draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]]
|
|
699
|
+
|
|
700
|
+
// Ref + cursor mesh + item mesh — no store update during drag
|
|
701
|
+
cursorGroupRef.current.rotation.y = newRotationY
|
|
702
|
+
const mesh = sceneRegistry.nodes.get(draft.id)
|
|
703
|
+
if (mesh) mesh.rotation.y = newRotationY
|
|
704
|
+
|
|
705
|
+
// Update live transform rotation for 2D floorplan
|
|
706
|
+
const currentLive = useLiveTransforms.getState().get(draft.id)
|
|
707
|
+
if (currentLive) {
|
|
708
|
+
useLiveTransforms.getState().set(draft.id, {
|
|
709
|
+
...currentLive,
|
|
710
|
+
rotation: newRotationY,
|
|
711
|
+
})
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
revalidate()
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const onKeyUp = (event: KeyboardEvent) => {
|
|
719
|
+
if (event.key === 'Shift') {
|
|
720
|
+
shiftFreeRef.current = false
|
|
721
|
+
revalidate()
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
window.addEventListener('keydown', onKeyDown)
|
|
726
|
+
window.addEventListener('keyup', onKeyUp)
|
|
727
|
+
|
|
728
|
+
// ---- tool:cancel (Escape / programmatic) ----
|
|
729
|
+
const onCancel = () => {
|
|
730
|
+
if (configRef.current.onCancel) {
|
|
731
|
+
configRef.current.onCancel()
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
emitter.on('tool:cancel', onCancel)
|
|
735
|
+
|
|
736
|
+
// ---- Right-click cancel ----
|
|
737
|
+
const onContextMenu = (event: MouseEvent) => {
|
|
738
|
+
if (configRef.current.onCancel) {
|
|
739
|
+
event.preventDefault()
|
|
740
|
+
configRef.current.onCancel()
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
window.addEventListener('contextmenu', onContextMenu)
|
|
744
|
+
|
|
745
|
+
// ---- Bounding box geometry ----
|
|
746
|
+
|
|
747
|
+
const draft = draftNode.current
|
|
748
|
+
const dims = draft ? getScaledDimensions(draft) : (asset.dimensions ?? DEFAULT_DIMENSIONS)
|
|
749
|
+
const boxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
|
|
750
|
+
const wallSideZOffset = asset.attachTo === 'wall-side' ? -dims[2] / 2 : 0
|
|
751
|
+
boxGeometry.translate(0, dims[1] / 2, wallSideZOffset)
|
|
752
|
+
const edgesGeometry = new EdgesGeometry(boxGeometry)
|
|
753
|
+
edgesRef.current.geometry = edgesGeometry
|
|
754
|
+
|
|
755
|
+
// ---- Subscribe ----
|
|
756
|
+
|
|
757
|
+
emitter.on('grid:move', onGridMove)
|
|
758
|
+
emitter.on('grid:click', onGridClick)
|
|
759
|
+
emitter.on('item:enter', onItemEnter)
|
|
760
|
+
emitter.on('item:move', onItemMove)
|
|
761
|
+
emitter.on('item:leave', onItemLeave)
|
|
762
|
+
emitter.on('item:click', onItemClick)
|
|
763
|
+
emitter.on('wall:enter', onWallEnter)
|
|
764
|
+
emitter.on('wall:move', onWallMove)
|
|
765
|
+
emitter.on('wall:click', onWallClick)
|
|
766
|
+
emitter.on('wall:leave', onWallLeave)
|
|
767
|
+
emitter.on('ceiling:enter', onCeilingEnter)
|
|
768
|
+
emitter.on('ceiling:move', onCeilingMove)
|
|
769
|
+
emitter.on('ceiling:click', onCeilingClick)
|
|
770
|
+
emitter.on('ceiling:leave', onCeilingLeave)
|
|
771
|
+
|
|
772
|
+
return () => {
|
|
773
|
+
// Clear live transform for any remaining draft
|
|
774
|
+
if (draftNode.current) {
|
|
775
|
+
useLiveTransforms.getState().clear(draftNode.current.id)
|
|
776
|
+
}
|
|
777
|
+
draftNode.destroy()
|
|
778
|
+
useScene.temporal.getState().resume()
|
|
779
|
+
emitter.off('grid:move', onGridMove)
|
|
780
|
+
emitter.off('grid:click', onGridClick)
|
|
781
|
+
emitter.off('item:enter', onItemEnter)
|
|
782
|
+
emitter.off('item:move', onItemMove)
|
|
783
|
+
emitter.off('item:leave', onItemLeave)
|
|
784
|
+
emitter.off('item:click', onItemClick)
|
|
785
|
+
emitter.off('wall:enter', onWallEnter)
|
|
786
|
+
emitter.off('wall:move', onWallMove)
|
|
787
|
+
emitter.off('wall:click', onWallClick)
|
|
788
|
+
emitter.off('wall:leave', onWallLeave)
|
|
789
|
+
emitter.off('ceiling:enter', onCeilingEnter)
|
|
790
|
+
emitter.off('ceiling:move', onCeilingMove)
|
|
791
|
+
emitter.off('ceiling:click', onCeilingClick)
|
|
792
|
+
emitter.off('ceiling:leave', onCeilingLeave)
|
|
793
|
+
emitter.off('tool:cancel', onCancel)
|
|
794
|
+
window.removeEventListener('keydown', onKeyDown)
|
|
795
|
+
window.removeEventListener('keyup', onKeyUp)
|
|
796
|
+
window.removeEventListener('contextmenu', onContextMenu)
|
|
797
|
+
}
|
|
798
|
+
}, [asset, canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling, draftNode])
|
|
799
|
+
|
|
800
|
+
// Reparent floor draft to the new level when the user switches levels mid-placement.
|
|
801
|
+
// Wall/ceiling items are managed by their own surface entry events (ensureDraft / reparent).
|
|
802
|
+
const viewerLevelId = useViewer((s) => s.selection.levelId)
|
|
803
|
+
useEffect(() => {
|
|
804
|
+
const draft = draftNode.current
|
|
805
|
+
if (!(draft && viewerLevelId) || asset.attachTo) return
|
|
806
|
+
if (draft.parentId === viewerLevelId) return
|
|
807
|
+
draft.parentId = viewerLevelId
|
|
808
|
+
useScene.getState().updateNode(draft.id as AnyNodeId, { parentId: viewerLevelId })
|
|
809
|
+
}, [viewerLevelId, draftNode, asset])
|
|
810
|
+
|
|
811
|
+
useFrame((_, delta) => {
|
|
812
|
+
if (!draftNode.current) return
|
|
813
|
+
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
|
|
814
|
+
if (!mesh) return
|
|
815
|
+
|
|
816
|
+
// Hide wall/ceiling-attached items when between surfaces (only cursor visible)
|
|
817
|
+
if (asset.attachTo && placementState.current.surface === 'floor') {
|
|
818
|
+
mesh.visible = false
|
|
819
|
+
return
|
|
820
|
+
}
|
|
821
|
+
mesh.visible = true
|
|
822
|
+
|
|
823
|
+
if (placementState.current.surface === 'floor') {
|
|
824
|
+
const distance = mesh.position.distanceToSquared(gridPosition.current)
|
|
825
|
+
if (distance > 1) {
|
|
826
|
+
mesh.position.copy(gridPosition.current)
|
|
827
|
+
} else {
|
|
828
|
+
mesh.position.lerp(gridPosition.current, delta * 20)
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Adjust Y for slab elevation (floor items on top of slabs)
|
|
832
|
+
if (!asset.attachTo) {
|
|
833
|
+
const nodes = useScene.getState().nodes
|
|
834
|
+
const levelId = resolveLevelId(draftNode.current, nodes)
|
|
835
|
+
const slabElevation = spatialGridManager.getSlabElevationForItem(
|
|
836
|
+
levelId,
|
|
837
|
+
[gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
|
|
838
|
+
getScaledDimensions(draftNode.current),
|
|
839
|
+
draftNode.current.rotation,
|
|
840
|
+
)
|
|
841
|
+
mesh.position.y = slabElevation
|
|
842
|
+
// Cursor group is at the world root (not inside a level group), so add the
|
|
843
|
+
// level group's current world Y to convert from level-local to world space.
|
|
844
|
+
const levelGroup = sceneRegistry.nodes.get(levelId as AnyNodeId)
|
|
845
|
+
cursorGroupRef.current.position.y = slabElevation + (levelGroup?.position.y ?? 0)
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
const initialDraft = draftNode.current
|
|
851
|
+
const dims = initialDraft
|
|
852
|
+
? getScaledDimensions(initialDraft)
|
|
853
|
+
: (config.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
|
854
|
+
const initialBoxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
|
|
855
|
+
const wallSideZOffset = config.asset.attachTo === 'wall-side' ? -dims[2] / 2 : 0
|
|
856
|
+
initialBoxGeometry.translate(0, dims[1] / 2, wallSideZOffset)
|
|
857
|
+
|
|
858
|
+
// Base plane geometry (colored rectangle on the ground)
|
|
859
|
+
const basePlaneGeometry = new PlaneGeometry(dims[0], dims[2])
|
|
860
|
+
basePlaneGeometry.rotateX(-Math.PI / 2) // Make it horizontal
|
|
861
|
+
basePlaneGeometry.translate(0, 0.01, wallSideZOffset) // Slightly above ground to avoid z-fighting
|
|
862
|
+
|
|
863
|
+
return (
|
|
864
|
+
<group ref={cursorGroupRef}>
|
|
865
|
+
<lineSegments layers={EDITOR_LAYER} material={edgeMaterial} ref={edgesRef} renderOrder={999}>
|
|
866
|
+
<edgesGeometry args={[initialBoxGeometry]} />
|
|
867
|
+
</lineSegments>
|
|
868
|
+
<mesh
|
|
869
|
+
geometry={basePlaneGeometry}
|
|
870
|
+
layers={EDITOR_LAYER}
|
|
871
|
+
material={basePlaneMaterial}
|
|
872
|
+
ref={basePlaneRef}
|
|
873
|
+
renderOrder={999}
|
|
874
|
+
/>
|
|
875
|
+
</group>
|
|
876
|
+
)
|
|
877
|
+
}
|