@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,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