@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
@@ -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,9 +1,11 @@
1
1
  import type {
2
2
  AnyNode,
3
+ AnyNodeId,
3
4
  AssetInput,
4
5
  CeilingNode,
5
6
  ItemNode,
6
7
  LevelNode,
8
+ SurfaceRejectReason,
7
9
  WallNode,
8
10
  } from '@pascal-app/core'
9
11
  import type { Vector3 } from 'three'
@@ -16,6 +18,7 @@ export type SurfaceType =
16
18
  | 'floor'
17
19
  | 'wall'
18
20
  | 'roof-wall'
21
+ | 'block-face'
19
22
  | 'ceiling'
20
23
  | 'item-surface'
21
24
  | 'shelf-surface'
@@ -33,6 +36,8 @@ export interface PlacementState {
33
36
  * (base walls + coplanar gable ends).
34
37
  */
35
38
  roofSegmentId: string | null
39
+ /** Active planar node face used as a wall-like attachment host. */
40
+ blockId?: AnyNodeId | null
36
41
  ceilingId: string | null
37
42
  surfaceItemId: string | null
38
43
  /**
@@ -52,6 +57,7 @@ export interface PlacementState {
52
57
  * Read-only snapshot passed to every strategy call.
53
58
  */
54
59
  export interface PlacementContext {
60
+ onSurfaceReject?: (reason: SurfaceRejectReason) => void
55
61
  asset: AssetInput
56
62
  levelId: LevelNode['id'] | null
57
63
  draftItem: ItemNode | null
@@ -74,6 +80,7 @@ export interface PlacementContext {
74
80
  * Returned by strategy move handlers.
75
81
  */
76
82
  export interface PlacementResult {
83
+ surfaceId?: string | null
77
84
  gridPosition: [number, number, number]
78
85
  cursorPosition: [number, number, number]
79
86
  cursorRotationY: number
@@ -81,12 +88,14 @@ export interface PlacementResult {
81
88
  nodeUpdate: Partial<ItemNode> | null
82
89
  stopPropagation: boolean
83
90
  dirtyNodeId: AnyNode['id'] | null
91
+ hostFaceId?: string | null
84
92
  }
85
93
 
86
94
  /**
87
95
  * Returned by enter/leave handlers (surface transitions).
88
96
  */
89
97
  export interface TransitionResult {
98
+ surfaceId?: string | null
90
99
  stateUpdate: Partial<PlacementState>
91
100
  nodeUpdate: Partial<ItemNode>
92
101
  gridPosition: [number, number, number]
@@ -94,6 +103,7 @@ export interface TransitionResult {
94
103
  cursorRotationY: number
95
104
  cursorRotation?: [number, number, number]
96
105
  stopPropagation: boolean
106
+ hostFaceId?: string | null
97
107
  }
98
108
 
99
109
  /**
@@ -0,0 +1,257 @@
1
+ import { afterEach, beforeEach, expect, spyOn, test } from 'bun:test'
2
+ import * as core from '@pascal-app/core'
3
+ import {
4
+ ItemNode,
5
+ nodeRegistry,
6
+ registerNode,
7
+ type ShelfEvent,
8
+ ShelfNode,
9
+ sceneRegistry,
10
+ useScene,
11
+ } from '@pascal-app/core'
12
+ import { Euler, Group, Quaternion, Vector3 } from 'three'
13
+ import useEditor from '../../../store/use-editor'
14
+ import useInteractionScope from '../../../store/use-interaction-scope'
15
+ import { shelfSurfaceStrategy } from './placement-strategies'
16
+ import type { PlacementContext } from './placement-types'
17
+
18
+ const host = ShelfNode.parse({ id: 'shelf_resolver-host', width: 2, depth: 1, height: 2 })
19
+ const child = ItemNode.parse({
20
+ id: 'item_shelf-resolver-child',
21
+ asset: {
22
+ id: 'book',
23
+ name: 'Book',
24
+ category: 'decor',
25
+ thumbnail: '',
26
+ src: '/book.glb',
27
+ dimensions: [0.5, 0.25, 0.25],
28
+ },
29
+ rotation: [0.1, 0.2, 0.3],
30
+ })
31
+ let rows: number[]
32
+ let restoreRegistry: () => void
33
+ let savedNodes: ReturnType<typeof useScene.getState>['nodes']
34
+ let savedEditor: ReturnType<typeof useEditor.getState>
35
+ let savedScope: ReturnType<typeof useInteractionScope.getState>
36
+ let resolver: ReturnType<typeof spyOn<typeof core, 'resolveSurfacePlacement'>>
37
+
38
+ beforeEach(() => {
39
+ restoreRegistry = nodeRegistry._snapshot()
40
+ nodeRegistry._reset()
41
+ rows = [0.5, 1.5]
42
+ registerNode({
43
+ kind: 'shelf',
44
+ schemaVersion: 1,
45
+ schema: ShelfNode,
46
+ category: 'furnish',
47
+ defaults: () => ({}),
48
+ capabilities: {
49
+ surfaces: {
50
+ custom: () => rows.map((y) => ({ position: [0, y, 0], normal: [0, 1, 0] })),
51
+ },
52
+ },
53
+ })
54
+ registerNode({
55
+ kind: 'item',
56
+ schemaVersion: 1,
57
+ schema: ItemNode,
58
+ category: 'furnish',
59
+ snapProfile: 'item',
60
+ defaults: () => ({}),
61
+ capabilities: {},
62
+ })
63
+ savedNodes = useScene.getState().nodes
64
+ savedEditor = useEditor.getState()
65
+ savedScope = useInteractionScope.getState()
66
+ useScene.setState({ nodes: { [host.id]: host, [child.id]: child } })
67
+ useInteractionScope.setState({ scope: { kind: 'idle' } })
68
+ useEditor.setState({ mode: 'build', tool: 'item', gridSnapStep: 0.5 })
69
+ useEditor.getState().setSnappingMode('item', 'grid')
70
+ const mesh = new Group()
71
+ mesh.updateMatrixWorld(true)
72
+ sceneRegistry.nodes.set(host.id, mesh)
73
+ resolver = spyOn(core, 'resolveSurfacePlacement')
74
+ })
75
+
76
+ afterEach(() => {
77
+ resolver.mockRestore()
78
+ restoreRegistry()
79
+ sceneRegistry.nodes.delete(host.id)
80
+ useScene.setState({ nodes: savedNodes })
81
+ useEditor.setState(savedEditor)
82
+ useInteractionScope.setState(savedScope)
83
+ })
84
+
85
+ function context(draftItem = child): PlacementContext {
86
+ return {
87
+ asset: draftItem.asset,
88
+ draftItem,
89
+ state: {
90
+ surface: 'floor',
91
+ wallId: null,
92
+ roofSegmentId: null,
93
+ ceilingId: null,
94
+ surfaceItemId: null,
95
+ shelfId: null,
96
+ },
97
+ currentCursorRotationY: 0.7,
98
+ gridPosition: new Vector3(),
99
+ levelId: 'level_test',
100
+ } as PlacementContext
101
+ }
102
+
103
+ function onShelf(ctx: PlacementContext): PlacementContext {
104
+ return { ...ctx, state: { ...ctx.state, surface: 'shelf-surface', shelfId: host.id } }
105
+ }
106
+
107
+ function hit(y = 1, z = -0.1): ShelfEvent {
108
+ const object = sceneRegistry.nodes.get(host.id)!
109
+ return {
110
+ node: host,
111
+ object,
112
+ normal: [0, 1, 0],
113
+ position: object.localToWorld(new Vector3(0.37, y, z)).toArray(),
114
+ localPosition: [0.37, y, z],
115
+ nativeEvent: {},
116
+ stopPropagation() {},
117
+ } as ShelfEvent
118
+ }
119
+
120
+ test.each([
121
+ 'grid',
122
+ 'off',
123
+ ] as const)('midpoint keeps the first declared row and preserves X/Z with grid %s', (mode) => {
124
+ useEditor.getState().setSnappingMode('item', mode)
125
+ for (const declared of [
126
+ [0.5, 1.5],
127
+ [1.5, 0.5],
128
+ ]) {
129
+ rows = declared
130
+ const expected: [number, number, number] =
131
+ mode === 'grid' ? [0.25, rows[0]!, 0.125] : [0.37, rows[0]!, -0.1]
132
+ const ctx = context()
133
+ const entered = shelfSurfaceStrategy.enter(ctx, hit())!
134
+ expect(entered.nodeUpdate).toEqual({
135
+ position: expected,
136
+ parentId: host.id,
137
+ rotation: [0.1, 0.7, 0.3],
138
+ })
139
+ expect(entered.gridPosition).toEqual(expected)
140
+ expect(entered.cursorPosition).toEqual(expected)
141
+ const moved = shelfSurfaceStrategy.move(onShelf(ctx), hit())!
142
+ expect(moved.nodeUpdate).toEqual({ position: expected })
143
+ expect(moved.cursorPosition).toEqual(expected)
144
+ const committed = shelfSurfaceStrategy.click(
145
+ { ...onShelf(ctx), gridPosition: new Vector3(...entered.gridPosition) },
146
+ hit(),
147
+ )!
148
+ expect(committed.nodeUpdate.position).toEqual(expected)
149
+ expect(committed.nodeUpdate.parentId).toBe(host.id)
150
+ }
151
+ expect(resolver).toHaveBeenCalledTimes(6)
152
+ })
153
+
154
+ test.each([
155
+ [0.999, 0.5],
156
+ [1.001, 1.5],
157
+ ])('hit Y %s elects nearest row %s on enter and move', (y, expected) => {
158
+ expect(shelfSurfaceStrategy.enter(context(), hit(y))!.gridPosition[1]).toBe(expected)
159
+ expect(shelfSurfaceStrategy.move(onShelf(context()), hit(y))!.gridPosition[1]).toBe(expected)
160
+ })
161
+
162
+ test.each([
163
+ [2.01, 0.25],
164
+ [0.5, 1.01],
165
+ ])('scaled footprint %s by %s overhang is accepted on enter and move', (width, depth) => {
166
+ const draft = ItemNode.parse({
167
+ ...child,
168
+ asset: { ...child.asset, dimensions: [width / 2, 0.25, depth / 2] },
169
+ scale: [2, 1, 2],
170
+ })
171
+ const ctx = context(draft)
172
+ expect(shelfSurfaceStrategy.enter(ctx, hit())).not.toBeNull()
173
+ expect(shelfSurfaceStrategy.move(onShelf(ctx), hit())).not.toBeNull()
174
+ expect(resolver.mock.calls.map(([args]) => args.checkFootprint)).toEqual([true, true])
175
+ })
176
+
177
+ test('a full-board object is refused when grid snapping puts its centre outside the board', () => {
178
+ const draft = ItemNode.parse({
179
+ ...child,
180
+ asset: { ...child.asset, dimensions: [host.width, 0.25, host.depth] },
181
+ })
182
+ expect(shelfSurfaceStrategy.enter(context(draft), hit(1, -0.39))).toBeNull()
183
+ })
184
+
185
+ test.each([
186
+ 'grid',
187
+ 'off',
188
+ ] as const)('shelf frame conversion retains exact poses with grid %s', (mode) => {
189
+ useEditor.getState().setSnappingMode('item', mode)
190
+ const mesh = sceneRegistry.nodes.get(host.id)!
191
+ mesh.position.set(4, 2, -3)
192
+ mesh.rotation.set(0, Math.PI / 3, 0)
193
+ mesh.updateMatrixWorld(true)
194
+ const event = hit(1.2)
195
+ const local = mesh.worldToLocal(new Vector3(...event.position))
196
+ const expected: [number, number, number] =
197
+ mode === 'grid' ? [0.25, 1.5, 0.125] : [local.x, 1.5, local.z]
198
+ const yaw = new Euler().setFromQuaternion(mesh.getWorldQuaternion(new Quaternion()), 'YXZ').y
199
+ const entered = shelfSurfaceStrategy.enter(context(), event)!
200
+ expect(entered.nodeUpdate).toEqual({
201
+ position: expected,
202
+ parentId: host.id,
203
+ rotation: [0.1, 0.7 - yaw, 0.3],
204
+ })
205
+ expect(entered.cursorPosition).toEqual(mesh.localToWorld(new Vector3(...expected)).toArray())
206
+ expect(shelfSurfaceStrategy.move(onShelf(context()), event)!.gridPosition).toEqual(expected)
207
+ })
208
+
209
+ test('only enter requires an upward normal; moves keep electing rows', () => {
210
+ for (const normal of [[1, 0, 0], [0, -1, 0], undefined] as ShelfEvent['normal'][]) {
211
+ const event = { ...hit(), normal }
212
+ expect(shelfSurfaceStrategy.enter(context(), event)).toBeNull()
213
+ expect(shelfSurfaceStrategy.move(onShelf(context()), event)!.gridPosition[1]).toBe(0.5)
214
+ }
215
+ })
216
+
217
+ test('missing rows and resolver rejection leave the draft untouched', () => {
218
+ const before = useScene.getState().nodes
219
+ rows = []
220
+ expect(shelfSurfaceStrategy.enter(context(), hit())).toBeNull()
221
+ expect(shelfSurfaceStrategy.move(onShelf(context()), hit())).toBeNull()
222
+ rows = [0.5, 1.5]
223
+ resolver.mockReturnValue(null)
224
+ expect(shelfSurfaceStrategy.enter(context(), hit())).toBeNull()
225
+ expect(shelfSurfaceStrategy.move(onShelf(context()), hit())).toBeNull()
226
+ expect(useScene.getState().nodes).toBe(before)
227
+ })
228
+
229
+ test('a centred unrotated object exactly matching the board is accepted with epsilon tolerance', () => {
230
+ useEditor.getState().setSnappingMode('item', 'off')
231
+ const region = core.shelfSurfaceProvider.surfaces!(host, {
232
+ scene: core.createSceneApi(useScene),
233
+ })[0]!.region
234
+ const draft = ItemNode.parse({
235
+ ...child,
236
+ rotation: [0, 0, 0],
237
+ asset: { ...child.asset, dimensions: [region.size![0] * 2, 0.25, region.size![1] * 2] },
238
+ })
239
+ const ctx = { ...context(draft), currentCursorRotationY: 0 }
240
+ for (const x of [0, 1e-7]) {
241
+ const event = { ...hit(), position: [x, 0.5, 0], localPosition: [x, 0.5, 0] } as ShelfEvent
242
+ expect(shelfSurfaceStrategy.enter(ctx, event)).not.toBeNull()
243
+ }
244
+ })
245
+
246
+ test('shelf validation uses the current shelf after leaving a different surface host', () => {
247
+ const other = ItemNode.parse({
248
+ ...child,
249
+ id: 'item_previous-host',
250
+ asset: { ...child.asset, dimensions: [0.01, 0.1, 0.01] },
251
+ })
252
+ useScene.setState({ nodes: { ...useScene.getState().nodes, [other.id]: other } })
253
+ const ctx = onShelf(context(ItemNode.parse({ ...child, rotation: [0, 0, 0] })))
254
+ ctx.state.surfaceItemId = other.id
255
+ ctx.gridPosition.set(0, 0.5, 0)
256
+ expect(shelfSurfaceStrategy.click(ctx, hit())).not.toBeNull()
257
+ })
@@ -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
+ }