@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
@@ -0,0 +1,494 @@
1
+ import { beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ BlockNode,
4
+ type GridEvent,
5
+ ItemNode,
6
+ type LevelNode,
7
+ type NodeEvent,
8
+ useScene,
9
+ type WallEvent,
10
+ type WallNode,
11
+ } from '@pascal-app/core'
12
+ import { BufferGeometry, Mesh, MeshBasicMaterial, type Object3D, Vector3 } from 'three'
13
+ import { faceHostStrategy, floorStrategy, wallStrategy } from './placement-strategies'
14
+ import type { PlacementContext, SpatialValidators } from './placement-types'
15
+ import { registerTestBlockFaceHost } from './test-face-host'
16
+
17
+ const BLOCK_ID = 'block_ceiling-host'
18
+ const LEVEL_ID = 'level_ceiling-host' as LevelNode['id']
19
+
20
+ beforeEach(() => {
21
+ registerTestBlockFaceHost()
22
+ useScene.setState((state) => ({
23
+ ...state,
24
+ nodes: {
25
+ ...state.nodes,
26
+ [BLOCK_ID]: BlockNode.parse({ id: BLOCK_ID, parentId: LEVEL_ID }),
27
+ },
28
+ }))
29
+ })
30
+
31
+ function ceilingContext(): PlacementContext {
32
+ return {
33
+ asset: {
34
+ id: 'ceiling-light',
35
+ category: 'lighting',
36
+ name: 'Ceiling light',
37
+ thumbnail: '/ceiling-light.png',
38
+ src: '/ceiling-light.glb',
39
+ dimensions: [1, 0.25, 1],
40
+ attachTo: 'ceiling',
41
+ },
42
+ levelId: LEVEL_ID,
43
+ draftItem: null,
44
+ gridPosition: new Vector3(),
45
+ state: {
46
+ surface: 'floor',
47
+ wallId: null,
48
+ roofSegmentId: null,
49
+ blockId: null,
50
+ ceilingId: null,
51
+ surfaceItemId: null,
52
+ shelfId: null,
53
+ },
54
+ currentCursorRotationY: 0,
55
+ }
56
+ }
57
+
58
+ function floorItemContext(): PlacementContext {
59
+ return {
60
+ ...ceilingContext(),
61
+ asset: {
62
+ id: 'potted-plant',
63
+ category: 'decor',
64
+ name: 'Potted plant',
65
+ thumbnail: '/potted-plant.png',
66
+ src: '/potted-plant.glb',
67
+ dimensions: [0.5, 0.39, 0.5],
68
+ },
69
+ }
70
+ }
71
+
72
+ function wallItemContext(): PlacementContext {
73
+ return {
74
+ ...ceilingContext(),
75
+ asset: {
76
+ id: 'wall-light',
77
+ category: 'lighting',
78
+ name: 'Wall light',
79
+ thumbnail: '/wall-light.png',
80
+ src: '/wall-light.glb',
81
+ dimensions: [0.5, 0.5, 0.25],
82
+ attachTo: 'wall-side',
83
+ },
84
+ }
85
+ }
86
+
87
+ function frontFaceEvent(slopeTopEdge = false): NodeEvent {
88
+ const box = BlockNode.parse({ id: BLOCK_ID, parentId: LEVEL_ID })
89
+ const node = slopeTopEdge
90
+ ? {
91
+ ...box,
92
+ topology: {
93
+ ...box.topology,
94
+ vertices: box.topology.vertices.map((vertex) =>
95
+ vertex.id === 'v4' || vertex.id === 'v5'
96
+ ? {
97
+ ...vertex,
98
+ position: [vertex.position[0], vertex.position[1], 0] satisfies [
99
+ number,
100
+ number,
101
+ number,
102
+ ],
103
+ }
104
+ : vertex,
105
+ ),
106
+ },
107
+ }
108
+ : box
109
+ const geometry = new BufferGeometry()
110
+ geometry.userData.blockFaces = [{ faceId: 'f-front', start: 0, count: 6 }]
111
+ const object = new Mesh(geometry, new MeshBasicMaterial())
112
+ object.updateMatrixWorld(true)
113
+
114
+ return {
115
+ node,
116
+ object,
117
+ faceIndex: 0,
118
+ position: [0, 1.2, slopeTopEdge ? -0.5 : -1],
119
+ localPosition: [0, 1.2, slopeTopEdge ? -0.5 : -1],
120
+ normal: [0, 0, -1],
121
+ stopPropagation: () => {},
122
+ nativeEvent: {} as NodeEvent['nativeEvent'],
123
+ }
124
+ }
125
+
126
+ function adjacentRightFaceEventOnFrontSurface(): NodeEvent {
127
+ const node = BlockNode.parse({ id: BLOCK_ID, parentId: LEVEL_ID })
128
+ const geometry = new BufferGeometry()
129
+ geometry.userData.blockFaces = [
130
+ { faceId: 'f-front', start: 0, count: 6 },
131
+ { faceId: 'f-right', start: 6, count: 6 },
132
+ ]
133
+ const object = new Mesh(geometry, new MeshBasicMaterial())
134
+ object.updateMatrixWorld(true)
135
+
136
+ return {
137
+ node,
138
+ object,
139
+ faceIndex: 2,
140
+ position: [0.5, 1.2, -1],
141
+ localPosition: [0.5, 1.2, -1],
142
+ normal: [1, 0, 0],
143
+ stopPropagation: () => {},
144
+ nativeEvent: {} as NodeEvent['nativeEvent'],
145
+ }
146
+ }
147
+
148
+ function bottomFaceEvent(): NodeEvent {
149
+ const node = BlockNode.parse({ id: BLOCK_ID, parentId: LEVEL_ID })
150
+ const geometry = new BufferGeometry()
151
+ geometry.userData.blockFaces = [{ faceId: 'f-bottom', start: 0, count: 6 }]
152
+ const object = new Mesh(geometry, new MeshBasicMaterial())
153
+ object.updateMatrixWorld(true)
154
+
155
+ return {
156
+ node,
157
+ object,
158
+ faceIndex: 0,
159
+ position: [0, 0, 0],
160
+ localPosition: [0, 0, 0],
161
+ normal: [0, -1, 0],
162
+ stopPropagation: () => {},
163
+ nativeEvent: {} as NodeEvent['nativeEvent'],
164
+ }
165
+ }
166
+
167
+ function topFaceEvent(): NodeEvent {
168
+ const node = BlockNode.parse({ id: BLOCK_ID, parentId: LEVEL_ID })
169
+ const geometry = new BufferGeometry()
170
+ geometry.userData.blockFaces = [{ faceId: 'f-top', start: 0, count: 6 }]
171
+ const object = new Mesh(geometry, new MeshBasicMaterial())
172
+ object.updateMatrixWorld(true)
173
+
174
+ return {
175
+ node,
176
+ object,
177
+ faceIndex: 0,
178
+ position: [0, 2.4, 0],
179
+ localPosition: [0, 2.4, 0],
180
+ normal: [0, 1, 0],
181
+ stopPropagation: () => {},
182
+ nativeEvent: {} as NodeEvent['nativeEvent'],
183
+ }
184
+ }
185
+
186
+ describe('faceHostStrategy', () => {
187
+ test('hosts a wall-mounted item on a vertical block face', () => {
188
+ expect(faceHostStrategy.enter(wallItemContext(), frontFaceEvent())).not.toBeNull()
189
+ })
190
+
191
+ test('does not host a wall-mounted item after the block face is edited into a slope', () => {
192
+ expect(faceHostStrategy.enter(wallItemContext(), frontFaceEvent(true))).toBeNull()
193
+ })
194
+
195
+ test('keeps a wall-mounted item on the active face during adjacent triangle hits', () => {
196
+ const context = wallItemContext()
197
+ const enter = faceHostStrategy.enter(context, frontFaceEvent())
198
+ expect(enter).not.toBeNull()
199
+
200
+ context.state.surface = 'block-face'
201
+ context.state.blockId = BLOCK_ID
202
+ context.draftItem = ItemNode.parse({
203
+ id: 'item_wall-light',
204
+ parentId: BLOCK_ID,
205
+ asset: context.asset,
206
+ ...enter?.nodeUpdate,
207
+ })
208
+ context.gridPosition.set(...enter!.gridPosition)
209
+
210
+ const move = faceHostStrategy.move(context, adjacentRightFaceEventOnFrontSurface())
211
+
212
+ expect(move?.nodeUpdate).toMatchObject({
213
+ blockFaceId: 'f-front',
214
+ } satisfies Partial<ItemNode>)
215
+ expect(move?.cursorPosition[2]).toBe(-1)
216
+ })
217
+
218
+ test('hosts a ceiling item on a downward-facing block face', () => {
219
+ const result = faceHostStrategy.enter(ceilingContext(), bottomFaceEvent())
220
+
221
+ expect(result).not.toBeNull()
222
+ expect(result?.stateUpdate).toMatchObject({
223
+ surface: 'block-face',
224
+ blockId: BLOCK_ID,
225
+ })
226
+ expect(result?.nodeUpdate).toMatchObject({
227
+ parentId: BLOCK_ID,
228
+ blockFaceId: 'f-bottom',
229
+ position: [0, 0, 0.25],
230
+ rotation: [-Math.PI / 2, 0, 0],
231
+ } satisfies Partial<ItemNode>)
232
+ expect(result?.cursorPosition).toEqual([0, -0.25, 0])
233
+ })
234
+
235
+ test('hosts a floor item on an upward-facing block face', () => {
236
+ const context = floorItemContext()
237
+ const event = topFaceEvent()
238
+ const result = faceHostStrategy.enter(context, event)
239
+
240
+ expect(result).not.toBeNull()
241
+ expect(result?.stateUpdate).toMatchObject({
242
+ surface: 'block-face',
243
+ blockId: BLOCK_ID,
244
+ })
245
+ expect(result?.nodeUpdate).toMatchObject({
246
+ parentId: BLOCK_ID,
247
+ blockFaceId: 'f-top',
248
+ position: [0, 0, 0],
249
+ rotation: [Math.PI / 2, 0, 0],
250
+ } satisfies Partial<ItemNode>)
251
+ expect(result?.cursorPosition).toEqual([0, 2.4, 0])
252
+
253
+ context.draftItem = ItemNode.parse({
254
+ id: 'item_potted-plant',
255
+ parentId: BLOCK_ID,
256
+ asset: context.asset,
257
+ ...result?.nodeUpdate,
258
+ })
259
+ Object.assign(context.state, result?.stateUpdate)
260
+ context.gridPosition.set(...result!.gridPosition)
261
+
262
+ expect(faceHostStrategy.click(context, event)?.nodeUpdate).toMatchObject({
263
+ parentId: BLOCK_ID,
264
+ blockFaceId: 'f-top',
265
+ position: [0, 0, 0],
266
+ rotation: [Math.PI / 2, 0, 0],
267
+ } satisfies Partial<ItemNode>)
268
+ })
269
+
270
+ test('restores floor-local position and rotation when an item leaves a block face', () => {
271
+ const context = floorItemContext()
272
+ context.state.surface = 'block-face'
273
+ context.state.blockId = BLOCK_ID
274
+ context.currentCursorRotationY = Math.PI / 4
275
+ context.gridPosition.set(1, -0.5, 2)
276
+ context.draftItem = ItemNode.parse({
277
+ id: 'item_moving-potted-plant',
278
+ parentId: BLOCK_ID,
279
+ asset: context.asset,
280
+ position: [0, 0, 0],
281
+ rotation: [Math.PI / 2, Math.PI / 4, 0],
282
+ blockFaceId: 'f-top',
283
+ })
284
+
285
+ expect(faceHostStrategy.leave(context)).toMatchObject({
286
+ nodeUpdate: {
287
+ parentId: LEVEL_ID,
288
+ blockFaceId: undefined,
289
+ position: [1, 0, 2],
290
+ rotation: [0, Math.PI / 4, 0],
291
+ } satisfies Partial<ItemNode>,
292
+ gridPosition: [1, 0, 2],
293
+ cursorPosition: [1, 0, 2],
294
+ })
295
+ })
296
+ })
297
+
298
+ /**
299
+ * The wall frame the runtime builds in `updateWallGeometry`: origin at
300
+ * `wall.start` lifted to the supporting slab's elevation, yawed by the wall
301
+ * angle. Wall-local Y is therefore measured from the slab, NOT from world zero
302
+ * — which is exactly why snapping the world hit and the wall-local hit
303
+ * separately used to put the preview box and the item on different points.
304
+ */
305
+ function makeWallFrame(wall: WallNode, slabElevation: number): Mesh {
306
+ const wallMesh = new Mesh()
307
+ wallMesh.position.set(wall.start[0], slabElevation, wall.start[1])
308
+ wallMesh.rotation.y = -Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0])
309
+ const collisionMesh = new Mesh()
310
+ wallMesh.add(collisionMesh)
311
+ wallMesh.updateMatrixWorld(true)
312
+ return collisionMesh
313
+ }
314
+
315
+ function makeWall(overrides: Partial<WallNode> = {}): WallNode {
316
+ return {
317
+ id: 'wall_test',
318
+ type: 'wall',
319
+ parentId: 'level_test',
320
+ children: [],
321
+ start: [2.3, 1.7],
322
+ end: [8.3, 1.7],
323
+ thickness: 0.2,
324
+ ...overrides,
325
+ } as WallNode
326
+ }
327
+
328
+ function makeDraft(): ItemNode {
329
+ return {
330
+ id: 'item_draft',
331
+ type: 'item',
332
+ position: [0, 0, 0],
333
+ rotation: [0, 0, 0],
334
+ scale: [1, 1, 1],
335
+ children: [],
336
+ asset: {
337
+ id: 'asset_hold',
338
+ category: 'sport',
339
+ name: 'Climbing hold',
340
+ thumbnail: '',
341
+ source: 'library',
342
+ src: '',
343
+ dimensions: [0.65, 0.33, 0.63],
344
+ attachTo: 'wall-side',
345
+ offset: [0, 0.165, 0.1],
346
+ rotation: [0, 0, 0],
347
+ scale: [1, 1, 1],
348
+ },
349
+ } as unknown as ItemNode
350
+ }
351
+
352
+ /** Front face of the wall: wall-local +Z. */
353
+ const FRONT_NORMAL: [number, number, number] = [0, 0, 1]
354
+
355
+ function makeWallEvent(wall: WallNode, collisionMesh: Object3D, localHit: Vector3): WallEvent {
356
+ const world = collisionMesh.localToWorld(localHit.clone())
357
+ return {
358
+ node: wall,
359
+ position: [world.x, world.y, world.z],
360
+ localPosition: [localHit.x, localHit.y, localHit.z],
361
+ normal: FRONT_NORMAL,
362
+ object: collisionMesh,
363
+ stopPropagation: () => undefined,
364
+ } as unknown as WallEvent
365
+ }
366
+
367
+ const validators: SpatialValidators = {
368
+ canPlaceOnFloor: () => ({ valid: true }),
369
+ canPlaceOnWall: () => ({ valid: true }),
370
+ canPlaceOnCeiling: () => ({ valid: true }),
371
+ }
372
+
373
+ function makeContext(draft: ItemNode): PlacementContext {
374
+ return {
375
+ asset: draft.asset,
376
+ levelId: 'level_test',
377
+ draftItem: draft,
378
+ gridPosition: new Vector3(),
379
+ state: {
380
+ surface: 'wall',
381
+ wallId: 'wall_test',
382
+ roofSegmentId: null,
383
+ ceilingId: null,
384
+ surfaceItemId: null,
385
+ shelfId: null,
386
+ },
387
+ currentCursorRotationY: 0,
388
+ } as unknown as PlacementContext
389
+ }
390
+
391
+ describe('wallStrategy.move', () => {
392
+ /**
393
+ * The preview wireframe (`cursorPosition`, world) and the committed node
394
+ * (`gridPosition`, wall-local) must describe ONE point. Snapping them
395
+ * independently drifted the box off the item by the slab elevation plus up to
396
+ * a grid step, and the commit then landed where the box was not.
397
+ */
398
+ test.each([
399
+ ['axis-aligned wall on an elevated slab', makeWall(), 0.4],
400
+ [
401
+ 'diagonal wall off the world grid',
402
+ makeWall({ start: [1.15, 0.35], end: [5.15, 4.35] } as Partial<WallNode>),
403
+ 0.15,
404
+ ],
405
+ ])('keeps the preview box on the committed point — %s', (_label, wall, slabElevation) => {
406
+ const collisionMesh = makeWallFrame(wall, slabElevation)
407
+ const draft = makeDraft()
408
+ const event = makeWallEvent(wall, collisionMesh, new Vector3(2.42, 1.38, 0.1))
409
+
410
+ const result = wallStrategy.move(makeContext(draft), event, validators)
411
+ if (!result) throw new Error('expected a placement result')
412
+
413
+ const cursorFromNode = collisionMesh.localToWorld(new Vector3(...result.gridPosition))
414
+ expect(cursorFromNode.x).toBeCloseTo(result.cursorPosition[0], 6)
415
+ expect(cursorFromNode.y).toBeCloseTo(result.cursorPosition[1], 6)
416
+ expect(cursorFromNode.z).toBeCloseTo(result.cursorPosition[2], 6)
417
+ })
418
+
419
+ test('mounts the wall-side preview on the hit face, not through the wall', () => {
420
+ const wall = makeWall()
421
+ const collisionMesh = makeWallFrame(wall, 0.4)
422
+ const draft = makeDraft()
423
+ const event = makeWallEvent(wall, collisionMesh, new Vector3(2.42, 1.38, 0.1))
424
+
425
+ const result = wallStrategy.move(makeContext(draft), event, validators)
426
+ if (!result) throw new Error('expected a placement result')
427
+
428
+ // Front face → wall-local +thickness/2, matching ItemSystem's per-frame push.
429
+ expect(result.gridPosition[2]).toBeCloseTo(0.1, 6)
430
+ // The cursor frame IS the item frame, so the box's +Z (its depth) points out
431
+ // of the same face the item body extends from.
432
+ const outward = new Vector3(0, 0, 1).applyAxisAngle(
433
+ new Vector3(0, 1, 0),
434
+ result.cursorRotationY,
435
+ )
436
+ const wallNormal = new Vector3(0, 0, 1).applyAxisAngle(
437
+ new Vector3(0, 1, 0),
438
+ collisionMesh.parent!.rotation.y,
439
+ )
440
+ expect(outward.dot(wallNormal)).toBeCloseTo(1, 6)
441
+ })
442
+
443
+ test('carries the wall auto-adjusted Y into the preview box', () => {
444
+ const wall = makeWall()
445
+ const collisionMesh = makeWallFrame(wall, 0.4)
446
+ const draft = makeDraft()
447
+ const event = makeWallEvent(wall, collisionMesh, new Vector3(2.42, 1.38, 0.1))
448
+
449
+ const result = wallStrategy.move(makeContext(draft), event, {
450
+ ...validators,
451
+ canPlaceOnWall: () => ({ valid: true, adjustedY: 0.05, wasAdjusted: true }),
452
+ })
453
+ if (!result) throw new Error('expected a placement result')
454
+
455
+ expect(result.gridPosition[1]).toBeCloseTo(0.05, 6)
456
+ expect(result.cursorPosition[1]).toBeCloseTo(0.4 + 0.05, 6)
457
+ })
458
+ })
459
+
460
+ describe('floorStrategy.move', () => {
461
+ function makeGridEvent(x: number, y: number, z: number): GridEvent {
462
+ return {
463
+ position: [x, y, z],
464
+ localPosition: [x, y, z],
465
+ nativeEvent: {} as GridEvent['nativeEvent'],
466
+ }
467
+ }
468
+
469
+ test('follows the live grid Y so a raised placement keeps its height', () => {
470
+ const context = floorItemContext()
471
+ context.gridPosition.set(0, 0.9, 0)
472
+
473
+ const result = floorStrategy.move(context, makeGridEvent(1.25, 0.9, 2.25))
474
+ if (!result) throw new Error('expected a placement result')
475
+
476
+ expect(result.gridPosition[1]).toBe(0.9)
477
+ expect(result.cursorPosition[1]).toBe(0.9)
478
+ })
479
+
480
+ // `detachItemSurfaceToFloor` zeroes the grid Y when an item is taken off a
481
+ // host; the floor path must honour that instead of a Y frozen at drag start,
482
+ // or the item commits floating at the shelf's height.
483
+ test('drops to the level plane once un-hosting zeroes the grid Y', () => {
484
+ const context = floorItemContext()
485
+ context.gridPosition.set(0, 0, 0)
486
+
487
+ const result = floorStrategy.move(context, makeGridEvent(1.25, 0.9, 2.25))
488
+ if (!result) throw new Error('expected a placement result')
489
+
490
+ expect(result.gridPosition[1]).toBe(0)
491
+ expect(result.cursorPosition[1]).toBe(0)
492
+ expect(result.nodeUpdate?.position?.[1]).toBe(0)
493
+ })
494
+ })