@pascal-app/editor 1.0.0-beta.5 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +13 -7
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +1 -0
  6. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  7. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  8. package/src/components/editor/capture-camera-rig.tsx +59 -0
  9. package/src/components/editor/custom-camera-controls.tsx +20 -11
  10. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  11. package/src/components/editor/editor-layout-v2.tsx +9 -2
  12. package/src/components/editor/export-manager.tsx +175 -33
  13. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  14. package/src/components/editor/first-person-controls.tsx +180 -12
  15. package/src/components/editor/floating-action-menu.tsx +42 -51
  16. package/src/components/editor/floorplan-panel.tsx +76 -12
  17. package/src/components/editor/grid.tsx +12 -7
  18. package/src/components/editor/group-actions.ts +34 -50
  19. package/src/components/editor/group-move-3d.ts +51 -21
  20. package/src/components/editor/group-transform-shared.test.ts +78 -0
  21. package/src/components/editor/group-transform-shared.ts +74 -7
  22. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  23. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  24. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  25. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  26. package/src/components/editor/handles/resize-snap.ts +10 -1
  27. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  28. package/src/components/editor/index.tsx +140 -21
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +47 -58
  31. package/src/components/editor/selection-manager.tsx +104 -42
  32. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  33. package/src/components/editor/snapshot-capture.test.ts +422 -0
  34. package/src/components/editor/snapshot-capture.ts +145 -0
  35. package/src/components/editor/thumbnail-generator.tsx +284 -237
  36. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  37. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  38. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  39. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  41. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  42. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  43. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  44. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  45. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  46. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +180 -32
  47. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  48. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  49. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  51. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  52. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  53. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  54. package/src/components/systems/selection-affordance-services.ts +19 -0
  55. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  56. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  57. package/src/components/tools/fence/fence-drafting.ts +6 -25
  58. package/src/components/tools/item/draft-creation.test.ts +12 -0
  59. package/src/components/tools/item/draft-creation.ts +10 -0
  60. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  61. package/src/components/tools/item/face-host-commit.ts +56 -0
  62. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  63. package/src/components/tools/item/face-host-preview.ts +82 -0
  64. package/src/components/tools/item/move-tool.tsx +4 -3
  65. package/src/components/tools/item/placement-math.test.ts +11 -1
  66. package/src/components/tools/item/placement-math.ts +0 -20
  67. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  68. package/src/components/tools/item/placement-strategies.ts +135 -2
  69. package/src/components/tools/item/placement-surface.test.ts +61 -0
  70. package/src/components/tools/item/placement-surface.ts +24 -0
  71. package/src/components/tools/item/placement-types.ts +6 -0
  72. package/src/components/tools/item/test-face-host.ts +121 -0
  73. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  74. package/src/components/tools/item/use-draft-node.ts +26 -4
  75. package/src/components/tools/item/use-placement-coordinator.tsx +428 -153
  76. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  77. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  78. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  79. package/src/components/tools/registry-tool-context.tsx +30 -0
  80. package/src/components/tools/select/box-select-tool.tsx +3 -1
  81. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  82. package/src/components/tools/select/marquee-footprint.ts +29 -0
  83. package/src/components/tools/shared/placement-box.tsx +57 -7
  84. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  85. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  86. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  89. package/src/components/tools/stair/stair-tool.tsx +127 -74
  90. package/src/components/tools/tool-manager.tsx +25 -8
  91. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  92. package/src/components/tools/wall/wall-drafting.ts +23 -114
  93. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  94. package/src/components/ui/action-menu/index.tsx +11 -1
  95. package/src/components/ui/command-palette/editor-commands.tsx +9 -12
  96. package/src/components/ui/command-palette/index.tsx +7 -2
  97. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  98. package/src/components/ui/controls/material-picker.tsx +4 -4
  99. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  100. package/src/components/ui/controls/segmented-control.tsx +8 -1
  101. package/src/components/ui/controls/slider-control.tsx +19 -3
  102. package/src/components/ui/controls/toggle-control.tsx +18 -5
  103. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  104. package/src/components/ui/floating-level-selector.tsx +22 -5
  105. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  106. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  107. package/src/components/ui/helpers/item-helper.tsx +1 -1
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  109. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  110. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  111. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  112. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  113. package/src/components/ui/panels/multi-field-value.ts +163 -0
  114. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  115. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  116. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  117. package/src/components/ui/panels/node-display.ts +4 -0
  118. package/src/components/ui/panels/panel-manager.tsx +77 -3
  119. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  120. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  121. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  122. package/src/components/ui/panels/reference-panel.tsx +31 -1
  123. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  124. package/src/components/ui/scene-loader.tsx +35 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  127. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  128. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  129. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  130. package/src/components/ui/sidebar/panels/site-panel/index.tsx +116 -46
  131. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  132. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  134. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +14 -6
  135. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  136. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  137. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  138. package/src/components/viewer/viewer-stage.tsx +4 -1
  139. package/src/components/viewer-overlay.tsx +1 -22
  140. package/src/components/viewer-zone-system.tsx +6 -2
  141. package/src/components/walkthrough-hud.tsx +19 -10
  142. package/src/hooks/use-auto-save.test.ts +73 -1
  143. package/src/hooks/use-auto-save.ts +76 -5
  144. package/src/hooks/use-ceiling-events.test.ts +122 -0
  145. package/src/hooks/use-drag-action.ts +17 -3
  146. package/src/hooks/use-grid-events.test.ts +240 -0
  147. package/src/hooks/use-grid-events.ts +190 -11
  148. package/src/hooks/use-keyboard.test.ts +244 -0
  149. package/src/hooks/use-keyboard.ts +129 -62
  150. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  151. package/src/hooks/use-save-shortcut.ts +33 -0
  152. package/src/index.tsx +82 -4
  153. package/src/lib/active-placement-surface.test.ts +16 -0
  154. package/src/lib/active-placement-surface.ts +14 -0
  155. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  156. package/src/lib/ceiling-surface-raycast.ts +12 -0
  157. package/src/lib/contextual-help-extension.ts +17 -0
  158. package/src/lib/continuation.test.ts +11 -0
  159. package/src/lib/continuation.ts +8 -1
  160. package/src/lib/direct-manipulation.test.ts +132 -0
  161. package/src/lib/direct-manipulation.ts +38 -1
  162. package/src/lib/editor-api.ts +6 -3
  163. package/src/lib/elevation-guides.test.ts +28 -1
  164. package/src/lib/elevation-guides.ts +22 -7
  165. package/src/lib/floating-menu-scale.test.ts +29 -0
  166. package/src/lib/floating-menu-scale.ts +14 -0
  167. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  168. package/src/lib/floorplan/apply-alignment.ts +10 -1
  169. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  170. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  171. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  172. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  173. package/src/lib/floorplan/floorplan-readonly.ts +1 -0
  174. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  175. package/src/lib/floorplan-grid-event-point.ts +13 -0
  176. package/src/lib/glb-export.test.ts +1169 -21
  177. package/src/lib/glb-export.ts +629 -101
  178. package/src/lib/grid-event-presentation.ts +16 -0
  179. package/src/lib/history.test.ts +528 -147
  180. package/src/lib/history.ts +81 -7
  181. package/src/lib/host-tree-children.test.ts +27 -0
  182. package/src/lib/host-tree-children.ts +79 -0
  183. package/src/lib/interaction/hot-set.test.ts +10 -1
  184. package/src/lib/interaction/hot-set.ts +3 -1
  185. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  186. package/src/lib/interaction/overlay-policy.ts +16 -0
  187. package/src/lib/interaction/registered-drafting.ts +36 -0
  188. package/src/lib/interaction/scope.ts +31 -0
  189. package/src/lib/level-print-export.test.ts +484 -0
  190. package/src/lib/level-print-export.ts +526 -0
  191. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  192. package/src/lib/local-project-presentation-persistence.ts +335 -0
  193. package/src/lib/material-paint.ts +2 -0
  194. package/src/lib/model-export.ts +29 -0
  195. package/src/lib/paint-preview-owner.ts +71 -0
  196. package/src/lib/planar-cursor-placement.test.ts +178 -1
  197. package/src/lib/planar-cursor-placement.ts +61 -5
  198. package/src/lib/plugin-panels.test.ts +92 -1
  199. package/src/lib/plugin-panels.ts +48 -0
  200. package/src/lib/portable-export.test.ts +366 -0
  201. package/src/lib/portable-export.ts +969 -0
  202. package/src/lib/print-3mf.test.ts +86 -0
  203. package/src/lib/print-3mf.ts +161 -0
  204. package/src/lib/print-content-scope.test.ts +107 -0
  205. package/src/lib/print-content-scope.ts +90 -0
  206. package/src/lib/print-export.test.ts +294 -0
  207. package/src/lib/print-export.ts +735 -0
  208. package/src/lib/print-feature-thickness.test.ts +147 -0
  209. package/src/lib/print-feature-thickness.ts +180 -0
  210. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  211. package/src/lib/print-golden-house.test.ts +249 -0
  212. package/src/lib/print-roof-solids.test.ts +133 -0
  213. package/src/lib/print-roof-solids.ts +630 -0
  214. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  215. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  216. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  217. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  218. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  219. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  220. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  221. package/src/lib/print-shell-compiler.ts +273 -0
  222. package/src/lib/print-wall-solids.test.ts +194 -0
  223. package/src/lib/print-wall-solids.ts +328 -0
  224. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  225. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  226. package/src/lib/scene.ts +22 -8
  227. package/src/lib/selection-routing.test.ts +105 -1
  228. package/src/lib/selection-routing.ts +19 -0
  229. package/src/lib/sfx-bus.ts +4 -0
  230. package/src/lib/sfx-player.ts +8 -0
  231. package/src/lib/snapping-mode.test.ts +32 -4
  232. package/src/lib/snapping-mode.ts +11 -7
  233. package/src/lib/usdz-export.ts +38 -0
  234. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  235. package/src/store/live-draft-preview-stores.test.ts +3 -0
  236. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  237. package/src/store/tool-mode.test.ts +87 -0
  238. package/src/store/use-camera-hint-focus.ts +48 -0
  239. package/src/store/use-editor.tsx +299 -188
  240. package/src/store/use-floorplan-draft-preview.ts +9 -0
  241. package/src/store/use-interaction-scope.test.ts +23 -1
  242. package/src/store/use-interaction-scope.ts +63 -2
  243. package/src/store/use-placement-preview.ts +56 -3
  244. package/src/components/tools/roof/roof-tool.tsx +0 -656
  245. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -6,6 +6,7 @@ import type {
6
6
  GridEvent,
7
7
  ItemEvent,
8
8
  ItemNode,
9
+ NodeEvent,
9
10
  RoofEvent,
10
11
  RoofNode,
11
12
  RoofSegmentNode,
@@ -268,13 +269,18 @@ export const wallStrategy = {
268
269
  const pose = resolveWallPlacementPose(event, x, adjustedY, attachTo, side, itemRotation)
269
270
 
270
271
  return {
271
- stateUpdate: { surface: 'wall', wallId: event.node.id, roofSegmentId: null },
272
+ stateUpdate: {
273
+ surface: 'wall',
274
+ wallId: event.node.id,
275
+ roofSegmentId: null,
276
+ },
272
277
  nodeUpdate: {
273
278
  position: pose.position,
274
279
  parentId: event.node.id,
275
280
  // The draft may arrive from a roof-segment wall face.
276
281
  roofSegmentId: undefined,
277
282
  roofFace: undefined,
283
+ blockFaceId: undefined,
278
284
  side,
279
285
  rotation: [0, itemRotation, 0],
280
286
  },
@@ -367,6 +373,7 @@ export const wallStrategy = {
367
373
  parentId: event.node.id,
368
374
  roofSegmentId: undefined,
369
375
  roofFace: undefined,
376
+ blockFaceId: undefined,
370
377
  side: ctx.draftItem.side,
371
378
  rotation: ctx.draftItem.rotation,
372
379
  metadata: stripTransient(ctx.draftItem.metadata),
@@ -520,12 +527,17 @@ export const roofWallStrategy = {
520
527
  if (!target) return null
521
528
 
522
529
  return {
523
- stateUpdate: { surface: 'roof-wall', roofSegmentId: target.segment.id, wallId: null },
530
+ stateUpdate: {
531
+ surface: 'roof-wall',
532
+ roofSegmentId: target.segment.id,
533
+ wallId: null,
534
+ },
524
535
  nodeUpdate: {
525
536
  position: target.position,
526
537
  parentId: target.segment.id,
527
538
  roofSegmentId: target.segment.id,
528
539
  roofFace: target.faceId,
540
+ blockFaceId: undefined,
529
541
  wallId: undefined,
530
542
  side: 'front',
531
543
  rotation: [0, 0, 0],
@@ -583,6 +595,7 @@ export const roofWallStrategy = {
583
595
  parentId: ctx.state.roofSegmentId,
584
596
  roofSegmentId: ctx.state.roofSegmentId,
585
597
  roofFace: ctx.draftItem.roofFace,
598
+ blockFaceId: undefined,
586
599
  wallId: undefined,
587
600
  side: 'front',
588
601
  rotation: [0, 0, 0],
@@ -615,6 +628,114 @@ export const roofWallStrategy = {
615
628
  },
616
629
  }
617
630
 
631
+ // ============================================================================
632
+ // FACE HOST STRATEGY
633
+ // ============================================================================
634
+
635
+ function resolveFaceHostTarget(ctx: PlacementContext, event: NodeEvent) {
636
+ const faceHost = nodeRegistry.get(event.node.type)?.capabilities.faceHost
637
+ if (!faceHost) return null
638
+ const rawDimensions = ctx.draftItem
639
+ ? getScaledDimensions(ctx.draftItem)
640
+ : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
641
+ return faceHost.resolvePlacement({
642
+ host: event.node,
643
+ asset: ctx.asset,
644
+ draftItem: ctx.draftItem,
645
+ localPosition: event.localPosition,
646
+ faceIndex: event.faceIndex,
647
+ object: event.object,
648
+ currentFaceId: faceHost.currentFaceId(ctx.draftItem),
649
+ rawDimensions,
650
+ dimensions: getGridAlignedDimensions(rawDimensions, ctx.asset.attachTo),
651
+ snapScalar: snapToHalf,
652
+ })
653
+ }
654
+
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
+ export const faceHostStrategy = {
668
+ enter(ctx: PlacementContext, event: NodeEvent): TransitionResult | null {
669
+ const target = resolveFaceHostTarget(ctx, event)
670
+ if (!target) return null
671
+ return {
672
+ stateUpdate: {
673
+ surface: 'block-face',
674
+ blockId: event.node.id,
675
+ wallId: null,
676
+ roofSegmentId: null,
677
+ },
678
+ nodeUpdate: {
679
+ ...target.nodeUpdate,
680
+ },
681
+ gridPosition: target.position,
682
+ cursorPosition: target.cursorPosition,
683
+ cursorRotationY: target.cursorRotation[1],
684
+ cursorRotation: target.cursorRotation,
685
+ stopPropagation: true,
686
+ hostFaceId: target.faceId,
687
+ }
688
+ },
689
+
690
+ move(ctx: PlacementContext, event: NodeEvent): PlacementResult | null {
691
+ if (ctx.state.surface !== 'block-face' || !ctx.draftItem) return null
692
+ const target = resolveFaceHostTarget(ctx, event)
693
+ if (!target || event.node.id !== ctx.state.blockId) return null
694
+ return {
695
+ gridPosition: target.position,
696
+ cursorPosition: target.cursorPosition,
697
+ cursorRotationY: target.cursorRotation[1],
698
+ cursorRotation: target.cursorRotation,
699
+ nodeUpdate: target.nodeUpdate,
700
+ stopPropagation: true,
701
+ dirtyNodeId: null,
702
+ hostFaceId: target.faceId,
703
+ }
704
+ },
705
+
706
+ click(ctx: PlacementContext, event: NodeEvent): CommitResult | null {
707
+ if (ctx.state.surface !== 'block-face' || !ctx.draftItem) return null
708
+ const target = resolveFaceHostTarget(ctx, event)
709
+ if (!target || event.node.id !== ctx.state.blockId) return null
710
+ return {
711
+ nodeUpdate: {
712
+ ...target.nodeUpdate,
713
+ metadata: stripTransient(ctx.draftItem.metadata),
714
+ },
715
+ stopPropagation: true,
716
+ dirtyNodeId: null,
717
+ }
718
+ },
719
+
720
+ leave(ctx: PlacementContext): TransitionResult | null {
721
+ if (ctx.state.surface !== 'block-face') return null
722
+ const floorPosition: [number, number, number] = [ctx.gridPosition.x, 0, ctx.gridPosition.z]
723
+ return {
724
+ stateUpdate: { surface: 'floor', blockId: null },
725
+ nodeUpdate: {
726
+ ...clearFaceHostItemFields(ctx),
727
+ position: floorPosition,
728
+ parentId: ctx.levelId,
729
+ rotation: [0, ctx.currentCursorRotationY, 0],
730
+ },
731
+ cursorRotationY: ctx.currentCursorRotationY,
732
+ gridPosition: floorPosition,
733
+ cursorPosition: floorPosition,
734
+ stopPropagation: true,
735
+ }
736
+ },
737
+ }
738
+
618
739
  // ============================================================================
619
740
  // CEILING STRATEGY
620
741
  // ============================================================================
@@ -1055,6 +1176,18 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
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(
@@ -0,0 +1,61 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { Matrix4, Quaternion, Vector3 } from 'three'
3
+ import { resolveItemPlacementSurfaceNormal } from './placement-surface'
4
+
5
+ describe('resolveItemPlacementSurfaceNormal', () => {
6
+ test('uses the full face normal for a sloped block host', () => {
7
+ const normal = new Vector3(0, 0.6, 0.8).normalize()
8
+ const xAxis = new Vector3(1, 0, 0)
9
+ const yAxis = new Vector3().crossVectors(normal, xAxis).normalize()
10
+ const faceQuaternion = new Quaternion().setFromRotationMatrix(
11
+ new Matrix4().makeBasis(xAxis, yAxis, normal),
12
+ )
13
+
14
+ const resolved = resolveItemPlacementSurfaceNormal(
15
+ 'block-face',
16
+ faceQuaternion,
17
+ null,
18
+ new Vector3(),
19
+ 'wall',
20
+ )
21
+
22
+ expect(resolved.toArray()).toEqual(normal.toArray())
23
+ })
24
+
25
+ test('uses the upward host normal for a floor item on a block top face', () => {
26
+ const faceQuaternion = new Quaternion().setFromRotationMatrix(
27
+ new Matrix4().makeBasis(new Vector3(1, 0, 0), new Vector3(0, 0, -1), new Vector3(0, 1, 0)),
28
+ )
29
+ const itemQuaternion = faceQuaternion.multiply(
30
+ new Quaternion().setFromAxisAngle(new Vector3(1, 0, 0), Math.PI / 2),
31
+ )
32
+
33
+ const resolved = resolveItemPlacementSurfaceNormal(
34
+ 'block-face',
35
+ itemQuaternion,
36
+ null,
37
+ new Vector3(),
38
+ )
39
+
40
+ expect(resolved.toArray().map((value) => Math.round(value))).toEqual([0, 1, 0])
41
+ })
42
+
43
+ test('uses the downward host normal for a block ceiling attachment', () => {
44
+ const faceQuaternion = new Quaternion().setFromRotationMatrix(
45
+ new Matrix4().makeBasis(new Vector3(1, 0, 0), new Vector3(0, 0, 1), new Vector3(0, -1, 0)),
46
+ )
47
+ const itemQuaternion = faceQuaternion.multiply(
48
+ new Quaternion().setFromAxisAngle(new Vector3(1, 0, 0), -Math.PI / 2),
49
+ )
50
+
51
+ const resolved = resolveItemPlacementSurfaceNormal(
52
+ 'block-face',
53
+ itemQuaternion,
54
+ null,
55
+ new Vector3(),
56
+ 'ceiling',
57
+ )
58
+
59
+ expect(resolved.toArray().map((value) => Math.round(value))).toEqual([0, -1, 0])
60
+ })
61
+ })
@@ -0,0 +1,24 @@
1
+ import type { Quaternion, Vector3 } from 'three'
2
+ import type { SurfaceType } from './placement-types'
3
+
4
+ export function resolveItemPlacementSurfaceNormal(
5
+ surface: SurfaceType,
6
+ ghostWorldQuaternion: Quaternion,
7
+ hostedItemWorldQuaternion: Quaternion | null,
8
+ target: Vector3,
9
+ attachTo?: 'wall' | 'wall-side' | 'ceiling',
10
+ ): Vector3 {
11
+ if (surface === 'block-face') {
12
+ if (attachTo === 'ceiling') target.set(0, -1, 0)
13
+ else if (attachTo === 'wall' || attachTo === 'wall-side') target.set(0, 0, 1)
14
+ else target.set(0, 1, 0)
15
+ target.applyQuaternion(ghostWorldQuaternion)
16
+ if (target.lengthSq() > 1e-6) return target.normalize()
17
+ }
18
+ if (surface === 'wall' || surface === 'roof-wall') {
19
+ target.set(0, 0, 1).applyQuaternion(hostedItemWorldQuaternion ?? ghostWorldQuaternion)
20
+ target.y = 0
21
+ if (target.lengthSq() > 1e-6) return target.normalize()
22
+ }
23
+ return target.set(0, 1, 0)
24
+ }
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  AnyNode,
3
+ AnyNodeId,
3
4
  AssetInput,
4
5
  CeilingNode,
5
6
  ItemNode,
@@ -16,6 +17,7 @@ export type SurfaceType =
16
17
  | 'floor'
17
18
  | 'wall'
18
19
  | 'roof-wall'
20
+ | 'block-face'
19
21
  | 'ceiling'
20
22
  | 'item-surface'
21
23
  | 'shelf-surface'
@@ -33,6 +35,8 @@ export interface PlacementState {
33
35
  * (base walls + coplanar gable ends).
34
36
  */
35
37
  roofSegmentId: string | null
38
+ /** Active planar node face used as a wall-like attachment host. */
39
+ blockId?: AnyNodeId | null
36
40
  ceilingId: string | null
37
41
  surfaceItemId: string | null
38
42
  /**
@@ -81,6 +85,7 @@ export interface PlacementResult {
81
85
  nodeUpdate: Partial<ItemNode> | null
82
86
  stopPropagation: boolean
83
87
  dirtyNodeId: AnyNode['id'] | null
88
+ hostFaceId?: string | null
84
89
  }
85
90
 
86
91
  /**
@@ -94,6 +99,7 @@ export interface TransitionResult {
94
99
  cursorRotationY: number
95
100
  cursorRotation?: [number, number, number]
96
101
  stopPropagation: boolean
102
+ hostFaceId?: string | null
97
103
  }
98
104
 
99
105
  /**
@@ -0,0 +1,121 @@
1
+ import {
2
+ BlockNode,
3
+ type BlockNode as BlockNodeType,
4
+ type FaceHostCapability,
5
+ type ItemNode,
6
+ nodeRegistry,
7
+ registerNode,
8
+ } from '@pascal-app/core'
9
+
10
+ const faceForHit = (object: unknown, faceIndex = 0) => {
11
+ const geometry = (object as { geometry?: { userData?: Record<string, unknown> } }).geometry
12
+ const faces = geometry?.userData?.blockFaces
13
+ if (!Array.isArray(faces)) return null
14
+ return (
15
+ faces.find((face) => {
16
+ if (!face || typeof face !== 'object') return false
17
+ const entry = face as { start?: number; count?: number }
18
+ return (
19
+ typeof entry.start === 'number' &&
20
+ typeof entry.count === 'number' &&
21
+ faceIndex * 3 >= entry.start &&
22
+ faceIndex * 3 < entry.start + entry.count
23
+ )
24
+ }) as { faceId?: string } | undefined
25
+ )?.faceId
26
+ }
27
+
28
+ const testFaceHost: FaceHostCapability<BlockNodeType> = {
29
+ currentFaceId: (item) => item?.blockFaceId ?? null,
30
+ clearItemFields: ['position', 'rotation', 'blockFaceId'],
31
+ resolvePlacement: ({ asset, currentFaceId, faceIndex, host, localPosition, object }) => {
32
+ const hitFaceId = faceForHit(object, faceIndex) ?? null
33
+ const faceId = currentFaceId ?? hitFaceId
34
+ if (!faceId) return null
35
+
36
+ if (faceId === 'f-front') {
37
+ if (asset.attachTo !== 'wall-side' || localPosition[2] > -0.75) return null
38
+ return {
39
+ cursorPosition: [localPosition[0], localPosition[1], -1],
40
+ position: [localPosition[0], localPosition[1], -1],
41
+ rotation: [0, 0, 0],
42
+ cursorRotation: [0, 0, 0],
43
+ faceId,
44
+ nodeUpdate: {
45
+ parentId: host.id,
46
+ blockFaceId: faceId,
47
+ position: [localPosition[0], localPosition[1], 0],
48
+ rotation: [0, 0, 0],
49
+ },
50
+ }
51
+ }
52
+
53
+ if (faceId === 'f-bottom') {
54
+ if (asset.attachTo !== 'ceiling') return null
55
+ return {
56
+ cursorPosition: [0, -0.25, 0],
57
+ position: [0, 0, 0.25],
58
+ rotation: [-Math.PI / 2, 0, 0],
59
+ cursorRotation: [-Math.PI / 2, 0, 0],
60
+ faceId,
61
+ nodeUpdate: {
62
+ parentId: host.id,
63
+ blockFaceId: faceId,
64
+ position: [0, 0, 0.25],
65
+ rotation: [-Math.PI / 2, 0, 0],
66
+ },
67
+ }
68
+ }
69
+
70
+ if (faceId === 'f-top') {
71
+ if (asset.attachTo) return null
72
+ return {
73
+ cursorPosition: [0, 2.4, 0],
74
+ position: [0, 2.4, 0],
75
+ rotation: [Math.PI / 2, 0, 0],
76
+ cursorRotation: [Math.PI / 2, 0, 0],
77
+ faceId,
78
+ nodeUpdate: {
79
+ parentId: host.id,
80
+ blockFaceId: faceId,
81
+ position: [0, 0, 0],
82
+ rotation: [Math.PI / 2, 0, 0],
83
+ },
84
+ }
85
+ }
86
+
87
+ return null
88
+ },
89
+ storedPlacementPatch: ({ host, item, position }) => {
90
+ if (!item.blockFaceId) return null
91
+ return {
92
+ parentId: host.id,
93
+ blockFaceId: item.blockFaceId,
94
+ position: [position[0], position[1], position[2]],
95
+ rotation: item.rotation,
96
+ roofSegmentId: undefined,
97
+ roofFace: undefined,
98
+ wallId: undefined,
99
+ side: 'front',
100
+ } satisfies Partial<ItemNode>
101
+ },
102
+ isStoredPlacementValid: ({ item }) => Boolean(item.blockFaceId),
103
+ }
104
+
105
+ export function registerTestBlockFaceHost() {
106
+ // The registry is a module singleton shared across test files, and other
107
+ // suites register their own minimal `block` (the wall drafting stub is
108
+ // floor-placed only). Skipping on name alone would leave that capability-less
109
+ // definition in place, so replace it unless it already hosts faces.
110
+ if (nodeRegistry.get('block')?.capabilities?.faceHost) return
111
+ if (nodeRegistry.has('block')) nodeRegistry._reset()
112
+ registerNode({
113
+ kind: 'block',
114
+ schemaVersion: 1,
115
+ schema: BlockNode,
116
+ category: 'structure',
117
+ defaults: () => BlockNode.parse({ id: 'block_test', parentId: null }) as never,
118
+ capabilities: { faceHost: testFaceHost },
119
+ renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
120
+ } as never)
121
+ }