@pascal-app/editor 0.6.0 → 0.7.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 (122) hide show
  1. package/package.json +9 -5
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +75 -7
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +20 -0
  6. package/src/components/editor/first-person/build-collider-world.ts +365 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
  8. package/src/components/editor/first-person-controls.tsx +496 -143
  9. package/src/components/editor/floating-action-menu.tsx +32 -55
  10. package/src/components/editor/floorplan-background-selection.ts +113 -0
  11. package/src/components/editor/floorplan-panel.tsx +9855 -3298
  12. package/src/components/editor/index.tsx +269 -21
  13. package/src/components/editor/selection-manager.tsx +575 -13
  14. package/src/components/editor/thumbnail-generator.tsx +38 -7
  15. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  16. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  17. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  18. package/src/components/editor/wall-measurement-label.tsx +267 -36
  19. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  20. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  21. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  22. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
  23. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  24. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
  25. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  26. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  27. package/src/components/editor-2d/svg-paths.ts +119 -0
  28. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  29. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  30. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  31. package/src/components/tools/column/column-tool.tsx +97 -0
  32. package/src/components/tools/column/move-column-tool.tsx +105 -0
  33. package/src/components/tools/door/door-tool.tsx +7 -0
  34. package/src/components/tools/door/move-door-tool.tsx +28 -8
  35. package/src/components/tools/fence/fence-drafting.ts +10 -3
  36. package/src/components/tools/fence/fence-tool.tsx +159 -3
  37. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +129 -18
  38. package/src/components/tools/fence/move-fence-tool.tsx +101 -34
  39. package/src/components/tools/item/move-tool.tsx +10 -1
  40. package/src/components/tools/item/placement-math.ts +30 -1
  41. package/src/components/tools/item/placement-strategies.ts +109 -31
  42. package/src/components/tools/item/placement-types.ts +7 -0
  43. package/src/components/tools/item/use-draft-node.ts +2 -0
  44. package/src/components/tools/item/use-placement-coordinator.tsx +660 -52
  45. package/src/components/tools/roof/move-roof-tool.tsx +22 -15
  46. package/src/components/tools/shared/polygon-editor.tsx +153 -28
  47. package/src/components/tools/shared/segment-angle.ts +156 -0
  48. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  49. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  50. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  51. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  52. package/src/components/tools/tool-manager.tsx +18 -3
  53. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +121 -20
  54. package/src/components/tools/wall/wall-drafting.ts +18 -9
  55. package/src/components/tools/wall/wall-tool.tsx +134 -2
  56. package/src/components/tools/window/move-window-tool.tsx +18 -0
  57. package/src/components/tools/window/window-tool.tsx +5 -0
  58. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  59. package/src/components/ui/action-menu/control-modes.tsx +28 -1
  60. package/src/components/ui/action-menu/index.tsx +91 -1
  61. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  62. package/src/components/ui/action-menu/view-toggles.tsx +424 -35
  63. package/src/components/ui/command-palette/editor-commands.tsx +18 -1
  64. package/src/components/ui/controls/material-picker.tsx +152 -165
  65. package/src/components/ui/controls/slider-control.tsx +66 -18
  66. package/src/components/ui/floating-level-selector.tsx +286 -55
  67. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  68. package/src/components/ui/item-catalog/catalog-items.tsx +1116 -1219
  69. package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
  70. package/src/components/ui/level-duplicate-dialog.tsx +115 -0
  71. package/src/components/ui/panels/ceiling-panel.tsx +1 -25
  72. package/src/components/ui/panels/column-panel.tsx +715 -0
  73. package/src/components/ui/panels/door-panel.tsx +981 -289
  74. package/src/components/ui/panels/fence-panel.tsx +3 -45
  75. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  76. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  77. package/src/components/ui/panels/node-display.ts +39 -0
  78. package/src/components/ui/panels/paint-panel.tsx +138 -0
  79. package/src/components/ui/panels/panel-manager.tsx +210 -1
  80. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  81. package/src/components/ui/panels/reference-panel.tsx +238 -5
  82. package/src/components/ui/panels/roof-panel.tsx +4 -105
  83. package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
  84. package/src/components/ui/panels/slab-panel.tsx +4 -30
  85. package/src/components/ui/panels/spawn-panel.tsx +155 -0
  86. package/src/components/ui/panels/stair-panel.tsx +11 -117
  87. package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
  88. package/src/components/ui/panels/wall-panel.tsx +1 -95
  89. package/src/components/ui/panels/window-panel.tsx +660 -139
  90. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  91. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  92. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
  93. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  94. package/src/components/ui/sidebar/panels/site-panel/index.tsx +109 -24
  95. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
  96. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
  97. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +9 -3
  98. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +8 -5
  99. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  100. package/src/components/ui/viewer-toolbar.tsx +42 -1
  101. package/src/hooks/use-auto-frame.ts +45 -0
  102. package/src/hooks/use-keyboard.ts +64 -7
  103. package/src/hooks/use-mobile.ts +12 -12
  104. package/src/lib/door-interaction.ts +88 -0
  105. package/src/lib/floorplan/geometry.ts +263 -0
  106. package/src/lib/floorplan/index.ts +38 -0
  107. package/src/lib/floorplan/items.ts +179 -0
  108. package/src/lib/floorplan/selection-tool.ts +231 -0
  109. package/src/lib/floorplan/stairs.ts +478 -0
  110. package/src/lib/floorplan/types.ts +57 -0
  111. package/src/lib/floorplan/walls.ts +23 -0
  112. package/src/lib/guide-events.ts +10 -0
  113. package/src/lib/level-duplication.test.ts +72 -0
  114. package/src/lib/level-duplication.ts +153 -0
  115. package/src/lib/local-guide-image.ts +42 -0
  116. package/src/lib/material-paint.ts +284 -0
  117. package/src/lib/roof-duplication.ts +214 -0
  118. package/src/lib/scene-bounds.test.ts +183 -0
  119. package/src/lib/scene-bounds.ts +169 -0
  120. package/src/lib/stair-duplication.ts +126 -0
  121. package/src/lib/window-interaction.ts +86 -0
  122. package/src/store/use-editor.tsx +164 -8
