@pascal-app/editor 1.0.0-beta.4 → 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 (283) 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/alignment-3d-guide-layer.tsx +12 -3
  6. package/src/components/editor/bake-exporter.tsx +1 -0
  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 +17 -5
  13. package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
  14. package/src/components/editor/export-manager.tsx +175 -33
  15. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  16. package/src/components/editor/first-person-controls.tsx +182 -57
  17. package/src/components/editor/floating-action-menu.tsx +55 -55
  18. package/src/components/editor/floorplan-background-selection.test.ts +31 -4
  19. package/src/components/editor/floorplan-background-selection.ts +27 -7
  20. package/src/components/editor/floorplan-panel.tsx +81 -57
  21. package/src/components/editor/grid.tsx +12 -7
  22. package/src/components/editor/group-actions.ts +34 -50
  23. package/src/components/editor/group-floating-action-menu.tsx +30 -15
  24. package/src/components/editor/group-move-3d.ts +51 -21
  25. package/src/components/editor/group-transform-shared.test.ts +78 -0
  26. package/src/components/editor/group-transform-shared.ts +74 -7
  27. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  28. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  29. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  30. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  31. package/src/components/editor/handles/resize-snap.ts +10 -1
  32. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  33. package/src/components/editor/index.tsx +252 -46
  34. package/src/components/editor/measurement-pill.tsx +12 -12
  35. package/src/components/editor/node-action-menu.tsx +42 -1
  36. package/src/components/editor/node-arrow-handles.tsx +47 -58
  37. package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
  38. package/src/components/editor/selection-manager.tsx +132 -47
  39. package/src/components/editor/site-edge-labels.tsx +2 -1
  40. package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
  41. package/src/components/editor/snapshot-capture.test.ts +422 -0
  42. package/src/components/editor/snapshot-capture.ts +145 -0
  43. package/src/components/editor/thumbnail-generator.tsx +295 -232
  44. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  45. package/src/components/editor/wall-measurement-label.tsx +3 -2
  46. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  47. package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
  48. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  49. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  50. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  51. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  52. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  53. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  54. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +204 -114
  57. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  59. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  60. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  61. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  62. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  63. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  64. package/src/components/systems/selection-affordance-services.ts +19 -0
  65. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  66. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  67. package/src/components/tools/fence/fence-drafting.ts +6 -25
  68. package/src/components/tools/item/draft-creation.test.ts +12 -0
  69. package/src/components/tools/item/draft-creation.ts +10 -0
  70. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  71. package/src/components/tools/item/face-host-commit.ts +56 -0
  72. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  73. package/src/components/tools/item/face-host-preview.ts +82 -0
  74. package/src/components/tools/item/move-tool.tsx +4 -3
  75. package/src/components/tools/item/placement-math.test.ts +11 -1
  76. package/src/components/tools/item/placement-math.ts +0 -20
  77. package/src/components/tools/item/placement-strategies.test.ts +494 -0
  78. package/src/components/tools/item/placement-strategies.ts +192 -24
  79. package/src/components/tools/item/placement-surface.test.ts +61 -0
  80. package/src/components/tools/item/placement-surface.ts +24 -0
  81. package/src/components/tools/item/placement-types.ts +6 -0
  82. package/src/components/tools/item/test-face-host.ts +121 -0
  83. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  84. package/src/components/tools/item/use-draft-node.ts +26 -4
  85. package/src/components/tools/item/use-placement-coordinator.tsx +447 -180
  86. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  87. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  88. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  89. package/src/components/tools/registry-tool-context.tsx +30 -0
  90. package/src/components/tools/select/box-select-tool.tsx +3 -1
  91. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  92. package/src/components/tools/select/marquee-footprint.ts +29 -0
  93. package/src/components/tools/shared/placement-box.tsx +63 -8
  94. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  95. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  96. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  97. package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
  98. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  99. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  100. package/src/components/tools/stair/stair-tool.tsx +127 -74
  101. package/src/components/tools/tool-manager.tsx +25 -8
  102. package/src/components/tools/wall/wall-drafting.test.ts +527 -4
  103. package/src/components/tools/wall/wall-drafting.ts +55 -462
  104. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  105. package/src/components/ui/action-menu/index.tsx +11 -1
  106. package/src/components/ui/command-palette/editor-commands.tsx +12 -13
  107. package/src/components/ui/command-palette/index.tsx +7 -2
  108. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  109. package/src/components/ui/controls/material-picker.tsx +4 -4
  110. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  111. package/src/components/ui/controls/segmented-control.tsx +8 -1
  112. package/src/components/ui/controls/slider-control.tsx +19 -3
  113. package/src/components/ui/controls/toggle-control.tsx +18 -5
  114. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  115. package/src/components/ui/floating-level-selector.tsx +23 -6
  116. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  117. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  118. package/src/components/ui/helpers/item-helper.tsx +1 -1
  119. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  120. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  121. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  122. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  123. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  124. package/src/components/ui/panels/multi-field-value.ts +163 -0
  125. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  126. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  127. package/src/components/ui/panels/multi-selection-panel.tsx +78 -26
  128. package/src/components/ui/panels/node-display.ts +4 -0
  129. package/src/components/ui/panels/panel-manager.tsx +77 -3
  130. package/src/components/ui/panels/panel-wrapper.tsx +228 -12
  131. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  132. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  133. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  134. package/src/components/ui/panels/reference-panel.tsx +31 -1
  135. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  136. package/src/components/ui/scene-loader.tsx +35 -0
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
  138. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  139. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -23
  140. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  142. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  143. package/src/components/ui/sidebar/panels/site-panel/index.tsx +128 -46
  144. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  145. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  146. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  147. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +22 -7
  148. package/src/components/ui/sidebar/tab-bar.tsx +9 -1
  149. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  150. package/src/components/viewer/floorplan-compass-button.tsx +50 -0
  151. package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
  152. package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
  153. package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
  154. package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
  155. package/src/components/viewer/floorplan-preview.tsx +1069 -0
  156. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
  157. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  158. package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
  159. package/src/components/viewer/viewer-stage-modes.ts +49 -0
  160. package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
  161. package/src/components/viewer/viewer-stage.test.tsx +47 -0
  162. package/src/components/viewer/viewer-stage.tsx +221 -0
  163. package/src/components/viewer-overlay.tsx +13 -30
  164. package/src/components/viewer-zone-system.tsx +6 -2
  165. package/src/components/walkthrough-hud.tsx +19 -10
  166. package/src/hooks/use-auto-save.test.ts +122 -1
  167. package/src/hooks/use-auto-save.ts +119 -13
  168. package/src/hooks/use-ceiling-events.test.ts +122 -0
  169. package/src/hooks/use-drag-action.ts +17 -3
  170. package/src/hooks/use-grid-events.test.ts +240 -0
  171. package/src/hooks/use-grid-events.ts +190 -11
  172. package/src/hooks/use-keyboard.test.ts +244 -0
  173. package/src/hooks/use-keyboard.ts +164 -63
  174. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  175. package/src/hooks/use-save-shortcut.ts +33 -0
  176. package/src/index.tsx +109 -4
  177. package/src/lib/active-placement-surface.test.ts +16 -0
  178. package/src/lib/active-placement-surface.ts +14 -0
  179. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  180. package/src/lib/ceiling-surface-raycast.ts +12 -0
  181. package/src/lib/contextual-help-extension.ts +17 -0
  182. package/src/lib/contextual-help.test.ts +8 -0
  183. package/src/lib/contextual-help.ts +8 -0
  184. package/src/lib/continuation.test.ts +11 -0
  185. package/src/lib/continuation.ts +8 -1
  186. package/src/lib/direct-manipulation.test.ts +132 -0
  187. package/src/lib/direct-manipulation.ts +38 -1
  188. package/src/lib/editor-api.ts +6 -3
  189. package/src/lib/elevation-guides.test.ts +28 -1
  190. package/src/lib/elevation-guides.ts +22 -7
  191. package/src/lib/floating-menu-scale.test.ts +29 -0
  192. package/src/lib/floating-menu-scale.ts +14 -0
  193. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  194. package/src/lib/floorplan/apply-alignment.ts +10 -1
  195. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  196. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  197. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  198. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  199. package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
  200. package/src/lib/floorplan/floorplan-readonly.ts +85 -0
  201. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  202. package/src/lib/floorplan-grid-event-point.ts +13 -0
  203. package/src/lib/glb-export.test.ts +1169 -21
  204. package/src/lib/glb-export.ts +629 -101
  205. package/src/lib/grid-event-presentation.ts +16 -0
  206. package/src/lib/history.test.ts +523 -77
  207. package/src/lib/history.ts +133 -14
  208. package/src/lib/host-tree-children.test.ts +27 -0
  209. package/src/lib/host-tree-children.ts +79 -0
  210. package/src/lib/inspector-card-mode.test.ts +92 -0
  211. package/src/lib/inspector-card-mode.ts +60 -0
  212. package/src/lib/interaction/hot-set.test.ts +10 -1
  213. package/src/lib/interaction/hot-set.ts +3 -1
  214. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  215. package/src/lib/interaction/overlay-policy.ts +16 -0
  216. package/src/lib/interaction/registered-drafting.ts +36 -0
  217. package/src/lib/interaction/scope.ts +31 -0
  218. package/src/lib/level-print-export.test.ts +484 -0
  219. package/src/lib/level-print-export.ts +526 -0
  220. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  221. package/src/lib/local-project-presentation-persistence.ts +335 -0
  222. package/src/lib/material-paint.ts +2 -0
  223. package/src/lib/measurement-parser.ts +3 -0
  224. package/src/lib/measurements.test.ts +4 -0
  225. package/src/lib/model-export.ts +29 -0
  226. package/src/lib/paint-preview-owner.ts +71 -0
  227. package/src/lib/paint-scope.test.ts +58 -2
  228. package/src/lib/paint-scope.ts +8 -2
  229. package/src/lib/planar-cursor-placement.test.ts +178 -1
  230. package/src/lib/planar-cursor-placement.ts +61 -5
  231. package/src/lib/plugin-panels.test.ts +92 -1
  232. package/src/lib/plugin-panels.ts +48 -0
  233. package/src/lib/portable-export.test.ts +366 -0
  234. package/src/lib/portable-export.ts +969 -0
  235. package/src/lib/print-3mf.test.ts +86 -0
  236. package/src/lib/print-3mf.ts +161 -0
  237. package/src/lib/print-content-scope.test.ts +107 -0
  238. package/src/lib/print-content-scope.ts +90 -0
  239. package/src/lib/print-export.test.ts +294 -0
  240. package/src/lib/print-export.ts +735 -0
  241. package/src/lib/print-feature-thickness.test.ts +147 -0
  242. package/src/lib/print-feature-thickness.ts +180 -0
  243. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  244. package/src/lib/print-golden-house.test.ts +249 -0
  245. package/src/lib/print-roof-solids.test.ts +133 -0
  246. package/src/lib/print-roof-solids.ts +630 -0
  247. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  248. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  249. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  250. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  251. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  252. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  253. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  254. package/src/lib/print-shell-compiler.ts +273 -0
  255. package/src/lib/print-wall-solids.test.ts +194 -0
  256. package/src/lib/print-wall-solids.ts +328 -0
  257. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  258. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  259. package/src/lib/scene.ts +22 -8
  260. package/src/lib/selection-routing.test.ts +125 -6
  261. package/src/lib/selection-routing.ts +34 -1
  262. package/src/lib/session-groups.test.ts +88 -0
  263. package/src/lib/session-groups.ts +201 -0
  264. package/src/lib/sfx-bus.ts +4 -0
  265. package/src/lib/sfx-player.ts +8 -0
  266. package/src/lib/snapping-mode.test.ts +32 -4
  267. package/src/lib/snapping-mode.ts +11 -7
  268. package/src/lib/structured-clone-fallback.test.ts +45 -0
  269. package/src/lib/structured-clone-fallback.ts +24 -0
  270. package/src/lib/usdz-export.ts +38 -0
  271. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  272. package/src/store/live-draft-preview-stores.test.ts +3 -0
  273. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  274. package/src/store/tool-mode.test.ts +87 -0
  275. package/src/store/use-camera-hint-focus.ts +48 -0
  276. package/src/store/use-editor.tsx +299 -188
  277. package/src/store/use-floorplan-draft-preview.ts +9 -0
  278. package/src/store/use-interaction-scope.test.ts +23 -1
  279. package/src/store/use-interaction-scope.ts +63 -2
  280. package/src/store/use-placement-preview.ts +56 -3
  281. package/src/store/use-session-groups.ts +103 -0
  282. package/src/components/tools/roof/roof-tool.tsx +0 -656
  283. 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,
