@pascal-app/editor 1.0.0 → 1.0.3

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 (177) hide show
  1. package/package.json +12 -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/custom-camera-controls.tsx +22 -74
  5. package/src/components/editor/editor-layout-mobile.tsx +1 -0
  6. package/src/components/editor/floating-action-menu.tsx +14 -1
  7. package/src/components/editor/floorplan-panel.tsx +233 -166
  8. package/src/components/editor/grid.tsx +31 -7
  9. package/src/components/editor/group-actions.ts +5 -9
  10. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  11. package/src/components/editor/group-move-3d.ts +4 -6
  12. package/src/components/editor/group-rotate-handle.tsx +81 -35
  13. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  14. package/src/components/editor/group-transform-shared.test.ts +126 -1
  15. package/src/components/editor/group-transform-shared.ts +79 -73
  16. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  17. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  18. package/src/components/editor/handles/handle-placement.ts +69 -0
  19. package/src/components/editor/handles/linear-resize-drag.ts +13 -2
  20. package/src/components/editor/handles/use-handle-drag.ts +80 -8
  21. package/src/components/editor/index.tsx +36 -13
  22. package/src/components/editor/node-action-menu.tsx +2 -0
  23. package/src/components/editor/node-arrow-handles.tsx +162 -69
  24. package/src/components/editor/registry-action-contributions.tsx +53 -0
  25. package/src/components/editor/selection-manager.tsx +49 -0
  26. package/src/components/editor/thumbnail-generator.tsx +11 -2
  27. package/src/components/editor/wall-move-side-handles.tsx +71 -21
  28. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  29. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  30. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  31. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  32. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
  33. package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
  34. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
  35. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +113 -24
  36. package/src/components/procedural-items/inspector.tsx +223 -0
  37. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  38. package/src/components/systems/zone/zone-system.tsx +8 -4
  39. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  40. package/src/components/tools/item/item-surface-placement.test.ts +169 -0
  41. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +204 -0
  42. package/src/components/tools/item/placement-strategies.ts +212 -200
  43. package/src/components/tools/item/placement-types.ts +4 -0
  44. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  45. package/src/components/tools/item/use-draft-node.ts +136 -47
  46. package/src/components/tools/item/use-placement-coordinator.tsx +391 -157
  47. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  48. package/src/components/tools/registry/item-surface-move.test.ts +509 -0
  49. package/src/components/tools/registry/item-surface-move.ts +463 -0
  50. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  51. package/src/components/tools/registry/move-registry-node-tool.tsx +237 -78
  52. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  53. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  54. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  55. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  56. package/src/components/tools/shared/pointer-support-cap.ts +27 -13
  57. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  58. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  59. package/src/components/tools/shared/surface-hit.ts +23 -0
  60. package/src/components/tools/shared/surface-rejection.test.tsx +107 -0
  61. package/src/components/tools/shared/surface-rejection.tsx +43 -0
  62. package/src/components/tools/tool-manager.tsx +36 -3
  63. package/src/components/tools/zone/zone-tool.tsx +13 -4
  64. package/src/components/ui/command-palette/editor-commands.tsx +54 -1
  65. package/src/components/ui/controls/material-paint-panel.tsx +6 -67
  66. package/src/components/ui/controls/material-picker.tsx +5 -67
  67. package/src/components/ui/controls/metric-control.tsx +29 -13
  68. package/src/components/ui/controls/slider-control.tsx +87 -35
  69. package/src/components/ui/floating-level-selector.tsx +81 -5
  70. package/src/components/ui/helpers/helper-manager.tsx +24 -6
  71. package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
  72. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  73. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  74. package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
  75. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  76. package/src/components/ui/panels/parametric-inspector.tsx +9 -17
  77. package/src/components/ui/panels/reference-panel.tsx +3 -3
  78. package/src/components/ui/primitives/checkbox.tsx +34 -0
  79. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  80. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  81. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +669 -336
  82. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  83. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
  84. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +30 -11
  85. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  86. package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
  87. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  88. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  89. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
  90. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  91. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  92. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  93. package/src/components/ui/snap-target-badge.tsx +2 -0
  94. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  95. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  96. package/src/components/viewer/floorplan-preview.tsx +1 -12
  97. package/src/components/viewer/viewer-scene-header.tsx +3 -0
  98. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  99. package/src/components/viewer-zone-system.tsx +7 -2
  100. package/src/hooks/use-auto-save.integration.test.tsx +197 -0
  101. package/src/hooks/use-auto-save.ts +21 -6
  102. package/src/hooks/use-ceiling-events.test.ts +25 -2
  103. package/src/hooks/use-ceiling-events.ts +7 -1
  104. package/src/hooks/use-grid-events.test.ts +2 -0
  105. package/src/hooks/use-grid-events.ts +16 -11
  106. package/src/hooks/use-keyboard.test.ts +7 -0
  107. package/src/hooks/use-keyboard.ts +43 -28
  108. package/src/index.tsx +68 -3
  109. package/src/lib/catalog-panel-model.ts +39 -0
  110. package/src/lib/catalog-selection.test.ts +27 -0
  111. package/src/lib/catalog-selection.ts +8 -0
  112. package/src/lib/contextual-help.test.ts +27 -1
  113. package/src/lib/contextual-help.ts +16 -9
  114. package/src/lib/export-texture-utils.test.ts +167 -0
  115. package/src/lib/export-texture-utils.ts +121 -0
  116. package/src/lib/export-user-data.test.ts +48 -0
  117. package/src/lib/export-user-data.ts +69 -0
  118. package/src/lib/floorplan/floorplan-export.test.ts +142 -2
  119. package/src/lib/floorplan/floorplan-export.tsx +9 -3
  120. package/src/lib/floorplan/floorplan-extension.ts +16 -0
  121. package/src/lib/floorplan/floorplan-readonly.ts +4 -0
  122. package/src/lib/fresh-planar-placement.ts +173 -33
  123. package/src/lib/glb-export-serialize.test.ts +280 -0
  124. package/src/lib/glb-export.test.ts +265 -16
  125. package/src/lib/glb-export.ts +197 -37
  126. package/src/lib/grid-frame.test.ts +19 -0
  127. package/src/lib/grid-frame.ts +18 -0
  128. package/src/lib/interaction/scope.ts +9 -1
  129. package/src/lib/keyboard-pan.test.ts +62 -0
  130. package/src/lib/keyboard-pan.ts +89 -0
  131. package/src/lib/level-duplication.test.ts +73 -0
  132. package/src/lib/level-duplication.ts +32 -2
  133. package/src/lib/linear-display.test.ts +59 -0
  134. package/src/lib/linear-display.ts +39 -0
  135. package/src/lib/local-guide-image.test.ts +24 -0
  136. package/src/lib/local-guide-image.ts +34 -1
  137. package/src/lib/material-catalog-model.ts +72 -0
  138. package/src/lib/material-paint-panel-model.ts +76 -0
  139. package/src/lib/node-action-movement.ts +21 -0
  140. package/src/lib/overlay-transform.test.ts +28 -0
  141. package/src/lib/overlay-transform.ts +12 -0
  142. package/src/lib/panel-rows.ts +75 -0
  143. package/src/lib/panel-tool-options.test.ts +34 -0
  144. package/src/lib/panel-tool-options.ts +46 -0
  145. package/src/lib/parametric-node-update.ts +37 -0
  146. package/src/lib/portable-export.test.ts +89 -0
  147. package/src/lib/portable-export.ts +84 -11
  148. package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
  149. package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
  150. package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
  151. package/src/lib/print-shell-compiler-protocol.ts +8 -0
  152. package/src/lib/scene-clipboard.ts +1 -1
  153. package/src/lib/scene.test.ts +99 -0
  154. package/src/lib/scene.ts +21 -3
  155. package/src/lib/send-to-app.test.ts +108 -0
  156. package/src/lib/send-to-app.ts +121 -0
  157. package/src/lib/sfx/movement-tick.ts +37 -0
  158. package/src/lib/snapping-mode.test.ts +13 -2
  159. package/src/lib/snapping-mode.ts +11 -6
  160. package/src/lib/spatial-drag-plane.test.ts +53 -0
  161. package/src/lib/spatial-drag-plane.ts +19 -0
  162. package/src/lib/spatial-pointer-input.test.ts +72 -0
  163. package/src/lib/spatial-pointer-input.ts +62 -0
  164. package/src/lib/spatial-snap.test.ts +47 -0
  165. package/src/lib/surface-attachment.ts +77 -0
  166. package/src/lib/terrain-panel-model.ts +117 -0
  167. package/src/lib/units.ts +254 -0
  168. package/src/lib/usdz-export.ts +43 -16
  169. package/src/lib/use-linear-display.ts +8 -32
  170. package/src/lib/world-grid-snap.test.ts +48 -0
  171. package/src/lib/world-grid-snap.ts +9 -5
  172. package/src/store/rotation-snapping.test.ts +53 -0
  173. package/src/store/use-editor.tsx +10 -2
  174. package/src/store/use-floorplan-draft-preview.ts +10 -1
  175. package/src/store/use-handle-group.ts +4 -0
  176. package/src/store/use-interaction-scope.ts +155 -6
  177. package/src/store/use-send-to-app.ts +21 -0
