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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +13 -7
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +1 -0
  6. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  7. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  8. package/src/components/editor/capture-camera-rig.tsx +59 -0
  9. package/src/components/editor/custom-camera-controls.tsx +20 -11
  10. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  11. package/src/components/editor/editor-layout-v2.tsx +9 -2
  12. package/src/components/editor/export-manager.tsx +175 -33
  13. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  14. package/src/components/editor/first-person-controls.tsx +180 -12
  15. package/src/components/editor/floating-action-menu.tsx +42 -51
  16. package/src/components/editor/floorplan-panel.tsx +76 -12
  17. package/src/components/editor/grid.tsx +12 -7
  18. package/src/components/editor/group-actions.ts +34 -50
  19. package/src/components/editor/group-move-3d.ts +51 -21
  20. package/src/components/editor/group-transform-shared.test.ts +78 -0
  21. package/src/components/editor/group-transform-shared.ts +74 -7
  22. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  23. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  24. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  25. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  26. package/src/components/editor/handles/resize-snap.ts +10 -1
  27. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  28. package/src/components/editor/index.tsx +140 -21
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +47 -58
  31. package/src/components/editor/selection-manager.tsx +104 -42
  32. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  33. package/src/components/editor/snapshot-capture.test.ts +422 -0
  34. package/src/components/editor/snapshot-capture.ts +145 -0
  35. package/src/components/editor/thumbnail-generator.tsx +284 -237
  36. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  37. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  38. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  39. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  41. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  42. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  43. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  44. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  45. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  46. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +180 -32
  47. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  48. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  49. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  51. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  52. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  53. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  54. package/src/components/systems/selection-affordance-services.ts +19 -0
  55. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  56. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  57. package/src/components/tools/fence/fence-drafting.ts +6 -25
  58. package/src/components/tools/item/draft-creation.test.ts +12 -0
  59. package/src/components/tools/item/draft-creation.ts +10 -0
  60. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  61. package/src/components/tools/item/face-host-commit.ts +56 -0
  62. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  63. package/src/components/tools/item/face-host-preview.ts +82 -0
  64. package/src/components/tools/item/move-tool.tsx +4 -3
  65. package/src/components/tools/item/placement-math.test.ts +11 -1
  66. package/src/components/tools/item/placement-math.ts +0 -20
  67. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  68. package/src/components/tools/item/placement-strategies.ts +135 -2
  69. package/src/components/tools/item/placement-surface.test.ts +61 -0
  70. package/src/components/tools/item/placement-surface.ts +24 -0
  71. package/src/components/tools/item/placement-types.ts +6 -0
  72. package/src/components/tools/item/test-face-host.ts +121 -0
  73. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  74. package/src/components/tools/item/use-draft-node.ts +26 -4
  75. package/src/components/tools/item/use-placement-coordinator.tsx +428 -153
  76. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  77. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  78. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  79. package/src/components/tools/registry-tool-context.tsx +30 -0
  80. package/src/components/tools/select/box-select-tool.tsx +3 -1
  81. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  82. package/src/components/tools/select/marquee-footprint.ts +29 -0
  83. package/src/components/tools/shared/placement-box.tsx +57 -7
  84. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  85. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  86. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  89. package/src/components/tools/stair/stair-tool.tsx +127 -74
  90. package/src/components/tools/tool-manager.tsx +25 -8
  91. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  92. package/src/components/tools/wall/wall-drafting.ts +23 -114
  93. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  94. package/src/components/ui/action-menu/index.tsx +11 -1
  95. package/src/components/ui/command-palette/editor-commands.tsx +9 -12
  96. package/src/components/ui/command-palette/index.tsx +7 -2
  97. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  98. package/src/components/ui/controls/material-picker.tsx +4 -4
  99. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  100. package/src/components/ui/controls/segmented-control.tsx +8 -1
  101. package/src/components/ui/controls/slider-control.tsx +19 -3
  102. package/src/components/ui/controls/toggle-control.tsx +18 -5
  103. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  104. package/src/components/ui/floating-level-selector.tsx +22 -5
  105. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  106. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  107. package/src/components/ui/helpers/item-helper.tsx +1 -1
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  109. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  110. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  111. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  112. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  113. package/src/components/ui/panels/multi-field-value.ts +163 -0
  114. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  115. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  116. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  117. package/src/components/ui/panels/node-display.ts +4 -0
  118. package/src/components/ui/panels/panel-manager.tsx +77 -3
  119. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  120. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  121. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  122. package/src/components/ui/panels/reference-panel.tsx +31 -1
  123. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  124. package/src/components/ui/scene-loader.tsx +35 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  127. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  128. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  129. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  130. package/src/components/ui/sidebar/panels/site-panel/index.tsx +116 -46
  131. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  132. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  134. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +14 -6
  135. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  136. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  137. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  138. package/src/components/viewer/viewer-stage.tsx +4 -1
  139. package/src/components/viewer-overlay.tsx +1 -22
  140. package/src/components/viewer-zone-system.tsx +6 -2
  141. package/src/components/walkthrough-hud.tsx +19 -10
  142. package/src/hooks/use-auto-save.test.ts +73 -1
  143. package/src/hooks/use-auto-save.ts +76 -5
  144. package/src/hooks/use-ceiling-events.test.ts +122 -0
  145. package/src/hooks/use-drag-action.ts +17 -3
  146. package/src/hooks/use-grid-events.test.ts +240 -0
  147. package/src/hooks/use-grid-events.ts +190 -11
  148. package/src/hooks/use-keyboard.test.ts +244 -0
  149. package/src/hooks/use-keyboard.ts +129 -62
  150. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  151. package/src/hooks/use-save-shortcut.ts +33 -0
  152. package/src/index.tsx +82 -4
  153. package/src/lib/active-placement-surface.test.ts +16 -0
  154. package/src/lib/active-placement-surface.ts +14 -0
  155. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  156. package/src/lib/ceiling-surface-raycast.ts +12 -0
  157. package/src/lib/contextual-help-extension.ts +17 -0
  158. package/src/lib/continuation.test.ts +11 -0
  159. package/src/lib/continuation.ts +8 -1
  160. package/src/lib/direct-manipulation.test.ts +132 -0
  161. package/src/lib/direct-manipulation.ts +38 -1
  162. package/src/lib/editor-api.ts +6 -3
  163. package/src/lib/elevation-guides.test.ts +28 -1
  164. package/src/lib/elevation-guides.ts +22 -7
  165. package/src/lib/floating-menu-scale.test.ts +29 -0
  166. package/src/lib/floating-menu-scale.ts +14 -0
  167. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  168. package/src/lib/floorplan/apply-alignment.ts +10 -1
  169. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  170. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  171. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  172. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  173. package/src/lib/floorplan/floorplan-readonly.ts +1 -0
  174. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  175. package/src/lib/floorplan-grid-event-point.ts +13 -0
  176. package/src/lib/glb-export.test.ts +1169 -21
  177. package/src/lib/glb-export.ts +629 -101
  178. package/src/lib/grid-event-presentation.ts +16 -0
  179. package/src/lib/history.test.ts +528 -147
  180. package/src/lib/history.ts +81 -7
  181. package/src/lib/host-tree-children.test.ts +27 -0
  182. package/src/lib/host-tree-children.ts +79 -0
  183. package/src/lib/interaction/hot-set.test.ts +10 -1
  184. package/src/lib/interaction/hot-set.ts +3 -1
  185. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  186. package/src/lib/interaction/overlay-policy.ts +16 -0
  187. package/src/lib/interaction/registered-drafting.ts +36 -0
  188. package/src/lib/interaction/scope.ts +31 -0
  189. package/src/lib/level-print-export.test.ts +484 -0
  190. package/src/lib/level-print-export.ts +526 -0
  191. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  192. package/src/lib/local-project-presentation-persistence.ts +335 -0
  193. package/src/lib/material-paint.ts +2 -0
  194. package/src/lib/model-export.ts +29 -0
  195. package/src/lib/paint-preview-owner.ts +71 -0
  196. package/src/lib/planar-cursor-placement.test.ts +178 -1
  197. package/src/lib/planar-cursor-placement.ts +61 -5
  198. package/src/lib/plugin-panels.test.ts +92 -1
  199. package/src/lib/plugin-panels.ts +48 -0
  200. package/src/lib/portable-export.test.ts +366 -0
  201. package/src/lib/portable-export.ts +969 -0
  202. package/src/lib/print-3mf.test.ts +86 -0
  203. package/src/lib/print-3mf.ts +161 -0
  204. package/src/lib/print-content-scope.test.ts +107 -0
  205. package/src/lib/print-content-scope.ts +90 -0
  206. package/src/lib/print-export.test.ts +294 -0
  207. package/src/lib/print-export.ts +735 -0
  208. package/src/lib/print-feature-thickness.test.ts +147 -0
  209. package/src/lib/print-feature-thickness.ts +180 -0
  210. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  211. package/src/lib/print-golden-house.test.ts +249 -0
  212. package/src/lib/print-roof-solids.test.ts +133 -0
  213. package/src/lib/print-roof-solids.ts +630 -0
  214. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  215. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  216. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  217. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  218. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  219. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  220. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  221. package/src/lib/print-shell-compiler.ts +273 -0
  222. package/src/lib/print-wall-solids.test.ts +194 -0
  223. package/src/lib/print-wall-solids.ts +328 -0
  224. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  225. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  226. package/src/lib/scene.ts +22 -8
  227. package/src/lib/selection-routing.test.ts +105 -1
  228. package/src/lib/selection-routing.ts +19 -0
  229. package/src/lib/sfx-bus.ts +4 -0
  230. package/src/lib/sfx-player.ts +8 -0
  231. package/src/lib/snapping-mode.test.ts +32 -4
  232. package/src/lib/snapping-mode.ts +11 -7
  233. package/src/lib/usdz-export.ts +38 -0
  234. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  235. package/src/store/live-draft-preview-stores.test.ts +3 -0
  236. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  237. package/src/store/tool-mode.test.ts +87 -0
  238. package/src/store/use-camera-hint-focus.ts +48 -0
  239. package/src/store/use-editor.tsx +299 -188
  240. package/src/store/use-floorplan-draft-preview.ts +9 -0
  241. package/src/store/use-interaction-scope.test.ts +23 -1
  242. package/src/store/use-interaction-scope.ts +63 -2
  243. package/src/store/use-placement-preview.ts +56 -3
  244. package/src/components/tools/roof/roof-tool.tsx +0 -656
  245. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -0,0 +1,77 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { Group } from 'three'