@@ -30,7 +31,6 @@ import { Euler, Matrix3, Quaternion, Vector3 } from 'three'
30
31
  import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../../../lib/roof-wall-hit'
31
32
  import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap'
32
33
  import {
33
- calculateCursorRotation,
34
34
  calculateItemRotation,
35
35
  getGridAlignedDimensions,
36
36
  getSideFromNormal,
@@ -178,6 +178,51 @@ export const floorStrategy = {
178
178
  // WALL STRATEGY
179
179
  // ============================================================================
180
180
 
181
+ /**
182
+ * Resolve the wall-local node position AND the world pose of the placement
183
+ * wireframe from ONE wall-local point, so the box can't drift from the item it
184
+ * previews.
185
+ *
186
+ * `event.object` is the wall's collision mesh — the frame `event.localPosition`
187
+ * was measured in — so `localToWorld` is the exact inverse of the hit. Snapping
188
+ * the raw world hit per axis instead (the old path) put the box on a different
189
+ * lattice than the node: wall-local X runs from `wall.start`, and wall-local Y
190
+ * is measured from the supporting slab's elevation, not from world zero.
191
+ *
192
+ * `z` follows the hosting convention rather than the hit depth: `wall` items
193
+ * center in the thickness, `wall-side` items mount on the hit face — mirroring
194
+ * `ItemSystem`'s per-frame push, so the wireframe's `z = 0` face lands flush
195
+ * with the wall instead of extending through it.
196
+ */
197
+ function resolveWallPlacementPose(
198
+ event: WallEvent,
199
+ localX: number,
200
+ localY: number,
201
+ attachTo: 'wall' | 'wall-side',
202
+ side: 'front' | 'back',
203
+ itemRotation: number,
204
+ ): {
205
+ position: [number, number, number]
206
+ cursorPosition: [number, number, number]
207
+ cursorRotationY: number
208
+ } {
209
+ const localZ =
210
+ attachTo === 'wall-side' ? ((event.node.thickness ?? 0.1) / 2) * (side === 'front' ? 1 : -1) : 0
211
+ event.object.updateWorldMatrix(true, false)
212
+ const world = event.object.localToWorld(new Vector3(localX, localY, localZ))
213
+ const wallYaw = -Math.atan2(
214
+ event.node.end[1] - event.node.start[1],
215
+ event.node.end[0] - event.node.start[0],
216
+ )
217
+ return {
218
+ position: [localX, localY, localZ],
219
+ cursorPosition: [world.x, world.y, world.z],
220
+ // Same composition the 2D floorplan resolves a wall child with
221
+ // (`resolveItemTransform`): the cursor frame IS the item frame.
222
+ cursorRotationY: wallYaw + itemRotation,
223
+ }
224
+ }
225
+
181
226
  export const wallStrategy = {
182
227
  /**
183
228
  * Handle wall:enter — transition from floor to wall surface.
@@ -201,11 +246,9 @@ export const wallStrategy = {
201
246
 
202
247
  const side = getSideFromNormal(event.normal)
203
248
  const itemRotation = calculateItemRotation(event.normal)
204
- const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
205
249
 
206
250
  const x = snapToHalf(event.localPosition[0])
207
251
  const y = snapToHalf(event.localPosition[1])
208
- const z = snapToHalf(event.localPosition[2])
209
252
 
210
253
  // Get auto-adjusted Y position from validator
211
254
  const rawDims = ctx.draftItem
@@ -223,25 +266,27 @@ export const wallStrategy = {
223
266
  )
224
267
 
225
268
  const adjustedY = validation.adjustedY ?? y
269
+ const pose = resolveWallPlacementPose(event, x, adjustedY, attachTo, side, itemRotation)
226
270
 
227
271
  return {
228
- stateUpdate: { surface: 'wall', wallId: event.node.id, roofSegmentId: null },
272
+ stateUpdate: {
273
+ surface: 'wall',
274
+ wallId: event.node.id,
275
+ roofSegmentId: null,
276
+ },
229
277
  nodeUpdate: {
230
- position: [x, adjustedY, z],
278
+ position: pose.position,
231
279
  parentId: event.node.id,
232
280
  // The draft may arrive from a roof-segment wall face.
233
281
  roofSegmentId: undefined,
234
282
  roofFace: undefined,
283
+ blockFaceId: undefined,
235
284
  side,
236
285
  rotation: [0, itemRotation, 0],
237
286
  },
238
- cursorRotationY: cursorRotation,
239
- gridPosition: [x, adjustedY, z],
240
- cursorPosition: [
241
- snapToHalf(event.position[0]),
242
- snapToHalf(event.position[1]),
243
- snapToHalf(event.position[2]),
244
- ],
287
+ cursorRotationY: pose.cursorRotationY,
288
+ gridPosition: pose.position,
289
+ cursorPosition: pose.cursorPosition,
245
290
  stopPropagation: true,
246
291
  }
247
292
  },
@@ -262,11 +307,10 @@ export const wallStrategy = {
262
307
 
263
308
  const side = getSideFromNormal(event.normal)
264
309
  const itemRotation = calculateItemRotation(event.normal)
265
- const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
310
+ const attachTo = ctx.draftItem.asset.attachTo as 'wall' | 'wall-side'
266
311
 
267
312
  const snappedX = snapToHalf(event.localPosition[0])
268
313
  const snappedY = snapToHalf(event.localPosition[1])
269
- const snappedZ = snapToHalf(event.localPosition[2])
270
314
 
271
315
  // Get auto-adjusted Y position from validator
272
316
  const validation = validators.canPlaceOnWall(
@@ -275,23 +319,20 @@ export const wallStrategy = {
275
319
  snappedX,
276
320
  snappedY,
277
321
  getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo),
278
- ctx.draftItem.asset.attachTo as 'wall' | 'wall-side',
322
+ attachTo,
279
323
  side,
280
324
  [ctx.draftItem.id],
281
325
  )
282
326
 
283
327
  const adjustedY = validation.adjustedY ?? snappedY
328
+ const pose = resolveWallPlacementPose(event, snappedX, adjustedY, attachTo, side, itemRotation)
284
329
 
285
330
  return {
286
- gridPosition: [snappedX, adjustedY, snappedZ],
287
- cursorPosition: [
288
- snapToHalf(event.position[0]),
289
- snapToHalf(event.position[1]),
290
- snapToHalf(event.position[2]),
291
- ],
292
- cursorRotationY: cursorRotation,
331
+ gridPosition: pose.position,
332
+ cursorPosition: pose.cursorPosition,
333
+ cursorRotationY: pose.cursorRotationY,
293
334
  nodeUpdate: {
294
- position: [snappedX, adjustedY, snappedZ],
335
+ position: pose.position,
295
336
  side,
296
337
  rotation: [0, itemRotation, 0],
297
338
  },
@@ -332,6 +373,7 @@ export const wallStrategy = {
332
373
  parentId: event.node.id,
333
374
  roofSegmentId: undefined,
334
375
  roofFace: undefined,
376
+ blockFaceId: undefined,
335
377
  side: ctx.draftItem.side,
336
378
  rotation: ctx.draftItem.rotation,
337
379
  metadata: stripTransient(ctx.draftItem.metadata),
@@ -485,12 +527,17 @@ export const roofWallStrategy = {
485
527
  if (!target) return null
486
528
 
487
529
  return {
488
- 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
+ },
489
535
  nodeUpdate: {
490
536
  position: target.position,
491
537
  parentId: target.segment.id,
492
538
  roofSegmentId: target.segment.id,
493
539
  roofFace: target.faceId,
540
+ blockFaceId: undefined,
494
541
  wallId: undefined,
495
542
  side: 'front',
496
543
  rotation: [0, 0, 0],
@@ -548,6 +595,7 @@ export const roofWallStrategy = {
548
595
  parentId: ctx.state.roofSegmentId,
549
596
  roofSegmentId: ctx.state.roofSegmentId,
550
597
  roofFace: ctx.draftItem.roofFace,
598
+ blockFaceId: undefined,
551
599
  wallId: undefined,
552
600
  side: 'front',
553
601
  rotation: [0, 0, 0],
@@ -580,6 +628,114 @@ export const roofWallStrategy = {
580
628
  },
581
629
  }
582
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
+
583
739
  // ============================================================================
584
740
  // CEILING STRATEGY
585
741
  // ============================================================================
@@ -1020,6 +1176,18 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
1020
1176
 
1021
1177
  const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo)
1022
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
+
1023
1191
  if (attachTo === 'ceiling') {
1024
1192
  if (ctx.state.surface !== 'ceiling' || !ctx.state.ceilingId) return false
1025
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
+ }