@pascal-app/editor 1.0.0 → 1.0.1

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 (97) hide show
  1. package/package.json +7 -6
  2. package/src/components/editor/bake-exporter.tsx +4 -0
  3. package/src/components/editor/bake-thumbnail.tsx +4 -1
  4. package/src/components/editor/floating-action-menu.tsx +14 -1
  5. package/src/components/editor/floorplan-panel.tsx +23 -3
  6. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  7. package/src/components/editor/handles/handle-placement.ts +69 -0
  8. package/src/components/editor/handles/linear-resize-drag.ts +13 -2
  9. package/src/components/editor/handles/use-handle-drag.ts +5 -2
  10. package/src/components/editor/index.tsx +12 -4
  11. package/src/components/editor/node-arrow-handles.tsx +21 -62
  12. package/src/components/editor/selection-manager.tsx +49 -0
  13. package/src/components/editor/thumbnail-generator.tsx +11 -2
  14. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  15. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +46 -3
  16. package/src/components/procedural-items/inspector.tsx +223 -0
  17. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  18. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  19. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  20. package/src/components/tools/item/placement-strategies.ts +169 -169
  21. package/src/components/tools/item/placement-types.ts +4 -0
  22. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  23. package/src/components/tools/item/use-draft-node.ts +95 -45
  24. package/src/components/tools/item/use-placement-coordinator.tsx +247 -106
  25. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  26. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  27. package/src/components/tools/registry/item-surface-move.ts +441 -0
  28. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  29. package/src/components/tools/registry/move-registry-node-tool.tsx +215 -70
  30. package/src/components/tools/shared/pointer-support-cap.ts +11 -9
  31. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  32. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  33. package/src/components/tools/shared/surface-hit.ts +23 -0
  34. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  35. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  36. package/src/components/tools/zone/zone-tool.tsx +13 -4
  37. package/src/components/ui/command-palette/editor-commands.tsx +54 -1
  38. package/src/components/ui/controls/metric-control.tsx +29 -13
  39. package/src/components/ui/controls/slider-control.tsx +25 -18
  40. package/src/components/ui/floating-level-selector.tsx +81 -5
  41. package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
  42. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  43. package/src/components/ui/panels/parametric-inspector.tsx +6 -2
  44. package/src/components/ui/panels/reference-panel.tsx +3 -3
  45. package/src/components/ui/primitives/checkbox.tsx +34 -0
  46. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +120 -3
  47. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
  48. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +29 -10
  49. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  50. package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
  51. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  52. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  53. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
  54. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  55. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  56. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  57. package/src/components/ui/snap-target-badge.tsx +2 -0
  58. package/src/components/viewer/viewer-scene-header.tsx +3 -0
  59. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  60. package/src/hooks/use-ceiling-events.test.ts +24 -2
  61. package/src/hooks/use-ceiling-events.ts +5 -1
  62. package/src/hooks/use-keyboard.ts +3 -1
  63. package/src/index.tsx +5 -0
  64. package/src/lib/export-texture-utils.test.ts +167 -0
  65. package/src/lib/export-texture-utils.ts +121 -0
  66. package/src/lib/export-user-data.test.ts +48 -0
  67. package/src/lib/export-user-data.ts +69 -0
  68. package/src/lib/floorplan/floorplan-export.test.ts +139 -1
  69. package/src/lib/floorplan/floorplan-export.tsx +9 -3
  70. package/src/lib/floorplan/floorplan-readonly.ts +4 -0
  71. package/src/lib/fresh-planar-placement.ts +13 -5
  72. package/src/lib/glb-export-serialize.test.ts +280 -0
  73. package/src/lib/glb-export.test.ts +206 -0
  74. package/src/lib/glb-export.ts +171 -24
  75. package/src/lib/level-duplication.test.ts +73 -0
  76. package/src/lib/level-duplication.ts +32 -2
  77. package/src/lib/linear-display.test.ts +59 -0
  78. package/src/lib/linear-display.ts +39 -0
  79. package/src/lib/local-guide-image.test.ts +24 -0
  80. package/src/lib/local-guide-image.ts +34 -1
  81. package/src/lib/node-action-movement.ts +21 -0
  82. package/src/lib/portable-export.test.ts +89 -0
  83. package/src/lib/portable-export.ts +84 -11
  84. package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
  85. package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
  86. package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
  87. package/src/lib/print-shell-compiler-protocol.ts +8 -0
  88. package/src/lib/scene-clipboard.ts +1 -1
  89. package/src/lib/send-to-app.test.ts +108 -0
  90. package/src/lib/send-to-app.ts +121 -0
  91. package/src/lib/sfx/movement-tick.ts +37 -0
  92. package/src/lib/surface-attachment.ts +77 -0
  93. package/src/lib/units.ts +254 -0
  94. package/src/lib/usdz-export.ts +43 -16
  95. package/src/lib/use-linear-display.ts +8 -32
  96. package/src/store/use-handle-group.ts +4 -0
  97. package/src/store/use-send-to-app.ts +21 -0