3
+ import {
4
+ applyFaceHostPreviewPose,
5
+ clampFaceHostCenterPosition,
6
+ clampFaceHostPosition,
7
+ resolveFaceHostSwitch,
8
+ shouldDetachFaceHostOnLeave,
9
+ } from './face-host-preview'
10
+
11
+ describe('applyFaceHostPreviewPose', () => {
12
+ test('moves the rendered draft synchronously with the pointer result', () => {
13
+ const mesh = new Group()
14
+ mesh.position.set(8, 8, 8)
15
+ mesh.rotation.set(1, 1, 1)
16
+
17
+ applyFaceHostPreviewPose(mesh, [1.25, -0.5, 0], [0, 0, 0])
18
+
19
+ expect(mesh.position.toArray()).toEqual([1.25, -0.5, 0])
20
+ expect(mesh.rotation.toArray().slice(0, 3)).toEqual([0, 0, 0])
21
+ })
22
+ })
23
+
24
+ describe('resolveFaceHostSwitch', () => {
25
+ test('ignores a single adjacent-face hit and accepts a repeated hit', () => {
26
+ const first = resolveFaceHostSwitch('face-a', 'face-b', null)
27
+ expect(first).toEqual({ accept: false, pendingFaceId: 'face-b' })
28
+
29
+ const second = resolveFaceHostSwitch('face-a', 'face-b', first.pendingFaceId)
30
+ expect(second).toEqual({ accept: true, pendingFaceId: null })
31
+ })
32
+
33
+ test('clears a pending switch when the pointer returns to the current face', () => {
34
+ expect(resolveFaceHostSwitch('face-a', 'face-a', 'face-b')).toEqual({
35
+ accept: true,
36
+ pendingFaceId: null,
37
+ })
38
+ })
39
+ })
40
+
41
+ describe('shouldDetachFaceHostOnLeave', () => {
42
+ test('detaches attached items when they leave a block face', () => {
43
+ expect(shouldDetachFaceHostOnLeave('wall')).toBe(true)
44
+ expect(shouldDetachFaceHostOnLeave('wall-side')).toBe(true)
45
+ expect(shouldDetachFaceHostOnLeave('ceiling')).toBe(true)
46
+ })
47
+
48
+ test('allows free floor items to leave a block face', () => {
49
+ expect(shouldDetachFaceHostOnLeave(undefined)).toBe(true)
50
+ })
51
+ })
52
+
53
+ describe('clampFaceHostPosition', () => {
54
+ test('keeps the complete wall item inside the face after snapping', () => {
55
+ expect(
56
+ clampFaceHostPosition([1.9, 2.8, 0], { minU: -2, maxU: 2, minV: 0, maxV: 3 }, [1, 1]),
57
+ ).toEqual([1.5, 2, 0])
58
+ })
59
+
60
+ test('rejects a face that is smaller than the item', () => {
61
+ expect(
62
+ clampFaceHostPosition([0, 0, 0], { minU: -0.25, maxU: 0.25, minV: 0, maxV: 0.5 }, [1, 1]),
63
+ ).toBeNull()
64
+ })
65
+ })
66
+
67
+ describe('clampFaceHostCenterPosition', () => {
68
+ test('keeps a ceiling fixture footprint inside the face on both axes', () => {
69
+ expect(
70
+ clampFaceHostCenterPosition(
71
+ [1.9, 1.9, 0.25],
72
+ { minU: -2, maxU: 2, minV: -2, maxV: 2 },
73
+ [1, 1],
74
+ ),
75
+ ).toEqual([1.5, 1.5, 0.25])
76
+ })
77
+ })
@@ -0,0 +1,82 @@
1
+ import type { Object3D } from 'three'
2
+
3
+ type Vector3Tuple = readonly [number, number, number]
4
+
5
+ type FaceBounds = {
6
+ minU: number
7
+ maxU: number
8
+ minV: number
9
+ maxV: number
10
+ }
11
+
12
+ export function applyFaceHostPreviewPose(
13
+ mesh: Object3D,
14
+ position: Vector3Tuple,
15
+ rotation: Vector3Tuple,
16
+ ): void {
17
+ mesh.position.set(position[0], position[1], position[2])
18
+ mesh.rotation.set(rotation[0], rotation[1], rotation[2])
19
+ }
20
+
21
+ export function resolveFaceHostSwitch(
22
+ currentFaceId: string | null | undefined,
23
+ nextFaceId: string | null | undefined,
24
+ pendingFaceId: string | null,
25
+ ): { accept: boolean; pendingFaceId: string | null } {
26
+ if (!currentFaceId || !nextFaceId || currentFaceId === nextFaceId) {
27
+ return { accept: true, pendingFaceId: null }
28
+ }
29
+
30
+ if (pendingFaceId === nextFaceId) {
31
+ return { accept: true, pendingFaceId: null }
32
+ }
33
+
34
+ return { accept: false, pendingFaceId: nextFaceId }
35
+ }
36
+
37
+ export function shouldDetachFaceHostOnLeave(attachTo: string | undefined): boolean {
38
+ return (
39
+ attachTo === undefined ||
40
+ attachTo === 'wall' ||
41
+ attachTo === 'wall-side' ||
42
+ attachTo === 'ceiling'
43
+ )
44
+ }
45
+
46
+ export function clampFaceHostPosition(
47
+ position: Vector3Tuple,
48
+ bounds: FaceBounds,
49
+ dimensions: readonly [width: number, height: number],
50
+ ): [number, number, number] | null {
51
+ const [width, height] = dimensions
52
+ const minU = bounds.minU + width / 2
53
+ const maxU = bounds.maxU - width / 2
54
+ const minV = bounds.minV
55
+ const maxV = bounds.maxV - height
56
+ if (minU > maxU || minV > maxV) return null
57
+
58
+ return [
59
+ Math.min(maxU, Math.max(minU, position[0])),
60
+ Math.min(maxV, Math.max(minV, position[1])),
61
+ position[2],
62
+ ]
63
+ }
64
+
65
+ export function clampFaceHostCenterPosition(
66
+ position: Vector3Tuple,
67
+ bounds: FaceBounds,
68
+ dimensions: readonly [width: number, depth: number],
69
+ ): [number, number, number] | null {
70
+ const [width, depth] = dimensions
71
+ const minU = bounds.minU + width / 2
72
+ const maxU = bounds.maxU - width / 2
73
+ const minV = bounds.minV + depth / 2
74
+ const maxV = bounds.maxV - depth / 2
75
+ if (minU > maxU || minV > maxV) return null
76
+
77
+ return [
78
+ Math.min(maxU, Math.max(minU, position[0])),
79
+ Math.min(maxV, Math.max(minV, position[1])),
80
+ position[2],
81
+ ]
82
+ }
@@ -1,6 +1,6 @@
1
1
  import type { AnyNodeId, ElevatorNode, SpawnNode } from '@pascal-app/core'
