@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,556 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AnyNode,
|
|
3
|
+
AnyNodeId,
|
|
4
|
+
CeilingEvent,
|
|
5
|
+
CeilingNode,
|
|
6
|
+
GridEvent,
|
|
7
|
+
ItemEvent,
|
|
8
|
+
ItemNode,
|
|
9
|
+
WallEvent,
|
|
10
|
+
WallNode,
|
|
11
|
+
} from '@pascal-app/core'
|
|
12
|
+
import { getScaledDimensions, sceneRegistry, useScene } from '@pascal-app/core'
|
|
13
|
+
import { Vector3 } from 'three'
|
|
14
|
+
import {
|
|
15
|
+
calculateCursorRotation,
|
|
16
|
+
calculateItemRotation,
|
|
17
|
+
getSideFromNormal,
|
|
18
|
+
isValidWallSideFace,
|
|
19
|
+
snapToGrid,
|
|
20
|
+
snapToHalf,
|
|
21
|
+
stripTransient,
|
|
22
|
+
} from './placement-math'
|
|
23
|
+
import type {
|
|
24
|
+
CommitResult,
|
|
25
|
+
LevelResolver,
|
|
26
|
+
PlacementContext,
|
|
27
|
+
PlacementResult,
|
|
28
|
+
SpatialValidators,
|
|
29
|
+
TransitionResult,
|
|
30
|
+
} from './placement-types'
|
|
31
|
+
|
|
32
|
+
const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
|
|
33
|
+
|
|
34
|
+
// ============================================================================
|
|
35
|
+
// FLOOR STRATEGY
|
|
36
|
+
// ============================================================================
|
|
37
|
+
|
|
38
|
+
export const floorStrategy = {
|
|
39
|
+
/**
|
|
40
|
+
* Handle grid:move — update position when on floor surface.
|
|
41
|
+
* Returns null if currently on wall/ceiling.
|
|
42
|
+
*/
|
|
43
|
+
move(ctx: PlacementContext, event: GridEvent): PlacementResult | null {
|
|
44
|
+
if (ctx.state.surface !== 'floor') return null
|
|
45
|
+
|
|
46
|
+
const dims = ctx.draftItem
|
|
47
|
+
? getScaledDimensions(ctx.draftItem)
|
|
48
|
+
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
|
49
|
+
const [dimX, , dimZ] = dims
|
|
50
|
+
const x = snapToGrid(event.position[0], dimX)
|
|
51
|
+
const z = snapToGrid(event.position[2], dimZ)
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
gridPosition: [x, 0, z],
|
|
55
|
+
cursorPosition: [x, event.position[1], z],
|
|
56
|
+
cursorRotationY: 0,
|
|
57
|
+
nodeUpdate: { position: [x, 0, z] },
|
|
58
|
+
stopPropagation: false,
|
|
59
|
+
dirtyNodeId: null,
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Handle grid:click — commit placement on floor.
|
|
65
|
+
* Returns null if on wall/ceiling or validation fails.
|
|
66
|
+
*/
|
|
67
|
+
click(
|
|
68
|
+
ctx: PlacementContext,
|
|
69
|
+
_event: GridEvent,
|
|
70
|
+
validators: SpatialValidators,
|
|
71
|
+
): CommitResult | null {
|
|
72
|
+
if (ctx.state.surface !== 'floor') return null
|
|
73
|
+
if (!(ctx.levelId && ctx.draftItem)) return null
|
|
74
|
+
|
|
75
|
+
const pos: [number, number, number] = [ctx.gridPosition.x, 0, ctx.gridPosition.z]
|
|
76
|
+
const valid = validators.canPlaceOnFloor(
|
|
77
|
+
ctx.levelId,
|
|
78
|
+
pos,
|
|
79
|
+
getScaledDimensions(ctx.draftItem),
|
|
80
|
+
[0, 0, 0],
|
|
81
|
+
[ctx.draftItem.id],
|
|
82
|
+
).valid
|
|
83
|
+
|
|
84
|
+
if (!valid) return null
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
nodeUpdate: {
|
|
88
|
+
position: pos,
|
|
89
|
+
parentId: ctx.levelId,
|
|
90
|
+
metadata: stripTransient(ctx.draftItem.metadata),
|
|
91
|
+
},
|
|
92
|
+
stopPropagation: false,
|
|
93
|
+
dirtyNodeId: null,
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ============================================================================
|
|
99
|
+
// WALL STRATEGY
|
|
100
|
+
// ============================================================================
|
|
101
|
+
|
|
102
|
+
export const wallStrategy = {
|
|
103
|
+
/**
|
|
104
|
+
* Handle wall:enter — transition from floor to wall surface.
|
|
105
|
+
* Returns null if item doesn't attach to walls, face is invalid, or wrong level.
|
|
106
|
+
* Auto-adjusts Y position to fit within wall bounds.
|
|
107
|
+
*/
|
|
108
|
+
enter(
|
|
109
|
+
ctx: PlacementContext,
|
|
110
|
+
event: WallEvent,
|
|
111
|
+
resolveLevelId: LevelResolver,
|
|
112
|
+
nodes: Record<string, AnyNode>,
|
|
113
|
+
validators: SpatialValidators,
|
|
114
|
+
): TransitionResult | null {
|
|
115
|
+
const attachTo = ctx.asset.attachTo
|
|
116
|
+
if (attachTo !== 'wall' && attachTo !== 'wall-side') return null
|
|
117
|
+
if (!isValidWallSideFace(event.normal)) return null
|
|
118
|
+
|
|
119
|
+
// Level guard
|
|
120
|
+
const wallLevelId = resolveLevelId(event.node, nodes)
|
|
121
|
+
if (ctx.levelId !== wallLevelId) return null
|
|
122
|
+
|
|
123
|
+
const side = getSideFromNormal(event.normal)
|
|
124
|
+
const itemRotation = calculateItemRotation(event.normal)
|
|
125
|
+
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
|
126
|
+
|
|
127
|
+
const x = snapToHalf(event.localPosition[0])
|
|
128
|
+
const y = snapToHalf(event.localPosition[1])
|
|
129
|
+
const z = snapToHalf(event.localPosition[2])
|
|
130
|
+
|
|
131
|
+
// Get auto-adjusted Y position from validator
|
|
132
|
+
const validation = validators.canPlaceOnWall(
|
|
133
|
+
ctx.levelId,
|
|
134
|
+
event.node.id,
|
|
135
|
+
x,
|
|
136
|
+
y,
|
|
137
|
+
ctx.draftItem
|
|
138
|
+
? getScaledDimensions(ctx.draftItem)
|
|
139
|
+
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS),
|
|
140
|
+
attachTo,
|
|
141
|
+
side,
|
|
142
|
+
[],
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
const adjustedY = validation.adjustedY ?? y
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
stateUpdate: { surface: 'wall', wallId: event.node.id },
|
|
149
|
+
nodeUpdate: {
|
|
150
|
+
position: [x, adjustedY, z],
|
|
151
|
+
parentId: event.node.id,
|
|
152
|
+
side,
|
|
153
|
+
rotation: [0, itemRotation, 0],
|
|
154
|
+
},
|
|
155
|
+
cursorRotationY: cursorRotation,
|
|
156
|
+
gridPosition: [x, adjustedY, z],
|
|
157
|
+
cursorPosition: [
|
|
158
|
+
snapToHalf(event.position[0]),
|
|
159
|
+
snapToHalf(event.position[1]),
|
|
160
|
+
snapToHalf(event.position[2]),
|
|
161
|
+
],
|
|
162
|
+
stopPropagation: true,
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Handle wall:move — update position while on wall.
|
|
168
|
+
* Returns null if not on a wall or face is invalid.
|
|
169
|
+
* Auto-adjusts Y position to fit within wall bounds.
|
|
170
|
+
*/
|
|
171
|
+
move(
|
|
172
|
+
ctx: PlacementContext,
|
|
173
|
+
event: WallEvent,
|
|
174
|
+
validators: SpatialValidators,
|
|
175
|
+
): PlacementResult | null {
|
|
176
|
+
if (ctx.state.surface !== 'wall') return null
|
|
177
|
+
if (!(ctx.draftItem && ctx.levelId)) return null
|
|
178
|
+
if (!isValidWallSideFace(event.normal)) return null
|
|
179
|
+
|
|
180
|
+
const side = getSideFromNormal(event.normal)
|
|
181
|
+
const itemRotation = calculateItemRotation(event.normal)
|
|
182
|
+
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
|
183
|
+
|
|
184
|
+
const snappedX = snapToHalf(event.localPosition[0])
|
|
185
|
+
const snappedY = snapToHalf(event.localPosition[1])
|
|
186
|
+
const snappedZ = snapToHalf(event.localPosition[2])
|
|
187
|
+
|
|
188
|
+
// Get auto-adjusted Y position from validator
|
|
189
|
+
const validation = validators.canPlaceOnWall(
|
|
190
|
+
ctx.levelId,
|
|
191
|
+
event.node.id,
|
|
192
|
+
snappedX,
|
|
193
|
+
snappedY,
|
|
194
|
+
getScaledDimensions(ctx.draftItem),
|
|
195
|
+
ctx.draftItem.asset.attachTo as 'wall' | 'wall-side',
|
|
196
|
+
side,
|
|
197
|
+
[ctx.draftItem.id],
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
const adjustedY = validation.adjustedY ?? snappedY
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
gridPosition: [snappedX, adjustedY, snappedZ],
|
|
204
|
+
cursorPosition: [
|
|
205
|
+
snapToHalf(event.position[0]),
|
|
206
|
+
snapToHalf(event.position[1]),
|
|
207
|
+
snapToHalf(event.position[2]),
|
|
208
|
+
],
|
|
209
|
+
cursorRotationY: cursorRotation,
|
|
210
|
+
nodeUpdate: {
|
|
211
|
+
position: [snappedX, adjustedY, snappedZ],
|
|
212
|
+
side,
|
|
213
|
+
rotation: [0, itemRotation, 0],
|
|
214
|
+
},
|
|
215
|
+
stopPropagation: true,
|
|
216
|
+
dirtyNodeId: event.node.id,
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Handle wall:click — commit placement on wall.
|
|
222
|
+
* Returns null if not on wall, face invalid, or validation fails.
|
|
223
|
+
*/
|
|
224
|
+
click(
|
|
225
|
+
ctx: PlacementContext,
|
|
226
|
+
event: WallEvent,
|
|
227
|
+
validators: SpatialValidators,
|
|
228
|
+
): CommitResult | null {
|
|
229
|
+
if (ctx.state.surface !== 'wall') return null
|
|
230
|
+
if (!isValidWallSideFace(event.normal)) return null
|
|
231
|
+
if (!(ctx.levelId && ctx.draftItem)) return null
|
|
232
|
+
|
|
233
|
+
const valid = validators.canPlaceOnWall(
|
|
234
|
+
ctx.levelId,
|
|
235
|
+
ctx.state.wallId as WallNode['id'],
|
|
236
|
+
ctx.gridPosition.x,
|
|
237
|
+
ctx.gridPosition.y,
|
|
238
|
+
getScaledDimensions(ctx.draftItem),
|
|
239
|
+
ctx.draftItem.asset.attachTo as 'wall' | 'wall-side',
|
|
240
|
+
ctx.draftItem.side,
|
|
241
|
+
[ctx.draftItem.id],
|
|
242
|
+
).valid
|
|
243
|
+
|
|
244
|
+
if (!valid) return null
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
nodeUpdate: {
|
|
248
|
+
position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
|
249
|
+
parentId: event.node.id,
|
|
250
|
+
side: ctx.draftItem.side,
|
|
251
|
+
rotation: ctx.draftItem.rotation,
|
|
252
|
+
metadata: stripTransient(ctx.draftItem.metadata),
|
|
253
|
+
},
|
|
254
|
+
stopPropagation: true,
|
|
255
|
+
dirtyNodeId: event.node.id,
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Handle wall:leave — transition back to floor surface.
|
|
261
|
+
*/
|
|
262
|
+
leave(ctx: PlacementContext): TransitionResult | null {
|
|
263
|
+
if (ctx.state.surface !== 'wall') return null
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
stateUpdate: { surface: 'floor', wallId: null },
|
|
267
|
+
nodeUpdate: {
|
|
268
|
+
position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
|
269
|
+
parentId: ctx.levelId,
|
|
270
|
+
},
|
|
271
|
+
cursorRotationY: 0,
|
|
272
|
+
gridPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
|
273
|
+
cursorPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
|
274
|
+
stopPropagation: true,
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ============================================================================
|
|
280
|
+
// CEILING STRATEGY
|
|
281
|
+
// ============================================================================
|
|
282
|
+
|
|
283
|
+
export const ceilingStrategy = {
|
|
284
|
+
/**
|
|
285
|
+
* Handle ceiling:enter — transition from floor to ceiling surface.
|
|
286
|
+
* Returns null if item doesn't attach to ceilings or wrong level.
|
|
287
|
+
*/
|
|
288
|
+
enter(
|
|
289
|
+
ctx: PlacementContext,
|
|
290
|
+
event: CeilingEvent,
|
|
291
|
+
resolveLevelId: LevelResolver,
|
|
292
|
+
nodes: Record<string, AnyNode>,
|
|
293
|
+
): TransitionResult | null {
|
|
294
|
+
if (ctx.asset.attachTo !== 'ceiling') return null
|
|
295
|
+
|
|
296
|
+
// Level guard
|
|
297
|
+
const ceilingLevelId = resolveLevelId(event.node, nodes)
|
|
298
|
+
if (ctx.levelId !== ceilingLevelId) return null
|
|
299
|
+
|
|
300
|
+
const dims = ctx.draftItem
|
|
301
|
+
? getScaledDimensions(ctx.draftItem)
|
|
302
|
+
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
|
303
|
+
const [dimX, , dimZ] = dims
|
|
304
|
+
const itemHeight = dims[1]
|
|
305
|
+
|
|
306
|
+
const x = snapToGrid(event.position[0], dimX)
|
|
307
|
+
const z = snapToGrid(event.position[2], dimZ)
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
stateUpdate: { surface: 'ceiling', ceilingId: event.node.id },
|
|
311
|
+
nodeUpdate: {
|
|
312
|
+
position: [x, -itemHeight, z],
|
|
313
|
+
parentId: event.node.id,
|
|
314
|
+
},
|
|
315
|
+
cursorRotationY: 0,
|
|
316
|
+
gridPosition: [x, -itemHeight, z],
|
|
317
|
+
cursorPosition: [x, event.position[1] - itemHeight, z],
|
|
318
|
+
stopPropagation: true,
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Handle ceiling:move — update position while on ceiling.
|
|
324
|
+
*/
|
|
325
|
+
move(ctx: PlacementContext, event: CeilingEvent): PlacementResult | null {
|
|
326
|
+
if (ctx.state.surface !== 'ceiling') return null
|
|
327
|
+
if (!ctx.draftItem) return null
|
|
328
|
+
|
|
329
|
+
const dims = getScaledDimensions(ctx.draftItem)
|
|
330
|
+
const [dimX, , dimZ] = dims
|
|
331
|
+
const itemHeight = dims[1]
|
|
332
|
+
|
|
333
|
+
const x = snapToGrid(event.position[0], dimX)
|
|
334
|
+
const z = snapToGrid(event.position[2], dimZ)
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
gridPosition: [x, -itemHeight, z],
|
|
338
|
+
cursorPosition: [x, event.position[1] - itemHeight, z],
|
|
339
|
+
cursorRotationY: 0,
|
|
340
|
+
nodeUpdate: null,
|
|
341
|
+
stopPropagation: true,
|
|
342
|
+
dirtyNodeId: null,
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Handle ceiling:click — commit placement on ceiling.
|
|
348
|
+
*/
|
|
349
|
+
click(
|
|
350
|
+
ctx: PlacementContext,
|
|
351
|
+
event: CeilingEvent,
|
|
352
|
+
validators: SpatialValidators,
|
|
353
|
+
): CommitResult | null {
|
|
354
|
+
if (ctx.state.surface !== 'ceiling') return null
|
|
355
|
+
if (!ctx.draftItem) return null
|
|
356
|
+
|
|
357
|
+
const pos: [number, number, number] = [
|
|
358
|
+
ctx.gridPosition.x,
|
|
359
|
+
ctx.gridPosition.y,
|
|
360
|
+
ctx.gridPosition.z,
|
|
361
|
+
]
|
|
362
|
+
|
|
363
|
+
const valid = validators.canPlaceOnCeiling(
|
|
364
|
+
ctx.state.ceilingId as CeilingNode['id'],
|
|
365
|
+
pos,
|
|
366
|
+
getScaledDimensions(ctx.draftItem),
|
|
367
|
+
ctx.draftItem.rotation,
|
|
368
|
+
[ctx.draftItem.id],
|
|
369
|
+
).valid
|
|
370
|
+
|
|
371
|
+
if (!valid) return null
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
nodeUpdate: {
|
|
375
|
+
position: pos,
|
|
376
|
+
parentId: event.node.id,
|
|
377
|
+
metadata: stripTransient(ctx.draftItem.metadata),
|
|
378
|
+
},
|
|
379
|
+
stopPropagation: true,
|
|
380
|
+
dirtyNodeId: null,
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Handle ceiling:leave — transition back to floor surface.
|
|
386
|
+
*/
|
|
387
|
+
leave(ctx: PlacementContext): TransitionResult | null {
|
|
388
|
+
if (ctx.state.surface !== 'ceiling') return null
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
stateUpdate: { surface: 'floor', ceilingId: null },
|
|
392
|
+
nodeUpdate: {
|
|
393
|
+
position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
|
394
|
+
parentId: ctx.levelId,
|
|
395
|
+
},
|
|
396
|
+
cursorRotationY: 0,
|
|
397
|
+
gridPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
|
398
|
+
cursorPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
|
399
|
+
stopPropagation: true,
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ============================================================================
|
|
405
|
+
// ITEM SURFACE STRATEGY
|
|
406
|
+
// ============================================================================
|
|
407
|
+
|
|
408
|
+
export const itemSurfaceStrategy = {
|
|
409
|
+
/**
|
|
410
|
+
* Handle item:enter — transition from floor to an item surface.
|
|
411
|
+
* Returns null if: item has no surface, our item doesn't fit, or it's the draft itself.
|
|
412
|
+
*/
|
|
413
|
+
enter(ctx: PlacementContext, event: ItemEvent): TransitionResult | null {
|
|
414
|
+
// Only floor items can be placed on surfaces
|
|
415
|
+
if (ctx.asset.attachTo) return null
|
|
416
|
+
|
|
417
|
+
const surfaceItem = event.node as ItemNode
|
|
418
|
+
// Don't surface-place on the draft itself
|
|
419
|
+
if (surfaceItem.id === ctx.draftItem?.id) return null
|
|
420
|
+
// Surface item must declare a surface
|
|
421
|
+
if (!surfaceItem.asset.surface) return null
|
|
422
|
+
|
|
423
|
+
// Size check: our footprint must fit on surface item's footprint
|
|
424
|
+
const ourDims = ctx.draftItem
|
|
425
|
+
? getScaledDimensions(ctx.draftItem)
|
|
426
|
+
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
|
427
|
+
const surfDims = getScaledDimensions(surfaceItem)
|
|
428
|
+
if (ourDims[0] > surfDims[0] || ourDims[2] > surfDims[2]) return null
|
|
429
|
+
|
|
430
|
+
const surfaceMesh = sceneRegistry.nodes.get(surfaceItem.id)
|
|
431
|
+
if (!surfaceMesh) return null
|
|
432
|
+
|
|
433
|
+
const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
|
|
434
|
+
const localPos = surfaceMesh.worldToLocal(worldPos)
|
|
435
|
+
|
|
436
|
+
const x = snapToGrid(localPos.x, ourDims[0])
|
|
437
|
+
const z = snapToGrid(localPos.z, ourDims[2])
|
|
438
|
+
const y = surfaceItem.asset.surface.height * surfaceItem.scale[1]
|
|
439
|
+
|
|
440
|
+
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
|
|
441
|
+
|
|
442
|
+
return {
|
|
443
|
+
stateUpdate: { surface: 'item-surface', surfaceItemId: surfaceItem.id },
|
|
444
|
+
nodeUpdate: { position: [x, y, z], parentId: surfaceItem.id },
|
|
445
|
+
cursorRotationY: 0,
|
|
446
|
+
gridPosition: [x, y, z],
|
|
447
|
+
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
|
|
448
|
+
stopPropagation: true,
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Handle item:move — update position while on an item surface.
|
|
454
|
+
*/
|
|
455
|
+
move(ctx: PlacementContext, event: ItemEvent): PlacementResult | null {
|
|
456
|
+
if (ctx.state.surface !== 'item-surface') return null
|
|
457
|
+
if (!(ctx.state.surfaceItemId && ctx.draftItem)) return null
|
|
458
|
+
|
|
459
|
+
const nodes = useScene.getState().nodes
|
|
460
|
+
const surfaceItem = nodes[ctx.state.surfaceItemId as AnyNodeId] as ItemNode | undefined
|
|
461
|
+
if (!surfaceItem?.asset.surface) return null
|
|
462
|
+
|
|
463
|
+
const surfaceMesh = sceneRegistry.nodes.get(ctx.state.surfaceItemId)
|
|
464
|
+
if (!surfaceMesh) return null
|
|
465
|
+
|
|
466
|
+
const ourDims = getScaledDimensions(ctx.draftItem)
|
|
467
|
+
const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
|
|
468
|
+
const localPos = surfaceMesh.worldToLocal(worldPos)
|
|
469
|
+
|
|
470
|
+
const x = snapToGrid(localPos.x, ourDims[0])
|
|
471
|
+
const z = snapToGrid(localPos.z, ourDims[2])
|
|
472
|
+
const y = surfaceItem.asset.surface.height * surfaceItem.scale[1]
|
|
473
|
+
|
|
474
|
+
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
|
|
475
|
+
|
|
476
|
+
return {
|
|
477
|
+
gridPosition: [x, y, z],
|
|
478
|
+
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
|
|
479
|
+
cursorRotationY: 0,
|
|
480
|
+
nodeUpdate: { position: [x, y, z] },
|
|
481
|
+
stopPropagation: true,
|
|
482
|
+
dirtyNodeId: null,
|
|
483
|
+
}
|
|
484
|
+
},
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Handle item:click — commit placement on item surface.
|
|
488
|
+
*/
|
|
489
|
+
click(ctx: PlacementContext, _event: ItemEvent): CommitResult | null {
|
|
490
|
+
if (ctx.state.surface !== 'item-surface') return null
|
|
491
|
+
if (!(ctx.draftItem && ctx.state.surfaceItemId)) return null
|
|
492
|
+
|
|
493
|
+
return {
|
|
494
|
+
nodeUpdate: {
|
|
495
|
+
position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
|
496
|
+
parentId: ctx.state.surfaceItemId,
|
|
497
|
+
metadata: stripTransient(ctx.draftItem.metadata),
|
|
498
|
+
},
|
|
499
|
+
stopPropagation: true,
|
|
500
|
+
dirtyNodeId: null,
|
|
501
|
+
}
|
|
502
|
+
},
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// ============================================================================
|
|
506
|
+
// VALIDATION
|
|
507
|
+
// ============================================================================
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Unified validation: check if the current draft item can be placed at its current position.
|
|
511
|
+
* Switches on the active surface type and calls the appropriate spatial validator.
|
|
512
|
+
*/
|
|
513
|
+
export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidators): boolean {
|
|
514
|
+
if (!(ctx.levelId && ctx.draftItem)) return false
|
|
515
|
+
|
|
516
|
+
// Item surface: valid if we entered (size check was in enter)
|
|
517
|
+
if (ctx.state.surface === 'item-surface') {
|
|
518
|
+
return ctx.state.surfaceItemId !== null
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const attachTo = ctx.draftItem.asset.attachTo
|
|
522
|
+
|
|
523
|
+
if (attachTo === 'ceiling') {
|
|
524
|
+
if (ctx.state.surface !== 'ceiling' || !ctx.state.ceilingId) return false
|
|
525
|
+
return validators.canPlaceOnCeiling(
|
|
526
|
+
ctx.state.ceilingId as CeilingNode['id'],
|
|
527
|
+
[ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
|
528
|
+
getScaledDimensions(ctx.draftItem),
|
|
529
|
+
ctx.draftItem.rotation,
|
|
530
|
+
[ctx.draftItem.id],
|
|
531
|
+
).valid
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (attachTo === 'wall' || attachTo === 'wall-side') {
|
|
535
|
+
if (ctx.state.surface !== 'wall' || !ctx.state.wallId) return false
|
|
536
|
+
return validators.canPlaceOnWall(
|
|
537
|
+
ctx.levelId,
|
|
538
|
+
ctx.state.wallId as WallNode['id'],
|
|
539
|
+
ctx.gridPosition.x,
|
|
540
|
+
ctx.gridPosition.y,
|
|
541
|
+
getScaledDimensions(ctx.draftItem),
|
|
542
|
+
attachTo,
|
|
543
|
+
ctx.draftItem.side,
|
|
544
|
+
[ctx.draftItem.id],
|
|
545
|
+
).valid
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Floor (no attachTo)
|
|
549
|
+
return validators.canPlaceOnFloor(
|
|
550
|
+
ctx.levelId,
|
|
551
|
+
[ctx.gridPosition.x, 0, ctx.gridPosition.z],
|
|
552
|
+
getScaledDimensions(ctx.draftItem),
|
|
553
|
+
[0, 0, 0],
|
|
554
|
+
[ctx.draftItem.id],
|
|
555
|
+
).valid
|
|
556
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AnyNode,
|
|
3
|
+
AssetInput,
|
|
4
|
+
CeilingNode,
|
|
5
|
+
ItemNode,
|
|
6
|
+
LevelNode,
|
|
7
|
+
WallNode,
|
|
8
|
+
} from '@pascal-app/core'
|
|
9
|
+
import type { Vector3 } from 'three'
|
|
10
|
+
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// PLACEMENT STATE
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Tracks which surface the draft item is currently on.
|
|
19
|
+
* Replaces the scattered isOnWall, isOnCeiling refs and currentWallId, currentCeilingId variables.
|
|
20
|
+
*/
|
|
21
|
+
export interface PlacementState {
|
|
22
|
+
surface: SurfaceType
|
|
23
|
+
wallId: string | null
|
|
24
|
+
ceilingId: string | null
|
|
25
|
+
surfaceItemId: string | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// STRATEGY CONTEXT
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read-only snapshot passed to every strategy call.
|
|
34
|
+
*/
|
|
35
|
+
export interface PlacementContext {
|
|
36
|
+
asset: AssetInput
|
|
37
|
+
levelId: LevelNode['id'] | null
|
|
38
|
+
draftItem: ItemNode | null
|
|
39
|
+
gridPosition: Vector3
|
|
40
|
+
state: PlacementState
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// STRATEGY RESULTS
|
|
45
|
+
// ============================================================================
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Returned by strategy move handlers.
|
|
49
|
+
*/
|
|
50
|
+
export interface PlacementResult {
|
|
51
|
+
gridPosition: [number, number, number]
|
|
52
|
+
cursorPosition: [number, number, number]
|
|
53
|
+
cursorRotationY: number
|
|
54
|
+
nodeUpdate: Partial<ItemNode> | null
|
|
55
|
+
stopPropagation: boolean
|
|
56
|
+
dirtyNodeId: AnyNode['id'] | null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Returned by enter/leave handlers (surface transitions).
|
|
61
|
+
*/
|
|
62
|
+
export interface TransitionResult {
|
|
63
|
+
stateUpdate: Partial<PlacementState>
|
|
64
|
+
nodeUpdate: Partial<ItemNode>
|
|
65
|
+
gridPosition: [number, number, number]
|
|
66
|
+
cursorPosition: [number, number, number]
|
|
67
|
+
cursorRotationY: number
|
|
68
|
+
stopPropagation: boolean
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Returned by click handlers (commit placement).
|
|
73
|
+
*/
|
|
74
|
+
export interface CommitResult {
|
|
75
|
+
nodeUpdate: Partial<ItemNode>
|
|
76
|
+
stopPropagation: boolean
|
|
77
|
+
dirtyNodeId: AnyNode['id'] | null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ============================================================================
|
|
81
|
+
// SPATIAL VALIDATORS
|
|
82
|
+
// ============================================================================
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Type for the useSpatialQuery() return value.
|
|
86
|
+
*/
|
|
87
|
+
export interface SpatialValidators {
|
|
88
|
+
canPlaceOnFloor: (
|
|
89
|
+
levelId: LevelNode['id'],
|
|
90
|
+
position: [number, number, number],
|
|
91
|
+
dimensions: [number, number, number],
|
|
92
|
+
rotation: [number, number, number],
|
|
93
|
+
ignoreIds?: string[],
|
|
94
|
+
) => { valid: boolean }
|
|
95
|
+
canPlaceOnWall: (
|
|
96
|
+
levelId: LevelNode['id'],
|
|
97
|
+
wallId: WallNode['id'],
|
|
98
|
+
localX: number,
|
|
99
|
+
localY: number,
|
|
100
|
+
dimensions: [number, number, number],
|
|
101
|
+
attachType: 'wall' | 'wall-side',
|
|
102
|
+
side?: 'front' | 'back',
|
|
103
|
+
ignoreIds?: string[],
|
|
104
|
+
) => { valid: boolean; adjustedY?: number; wasAdjusted?: boolean }
|
|
105
|
+
canPlaceOnCeiling: (
|
|
106
|
+
ceilingId: CeilingNode['id'],
|
|
107
|
+
position: [number, number, number],
|
|
108
|
+
dimensions: [number, number, number],
|
|
109
|
+
rotation: [number, number, number],
|
|
110
|
+
ignoreIds?: string[],
|
|
111
|
+
) => { valid: boolean }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Resolver function type for finding a node's level.
|
|
116
|
+
*/
|
|
117
|
+
export type LevelResolver = (node: AnyNode, nodes: Record<string, AnyNode>) => string
|