@@ -4,7 +4,6 @@ import type {
4
4
  CeilingEvent,
5
5
  CeilingNode,
6
6
  GridEvent,
7
- ItemEvent,
8
7
  ItemNode,
9
8
  NodeEvent,
10
9
  RoofEvent,
@@ -17,19 +16,20 @@ import type {
17
16
  WallNode,
18
17
  } from '@pascal-app/core'
19
18
  import {
20
- canHostOnTop,
21
19
  clampRectToRoofWallFace,
20
+ createSceneApi,
22
21
  getRoofSegmentWallFace,
23
22
  getScaledDimensions,
24
- isLowProfileItemSurface,
25
23
  nodeRegistry,
24
+ resolveSurfacePlacement,
26
25
  roofFacePointToSegment,
27
26
  sceneRegistry,
28
27
  useScene,
29
28
  } from '@pascal-app/core'
30
- import { Euler, Matrix3, Quaternion, Vector3 } from 'three'
29
+ import { Euler, Quaternion, Vector3 } from 'three'
31
30
  import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../../../lib/roof-wall-hit'
32
31
  import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap'
32
+ import { itemEventToSurfaceHit, surfaceWorldNormalY } from '../shared/surface-hit'
33
33
  import {
34
34
  calculateItemRotation,
35
35
  getGridAlignedDimensions,
@@ -49,42 +49,14 @@ import type {
49
49
  } from './placement-types'
50
50
 
51
51
  const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
52
- const UPWARD_SURFACE_NORMAL_MIN_Y = 0.75
53
-
54
- function getWorldNormalY(event: ItemEvent): number | null {
55
- if (!event.normal) return null
56
-
57
- const normal = new Vector3(event.normal[0], event.normal[1], event.normal[2])
58
- normal.applyNormalMatrix(new Matrix3().getNormalMatrix(event.object.matrixWorld)).normalize()
59
- return normal.y
60
- }
61
-
62
- function isUpwardItemSurfaceHit(event: ItemEvent): boolean {
63
- const normalY = getWorldNormalY(event)
64
- return normalY !== null && normalY >= UPWARD_SURFACE_NORMAL_MIN_Y
65
- }
66
-
67
- function getSurfacePlacementHeight(surfaceItem: ItemNode, event: ItemEvent, localPos: Vector3) {
68
- if (!canHostOnTop(surfaceItem)) return null
69
- if (isLowProfileItemSurface(surfaceItem)) return null
70
- if (!isUpwardItemSurfaceHit(event)) return null
71
-
72
- if (surfaceItem.asset.surface) {
73
- return surfaceItem.asset.surface.height * surfaceItem.scale[1]
74
- }
75
-
76
- if (!Number.isFinite(localPos.y)) return null
77
- return localPos.y
78
- }
79
-
80
52
  function isDescendantOfItem(
81
- candidate: ItemNode,
82
- ancestor: ItemNode,
53
+ candidate: AnyNode,
54
+ ancestorId: string,
83
55
  nodes: Record<string, AnyNode>,
84
56
  ): boolean {
85
57
  let parentId = candidate.parentId
86
58
  while (parentId) {
87
- if (parentId === ancestor.id) return true
59
+ if (parentId === ancestorId) return true
88
60
  const parent = nodes[parentId as AnyNodeId]
89
61
  parentId = parent?.parentId ?? null
90
62
  }
@@ -881,65 +853,114 @@ export const ceilingStrategy = {
881
853
  // ITEM SURFACE STRATEGY
882
854
  // ============================================================================
883
855
 
856
+ function resolveCatalogItemSurfacePlacement(
857
+ host: AnyNode,
858
+ event: NodeEvent<AnyNode>,
859
+ dimensions: [number, number, number],
860
+ worldYaw: number,
861
+ onReject?: PlacementContext['onSurfaceReject'],
862
+ rawEvent = event,
863
+ ) {
864
+ const mesh = sceneRegistry.nodes.get(host.id)
865
+ if (!mesh) return null
866
+ const hit = itemEventToSurfaceHit(host, event)
867
+ if (!hit) return null
868
+ const quaternion = mesh.getWorldQuaternion(new Quaternion())
869
+ const hostYaw = new Euler().setFromQuaternion(quaternion, 'YXZ').y
870
+ const placement = resolveSurfacePlacement({
871
+ host,
872
+ childKind: 'item',
873
+ childFootprint: { size: dimensions, rotationY: worldYaw - hostYaw },
874
+ hit: host.type === 'procedural-item' ? (itemEventToSurfaceHit(host, rawEvent) ?? hit) : hit,
875
+ origin: host.type === 'procedural-item' ? hit.point : undefined,
876
+ scene: createSceneApi(useScene),
877
+ snapScalar: snapToGrid,
878
+ checkFootprint: true,
879
+ onReject,
880
+ })
881
+ if (!placement) return null
882
+ return {
883
+ position: [...placement.position] as [number, number, number],
884
+ rotationY: placement.rotationY,
885
+ surfaceId: placement.surfaceId,
886
+ worldPosition: mesh.localToWorld(new Vector3(...placement.position)).toArray(),
887
+ }
888
+ }
889
+
890
+ export function validCatalogCounterPose(ctx: PlacementContext): boolean {
891
+ const hostId = ctx.state.surface === 'shelf-surface' ? ctx.state.shelfId : ctx.state.surfaceItemId
892
+ const host = hostId ? useScene.getState().nodes[hostId as AnyNodeId] : undefined
893
+ if (
894
+ host?.type !== 'cabinet' &&
895
+ host?.type !== 'shelf' &&
896
+ host?.type !== 'item' &&
897
+ host?.type !== 'procedural-item'
898
+ )
899
+ return true
900
+ if (!ctx.draftItem) return false
901
+ const placement = resolveSurfacePlacement({
902
+ host,
903
+ childKind: 'item',
904
+ childFootprint: {
905
+ size: getScaledDimensions(ctx.draftItem),
906
+ rotationY: ctx.draftItem.rotation[1],
907
+ rotation: ctx.draftItem.rotation,
908
+ },
909
+ hit: { point: ctx.gridPosition.toArray(), normalWorldY: 1 },
910
+ scene: createSceneApi(useScene),
911
+ onReject: ctx.onSurfaceReject,
912
+ })
913
+ return (
914
+ !!placement &&
915
+ (host.type !== 'cabinet' || Math.abs(placement.position[1] - ctx.gridPosition.y) < 1e-5)
916
+ )
917
+ }
918
+
884
919
  export const itemSurfaceStrategy = {
885
920
  /**
886
921
  * Handle item:enter — transition from floor to an item surface.
887
922
  * Returns null if: item has no surface, our item doesn't fit, or it's the draft itself.
888
923
  */
889
- enter(ctx: PlacementContext, event: ItemEvent): TransitionResult | null {
924
+ enter(ctx: PlacementContext, event: NodeEvent<AnyNode>): TransitionResult | null {
890
925
  // Only floor items can be placed on surfaces
891
926
  if (ctx.asset.attachTo) return null
892
927
 
893
- const surfaceItem = event.node as ItemNode
928
+ const surfaceItem = event.node
894
929
  // Don't surface-place on the draft itself
895
930
  if (surfaceItem.id === ctx.draftItem?.id) return null
896
931
  if (ctx.state.surface === 'item-surface' && ctx.state.surfaceItemId === surfaceItem.id) {
897
932
  return null
898
933
  }
899
- const nodes = useScene.getState().nodes
900
- if (ctx.draftItem && isDescendantOfItem(surfaceItem, ctx.draftItem, nodes)) return null
901
-
902
- // Size check: our footprint must fit on surface item's footprint
903
934
  const ourDims = ctx.draftItem
904
935
  ? getScaledDimensions(ctx.draftItem)
905
936
  : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
906
- const surfDims = getScaledDimensions(surfaceItem)
907
- if (ourDims[0] > surfDims[0] || ourDims[2] > surfDims[2]) return null
908
-
909
- const surfaceMesh = sceneRegistry.nodes.get(surfaceItem.id)
910
- if (!surfaceMesh) return null
911
-
912
- const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
913
- const localPos = surfaceMesh.worldToLocal(worldPos)
914
- const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
915
- if (surfaceHeight === null) return null
916
-
917
- const x = snapToGrid(localPos.x, ourDims[0])
918
- const z = snapToGrid(localPos.z, ourDims[2])
919
- const y = surfaceHeight
920
-
921
- const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
922
-
923
- // Counter-rotate so the draft's world Y rotation stays continuous when
924
- // the user drags onto a rotated surface item. The cursor wireframe
925
- // already shows the user's intended world rotation; we just need to
926
- // store the right local value relative to the new parent.
927
- const surfaceQuat = new Quaternion()
928
- surfaceMesh.getWorldQuaternion(surfaceQuat)
929
- const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
930
- const localRotationY = ctx.currentCursorRotationY - surfaceWorldY
937
+ if (
938
+ ctx.draftItem &&
939
+ isDescendantOfItem(surfaceItem, ctx.draftItem.id, useScene.getState().nodes)
940
+ )
941
+ return null
942
+ const pose = resolveCatalogItemSurfacePlacement(
943
+ surfaceItem,
944
+ event,
945
+ ourDims,
946
+ ctx.currentCursorRotationY,
947
+ ctx.onSurfaceReject,
948
+ )
949
+ if (!pose) return null
931
950
  const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0]
932
951
 
933
952
  return {
953
+ surfaceId: pose.surfaceId,
934
954
  stateUpdate: { surface: 'item-surface', surfaceItemId: surfaceItem.id },
935
955
  nodeUpdate: {
936
- position: [x, y, z],
956
+ position: pose.position,
937
957
  parentId: surfaceItem.id,
938
- rotation: [draftRotation[0], localRotationY, draftRotation[2]],
958
+ supportSlabId: undefined,
959
+ rotation: [draftRotation[0], pose.rotationY, draftRotation[2]],
939
960
  },
940
961
  cursorRotationY: ctx.currentCursorRotationY,
941
- gridPosition: [x, y, z],
942
- cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
962
+ gridPosition: pose.position,
963
+ cursorPosition: pose.worldPosition,
943
964
  stopPropagation: true,
944
965
  }
945
966
  },
@@ -947,35 +968,31 @@ export const itemSurfaceStrategy = {
947
968
  /**
948
969
  * Handle item:move — update position while on an item surface.
949
970
  */
950
- move(ctx: PlacementContext, event: ItemEvent): PlacementResult | null {
971
+ move(ctx: PlacementContext, event: NodeEvent<AnyNode>, rawEvent = event): PlacementResult | null {
951
972
  if (ctx.state.surface !== 'item-surface') return null
952
973
  if (!(ctx.state.surfaceItemId && ctx.draftItem)) return null
953
974
  if (event.node.id !== ctx.state.surfaceItemId) return null
954
975
 
955
976
  const nodes = useScene.getState().nodes
956
- const surfaceItem = nodes[ctx.state.surfaceItemId as AnyNodeId] as ItemNode | undefined
977
+ const surfaceItem = nodes[ctx.state.surfaceItemId as AnyNodeId]
957
978
  if (!surfaceItem) return null
958
979
 
959
- const surfaceMesh = sceneRegistry.nodes.get(ctx.state.surfaceItemId)
960
- if (!surfaceMesh) return null
961
-
962
- const ourDims = getScaledDimensions(ctx.draftItem)
963
- const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
964
- const localPos = surfaceMesh.worldToLocal(worldPos)
965
- const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
966
- if (surfaceHeight === null) return null
967
-
968
- const x = snapToGrid(localPos.x, ourDims[0])
969
- const z = snapToGrid(localPos.z, ourDims[2])
970
- const y = surfaceHeight
971
-
972
- const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
980
+ const pose = resolveCatalogItemSurfacePlacement(
981
+ surfaceItem,
982
+ event,
983
+ getScaledDimensions(ctx.draftItem),
984
+ ctx.currentCursorRotationY,
985
+ ctx.onSurfaceReject,
986
+ rawEvent,
987
+ )
988
+ if (!pose) return null
973
989
 
974
990
  return {
975
- gridPosition: [x, y, z],
976
- cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
991
+ surfaceId: pose.surfaceId,
992
+ gridPosition: pose.position,
993
+ cursorPosition: pose.worldPosition,
977
994
  cursorRotationY: ctx.currentCursorRotationY,
978
- nodeUpdate: { position: [x, y, z] },
995
+ nodeUpdate: { position: pose.position },
979
996
  stopPropagation: true,
980
997
  dirtyNodeId: null,
981
998
  }
@@ -984,10 +1001,11 @@ export const itemSurfaceStrategy = {
984
1001
  /**
985
1002
  * Handle item:click — commit placement on item surface.
986
1003
  */
987
- click(ctx: PlacementContext, _event: ItemEvent): CommitResult | null {
1004
+ click(ctx: PlacementContext, _event: NodeEvent<AnyNode>): CommitResult | null {
988
1005
  if (ctx.state.surface !== 'item-surface') return null
989
1006
  if (!(ctx.draftItem && ctx.state.surfaceItemId)) return null
990
1007
  if (_event.node.id !== ctx.state.surfaceItemId) return null
1008
+ if (!validCatalogCounterPose(ctx)) return null
991
1009
 
992
1010
  return {
993
1011
  nodeUpdate: {
@@ -1005,32 +1023,40 @@ export const itemSurfaceStrategy = {
1005
1023
  // SHELF SURFACE STRATEGY
1006
1024
  // ============================================================================
1007
1025
 
1008
- /**
1009
- * Resolve the row Y closest to the cursor's local Y. Reads candidate row
1010
- * positions from the kind's `capabilities.surfaces.custom` — the shelf
1011
- * declaration emits one `SurfacePoint` per board's top surface. The
1012
- * strategy stays kind-agnostic at this level: any future "multi-board"
1013
- * kind that declares `surfaces.custom` with upward normals gets the
1014
- * same hit behaviour for free.
1015
- */
1016
- function getShelfRowSurfaceY(shelfNode: ShelfNode, localY: number): number | null {
1017
- const def = nodeRegistry.get('shelf')
1018
- const custom = def?.capabilities?.surfaces?.custom
1019
- if (!custom) return null
1020
- const candidates = custom(shelfNode as AnyNode)
1021
- if (candidates.length === 0) return null
1022
- let best = candidates[0]
1023
- let bestDist = Math.abs(best!.position[1] - localY)
1024
- for (let i = 1; i < candidates.length; i++) {
1025
- const c = candidates[i]
1026
- if (!c) continue
1027
- const dist = Math.abs(c.position[1] - localY)
1028
- if (dist < bestDist) {
1029
- best = c
1030
- bestDist = dist
1031
- }
1026
+ function resolveShelfSurfacePlacement(
1027
+ host: ShelfNode,
1028
+ event: ShelfEvent,
1029
+ dimensions: [number, number, number],
1030
+ worldYaw: number,
1031
+ entering: boolean,
1032
+ onReject?: PlacementContext['onSurfaceReject'],
1033
+ ) {
1034
+ const mesh = sceneRegistry.nodes.get(host.id)
1035
+ if (!mesh) return null
1036
+ const local = mesh.worldToLocal(new Vector3(...event.position))
1037
+ const quaternion = mesh.getWorldQuaternion(new Quaternion())
1038
+ const hostYaw = new Euler().setFromQuaternion(quaternion, 'YXZ').y
1039
+ const placement = resolveSurfacePlacement({
1040
+ host,
1041
+ childKind: 'item',
1042
+ childFootprint: { size: dimensions, rotationY: worldYaw - hostYaw },
1043
+ hit: {
1044
+ point: local.toArray(),
1045
+ // Existing shelf movement elects rows even over side faces or board gaps;
1046
+ // only entering requires an upward hit.
1047
+ normalWorldY: entering ? surfaceWorldNormalY(event.normal, event.object.matrixWorld) : 1,
1048
+ },
1049
+ scene: createSceneApi(useScene),
1050
+ snapScalar: snapToGrid,
1051
+ onReject,
1052
+ checkFootprint: true,
1053
+ })
1054
+ if (!placement) return null
1055
+ return {
1056
+ position: [...placement.position] as [number, number, number],
1057
+ rotationY: placement.rotationY,
1058
+ worldPosition: mesh.localToWorld(new Vector3(...placement.position)).toArray(),
1032
1059
  }
1033
- return best?.position[1] ?? null
1034
1060
  }
1035
1061
 
1036
1062
  export const shelfSurfaceStrategy = {
@@ -1049,43 +1075,30 @@ export const shelfSurfaceStrategy = {
1049
1075
  if (ctx.state.surface === 'shelf-surface' && ctx.state.shelfId === shelfNode.id) {
1050
1076
  return null
1051
1077
  }
1052
- if (!isUpwardShelfSurfaceHit(event)) return null
1053
-
1054
- // Size check: draft footprint must fit on the shelf board (width × depth).
1055
1078
  const ourDims = ctx.draftItem
1056
1079
  ? getScaledDimensions(ctx.draftItem)
1057
1080
  : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
1058
- if (ourDims[0] > shelfNode.width || ourDims[2] > shelfNode.depth) return null
1059
-
1060
- const shelfMesh = sceneRegistry.nodes.get(shelfNode.id)
1061
- if (!shelfMesh) return null
1062
-
1063
- const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
1064
- const localPos = shelfMesh.worldToLocal(worldPos)
1065
- const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
1066
- if (rowY === null) return null
1067
-
1068
- const x = snapToGrid(localPos.x, ourDims[0])
1069
- const z = snapToGrid(localPos.z, ourDims[2])
1070
-
1071
- const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
1072
-
1073
- const surfaceQuat = new Quaternion()
1074
- shelfMesh.getWorldQuaternion(surfaceQuat)
1075
- const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
1076
- const localRotationY = ctx.currentCursorRotationY - surfaceWorldY
1081
+ const pose = resolveShelfSurfacePlacement(
1082
+ shelfNode,
1083
+ event,
1084
+ ourDims,
1085
+ ctx.currentCursorRotationY,
1086
+ true,
1087
+ ctx.onSurfaceReject,
1088
+ )
1089
+ if (!pose) return null
1077
1090
  const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0]
1078
1091
 
1079
1092
  return {
1080
1093
  stateUpdate: { surface: 'shelf-surface', shelfId: shelfNode.id },
1081
1094
  nodeUpdate: {
1082
- position: [x, rowY, z],
1095
+ position: pose.position,
1083
1096
  parentId: shelfNode.id,
1084
- rotation: [draftRotation[0], localRotationY, draftRotation[2]],
1097
+ rotation: [draftRotation[0], pose.rotationY, draftRotation[2]],
1085
1098
  },
1086
1099
  cursorRotationY: ctx.currentCursorRotationY,
1087
- gridPosition: [x, rowY, z],
1088
- cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
1100
+ gridPosition: pose.position,
1101
+ cursorPosition: pose.worldPosition,
1089
1102
  stopPropagation: true,
1090
1103
  }
1091
1104
  },
@@ -1099,25 +1112,21 @@ export const shelfSurfaceStrategy = {
1099
1112
  if (!(ctx.state.shelfId && ctx.draftItem)) return null
1100
1113
  if (event.node.id !== ctx.state.shelfId) return null
1101
1114
 
1102
- const shelfNode = event.node as ShelfNode
1103
- const shelfMesh = sceneRegistry.nodes.get(shelfNode.id)
1104
- if (!shelfMesh) return null
1105
-
1106
- const ourDims = getScaledDimensions(ctx.draftItem)
1107
- const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
1108
- const localPos = shelfMesh.worldToLocal(worldPos)
1109
- const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
1110
- if (rowY === null) return null
1111
-
1112
- const x = snapToGrid(localPos.x, ourDims[0])
1113
- const z = snapToGrid(localPos.z, ourDims[2])
1114
- const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
1115
+ const pose = resolveShelfSurfacePlacement(
1116
+ event.node as ShelfNode,
1117
+ event,
1118
+ getScaledDimensions(ctx.draftItem),
1119
+ ctx.currentCursorRotationY,
1120
+ false,
1121
+ ctx.onSurfaceReject,
1122
+ )
1123
+ if (!pose) return null
1115
1124
 
1116
1125
  return {
1117
- gridPosition: [x, rowY, z],
1118
- cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
1126
+ gridPosition: pose.position,
1127
+ cursorPosition: pose.worldPosition,
1119
1128
  cursorRotationY: ctx.currentCursorRotationY,
1120
- nodeUpdate: { position: [x, rowY, z] },
1129
+ nodeUpdate: { position: pose.position },
1121
1130
  stopPropagation: true,
1122
1131
  dirtyNodeId: null,
1123
1132
  }
@@ -1130,6 +1139,7 @@ export const shelfSurfaceStrategy = {
1130
1139
  if (ctx.state.surface !== 'shelf-surface') return null
1131
1140
  if (!(ctx.draftItem && ctx.state.shelfId)) return null
1132
1141
  if (event.node.id !== ctx.state.shelfId) return null
1142
+ if (!validCatalogCounterPose(ctx)) return null
1133
1143
 
1134
1144
  return {
1135
1145
  nodeUpdate: {
@@ -1143,14 +1153,6 @@ export const shelfSurfaceStrategy = {
1143
1153
  },
1144
1154
  }
1145
1155
 
1146
- /** Same upward-normal heuristic as `isUpwardItemSurfaceHit`, but typed
1147
- * for `ShelfEvent`. Re-uses the matrix-driven world normal calculation
1148
- * via a tiny `ItemEvent`-shaped adapter — the function only reads
1149
- * `event.normal` + `event.object`. */
1150
- function isUpwardShelfSurfaceHit(event: ShelfEvent): boolean {
1151
- return isUpwardItemSurfaceHit(event as unknown as ItemEvent)
1152
- }
1153
-
1154
1156
  // ============================================================================
1155
1157
  // VALIDATION
1156
1158
  // ============================================================================
@@ -1162,14 +1164,12 @@ function isUpwardShelfSurfaceHit(event: ShelfEvent): boolean {
1162
1164
  export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidators): boolean {
1163
1165
  if (!(ctx.levelId && ctx.draftItem)) return false
1164
1166
 
1165
- // Item surface: valid if we entered (size check was in enter)
1166
1167
  if (ctx.state.surface === 'item-surface') {
1167
- return ctx.state.surfaceItemId !== null
1168
+ return ctx.state.surfaceItemId !== null && validCatalogCounterPose(ctx)
1168
1169
  }
1169
1170
 
1170
- // Shelf surface: same — size check already happened on enter
1171
1171
  if (ctx.state.surface === 'shelf-surface') {
1172
- return ctx.state.shelfId !== null
1172
+ return ctx.state.shelfId !== null && validCatalogCounterPose(ctx)
1173
1173
  }
1174
1174
 
1175
1175
  const attachTo = ctx.draftItem.asset.attachTo
@@ -5,6 +5,7 @@ import type {
5
5
  CeilingNode,
6
6
  ItemNode,
7
7
  LevelNode,
8
+ SurfaceRejectReason,
8
9
  WallNode,
9
10
  } from '@pascal-app/core'
10
11
  import type { Vector3 } from 'three'
@@ -56,6 +57,7 @@ export interface PlacementState {
56
57
  * Read-only snapshot passed to every strategy call.
57
58
  */
58
59
  export interface PlacementContext {
60
+ onSurfaceReject?: (reason: SurfaceRejectReason) => void
59
61
  asset: AssetInput
60
62
  levelId: LevelNode['id'] | null
61
63
  draftItem: ItemNode | null
@@ -78,6 +80,7 @@ export interface PlacementContext {
78
80
  * Returned by strategy move handlers.
79
81
  */
80
82
  export interface PlacementResult {
83
+ surfaceId?: string | null
81
84
  gridPosition: [number, number, number]
82
85
  cursorPosition: [number, number, number]
83
86
  cursorRotationY: number
@@ -92,6 +95,7 @@ export interface PlacementResult {
92
95
  * Returned by enter/leave handlers (surface transitions).
93
96
  */
94
97
  export interface TransitionResult {
98
+ surfaceId?: string | null
95
99
  stateUpdate: Partial<PlacementState>
96
100
  nodeUpdate: Partial<ItemNode>
97
101
  gridPosition: [number, number, number]