@@ -4,8 +4,6 @@ import type {
4
4
  CeilingEvent,
5
5
  CeilingNode,
6
6
  GridEvent,
7
- ItemEvent,
8
- ItemNode,
9
7
  NodeEvent,
10
8
  RoofEvent,
11
9
  RoofNode,
@@ -17,19 +15,23 @@ import type {
17
15
  WallNode,
18
16
  } from '@pascal-app/core'
19
17
  import {
20
- canHostOnTop,
21
18
  clampRectToRoofWallFace,
19
+ clearFaceHostItemFields,
20
+ createSceneApi,
22
21
  getRoofSegmentWallFace,
23
22
  getScaledDimensions,
24
- isLowProfileItemSurface,
25
23
  nodeRegistry,
24
+ resolveSurfacePlacement,
26
25
  roofFacePointToSegment,
27
26
  sceneRegistry,
27
+ snapLocalXZInWorld,
28
28
  useScene,
29
+ wouldCreateHostingCycle,
29
30
  } from '@pascal-app/core'
30
- import { Euler, Matrix3, Quaternion, Vector3 } from 'three'
31
+ import { Euler, Quaternion, Vector3 } from 'three'
31
32
  import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../../../lib/roof-wall-hit'
32
- import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap'
33
+ import { getActiveBuildingPose } from '../../../lib/world-grid-snap'
34
+ import { itemEventToSurfaceHit, surfaceWorldNormalY } from '../shared/surface-hit'
33
35
  import {
34
36
  calculateItemRotation,
35
37
  getGridAlignedDimensions,
@@ -49,48 +51,6 @@ import type {
49
51
  } from './placement-types'
50
52
 
51
53
  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
- function isDescendantOfItem(
81
- candidate: ItemNode,
82
- ancestor: ItemNode,
83
- nodes: Record<string, AnyNode>,
84
- ): boolean {
85
- let parentId = candidate.parentId
86
- while (parentId) {
87
- if (parentId === ancestor.id) return true
88
- const parent = nodes[parentId as AnyNodeId]
89
- parentId = parent?.parentId ?? null
90
- }
91
- return false
92
- }
93
-
94
54
  // ============================================================================
95
55
  // FLOOR STRATEGY
96
56
  // ============================================================================
@@ -117,11 +77,12 @@ export const floorStrategy = {
117
77
  // the world grid.
118
78
  // Snapping is governed by the active mode (snapToGrid returns raw in Off /
119
79
  // non-grid modes); Alt is force-place only and never bypasses snapping here.
120
- const [x, z] = snapWorldXZForActiveBuilding(
121
- snapToGrid(event.position[0], swapDims ? dimZ : dimX),
122
- snapToGrid(event.position[2], swapDims ? dimX : dimZ),
123
- 0,
124
- ).local
80
+ const dimensions = [swapDims ? dimZ : dimX, swapDims ? dimX : dimZ] as const
81
+ const [x, z] = snapLocalXZInWorld(
82
+ [event.localPosition[0], event.localPosition[2]],
83
+ getActiveBuildingPose(),
84
+ (value, axis) => snapToGrid(value, dimensions[axis]),
85
+ )
125
86
  const y = ctx.gridPosition.y
126
87
 
127
88
  return {
@@ -240,6 +201,12 @@ export const wallStrategy = {
240
201
  if (attachTo !== 'wall' && attachTo !== 'wall-side') return null
241
202
  if (!isValidWallSideFace(event.normal)) return null
242
203
 
204
+ if (
205
+ ctx.draftItem &&
206
+ wouldCreateHostingCycle(ctx.draftItem.id, event.node, createSceneApi(useScene))
207
+ )
208
+ return null
209
+
243
210
  // Level guard
244
211
  const wallLevelId = resolveLevelId(event.node, nodes)
245
212
  if (ctx.levelId !== wallLevelId) return null
@@ -444,6 +411,11 @@ function resolveRoofWallTarget(
444
411
 
445
412
  const hit = resolveRoofWallHit(event.node as RoofNode, event.position, event.normal, event.object)
446
413
  if (!hit) return null
414
+ if (
415
+ ctx.draftItem &&
416
+ wouldCreateHostingCycle(ctx.draftItem.id, hit.segment, createSceneApi(useScene))
417
+ )
418
+ return null
447
419
 
448
420
  const rawDims = ctx.draftItem
449
421
  ? getScaledDimensions(ctx.draftItem)
@@ -633,6 +605,11 @@ export const roofWallStrategy = {
633
605
  // ============================================================================
634
606
 
635
607
  function resolveFaceHostTarget(ctx: PlacementContext, event: NodeEvent) {
608
+ if (
609
+ ctx.draftItem &&
610
+ wouldCreateHostingCycle(ctx.draftItem.id, event.node, createSceneApi(useScene))
611
+ )
612
+ return null
636
613
  const faceHost = nodeRegistry.get(event.node.type)?.capabilities.faceHost
637
614
  if (!faceHost) return null
638
615
  const rawDimensions = ctx.draftItem
@@ -652,18 +629,6 @@ function resolveFaceHostTarget(ctx: PlacementContext, event: NodeEvent) {
652
629
  })
653
630
  }
654
631
 
655
- function clearFaceHostItemFields(ctx: PlacementContext): Partial<ItemNode> {
656
- const host = ctx.state.blockId ? useScene.getState().nodes[ctx.state.blockId] : undefined
657
- const clearFields = host
658
- ? nodeRegistry.get(host.type)?.capabilities.faceHost?.clearItemFields
659
- : undefined
660
- const patch: Partial<ItemNode> = {}
661
- for (const field of clearFields ?? []) {
662
- ;(patch as Record<string, unknown>)[field] = undefined
663
- }
664
- return patch
665
- }
666
-
667
632
  export const faceHostStrategy = {
668
633
  enter(ctx: PlacementContext, event: NodeEvent): TransitionResult | null {
669
634
  const target = resolveFaceHostTarget(ctx, event)
@@ -723,7 +688,9 @@ export const faceHostStrategy = {
723
688
  return {
724
689
  stateUpdate: { surface: 'floor', blockId: null },
725
690
  nodeUpdate: {
726
- ...clearFaceHostItemFields(ctx),
691
+ ...clearFaceHostItemFields(
692
+ ctx.state.blockId ? useScene.getState().nodes[ctx.state.blockId] : undefined,
693
+ ),
727
694
  position: floorPosition,
728
695
  parentId: ctx.levelId,
729
696
  rotation: [0, ctx.currentCursorRotationY, 0],
@@ -753,6 +720,12 @@ export const ceilingStrategy = {
753
720
  ): TransitionResult | null {
754
721
  if (ctx.asset.attachTo !== 'ceiling') return null
755
722
 
723
+ if (
724
+ ctx.draftItem &&
725
+ wouldCreateHostingCycle(ctx.draftItem.id, event.node, createSceneApi(useScene))
726
+ )
727
+ return null
728
+
756
729
  // Level guard
757
730
  const ceilingLevelId = resolveLevelId(event.node, nodes)
758
731
  if (ctx.levelId !== ceilingLevelId) return null
@@ -881,65 +854,119 @@ export const ceilingStrategy = {
881
854
  // ITEM SURFACE STRATEGY
882
855
  // ============================================================================
883
856
 
857
+ function resolveCatalogItemSurfacePlacement(
858
+ host: AnyNode,
859
+ event: NodeEvent<AnyNode>,
860
+ dimensions: [number, number, number],
861
+ worldYaw: number,
862
+ onReject?: PlacementContext['onSurfaceReject'],
863
+ rawEvent = event,
864
+ childId?: string,
865
+ childRotation?: [number, number, number],
866
+ ) {
867
+ const mesh = sceneRegistry.nodes.get(host.id)
868
+ if (!mesh) return null
869
+ const hit = itemEventToSurfaceHit(host, event)
870
+ if (!hit) return null
871
+ const quaternion = mesh.getWorldQuaternion(new Quaternion())
872
+ const hostYaw = new Euler().setFromQuaternion(quaternion, 'YXZ').y
873
+ const placement = resolveSurfacePlacement({
874
+ host,
875
+ childKind: 'item',
876
+ childId,
877
+ childFootprint: {
878
+ size: dimensions,
879
+ rotationY: worldYaw - hostYaw,
880
+ rotation: [childRotation?.[0] ?? 0, worldYaw - hostYaw, childRotation?.[2] ?? 0],
881
+ },
882
+ hit: host.type === 'procedural-item' ? (itemEventToSurfaceHit(host, rawEvent) ?? hit) : hit,
883
+ origin: host.type === 'procedural-item' ? hit.point : undefined,
884
+ scene: createSceneApi(useScene),
885
+ snapScalar: snapToGrid,
886
+ checkFootprint: true,
887
+ onReject,
888
+ })
889
+ if (!placement) return null
890
+ return {
891
+ position: [...placement.position] as [number, number, number],
892
+ rotationY: placement.rotationY,
893
+ surfaceId: placement.surfaceId,
894
+ worldPosition: mesh.localToWorld(new Vector3(...placement.position)).toArray(),
895
+ }
896
+ }
897
+
898
+ export function validCatalogCounterPose(ctx: PlacementContext): boolean {
899
+ const hostId = ctx.state.surface === 'shelf-surface' ? ctx.state.shelfId : ctx.state.surfaceItemId
900
+ const host = hostId ? useScene.getState().nodes[hostId as AnyNodeId] : undefined
901
+ if (!host) return true
902
+ if (!ctx.draftItem) return false
903
+ const placement = resolveSurfacePlacement({
904
+ host,
905
+ childKind: 'item',
906
+ childId: ctx.draftItem.id,
907
+ childFootprint: {
908
+ size: getScaledDimensions(ctx.draftItem),
909
+ rotationY: ctx.draftItem.rotation[1],
910
+ rotation: ctx.draftItem.rotation,
911
+ },
912
+ hit: { point: ctx.gridPosition.toArray(), normalWorldY: 1 },
913
+ scene: createSceneApi(useScene),
914
+ onReject: ctx.onSurfaceReject,
915
+ })
916
+ return (
917
+ !!placement &&
918
+ (host.type !== 'cabinet' || Math.abs(placement.position[1] - ctx.gridPosition.y) < 1e-5)
919
+ )
920
+ }
921
+
884
922
  export const itemSurfaceStrategy = {
885
923
  /**
886
924
  * Handle item:enter — transition from floor to an item surface.
887
925
  * Returns null if: item has no surface, our item doesn't fit, or it's the draft itself.
888
926
  */
889
- enter(ctx: PlacementContext, event: ItemEvent): TransitionResult | null {
927
+ enter(ctx: PlacementContext, event: NodeEvent<AnyNode>): TransitionResult | null {
890
928
  // Only floor items can be placed on surfaces
891
929
  if (ctx.asset.attachTo) return null
892
930
 
893
- const surfaceItem = event.node as ItemNode
931
+ const surfaceItem = event.node
894
932
  // Don't surface-place on the draft itself
895
933
  if (surfaceItem.id === ctx.draftItem?.id) return null
896
934
  if (ctx.state.surface === 'item-surface' && ctx.state.surfaceItemId === surfaceItem.id) {
897
935
  return null
898
936
  }
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
937
  const ourDims = ctx.draftItem
904
938
  ? getScaledDimensions(ctx.draftItem)
905
939
  : (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
940
+ if (
941
+ ctx.draftItem &&
942
+ wouldCreateHostingCycle(ctx.draftItem.id, surfaceItem, createSceneApi(useScene))
943
+ )
944
+ return null
945
+ const pose = resolveCatalogItemSurfacePlacement(
946
+ surfaceItem,
947
+ event,
948
+ ourDims,
949
+ ctx.currentCursorRotationY,
950
+ ctx.onSurfaceReject,
951
+ event,
952
+ ctx.draftItem?.id,
953
+ ctx.draftItem?.rotation,
954
+ )
955
+ if (!pose) return null
931
956
  const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0]
932
957
 
933
958
  return {
959
+ surfaceId: pose.surfaceId,
934
960
  stateUpdate: { surface: 'item-surface', surfaceItemId: surfaceItem.id },
935
961
  nodeUpdate: {
936
- position: [x, y, z],
962
+ position: pose.position,
937
963
  parentId: surfaceItem.id,
938
- rotation: [draftRotation[0], localRotationY, draftRotation[2]],
964
+ supportSlabId: undefined,
965
+ rotation: [draftRotation[0], pose.rotationY, draftRotation[2]],
939
966
  },
940
967
  cursorRotationY: ctx.currentCursorRotationY,
941
- gridPosition: [x, y, z],
942
- cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
968
+ gridPosition: pose.position,
969
+ cursorPosition: pose.worldPosition,
943
970
  stopPropagation: true,
944
971
  }
945
972
  },
@@ -947,35 +974,33 @@ export const itemSurfaceStrategy = {
947
974
  /**
948
975
  * Handle item:move — update position while on an item surface.
949
976
  */
950
- move(ctx: PlacementContext, event: ItemEvent): PlacementResult | null {
977
+ move(ctx: PlacementContext, event: NodeEvent<AnyNode>, rawEvent = event): PlacementResult | null {
951
978
  if (ctx.state.surface !== 'item-surface') return null
952
979
  if (!(ctx.state.surfaceItemId && ctx.draftItem)) return null
953
980
  if (event.node.id !== ctx.state.surfaceItemId) return null
954
981
 
955
982
  const nodes = useScene.getState().nodes
956
- const surfaceItem = nodes[ctx.state.surfaceItemId as AnyNodeId] as ItemNode | undefined
983
+ const surfaceItem = nodes[ctx.state.surfaceItemId as AnyNodeId]
957
984
  if (!surfaceItem) return null
958
985
 
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))
986
+ const pose = resolveCatalogItemSurfacePlacement(
987
+ surfaceItem,
988
+ event,
989
+ getScaledDimensions(ctx.draftItem),
990
+ ctx.currentCursorRotationY,
991
+ ctx.onSurfaceReject,
992
+ rawEvent,
993
+ ctx.draftItem.id,
994
+ ctx.draftItem.rotation,
995
+ )
996
+ if (!pose) return null
973
997
 
974
998
  return {
975
- gridPosition: [x, y, z],
976
- cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
999
+ surfaceId: pose.surfaceId,
1000
+ gridPosition: pose.position,
1001
+ cursorPosition: pose.worldPosition,
977
1002
  cursorRotationY: ctx.currentCursorRotationY,
978
- nodeUpdate: { position: [x, y, z] },
1003
+ nodeUpdate: { position: pose.position },
979
1004
  stopPropagation: true,
980
1005
  dirtyNodeId: null,
981
1006
  }
@@ -984,10 +1009,11 @@ export const itemSurfaceStrategy = {
984
1009
  /**
985
1010
  * Handle item:click — commit placement on item surface.
986
1011
  */
987
- click(ctx: PlacementContext, _event: ItemEvent): CommitResult | null {
1012
+ click(ctx: PlacementContext, _event: NodeEvent<AnyNode>): CommitResult | null {
988
1013
  if (ctx.state.surface !== 'item-surface') return null
989
1014
  if (!(ctx.draftItem && ctx.state.surfaceItemId)) return null
990
1015
  if (_event.node.id !== ctx.state.surfaceItemId) return null
1016
+ if (!validCatalogCounterPose(ctx)) return null
991
1017
 
992
1018
  return {
993
1019
  nodeUpdate: {
@@ -1005,32 +1031,42 @@ export const itemSurfaceStrategy = {
1005
1031
  // SHELF SURFACE STRATEGY
1006
1032
  // ============================================================================
1007
1033
 
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
- }
1034
+ function resolveShelfSurfacePlacement(
1035
+ host: ShelfNode,
1036
+ event: ShelfEvent,
1037
+ dimensions: [number, number, number],
1038
+ worldYaw: number,
1039
+ entering: boolean,
1040
+ onReject?: PlacementContext['onSurfaceReject'],
1041
+ childId?: string,
1042
+ ) {
1043
+ const mesh = sceneRegistry.nodes.get(host.id)
1044
+ if (!mesh) return null
1045
+ const local = mesh.worldToLocal(new Vector3(...event.position))
1046
+ const quaternion = mesh.getWorldQuaternion(new Quaternion())
1047
+ const hostYaw = new Euler().setFromQuaternion(quaternion, 'YXZ').y
1048
+ const placement = resolveSurfacePlacement({
1049
+ host,
1050
+ childKind: 'item',
1051
+ childId,
1052
+ childFootprint: { size: dimensions, rotationY: worldYaw - hostYaw },
1053
+ hit: {
1054
+ point: local.toArray(),
1055
+ // Existing shelf movement elects rows even over side faces or board gaps;
1056
+ // only entering requires an upward hit.
1057
+ normalWorldY: entering ? surfaceWorldNormalY(event.normal, event.object.matrixWorld) : 1,
1058
+ },
1059
+ scene: createSceneApi(useScene),
1060
+ snapScalar: snapToGrid,
1061
+ onReject,
1062
+ checkFootprint: true,
1063
+ })
1064
+ if (!placement) return null
1065
+ return {
1066
+ position: [...placement.position] as [number, number, number],
1067
+ rotationY: placement.rotationY,
1068
+ worldPosition: mesh.localToWorld(new Vector3(...placement.position)).toArray(),
1032
1069
  }
1033
- return best?.position[1] ?? null
1034
1070
  }
1035
1071
 
1036
1072
  export const shelfSurfaceStrategy = {
@@ -1049,43 +1085,31 @@ export const shelfSurfaceStrategy = {
1049
1085
  if (ctx.state.surface === 'shelf-surface' && ctx.state.shelfId === shelfNode.id) {
1050
1086
  return null
1051
1087
  }
1052
- if (!isUpwardShelfSurfaceHit(event)) return null
1053
-
1054
- // Size check: draft footprint must fit on the shelf board (width × depth).
1055
1088
  const ourDims = ctx.draftItem
1056
1089
  ? getScaledDimensions(ctx.draftItem)
1057
1090
  : (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
1091
+ const pose = resolveShelfSurfacePlacement(
1092
+ shelfNode,
1093
+ event,
1094
+ ourDims,
1095
+ ctx.currentCursorRotationY,
1096
+ true,
1097
+ ctx.onSurfaceReject,
1098
+ ctx.draftItem?.id,
1099
+ )
1100
+ if (!pose) return null
1077
1101
  const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0]
1078
1102
 
1079
1103
  return {
1080
1104
  stateUpdate: { surface: 'shelf-surface', shelfId: shelfNode.id },
1081
1105
  nodeUpdate: {
1082
- position: [x, rowY, z],
1106
+ position: pose.position,
1083
1107
  parentId: shelfNode.id,
1084
- rotation: [draftRotation[0], localRotationY, draftRotation[2]],
1108
+ rotation: [draftRotation[0], pose.rotationY, draftRotation[2]],
1085
1109
  },
1086
1110
  cursorRotationY: ctx.currentCursorRotationY,
1087
- gridPosition: [x, rowY, z],
1088
- cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
1111
+ gridPosition: pose.position,
1112
+ cursorPosition: pose.worldPosition,
1089
1113
  stopPropagation: true,
1090
1114
  }
1091
1115
  },
@@ -1099,25 +1123,22 @@ export const shelfSurfaceStrategy = {
1099
1123
  if (!(ctx.state.shelfId && ctx.draftItem)) return null
1100
1124
  if (event.node.id !== ctx.state.shelfId) return null
1101
1125
 
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))
1126
+ const pose = resolveShelfSurfacePlacement(
1127
+ event.node as ShelfNode,
1128
+ event,
1129
+ getScaledDimensions(ctx.draftItem),
1130
+ ctx.currentCursorRotationY,
1131
+ false,
1132
+ ctx.onSurfaceReject,
1133
+ ctx.draftItem.id,
1134
+ )
1135
+ if (!pose) return null
1115
1136
 
1116
1137
  return {
1117
- gridPosition: [x, rowY, z],
1118
- cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
1138
+ gridPosition: pose.position,
1139
+ cursorPosition: pose.worldPosition,
1119
1140
  cursorRotationY: ctx.currentCursorRotationY,
1120
- nodeUpdate: { position: [x, rowY, z] },
1141
+ nodeUpdate: { position: pose.position },
1121
1142
  stopPropagation: true,
1122
1143
  dirtyNodeId: null,
1123
1144
  }
@@ -1130,6 +1151,7 @@ export const shelfSurfaceStrategy = {
1130
1151
  if (ctx.state.surface !== 'shelf-surface') return null
1131
1152
  if (!(ctx.draftItem && ctx.state.shelfId)) return null
1132
1153
  if (event.node.id !== ctx.state.shelfId) return null
1154
+ if (!validCatalogCounterPose(ctx)) return null
1133
1155
 
1134
1156
  return {
1135
1157
  nodeUpdate: {
@@ -1143,14 +1165,6 @@ export const shelfSurfaceStrategy = {
1143
1165
  },
1144
1166
  }
1145
1167
 
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
1168
  // ============================================================================
1155
1169
  // VALIDATION
1156
1170
  // ============================================================================
@@ -1162,14 +1176,12 @@ function isUpwardShelfSurfaceHit(event: ShelfEvent): boolean {
1162
1176
  export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidators): boolean {
1163
1177
  if (!(ctx.levelId && ctx.draftItem)) return false
1164
1178
 
1165
- // Item surface: valid if we entered (size check was in enter)
1166
1179
  if (ctx.state.surface === 'item-surface') {
1167
- return ctx.state.surfaceItemId !== null
1180
+ return ctx.state.surfaceItemId !== null && validCatalogCounterPose(ctx)
1168
1181
  }
1169
1182
 
1170
- // Shelf surface: same — size check already happened on enter
1171
1183
  if (ctx.state.surface === 'shelf-surface') {
1172
- return ctx.state.shelfId !== null
1184
+ return ctx.state.shelfId !== null && validCatalogCounterPose(ctx)
1173
1185
  }
1174
1186
 
1175
1187
  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]