@@ -9,11 +9,17 @@ import type {
9
9
  WallEvent,
10
10
  WallNode,
11
11
  } from '@pascal-app/core'
12
- import { getScaledDimensions, sceneRegistry, useScene } from '@pascal-app/core'
13
- import { Vector3 } from 'three'
12
+ import {
13
+ getScaledDimensions,
14
+ isLowProfileItemSurface,
15
+ sceneRegistry,
16
+ useScene,
17
+ } from '@pascal-app/core'
18
+ import { Euler, Matrix3, Quaternion, Vector3 } from 'three'
14
19
  import {
15
20
  calculateCursorRotation,
16
21
  calculateItemRotation,
22
+ getGridAlignedDimensions,
17
23
  getSideFromNormal,
18
24
  isValidWallSideFace,
19
25
  snapToGrid,
@@ -30,6 +36,46 @@ import type {
30
36
  } from './placement-types'
31
37
 
32
38
  const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
39
+ const UPWARD_SURFACE_NORMAL_MIN_Y = 0.75
40
+
41
+ function getWorldNormalY(event: ItemEvent): number | null {
42
+ if (!event.normal) return null
43
+
44
+ const normal = new Vector3(event.normal[0], event.normal[1], event.normal[2])
45
+ normal.applyNormalMatrix(new Matrix3().getNormalMatrix(event.object.matrixWorld)).normalize()
46
+ return normal.y
47
+ }
48
+
49
+ function isUpwardItemSurfaceHit(event: ItemEvent): boolean {
50
+ const normalY = getWorldNormalY(event)
51
+ return normalY !== null && normalY >= UPWARD_SURFACE_NORMAL_MIN_Y
52
+ }
53
+
54
+ function getSurfacePlacementHeight(surfaceItem: ItemNode, event: ItemEvent, localPos: Vector3) {
55
+ if (isLowProfileItemSurface(surfaceItem)) return null
56
+ if (!isUpwardItemSurfaceHit(event)) return null
57
+
58
+ if (surfaceItem.asset.surface) {
59
+ return surfaceItem.asset.surface.height * surfaceItem.scale[1]
60
+ }
61
+
62
+ if (!Number.isFinite(localPos.y)) return null
63
+ return localPos.y
64
+ }
65
+
66
+ function isDescendantOfItem(
67
+ candidate: ItemNode,
68
+ ancestor: ItemNode,
69
+ nodes: Record<string, AnyNode>,
70
+ ): boolean {
71
+ let parentId = candidate.parentId
72
+ while (parentId) {
73
+ if (parentId === ancestor.id) return true
74
+ const parent = nodes[parentId as AnyNodeId]
75
+ parentId = parent?.parentId ?? null
76
+ }
77
+ return false
78
+ }
33
79
 
34
80
  // ============================================================================
35
81
  // FLOOR STRATEGY
@@ -43,9 +89,10 @@ export const floorStrategy = {
43
89
  move(ctx: PlacementContext, event: GridEvent): PlacementResult | null {
44
90
  if (ctx.state.surface !== 'floor') return null
45
91
 
46
- const dims = ctx.draftItem
92
+ const rawDims = ctx.draftItem
47
93
  ? getScaledDimensions(ctx.draftItem)
48
94
  : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
95
+ const dims = getGridAlignedDimensions(rawDims, ctx.asset.attachTo)
49
96
  const [dimX, , dimZ] = dims
50
97
  const rotY = ctx.draftItem?.rotation?.[1] ?? 0
51
98
  const swapDims = Math.abs(Math.sin(rotY)) > 0.9
@@ -80,7 +127,7 @@ export const floorStrategy = {
80
127
  const valid = validators.canPlaceOnFloor(
81
128
  ctx.levelId,
82
129
  pos,
83
- getScaledDimensions(ctx.draftItem),
130
+ getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo),
84
131
  ctx.draftItem.rotation,
85
132
  [ctx.draftItem.id],
86
133
  ).valid
@@ -133,14 +180,15 @@ export const wallStrategy = {
133
180
  const z = snapToHalf(event.localPosition[2])
134
181
 
135
182
  // Get auto-adjusted Y position from validator
183
+ const rawDims = ctx.draftItem
184
+ ? getScaledDimensions(ctx.draftItem)
185
+ : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
136
186
  const validation = validators.canPlaceOnWall(
137
187
  ctx.levelId,
138
188
  event.node.id,
139
189
  x,
140
190
  y,
141
- ctx.draftItem
142
- ? getScaledDimensions(ctx.draftItem)
143
- : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS),
191
+ getGridAlignedDimensions(rawDims, attachTo),
144
192
  attachTo,
145
193
  side,
146
194
  [],
@@ -195,7 +243,7 @@ export const wallStrategy = {
195
243
  event.node.id,
196
244
  snappedX,
197
245
  snappedY,
198
- getScaledDimensions(ctx.draftItem),
246
+ getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo),
199
247
  ctx.draftItem.asset.attachTo as 'wall' | 'wall-side',
200
248
  side,
201
249
  [ctx.draftItem.id],
@@ -239,7 +287,7 @@ export const wallStrategy = {
239
287
  ctx.state.wallId as WallNode['id'],
240
288
  ctx.gridPosition.x,
241
289
  ctx.gridPosition.y,
242
- getScaledDimensions(ctx.draftItem),
290
+ getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo),
243
291
  ctx.draftItem.asset.attachTo as 'wall' | 'wall-side',
244
292
  ctx.draftItem.side,
245
293
  [ctx.draftItem.id],
@@ -301,16 +349,20 @@ export const ceilingStrategy = {
301
349
  const ceilingLevelId = resolveLevelId(event.node, nodes)
302
350
  if (ctx.levelId !== ceilingLevelId) return null
303
351
 
304
- const dims = ctx.draftItem
352
+ const rawDims = ctx.draftItem
305
353
  ? getScaledDimensions(ctx.draftItem)
306
354
  : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
355
+ const dims = getGridAlignedDimensions(rawDims, ctx.asset.attachTo)
307
356
  const [dimX, , dimZ] = dims
308
- const itemHeight = dims[1]
357
+ const itemHeight = rawDims[1]
309
358
  const rotY = ctx.draftItem?.rotation?.[1] ?? 0
310
359
  const swapDims = Math.abs(Math.sin(rotY)) > 0.9
311
360
 
312
- const x = snapToGrid(event.position[0], swapDims ? dimZ : dimX)
313
- const z = snapToGrid(event.position[2], swapDims ? dimX : dimZ)
361
+ // Ceiling items are stored in ceiling-local coordinates, so snapping must
362
+ // use the ceiling hit's local position rather than world position.
363
+ const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
364
+ const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
365
+ const worldSnapped = event.object.localToWorld(new Vector3(x, -itemHeight, z))
314
366
 
315
367
  return {
316
368
  stateUpdate: { surface: 'ceiling', ceilingId: event.node.id },
@@ -320,7 +372,7 @@ export const ceilingStrategy = {
320
372
  },
321
373
  cursorRotationY: 0,
322
374
  gridPosition: [x, -itemHeight, z],
323
- cursorPosition: [x, event.position[1] - itemHeight, z],
375
+ cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
324
376
  stopPropagation: true,
325
377
  }
326
378
  },
@@ -332,18 +384,20 @@ export const ceilingStrategy = {
332
384
  if (ctx.state.surface !== 'ceiling') return null
333
385
  if (!ctx.draftItem) return null
334
386
 
335
- const dims = getScaledDimensions(ctx.draftItem)
387
+ const rawDims = getScaledDimensions(ctx.draftItem)
388
+ const dims = getGridAlignedDimensions(rawDims, ctx.draftItem.asset.attachTo)
336
389
  const [dimX, , dimZ] = dims
337
- const itemHeight = dims[1]
390
+ const itemHeight = rawDims[1]
338
391
  const rotY = ctx.draftItem.rotation?.[1] ?? 0
339
392
  const swapDims = Math.abs(Math.sin(rotY)) > 0.9
340
393
 
341
- const x = snapToGrid(event.position[0], swapDims ? dimZ : dimX)
342
- const z = snapToGrid(event.position[2], swapDims ? dimX : dimZ)
394
+ const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
395
+ const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
396
+ const worldSnapped = event.object.localToWorld(new Vector3(x, -itemHeight, z))
343
397
 
344
398
  return {
345
399
  gridPosition: [x, -itemHeight, z],
346
- cursorPosition: [x, event.position[1] - itemHeight, z],
400
+ cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
347
401
  cursorRotationY: 0,
348
402
  nodeUpdate: null,
349
403
  stopPropagation: true,
@@ -371,7 +425,7 @@ export const ceilingStrategy = {
371
425
  const valid = validators.canPlaceOnCeiling(
372
426
  ctx.state.ceilingId as CeilingNode['id'],
373
427
  pos,
374
- getScaledDimensions(ctx.draftItem),
428
+ getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo),
375
429
  ctx.draftItem.rotation,
376
430
  [ctx.draftItem.id],
377
431
  ).valid
@@ -425,8 +479,11 @@ export const itemSurfaceStrategy = {
425
479
  const surfaceItem = event.node as ItemNode
426
480
  // Don't surface-place on the draft itself
427
481
  if (surfaceItem.id === ctx.draftItem?.id) return null
428
- // Surface item must declare a surface
429
- if (!surfaceItem.asset.surface) return null
482
+ if (ctx.state.surface === 'item-surface' && ctx.state.surfaceItemId === surfaceItem.id) {
483
+ return null
484
+ }
485
+ const nodes = useScene.getState().nodes
486
+ if (ctx.draftItem && isDescendantOfItem(surfaceItem, ctx.draftItem, nodes)) return null
430
487
 
431
488
  // Size check: our footprint must fit on surface item's footprint
432
489
  const ourDims = ctx.draftItem
@@ -440,17 +497,32 @@ export const itemSurfaceStrategy = {
440
497
 
441
498
  const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
442
499
  const localPos = surfaceMesh.worldToLocal(worldPos)
500
+ const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
501
+ if (surfaceHeight === null) return null
443
502
 
444
503
  const x = snapToGrid(localPos.x, ourDims[0])
445
504
  const z = snapToGrid(localPos.z, ourDims[2])
446
- const y = surfaceItem.asset.surface.height * surfaceItem.scale[1]
505
+ const y = surfaceHeight
447
506
 
448
507
  const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
449
508
 
450
509
  return {
451
510
  stateUpdate: { surface: 'item-surface', surfaceItemId: surfaceItem.id },
452
- nodeUpdate: { position: [x, y, z], parentId: surfaceItem.id },
453
- cursorRotationY: 0,
511
+ nodeUpdate: {
512
+ position: [x, y, z],
513
+ parentId: surfaceItem.id,
514
+ rotation: [
515
+ (ctx.draftItem?.rotation ?? [0, 0, 0])[0],
516
+ (() => {
517
+ const surfaceQuat = new Quaternion()
518
+ surfaceMesh.getWorldQuaternion(surfaceQuat)
519
+ const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
520
+ return ctx.currentCursorRotationY - surfaceWorldY
521
+ })(),
522
+ (ctx.draftItem?.rotation ?? [0, 0, 0])[2],
523
+ ] as [number, number, number],
524
+ },
525
+ cursorRotationY: ctx.currentCursorRotationY,
454
526
  gridPosition: [x, y, z],
455
527
  cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
456
528
  stopPropagation: true,
@@ -463,10 +535,11 @@ export const itemSurfaceStrategy = {
463
535
  move(ctx: PlacementContext, event: ItemEvent): PlacementResult | null {
464
536
  if (ctx.state.surface !== 'item-surface') return null
465
537
  if (!(ctx.state.surfaceItemId && ctx.draftItem)) return null
538
+ if (event.node.id !== ctx.state.surfaceItemId) return null
466
539
 
467
540
  const nodes = useScene.getState().nodes
468
541
  const surfaceItem = nodes[ctx.state.surfaceItemId as AnyNodeId] as ItemNode | undefined
469
- if (!surfaceItem?.asset.surface) return null
542
+ if (!surfaceItem) return null
470
543
 
471
544
  const surfaceMesh = sceneRegistry.nodes.get(ctx.state.surfaceItemId)
472
545
  if (!surfaceMesh) return null
@@ -474,17 +547,19 @@ export const itemSurfaceStrategy = {
474
547
  const ourDims = getScaledDimensions(ctx.draftItem)
475
548
  const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
476
549
  const localPos = surfaceMesh.worldToLocal(worldPos)
550
+ const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
551
+ if (surfaceHeight === null) return null
477
552
 
478
553
  const x = snapToGrid(localPos.x, ourDims[0])
479
554
  const z = snapToGrid(localPos.z, ourDims[2])
480
- const y = surfaceItem.asset.surface.height * surfaceItem.scale[1]
555
+ const y = surfaceHeight
481
556
 
482
557
  const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
483
558
 
484
559
  return {
485
560
  gridPosition: [x, y, z],
486
561
  cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
487
- cursorRotationY: 0,
562
+ cursorRotationY: ctx.currentCursorRotationY,
488
563
  nodeUpdate: { position: [x, y, z] },
489
564
  stopPropagation: true,
490
565
  dirtyNodeId: null,
@@ -497,6 +572,7 @@ export const itemSurfaceStrategy = {
497
572
  click(ctx: PlacementContext, _event: ItemEvent): CommitResult | null {
498
573
  if (ctx.state.surface !== 'item-surface') return null
499
574
  if (!(ctx.draftItem && ctx.state.surfaceItemId)) return null
575
+ if (_event.node.id !== ctx.state.surfaceItemId) return null
500
576
 
501
577
  return {
502
578
  nodeUpdate: {
@@ -528,12 +604,14 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
528
604
 
529
605
  const attachTo = ctx.draftItem.asset.attachTo
530
606
 
607
+ const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo)
608
+
531
609
  if (attachTo === 'ceiling') {
532
610
  if (ctx.state.surface !== 'ceiling' || !ctx.state.ceilingId) return false
533
611
  return validators.canPlaceOnCeiling(
534
612
  ctx.state.ceilingId as CeilingNode['id'],
535
613
  [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
536
- getScaledDimensions(ctx.draftItem),
614
+ alignedDims,
537
615
  ctx.draftItem.rotation,
538
616
  [ctx.draftItem.id],
539
617
  ).valid
@@ -546,7 +624,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
546
624
  ctx.state.wallId as WallNode['id'],
547
625
  ctx.gridPosition.x,
548
626
  ctx.gridPosition.y,
549
- getScaledDimensions(ctx.draftItem),
627
+ alignedDims,
550
628
  attachTo,
551
629
  ctx.draftItem.side,
552
630
  [ctx.draftItem.id],
@@ -557,7 +635,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
557
635
  return validators.canPlaceOnFloor(
558
636
  ctx.levelId,
559
637
  [ctx.gridPosition.x, 0, ctx.gridPosition.z],
560
- getScaledDimensions(ctx.draftItem),
638
+ alignedDims,
561
639
  ctx.draftItem.rotation,
562
640
  [ctx.draftItem.id],
563
641
  ).valid
@@ -38,6 +38,13 @@ export interface PlacementContext {
38
38
  draftItem: ItemNode | null
39
39
  gridPosition: Vector3
40
40
  state: PlacementState
41
+ /**
42
+ * Current world Y rotation of the placement cursor — the user's intended
43
+ * orientation, preserved across surface transitions. Strategies that
44
+ * re-parent the draft (e.g. floor → item-surface) read this to compute the
45
+ * matching parent-local rotation so the world orientation doesn't jump.
46
+ */
47
+ currentCursorRotationY: number
41
48
  }
42
49
 
43
50
  // ============================================================================
@@ -130,6 +130,7 @@ export function useDraftNode(): DraftNodeHandle {
130
130
  useScene.getState().updateNode(draft.id, {
131
131
  position: updateProps.position ?? draft.position,
132
132
  rotation: updateProps.rotation ?? draft.rotation,
133
+ scale: updateProps.scale ?? draft.scale,
133
134
  side: updateProps.side ?? draft.side,
134
135
  metadata: updateProps.metadata ?? stripTransient(draft.metadata),
135
136
  parentId: parentId as string,
@@ -161,6 +162,7 @@ export function useDraftNode(): DraftNodeHandle {
161
162
  asset: draft.asset,
162
163
  position: updateProps.position ?? draft.position,
163
164
  rotation: updateProps.rotation ?? draft.rotation,
165
+ scale: updateProps.scale ?? draft.scale,
164
166
  side: updateProps.side ?? draft.side,
165
167
  metadata: updateProps.metadata ?? stripTransient(draft.metadata),
166
168
  })