@pascal-app/editor 1.0.0-beta.5 → 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 (297) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +15 -8
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +5 -0
  6. package/src/components/editor/bake-thumbnail.tsx +4 -1
  7. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  8. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  9. package/src/components/editor/capture-camera-rig.tsx +59 -0
  10. package/src/components/editor/custom-camera-controls.tsx +20 -11
  11. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  12. package/src/components/editor/editor-layout-v2.tsx +9 -2
  13. package/src/components/editor/export-manager.tsx +175 -33
  14. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  15. package/src/components/editor/first-person-controls.tsx +180 -12
  16. package/src/components/editor/floating-action-menu.tsx +55 -51
  17. package/src/components/editor/floorplan-panel.tsx +99 -15
  18. package/src/components/editor/grid.tsx +12 -7
  19. package/src/components/editor/group-actions.ts +34 -50
  20. package/src/components/editor/group-move-3d.ts +51 -21
  21. package/src/components/editor/group-transform-shared.test.ts +78 -0
  22. package/src/components/editor/group-transform-shared.ts +74 -7
  23. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  24. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  25. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  26. package/src/components/editor/handles/handle-placement.ts +69 -0
  27. package/src/components/editor/handles/linear-resize-drag.ts +78 -0
  28. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  29. package/src/components/editor/handles/resize-snap.ts +10 -1
  30. package/src/components/editor/handles/use-handle-drag.ts +23 -3
  31. package/src/components/editor/index.tsx +151 -24
  32. package/src/components/editor/node-action-menu.tsx +14 -1
  33. package/src/components/editor/node-arrow-handles.tsx +67 -119
  34. package/src/components/editor/selection-manager.tsx +153 -42
  35. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  36. package/src/components/editor/snapshot-capture.test.ts +422 -0
  37. package/src/components/editor/snapshot-capture.ts +145 -0
  38. package/src/components/editor/thumbnail-generator.tsx +295 -239
  39. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  40. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  41. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  42. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  43. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  44. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  45. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  46. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  47. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  48. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  49. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  50. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +226 -35
  51. package/src/components/procedural-items/inspector.tsx +223 -0
  52. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  53. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  54. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  55. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  56. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  57. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  58. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  59. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  60. package/src/components/systems/selection-affordance-services.ts +19 -0
  61. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  62. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  63. package/src/components/tools/fence/fence-drafting.ts +6 -25
  64. package/src/components/tools/item/draft-creation.test.ts +12 -0
  65. package/src/components/tools/item/draft-creation.ts +10 -0
  66. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  67. package/src/components/tools/item/face-host-commit.ts +56 -0
  68. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  69. package/src/components/tools/item/face-host-preview.ts +82 -0
  70. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  71. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  72. package/src/components/tools/item/move-tool.tsx +4 -3
  73. package/src/components/tools/item/placement-math.test.ts +11 -1
  74. package/src/components/tools/item/placement-math.ts +0 -20
  75. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  76. package/src/components/tools/item/placement-strategies.ts +304 -171
  77. package/src/components/tools/item/placement-surface.test.ts +61 -0
  78. package/src/components/tools/item/placement-surface.ts +24 -0
  79. package/src/components/tools/item/placement-types.ts +10 -0
  80. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  81. package/src/components/tools/item/test-face-host.ts +121 -0
  82. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  83. package/src/components/tools/item/use-draft-node.ts +116 -44
  84. package/src/components/tools/item/use-placement-coordinator.tsx +672 -256
  85. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  86. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  87. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  88. package/src/components/tools/registry/item-surface-move.ts +441 -0
  89. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  90. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  91. package/src/components/tools/registry/move-registry-node-tool.tsx +505 -164
  92. package/src/components/tools/registry-tool-context.tsx +30 -0
  93. package/src/components/tools/select/box-select-tool.tsx +3 -1
  94. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  95. package/src/components/tools/select/marquee-footprint.ts +29 -0
  96. package/src/components/tools/shared/placement-box.tsx +57 -7
  97. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  98. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  99. package/src/components/tools/shared/pointer-support-cap.ts +56 -17
  100. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  101. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  102. package/src/components/tools/shared/surface-hit.ts +23 -0
  103. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  104. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  105. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  106. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  107. package/src/components/tools/stair/stair-tool.tsx +127 -74
  108. package/src/components/tools/tool-manager.tsx +25 -8
  109. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  110. package/src/components/tools/wall/wall-drafting.ts +23 -114
  111. package/src/components/tools/zone/zone-tool.tsx +13 -4
  112. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  113. package/src/components/ui/action-menu/index.tsx +11 -1
  114. package/src/components/ui/command-palette/editor-commands.tsx +63 -13
  115. package/src/components/ui/command-palette/index.tsx +7 -2
  116. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  117. package/src/components/ui/controls/material-picker.tsx +4 -4
  118. package/src/components/ui/controls/metric-control.tsx +29 -13
  119. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  120. package/src/components/ui/controls/segmented-control.tsx +8 -1
  121. package/src/components/ui/controls/slider-control.tsx +44 -21
  122. package/src/components/ui/controls/toggle-control.tsx +18 -5
  123. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  124. package/src/components/ui/floating-level-selector.tsx +103 -10
  125. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  126. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  127. package/src/components/ui/helpers/item-helper.tsx +1 -1
  128. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  129. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  130. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  131. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  132. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  133. package/src/components/ui/panels/multi-field-value.ts +163 -0
  134. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  135. package/src/components/ui/panels/multi-parametric-inspector.tsx +266 -0
  136. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  137. package/src/components/ui/panels/node-display.ts +4 -0
  138. package/src/components/ui/panels/panel-manager.tsx +77 -3
  139. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  140. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  141. package/src/components/ui/panels/parametric-inspector.tsx +35 -164
  142. package/src/components/ui/panels/reference-panel.tsx +34 -4
  143. package/src/components/ui/primitives/checkbox.tsx +34 -0
  144. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  145. package/src/components/ui/scene-loader.tsx +35 -0
  146. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +502 -31
  147. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  148. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +167 -0
  149. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +128 -0
  150. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  151. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  152. package/src/components/ui/sidebar/panels/site-panel/index.tsx +222 -50
  153. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  154. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  155. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  156. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  157. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  158. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -7
  159. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  160. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  161. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  162. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  163. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  164. package/src/components/ui/snap-target-badge.tsx +2 -0
  165. package/src/components/viewer/viewer-scene-header.tsx +5 -1
  166. package/src/components/viewer/viewer-stage.tsx +4 -1
  167. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  168. package/src/components/viewer-overlay.tsx +1 -22
  169. package/src/components/viewer-zone-system.tsx +6 -2
  170. package/src/components/walkthrough-hud.tsx +19 -10
  171. package/src/hooks/use-auto-save.test.ts +73 -1
  172. package/src/hooks/use-auto-save.ts +76 -5
  173. package/src/hooks/use-ceiling-events.test.ts +144 -0
  174. package/src/hooks/use-ceiling-events.ts +5 -1
  175. package/src/hooks/use-drag-action.ts +17 -3
  176. package/src/hooks/use-grid-events.test.ts +240 -0
  177. package/src/hooks/use-grid-events.ts +190 -11
  178. package/src/hooks/use-keyboard.test.ts +244 -0
  179. package/src/hooks/use-keyboard.ts +132 -63
  180. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  181. package/src/hooks/use-save-shortcut.ts +33 -0
  182. package/src/index.tsx +87 -4
  183. package/src/lib/active-placement-surface.test.ts +16 -0
  184. package/src/lib/active-placement-surface.ts +14 -0
  185. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  186. package/src/lib/ceiling-surface-raycast.ts +12 -0
  187. package/src/lib/contextual-help-extension.ts +17 -0
  188. package/src/lib/continuation.test.ts +11 -0
  189. package/src/lib/continuation.ts +8 -1
  190. package/src/lib/direct-manipulation.test.ts +132 -0
  191. package/src/lib/direct-manipulation.ts +38 -1
  192. package/src/lib/editor-api.ts +6 -3
  193. package/src/lib/elevation-guides.test.ts +28 -1
  194. package/src/lib/elevation-guides.ts +22 -7
  195. package/src/lib/export-texture-utils.test.ts +167 -0
  196. package/src/lib/export-texture-utils.ts +121 -0
  197. package/src/lib/export-user-data.test.ts +48 -0
  198. package/src/lib/export-user-data.ts +69 -0
  199. package/src/lib/floating-menu-scale.test.ts +29 -0
  200. package/src/lib/floating-menu-scale.ts +14 -0
  201. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  202. package/src/lib/floorplan/apply-alignment.ts +10 -1
  203. package/src/lib/floorplan/floorplan-export.test.ts +536 -2
  204. package/src/lib/floorplan/floorplan-export.tsx +120 -19
  205. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  206. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  207. package/src/lib/floorplan/floorplan-readonly.ts +5 -0
  208. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  209. package/src/lib/floorplan-grid-event-point.ts +13 -0
  210. package/src/lib/fresh-planar-placement.ts +13 -5
  211. package/src/lib/glb-export-serialize.test.ts +280 -0
  212. package/src/lib/glb-export.test.ts +1375 -21
  213. package/src/lib/glb-export.ts +781 -106
  214. package/src/lib/grid-event-presentation.ts +16 -0
  215. package/src/lib/history.test.ts +528 -147
  216. package/src/lib/history.ts +81 -7
  217. package/src/lib/host-tree-children.test.ts +27 -0
  218. package/src/lib/host-tree-children.ts +79 -0
  219. package/src/lib/interaction/hot-set.test.ts +10 -1
  220. package/src/lib/interaction/hot-set.ts +3 -1
  221. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  222. package/src/lib/interaction/overlay-policy.ts +16 -0
  223. package/src/lib/interaction/registered-drafting.ts +36 -0
  224. package/src/lib/interaction/scope.ts +31 -0
  225. package/src/lib/level-duplication.test.ts +73 -0
  226. package/src/lib/level-duplication.ts +32 -2
  227. package/src/lib/level-print-export.test.ts +484 -0
  228. package/src/lib/level-print-export.ts +526 -0
  229. package/src/lib/linear-display.test.ts +59 -0
  230. package/src/lib/linear-display.ts +39 -0
  231. package/src/lib/local-guide-image.test.ts +24 -0
  232. package/src/lib/local-guide-image.ts +34 -1
  233. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  234. package/src/lib/local-project-presentation-persistence.ts +335 -0
  235. package/src/lib/material-paint.ts +2 -0
  236. package/src/lib/model-export.ts +29 -0
  237. package/src/lib/node-action-movement.ts +21 -0
  238. package/src/lib/paint-preview-owner.ts +71 -0
  239. package/src/lib/planar-cursor-placement.test.ts +178 -1
  240. package/src/lib/planar-cursor-placement.ts +61 -5
  241. package/src/lib/plugin-panels.test.ts +92 -1
  242. package/src/lib/plugin-panels.ts +48 -0
  243. package/src/lib/portable-export.test.ts +455 -0
  244. package/src/lib/portable-export.ts +1042 -0
  245. package/src/lib/print-3mf.test.ts +86 -0
  246. package/src/lib/print-3mf.ts +161 -0
  247. package/src/lib/print-content-scope.test.ts +107 -0
  248. package/src/lib/print-content-scope.ts +90 -0
  249. package/src/lib/print-export.test.ts +294 -0
  250. package/src/lib/print-export.ts +735 -0
  251. package/src/lib/print-feature-thickness.test.ts +147 -0
  252. package/src/lib/print-feature-thickness.ts +180 -0
  253. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  254. package/src/lib/print-golden-house.test.ts +249 -0
  255. package/src/lib/print-roof-solids.test.ts +133 -0
  256. package/src/lib/print-roof-solids.ts +630 -0
  257. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  258. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  259. package/src/lib/print-shell-compiler-manifold-core.ts +379 -0
  260. package/src/lib/print-shell-compiler-manifold-worker.ts +200 -0
  261. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  262. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  263. package/src/lib/print-shell-compiler-protocol.ts +38 -0
  264. package/src/lib/print-shell-compiler.ts +273 -0
  265. package/src/lib/print-wall-solids.test.ts +194 -0
  266. package/src/lib/print-wall-solids.ts +328 -0
  267. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  268. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  269. package/src/lib/scene-clipboard.ts +1 -1
  270. package/src/lib/scene.ts +22 -8
  271. package/src/lib/selection-routing.test.ts +105 -1
  272. package/src/lib/selection-routing.ts +19 -0
  273. package/src/lib/send-to-app.test.ts +108 -0
  274. package/src/lib/send-to-app.ts +121 -0
  275. package/src/lib/sfx/movement-tick.ts +37 -0
  276. package/src/lib/sfx-bus.ts +4 -0
  277. package/src/lib/sfx-player.ts +8 -0
  278. package/src/lib/snapping-mode.test.ts +32 -4
  279. package/src/lib/snapping-mode.ts +11 -7
  280. package/src/lib/surface-attachment.ts +77 -0
  281. package/src/lib/units.ts +254 -0
  282. package/src/lib/usdz-export.ts +65 -0
  283. package/src/lib/use-linear-display.ts +8 -32
  284. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  285. package/src/store/live-draft-preview-stores.test.ts +3 -0
  286. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  287. package/src/store/tool-mode.test.ts +87 -0
  288. package/src/store/use-camera-hint-focus.ts +48 -0
  289. package/src/store/use-editor.tsx +299 -188
  290. package/src/store/use-floorplan-draft-preview.ts +9 -0
  291. package/src/store/use-handle-group.ts +4 -0
  292. package/src/store/use-interaction-scope.test.ts +23 -1
  293. package/src/store/use-interaction-scope.ts +63 -2
  294. package/src/store/use-placement-preview.ts +56 -3
  295. package/src/store/use-send-to-app.ts +21 -0
  296. package/src/components/tools/roof/roof-tool.tsx +0 -656
  297. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -4,8 +4,8 @@ import type {
4
4
  CeilingEvent,
5
5
  CeilingNode,
6
6
  GridEvent,
7
- ItemEvent,
8
7
  ItemNode,
8
+ NodeEvent,
9
9
  RoofEvent,
10
10
  RoofNode,
11
11
  RoofSegmentNode,
@@ -16,19 +16,20 @@ import type {
16
16
  WallNode,
17
17
  } from '@pascal-app/core'
18
18
  import {
19
- canHostOnTop,
20
19
  clampRectToRoofWallFace,
20
+ createSceneApi,
21
21
  getRoofSegmentWallFace,
22
22
  getScaledDimensions,
23
- isLowProfileItemSurface,
24
23
  nodeRegistry,
24
+ resolveSurfacePlacement,
25
25
  roofFacePointToSegment,
26
26
  sceneRegistry,
27
27
  useScene,
28
28
  } from '@pascal-app/core'
29
- import { Euler, Matrix3, Quaternion, Vector3 } from 'three'
29
+ import { Euler, Quaternion, Vector3 } from 'three'
30
30
  import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../../../lib/roof-wall-hit'
31
31
  import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap'
32
+ import { itemEventToSurfaceHit, surfaceWorldNormalY } from '../shared/surface-hit'
32
33
  import {
33
34
  calculateItemRotation,
34
35
  getGridAlignedDimensions,
@@ -48,42 +49,14 @@ import type {
48
49
  } from './placement-types'
49
50
 
50
51
  const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
51
- const UPWARD_SURFACE_NORMAL_MIN_Y = 0.75
52
-
53
- function getWorldNormalY(event: ItemEvent): number | null {
54
- if (!event.normal) return null
55
-
56
- const normal = new Vector3(event.normal[0], event.normal[1], event.normal[2])
57
- normal.applyNormalMatrix(new Matrix3().getNormalMatrix(event.object.matrixWorld)).normalize()
58
- return normal.y
59
- }
60
-
61
- function isUpwardItemSurfaceHit(event: ItemEvent): boolean {
62
- const normalY = getWorldNormalY(event)
63
- return normalY !== null && normalY >= UPWARD_SURFACE_NORMAL_MIN_Y
64
- }
65
-
66
- function getSurfacePlacementHeight(surfaceItem: ItemNode, event: ItemEvent, localPos: Vector3) {
67
- if (!canHostOnTop(surfaceItem)) return null
68
- if (isLowProfileItemSurface(surfaceItem)) return null
69
- if (!isUpwardItemSurfaceHit(event)) return null
70
-
71
- if (surfaceItem.asset.surface) {
72
- return surfaceItem.asset.surface.height * surfaceItem.scale[1]
73
- }
74
-
75
- if (!Number.isFinite(localPos.y)) return null
76
- return localPos.y
77
- }
78
-
79
52
  function isDescendantOfItem(
80
- candidate: ItemNode,
81
- ancestor: ItemNode,
53
+ candidate: AnyNode,
54
+ ancestorId: string,
82
55
  nodes: Record<string, AnyNode>,
83
56
  ): boolean {
84
57
  let parentId = candidate.parentId
85
58
  while (parentId) {
86
- if (parentId === ancestor.id) return true
59
+ if (parentId === ancestorId) return true
87
60
  const parent = nodes[parentId as AnyNodeId]
88
61
  parentId = parent?.parentId ?? null
89
62
  }
@@ -268,13 +241,18 @@ export const wallStrategy = {
268
241
  const pose = resolveWallPlacementPose(event, x, adjustedY, attachTo, side, itemRotation)
269
242
 
270
243
  return {
271
- stateUpdate: { surface: 'wall', wallId: event.node.id, roofSegmentId: null },
244
+ stateUpdate: {
245
+ surface: 'wall',
246
+ wallId: event.node.id,
247
+ roofSegmentId: null,
248
+ },
272
249
  nodeUpdate: {
273
250
  position: pose.position,
274
251
  parentId: event.node.id,
275
252
  // The draft may arrive from a roof-segment wall face.
276
253
  roofSegmentId: undefined,
277
254
  roofFace: undefined,
255
+ blockFaceId: undefined,
278
256
  side,
279
257
  rotation: [0, itemRotation, 0],
280
258
  },
@@ -367,6 +345,7 @@ export const wallStrategy = {
367
345
  parentId: event.node.id,
368
346
  roofSegmentId: undefined,
369
347
  roofFace: undefined,
348
+ blockFaceId: undefined,
370
349
  side: ctx.draftItem.side,
371
350
  rotation: ctx.draftItem.rotation,
372
351
  metadata: stripTransient(ctx.draftItem.metadata),
@@ -520,12 +499,17 @@ export const roofWallStrategy = {
520
499
  if (!target) return null
521
500
 
522
501
  return {
523
- stateUpdate: { surface: 'roof-wall', roofSegmentId: target.segment.id, wallId: null },
502
+ stateUpdate: {
503
+ surface: 'roof-wall',
504
+ roofSegmentId: target.segment.id,
505
+ wallId: null,
506
+ },
524
507
  nodeUpdate: {
525
508
  position: target.position,
526
509
  parentId: target.segment.id,
527
510
  roofSegmentId: target.segment.id,
528
511
  roofFace: target.faceId,
512
+ blockFaceId: undefined,
529
513
  wallId: undefined,
530
514
  side: 'front',
531
515
  rotation: [0, 0, 0],
@@ -583,6 +567,7 @@ export const roofWallStrategy = {
583
567
  parentId: ctx.state.roofSegmentId,
584
568
  roofSegmentId: ctx.state.roofSegmentId,
585
569
  roofFace: ctx.draftItem.roofFace,
570
+ blockFaceId: undefined,
586
571
  wallId: undefined,
587
572
  side: 'front',
588
573
  rotation: [0, 0, 0],
@@ -615,6 +600,114 @@ export const roofWallStrategy = {
615
600
  },
616
601
  }
617
602
 
603
+ // ============================================================================
604
+ // FACE HOST STRATEGY
605
+ // ============================================================================
606
+
607
+ function resolveFaceHostTarget(ctx: PlacementContext, event: NodeEvent) {
608
+ const faceHost = nodeRegistry.get(event.node.type)?.capabilities.faceHost
609
+ if (!faceHost) return null
610
+ const rawDimensions = ctx.draftItem
611
+ ? getScaledDimensions(ctx.draftItem)
612
+ : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
613
+ return faceHost.resolvePlacement({
614
+ host: event.node,
615
+ asset: ctx.asset,
616
+ draftItem: ctx.draftItem,
617
+ localPosition: event.localPosition,
618
+ faceIndex: event.faceIndex,
619
+ object: event.object,
620
+ currentFaceId: faceHost.currentFaceId(ctx.draftItem),
621
+ rawDimensions,
622
+ dimensions: getGridAlignedDimensions(rawDimensions, ctx.asset.attachTo),
623
+ snapScalar: snapToHalf,
624
+ })
625
+ }
626
+
627
+ function clearFaceHostItemFields(ctx: PlacementContext): Partial<ItemNode> {
628
+ const host = ctx.state.blockId ? useScene.getState().nodes[ctx.state.blockId] : undefined
629
+ const clearFields = host
630
+ ? nodeRegistry.get(host.type)?.capabilities.faceHost?.clearItemFields
631
+ : undefined
632
+ const patch: Partial<ItemNode> = {}
633
+ for (const field of clearFields ?? []) {
634
+ ;(patch as Record<string, unknown>)[field] = undefined
635
+ }
636
+ return patch
637
+ }
638
+
639
+ export const faceHostStrategy = {
640
+ enter(ctx: PlacementContext, event: NodeEvent): TransitionResult | null {
641
+ const target = resolveFaceHostTarget(ctx, event)
642
+ if (!target) return null
643
+ return {
644
+ stateUpdate: {
645
+ surface: 'block-face',
646
+ blockId: event.node.id,
647
+ wallId: null,
648
+ roofSegmentId: null,
649
+ },
650
+ nodeUpdate: {
651
+ ...target.nodeUpdate,
652
+ },
653
+ gridPosition: target.position,
654
+ cursorPosition: target.cursorPosition,
655
+ cursorRotationY: target.cursorRotation[1],
656
+ cursorRotation: target.cursorRotation,
657
+ stopPropagation: true,
658
+ hostFaceId: target.faceId,
659
+ }
660
+ },
661
+
662
+ move(ctx: PlacementContext, event: NodeEvent): PlacementResult | null {
663
+ if (ctx.state.surface !== 'block-face' || !ctx.draftItem) return null
664
+ const target = resolveFaceHostTarget(ctx, event)
665
+ if (!target || event.node.id !== ctx.state.blockId) return null
666
+ return {
667
+ gridPosition: target.position,
668
+ cursorPosition: target.cursorPosition,
669
+ cursorRotationY: target.cursorRotation[1],
670
+ cursorRotation: target.cursorRotation,
671
+ nodeUpdate: target.nodeUpdate,
672
+ stopPropagation: true,
673
+ dirtyNodeId: null,
674
+ hostFaceId: target.faceId,
675
+ }
676
+ },
677
+
678
+ click(ctx: PlacementContext, event: NodeEvent): CommitResult | null {
679
+ if (ctx.state.surface !== 'block-face' || !ctx.draftItem) return null
680
+ const target = resolveFaceHostTarget(ctx, event)
681
+ if (!target || event.node.id !== ctx.state.blockId) return null
682
+ return {
683
+ nodeUpdate: {
684
+ ...target.nodeUpdate,
685
+ metadata: stripTransient(ctx.draftItem.metadata),
686
+ },
687
+ stopPropagation: true,
688
+ dirtyNodeId: null,
689
+ }
690
+ },
691
+
692
+ leave(ctx: PlacementContext): TransitionResult | null {
693
+ if (ctx.state.surface !== 'block-face') return null
694
+ const floorPosition: [number, number, number] = [ctx.gridPosition.x, 0, ctx.gridPosition.z]
695
+ return {
696
+ stateUpdate: { surface: 'floor', blockId: null },
697
+ nodeUpdate: {
698
+ ...clearFaceHostItemFields(ctx),
699
+ position: floorPosition,
700
+ parentId: ctx.levelId,
701
+ rotation: [0, ctx.currentCursorRotationY, 0],
702
+ },
703
+ cursorRotationY: ctx.currentCursorRotationY,
704
+ gridPosition: floorPosition,
705
+ cursorPosition: floorPosition,
706
+ stopPropagation: true,
707
+ }
708
+ },
709
+ }
710
+
618
711
  // ============================================================================
619
712
  // CEILING STRATEGY
620
713
  // ============================================================================
@@ -760,65 +853,114 @@ export const ceilingStrategy = {
760
853
  // ITEM SURFACE STRATEGY
761
854
  // ============================================================================
762
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
+
763
919
  export const itemSurfaceStrategy = {
764
920
  /**
765
921
  * Handle item:enter — transition from floor to an item surface.
766
922
  * Returns null if: item has no surface, our item doesn't fit, or it's the draft itself.
767
923
  */
768
- enter(ctx: PlacementContext, event: ItemEvent): TransitionResult | null {
924
+ enter(ctx: PlacementContext, event: NodeEvent<AnyNode>): TransitionResult | null {
769
925
  // Only floor items can be placed on surfaces
770
926
  if (ctx.asset.attachTo) return null
771
927
 
772
- const surfaceItem = event.node as ItemNode
928
+ const surfaceItem = event.node
773
929
  // Don't surface-place on the draft itself
774
930
  if (surfaceItem.id === ctx.draftItem?.id) return null
775
931
  if (ctx.state.surface === 'item-surface' && ctx.state.surfaceItemId === surfaceItem.id) {
776
932
  return null
777
933
  }
778
- const nodes = useScene.getState().nodes
779
- if (ctx.draftItem && isDescendantOfItem(surfaceItem, ctx.draftItem, nodes)) return null
780
-
781
- // Size check: our footprint must fit on surface item's footprint
782
934
  const ourDims = ctx.draftItem
783
935
  ? getScaledDimensions(ctx.draftItem)
784
936
  : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
785
- const surfDims = getScaledDimensions(surfaceItem)
786
- if (ourDims[0] > surfDims[0] || ourDims[2] > surfDims[2]) return null
787
-
788
- const surfaceMesh = sceneRegistry.nodes.get(surfaceItem.id)
789
- if (!surfaceMesh) return null
790
-
791
- const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
792
- const localPos = surfaceMesh.worldToLocal(worldPos)
793
- const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
794
- if (surfaceHeight === null) return null
795
-
796
- const x = snapToGrid(localPos.x, ourDims[0])
797
- const z = snapToGrid(localPos.z, ourDims[2])
798
- const y = surfaceHeight
799
-
800
- const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
801
-
802
- // Counter-rotate so the draft's world Y rotation stays continuous when
803
- // the user drags onto a rotated surface item. The cursor wireframe
804
- // already shows the user's intended world rotation; we just need to
805
- // store the right local value relative to the new parent.
806
- const surfaceQuat = new Quaternion()
807
- surfaceMesh.getWorldQuaternion(surfaceQuat)
808
- const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
809
- 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
810
950
  const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0]
811
951
 
812
952
  return {
953
+ surfaceId: pose.surfaceId,
813
954
  stateUpdate: { surface: 'item-surface', surfaceItemId: surfaceItem.id },
814
955
  nodeUpdate: {
815
- position: [x, y, z],
956
+ position: pose.position,
816
957
  parentId: surfaceItem.id,
817
- rotation: [draftRotation[0], localRotationY, draftRotation[2]],
958
+ supportSlabId: undefined,
959
+ rotation: [draftRotation[0], pose.rotationY, draftRotation[2]],
818
960
  },
819
961
  cursorRotationY: ctx.currentCursorRotationY,
820
- gridPosition: [x, y, z],
821
- cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
962
+ gridPosition: pose.position,
963
+ cursorPosition: pose.worldPosition,
822
964
  stopPropagation: true,
823
965
  }
824
966
  },
@@ -826,35 +968,31 @@ export const itemSurfaceStrategy = {
826
968
  /**
827
969
  * Handle item:move — update position while on an item surface.
828
970
  */
829
- move(ctx: PlacementContext, event: ItemEvent): PlacementResult | null {
971
+ move(ctx: PlacementContext, event: NodeEvent<AnyNode>, rawEvent = event): PlacementResult | null {
830
972
  if (ctx.state.surface !== 'item-surface') return null
831
973
  if (!(ctx.state.surfaceItemId && ctx.draftItem)) return null
832
974
  if (event.node.id !== ctx.state.surfaceItemId) return null
833
975
 
834
976
  const nodes = useScene.getState().nodes
835
- const surfaceItem = nodes[ctx.state.surfaceItemId as AnyNodeId] as ItemNode | undefined
977
+ const surfaceItem = nodes[ctx.state.surfaceItemId as AnyNodeId]
836
978
  if (!surfaceItem) return null
837
979
 
838
- const surfaceMesh = sceneRegistry.nodes.get(ctx.state.surfaceItemId)
839
- if (!surfaceMesh) return null
840
-
841
- const ourDims = getScaledDimensions(ctx.draftItem)
842
- const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
843
- const localPos = surfaceMesh.worldToLocal(worldPos)
844
- const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
845
- if (surfaceHeight === null) return null
846
-
847
- const x = snapToGrid(localPos.x, ourDims[0])
848
- const z = snapToGrid(localPos.z, ourDims[2])
849
- const y = surfaceHeight
850
-
851
- 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
852
989
 
853
990
  return {
854
- gridPosition: [x, y, z],
855
- cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
991
+ surfaceId: pose.surfaceId,
992
+ gridPosition: pose.position,
993
+ cursorPosition: pose.worldPosition,
856
994
  cursorRotationY: ctx.currentCursorRotationY,
857
- nodeUpdate: { position: [x, y, z] },
995
+ nodeUpdate: { position: pose.position },
858
996
  stopPropagation: true,
859
997
  dirtyNodeId: null,
860
998
  }
@@ -863,10 +1001,11 @@ export const itemSurfaceStrategy = {
863
1001
  /**
864
1002
  * Handle item:click — commit placement on item surface.
865
1003
  */
866
- click(ctx: PlacementContext, _event: ItemEvent): CommitResult | null {
1004
+ click(ctx: PlacementContext, _event: NodeEvent<AnyNode>): CommitResult | null {
867
1005
  if (ctx.state.surface !== 'item-surface') return null
868
1006
  if (!(ctx.draftItem && ctx.state.surfaceItemId)) return null
869
1007
  if (_event.node.id !== ctx.state.surfaceItemId) return null
1008
+ if (!validCatalogCounterPose(ctx)) return null
870
1009
 
871
1010
  return {
872
1011
  nodeUpdate: {
@@ -884,32 +1023,40 @@ export const itemSurfaceStrategy = {
884
1023
  // SHELF SURFACE STRATEGY
885
1024
  // ============================================================================
886
1025
 
887
- /**
888
- * Resolve the row Y closest to the cursor's local Y. Reads candidate row
889
- * positions from the kind's `capabilities.surfaces.custom` — the shelf
890
- * declaration emits one `SurfacePoint` per board's top surface. The
891
- * strategy stays kind-agnostic at this level: any future "multi-board"
892
- * kind that declares `surfaces.custom` with upward normals gets the
893
- * same hit behaviour for free.
894
- */
895
- function getShelfRowSurfaceY(shelfNode: ShelfNode, localY: number): number | null {
896
- const def = nodeRegistry.get('shelf')
897
- const custom = def?.capabilities?.surfaces?.custom
898
- if (!custom) return null
899
- const candidates = custom(shelfNode as AnyNode)
900
- if (candidates.length === 0) return null
901
- let best = candidates[0]
902
- let bestDist = Math.abs(best!.position[1] - localY)
903
- for (let i = 1; i < candidates.length; i++) {
904
- const c = candidates[i]
905
- if (!c) continue
906
- const dist = Math.abs(c.position[1] - localY)
907
- if (dist < bestDist) {
908
- best = c
909
- bestDist = dist
910
- }
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(),
911
1059
  }
912
- return best?.position[1] ?? null
913
1060
  }
914
1061
 
915
1062
  export const shelfSurfaceStrategy = {
@@ -928,43 +1075,30 @@ export const shelfSurfaceStrategy = {
928
1075
  if (ctx.state.surface === 'shelf-surface' && ctx.state.shelfId === shelfNode.id) {
929
1076
  return null
930
1077
  }
931
- if (!isUpwardShelfSurfaceHit(event)) return null
932
-
933
- // Size check: draft footprint must fit on the shelf board (width × depth).
934
1078
  const ourDims = ctx.draftItem
935
1079
  ? getScaledDimensions(ctx.draftItem)
936
1080
  : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
937
- if (ourDims[0] > shelfNode.width || ourDims[2] > shelfNode.depth) return null
938
-
939
- const shelfMesh = sceneRegistry.nodes.get(shelfNode.id)
940
- if (!shelfMesh) return null
941
-
942
- const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
943
- const localPos = shelfMesh.worldToLocal(worldPos)
944
- const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
945
- if (rowY === null) return null
946
-
947
- const x = snapToGrid(localPos.x, ourDims[0])
948
- const z = snapToGrid(localPos.z, ourDims[2])
949
-
950
- const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
951
-
952
- const surfaceQuat = new Quaternion()
953
- shelfMesh.getWorldQuaternion(surfaceQuat)
954
- const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
955
- 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
956
1090
  const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0]
957
1091
 
958
1092
  return {
959
1093
  stateUpdate: { surface: 'shelf-surface', shelfId: shelfNode.id },
960
1094
  nodeUpdate: {
961
- position: [x, rowY, z],
1095
+ position: pose.position,
962
1096
  parentId: shelfNode.id,
963
- rotation: [draftRotation[0], localRotationY, draftRotation[2]],
1097
+ rotation: [draftRotation[0], pose.rotationY, draftRotation[2]],
964
1098
  },
965
1099
  cursorRotationY: ctx.currentCursorRotationY,
966
- gridPosition: [x, rowY, z],
967
- cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
1100
+ gridPosition: pose.position,
1101
+ cursorPosition: pose.worldPosition,
968
1102
  stopPropagation: true,
969
1103
  }
970
1104
  },
@@ -978,25 +1112,21 @@ export const shelfSurfaceStrategy = {
978
1112
  if (!(ctx.state.shelfId && ctx.draftItem)) return null
979
1113
  if (event.node.id !== ctx.state.shelfId) return null
980
1114
 
981
- const shelfNode = event.node as ShelfNode
982
- const shelfMesh = sceneRegistry.nodes.get(shelfNode.id)
983
- if (!shelfMesh) return null
984
-
985
- const ourDims = getScaledDimensions(ctx.draftItem)
986
- const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
987
- const localPos = shelfMesh.worldToLocal(worldPos)
988
- const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
989
- if (rowY === null) return null
990
-
991
- const x = snapToGrid(localPos.x, ourDims[0])
992
- const z = snapToGrid(localPos.z, ourDims[2])
993
- 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
994
1124
 
995
1125
  return {
996
- gridPosition: [x, rowY, z],
997
- cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
1126
+ gridPosition: pose.position,
1127
+ cursorPosition: pose.worldPosition,
998
1128
  cursorRotationY: ctx.currentCursorRotationY,
999
- nodeUpdate: { position: [x, rowY, z] },
1129
+ nodeUpdate: { position: pose.position },
1000
1130
  stopPropagation: true,
1001
1131
  dirtyNodeId: null,
1002
1132
  }
@@ -1009,6 +1139,7 @@ export const shelfSurfaceStrategy = {
1009
1139
  if (ctx.state.surface !== 'shelf-surface') return null
1010
1140
  if (!(ctx.draftItem && ctx.state.shelfId)) return null
1011
1141
  if (event.node.id !== ctx.state.shelfId) return null
1142
+ if (!validCatalogCounterPose(ctx)) return null
1012
1143
 
1013
1144
  return {
1014
1145
  nodeUpdate: {
@@ -1022,14 +1153,6 @@ export const shelfSurfaceStrategy = {
1022
1153
  },
1023
1154
  }
1024
1155
 
1025
- /** Same upward-normal heuristic as `isUpwardItemSurfaceHit`, but typed
1026
- * for `ShelfEvent`. Re-uses the matrix-driven world normal calculation
1027
- * via a tiny `ItemEvent`-shaped adapter — the function only reads
1028
- * `event.normal` + `event.object`. */
1029
- function isUpwardShelfSurfaceHit(event: ShelfEvent): boolean {
1030
- return isUpwardItemSurfaceHit(event as unknown as ItemEvent)
1031
- }
1032
-
1033
1156
  // ============================================================================
1034
1157
  // VALIDATION
1035
1158
  // ============================================================================
@@ -1041,20 +1164,30 @@ function isUpwardShelfSurfaceHit(event: ShelfEvent): boolean {
1041
1164
  export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidators): boolean {
1042
1165
  if (!(ctx.levelId && ctx.draftItem)) return false
1043
1166
 
1044
- // Item surface: valid if we entered (size check was in enter)
1045
1167
  if (ctx.state.surface === 'item-surface') {
1046
- return ctx.state.surfaceItemId !== null
1168
+ return ctx.state.surfaceItemId !== null && validCatalogCounterPose(ctx)
1047
1169
  }
1048
1170
 
1049
- // Shelf surface: same — size check already happened on enter
1050
1171
  if (ctx.state.surface === 'shelf-surface') {
1051
- return ctx.state.shelfId !== null
1172
+ return ctx.state.shelfId !== null && validCatalogCounterPose(ctx)
1052
1173
  }
1053
1174
 
1054
1175
  const attachTo = ctx.draftItem.asset.attachTo
1055
1176
 
1056
1177
  const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo)
1057
1178
 
1179
+ if (ctx.state.surface === 'block-face') {
1180
+ const hostId = ctx.state.blockId
1181
+ const host = hostId ? useScene.getState().nodes[hostId as AnyNodeId] : undefined
1182
+ const faceHost = host ? nodeRegistry.get(host.type)?.capabilities.faceHost : undefined
1183
+ if (!(host && faceHost)) return false
1184
+ return faceHost.isStoredPlacementValid({
1185
+ host,
1186
+ item: ctx.draftItem,
1187
+ asset: ctx.draftItem.asset,
1188
+ })
1189
+ }
1190
+
1058
1191
  if (attachTo === 'ceiling') {
1059
1192
  if (ctx.state.surface !== 'ceiling' || !ctx.state.ceilingId) return false
1060
1193
  return validators.canPlaceOnCeiling(