2
- import { nodeRegistry } from '@pascal-app/core'
3
- import { Suspense } from 'react'
2
+ import { createSceneApi, nodeRegistry, useScene } from '@pascal-app/core'
3
+ import { Suspense, useMemo } from 'react'
4
4
  import { useMovingNode } from '../../../store/use-interaction-scope'
5
5
  import { MoveElevatorTool } from '../elevator/move-elevator-tool'
6
6
  import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool'
@@ -28,6 +28,7 @@ export const MoveTool: React.FC<{
28
28
  onSpawnMoved?: (nodeId: SpawnNode['id']) => void
29
29
  }> = ({ onNodeMoved }) => {
30
30
  const movingNode = useMovingNode()
31
+ const sceneApi = useMemo(() => createSceneApi(useScene), [])
31
32
 
32
33
  if (!movingNode) return null
33
34
 
@@ -37,7 +38,7 @@ export const MoveTool: React.FC<{
37
38
  if (RegistryMove) {
38
39
  return (
39
40
  <Suspense fallback={null}>
40
- <RegistryMove node={movingNode} />
41
+ <RegistryMove node={movingNode} sceneApi={sceneApi} />
41
42
  </Suspense>
42
43
  )
43
44
  }
@@ -1,5 +1,15 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { getDetachedAttachmentPreviewLift, stripTransient } from './placement-math'
2
+ import { getDetachedAttachmentPreviewLift, steppedRotation, stripTransient } from './placement-math'
3
+
4
+ describe('steppedRotation', () => {
5
+ test('rotates a placement clockwise to the next 45 degree increment', () => {
6
+ expect(steppedRotation(Math.PI / 15, 1)).toBeCloseTo(Math.PI / 4)
7
+ })
8
+
9
+ test('rotates a placement counter-clockwise to the previous 45 degree increment', () => {
10
+ expect(steppedRotation(Math.PI / 15, -1)).toBeCloseTo(-Math.PI / 4)
11
+ })
12
+ })
3
13
 
4
14
  describe('stripTransient', () => {
5
15
  test('removes placement-only metadata flags before commit', () => {
@@ -81,26 +81,6 @@ export function getDetachedAttachmentPreviewLift(
81
81
  return attachTo ? 0.45 : 0
82
82
  }
83
83
 
84
- /**
85
- * Calculate cursor rotation in WORLD space from wall normal and orientation.
86
- */
87
- export function calculateCursorRotation(
88
- normal: [number, number, number] | undefined,
89
- wallStart: [number, number],
90
- wallEnd: [number, number],
91
- ): number {
92
- if (!normal) return 0
93
-
94
- // Wall direction angle in world XZ plane
95
- const wallAngle = Math.atan2(wallEnd[1] - wallStart[1], wallEnd[0] - wallStart[0])
96
-
97
- // In local wall space, front face has normal.z < 0, back face has normal.z > 0
98
- if (normal[2] < 0) {
99
- return -wallAngle
100
- }
101
- return Math.PI - wallAngle
102
- }
103
-
104
84
  /**
105
85
  * Calculate item rotation in WALL-LOCAL space from normal.
106
86
  * Items are children of the wall mesh, so their rotation is relative to wall's local space.
@@ -1,8 +1,299 @@
1
- import { describe, expect, test } from 'bun:test'
2
- import type { ItemNode, WallEvent, WallNode } from '@pascal-app/core'
3
- import { Mesh, type Object3D, Vector3 } from 'three'
4
- import { wallStrategy } from './placement-strategies'
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'
5
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
+ })
6
297
 
7
298
  /**
8
299
  * The wall frame the runtime builds in `updateWallGeometry`: origin at
@@ -165,3 +456,39 @@ describe('wallStrategy.move', () => {
165
456
  expect(result.cursorPosition[1]).toBeCloseTo(0.4 + 0.05, 6)
166
457
  })
167
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
+ })