@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,336 @@
1
+ import { beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNodeId,
4
+ BlockNode,
5
+ BuildingNode,
6
+ getBlockFaceFrame,
7
+ ItemNode,
8
+ LevelNode,
9
+ type NodeEvent,
10
+ useScene,
11
+ } from '@pascal-app/core'
12
+ import { useViewer } from '@pascal-app/viewer'
13
+ import { renderToString } from 'react-dom/server'
14
+ import { BufferGeometry, Mesh, MeshBasicMaterial, Vector3 } from 'three'
15
+ import { commitFaceHostClick } from './face-host-commit'
16
+ import type { PlacementContext } from './placement-types'
17
+ import { registerTestBlockFaceHost } from './test-face-host'
18
+ import { type DraftNodeHandle, useDraftNode } from './use-draft-node'
19
+
20
+ type RafFn = (callback: (time: number) => void) => number
21
+ ;(globalThis as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (callback) => {
22
+ callback(0)
23
+ return 0
24
+ }
25
+ ;(globalThis as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= () => {}
26
+
27
+ const BUILDING_ID = 'building_draft_custom_mesh'
28
+ const LEVEL_ID = 'level_draft_custom_mesh'
29
+ const BLOCK_ID = 'block_draft_host'
30
+
31
+ let draftNode: DraftNodeHandle | null = null
32
+
33
+ function DraftHarness() {
34
+ draftNode = useDraftNode()
35
+ return null
36
+ }
37
+
38
+ beforeEach(() => {
39
+ registerTestBlockFaceHost()
40
+ const block = BlockNode.parse({
41
+ id: BLOCK_ID,
42
+ parentId: LEVEL_ID,
43
+ })
44
+ const level = LevelNode.parse({
45
+ id: LEVEL_ID,
46
+ parentId: BUILDING_ID,
47
+ children: [BLOCK_ID],
48
+ level: 0,
49
+ })
50
+ const building = BuildingNode.parse({
51
+ id: BUILDING_ID,
52
+ children: [LEVEL_ID],
53
+ })
54
+ useScene.setState({
55
+ nodes: {
56
+ [BUILDING_ID]: building,
57
+ [LEVEL_ID]: level,
58
+ [BLOCK_ID]: block,
59
+ },
60
+ rootNodeIds: [BUILDING_ID],
61
+ collections: {},
62
+ dirtyNodes: new Set(),
63
+ } as never)
64
+ useScene.temporal.getState().clear()
65
+ useScene.temporal.getState().resume()
66
+ useViewer.setState({
67
+ selection: {
68
+ buildingId: BUILDING_ID,
69
+ levelId: LEVEL_ID,
70
+ zoneId: null,
71
+ selectedIds: [],
72
+ },
73
+ })
74
+ draftNode = null
75
+ renderToString(<DraftHarness />)
76
+ })
77
+
78
+ describe('useDraftNode block face commit', () => {
79
+ test('persists the face host used by the placement preview', () => {
80
+ const draft = draftNode!
81
+ draft.create(new Vector3(0, 0, 0), {
82
+ id: 'wall-art',
83
+ category: 'decor',
84
+ name: 'Wall art',
85
+ thumbnail: '/wall-art.png',
86
+ src: '/wall-art.glb',
87
+ dimensions: [1, 1, 0.1],
88
+ attachTo: 'wall-side',
89
+ })
90
+
91
+ const committedId = draft.commit({
92
+ parentId: BLOCK_ID,
93
+ position: [0.5, -0.5, 0],
94
+ rotation: [0, 0, 0],
95
+ blockFaceId: 'face-front',
96
+ })
97
+
98
+ const committed = useScene.getState().nodes[committedId as AnyNodeId]
99
+ expect(committed).toMatchObject({
100
+ parentId: BLOCK_ID,
101
+ position: [0.5, -0.5, 0],
102
+ blockFaceId: 'face-front',
103
+ })
104
+ })
105
+
106
+ test('keeps a block-face placement visible until undo removes the committed item', () => {
107
+ useScene.temporal.getState().pause()
108
+ const draft = draftNode!
109
+ const transient = draft.create(new Vector3(0, 0, 0), {
110
+ id: 'potted-plant',
111
+ category: 'decor',
112
+ name: 'Potted plant',
113
+ thumbnail: '/potted-plant.png',
114
+ src: '/potted-plant.glb',
115
+ dimensions: [0.5, 0.39, 0.5],
116
+ })!
117
+
118
+ const committedId = draft.commit({
119
+ parentId: BLOCK_ID,
120
+ position: [0.5, 0, 0],
121
+ rotation: [Math.PI / 2, 0, 0],
122
+ blockFaceId: 'face-top',
123
+ })!
124
+
125
+ const afterCommit = useScene.getState().nodes
126
+ expect(afterCommit[transient.id as AnyNodeId]).toBeUndefined()
127
+ expect(afterCommit[committedId as AnyNodeId]).toMatchObject({
128
+ parentId: BLOCK_ID,
129
+ blockFaceId: 'face-top',
130
+ })
131
+ expect((afterCommit[BLOCK_ID as AnyNodeId] as BlockNode).children).toContain(
132
+ committedId as ItemNode['id'],
133
+ )
134
+
135
+ useScene.temporal.getState().undo()
136
+
137
+ const afterUndo = useScene.getState().nodes
138
+ expect(afterUndo[committedId as AnyNodeId]).toBeUndefined()
139
+ expect(afterUndo[transient.id as AnyNodeId]).toBeUndefined()
140
+ expect((afterUndo[BLOCK_ID as AnyNodeId] as BlockNode).children).not.toContain(
141
+ committedId as ItemNode['id'],
142
+ )
143
+ })
144
+
145
+ test('moves a block-face item to the floor as one undoable reparent', () => {
146
+ const hosted = ItemNode.parse({
147
+ id: 'item_hosted-potted-plant',
148
+ parentId: BLOCK_ID,
149
+ asset: {
150
+ id: 'potted-plant',
151
+ category: 'decor',
152
+ name: 'Potted plant',
153
+ thumbnail: '/potted-plant.png',
154
+ src: '/potted-plant.glb',
155
+ dimensions: [0.5, 0.39, 0.5],
156
+ },
157
+ position: [0.5, 0, 0],
158
+ rotation: [Math.PI / 2, 0, 0],
159
+ blockFaceId: 'face-top',
160
+ })
161
+ useScene.getState().createNode(hosted, BLOCK_ID as AnyNodeId)
162
+ useScene.temporal.getState().clear()
163
+ useScene.temporal.getState().pause()
164
+
165
+ const draft = draftNode!
166
+ draft.adopt(hosted)
167
+ draft.commit({
168
+ parentId: LEVEL_ID,
169
+ position: [2, 0, 3],
170
+ rotation: [0, Math.PI / 4, 0],
171
+ blockFaceId: undefined,
172
+ })
173
+
174
+ expect(useScene.getState().nodes[hosted.id as AnyNodeId]).toMatchObject({
175
+ parentId: LEVEL_ID,
176
+ position: [2, 0, 3],
177
+ rotation: [0, Math.PI / 4, 0],
178
+ })
179
+ expect(
180
+ (useScene.getState().nodes[hosted.id as AnyNodeId] as ItemNode).blockFaceId,
181
+ ).toBeUndefined()
182
+ expect((useScene.getState().nodes[BLOCK_ID as AnyNodeId] as BlockNode).children).not.toContain(
183
+ hosted.id,
184
+ )
185
+ expect((useScene.getState().nodes[LEVEL_ID as AnyNodeId] as LevelNode).children).toContain(
186
+ hosted.id,
187
+ )
188
+
189
+ useScene.temporal.getState().undo()
190
+
191
+ expect(useScene.getState().nodes[hosted.id as AnyNodeId]).toMatchObject({
192
+ parentId: BLOCK_ID,
193
+ position: [0.5, 0, 0],
194
+ rotation: [Math.PI / 2, 0, 0],
195
+ blockFaceId: 'face-top',
196
+ })
197
+ expect((useScene.getState().nodes[BLOCK_ID as AnyNodeId] as BlockNode).children).toContain(
198
+ hosted.id,
199
+ )
200
+ expect((useScene.getState().nodes[LEVEL_ID as AnyNodeId] as LevelNode).children).not.toContain(
201
+ hosted.id,
202
+ )
203
+ })
204
+
205
+ test('keeps a hosted item visible through a block topology edit and its undo', () => {
206
+ useScene.temporal.getState().pause()
207
+ const draft = draftNode!
208
+ draft.create(new Vector3(0, 0, 0), {
209
+ id: 'potted-plant',
210
+ category: 'decor',
211
+ name: 'Potted plant',
212
+ thumbnail: '/potted-plant.png',
213
+ src: '/potted-plant.glb',
214
+ dimensions: [0.5, 0.39, 0.5],
215
+ })
216
+ const committedId = draft.commit({
217
+ parentId: BLOCK_ID,
218
+ position: [0, 0, 0],
219
+ rotation: [Math.PI / 2, 0, 0],
220
+ blockFaceId: 'f-top',
221
+ })!
222
+
223
+ const beforeEdit = useScene.getState().nodes[BLOCK_ID as AnyNodeId] as BlockNode
224
+ const topVertexIds = new Set(
225
+ beforeEdit.topology.faces.find((face) => face.id === 'f-top')?.vertexIds ?? [],
226
+ )
227
+ const editedTopology = {
228
+ ...beforeEdit.topology,
229
+ vertices: beforeEdit.topology.vertices.map((vertex) =>
230
+ topVertexIds.has(vertex.id)
231
+ ? {
232
+ ...vertex,
233
+ position: [vertex.position[0], vertex.position[1] + 0.5, vertex.position[2]] as [
234
+ number,
235
+ number,
236
+ number,
237
+ ],
238
+ }
239
+ : vertex,
240
+ ),
241
+ }
242
+
243
+ useScene.temporal.getState().resume()
244
+ useScene.getState().updateNode(BLOCK_ID as AnyNodeId, { topology: editedTopology })
245
+ useScene.temporal.getState().pause()
246
+
247
+ const afterEdit = useScene.getState().nodes
248
+ const editedHost = afterEdit[BLOCK_ID as AnyNodeId] as BlockNode
249
+ expect(editedHost.children).toContain(committedId as ItemNode['id'])
250
+ expect(afterEdit[committedId as AnyNodeId]).toMatchObject({
251
+ parentId: BLOCK_ID,
252
+ blockFaceId: 'f-top',
253
+ })
254
+ expect(getBlockFaceFrame(editedHost.topology, 'f-top')?.origin[1]).toBe(2.9)
255
+
256
+ useScene.temporal.getState().undo()
257
+
258
+ const afterUndo = useScene.getState().nodes
259
+ const restoredHost = afterUndo[BLOCK_ID as AnyNodeId] as BlockNode
260
+ expect(restoredHost.children).toContain(committedId as ItemNode['id'])
261
+ expect(afterUndo[committedId as AnyNodeId]).toMatchObject({
262
+ parentId: BLOCK_ID,
263
+ blockFaceId: 'f-top',
264
+ })
265
+ expect(getBlockFaceFrame(restoredHost.topology, 'f-top')?.origin[1]).toBe(2.4)
266
+ })
267
+
268
+ test('commits before a stop-propagation leave can destroy the block-face draft', () => {
269
+ useScene.temporal.getState().pause()
270
+ const draft = draftNode!
271
+ const transient = draft.create(new Vector3(), {
272
+ id: 'potted-plant',
273
+ category: 'decor',
274
+ name: 'Potted plant',
275
+ thumbnail: '/potted-plant.png',
276
+ src: '/potted-plant.glb',
277
+ dimensions: [0.5, 0.39, 0.5],
278
+ })!
279
+ Object.assign(transient, {
280
+ parentId: BLOCK_ID,
281
+ position: [0, 0, 0],
282
+ rotation: [Math.PI / 2, 0, 0],
283
+ blockFaceId: 'f-top',
284
+ })
285
+ useScene.getState().updateNode(transient.id, transient)
286
+
287
+ const host = useScene.getState().nodes[BLOCK_ID as AnyNodeId] as BlockNode
288
+ const geometry = new BufferGeometry()
289
+ geometry.userData.blockFaces = [{ faceId: 'f-top', start: 0, count: 6 }]
290
+ const object = new Mesh(geometry, new MeshBasicMaterial())
291
+ object.updateMatrixWorld(true)
292
+ const event: NodeEvent = {
293
+ node: host,
294
+ object,
295
+ faceIndex: 0,
296
+ position: [0, 2.4, 0],
297
+ localPosition: [0, 2.4, 0],
298
+ normal: [0, 1, 0],
299
+ stopPropagation: () => draft.destroy(),
300
+ nativeEvent: {} as NodeEvent['nativeEvent'],
301
+ }
302
+ const getContext = (): PlacementContext => ({
303
+ asset: transient.asset,
304
+ levelId: LEVEL_ID,
305
+ draftItem: draft.current,
306
+ gridPosition: new Vector3(),
307
+ state: {
308
+ surface: 'block-face',
309
+ blockId: BLOCK_ID,
310
+ wallId: null,
311
+ roofSegmentId: null,
312
+ ceilingId: null,
313
+ surfaceItemId: null,
314
+ shelfId: null,
315
+ },
316
+ currentCursorRotationY: 0,
317
+ })
318
+
319
+ const outcome = commitFaceHostClick({
320
+ getContext,
321
+ event,
322
+ enterFaceHost: () => false,
323
+ commitDraft: (nodeUpdate) => ({
324
+ committedId: draft.commit(nodeUpdate),
325
+ wasAdopted: draft.isAdopted,
326
+ }),
327
+ })
328
+
329
+ expect(outcome?.committedId).not.toBeNull()
330
+ expect(useScene.getState().nodes[outcome!.committedId as AnyNodeId]).toMatchObject({
331
+ parentId: BLOCK_ID,
332
+ blockFaceId: 'f-top',
333
+ metadata: {},
334
+ })
335
+ })
336
+ })
@@ -6,13 +6,20 @@ import {
6
6
  sceneRegistry,
7
7
  useScene,
8
8
  } from '@pascal-app/core'
9
- import { useViewer } from '@pascal-app/viewer'
9
+ import { beginPerfAction, commitPerfAction, useViewer } from '@pascal-app/viewer'
10
10
  import { useCallback, useMemo, useRef } from 'react'
11
11
  import type { Vector3 } from 'three'
12
+ import {
13
+ surfaceAttachmentId,
14
+ surfaceAttachmentUpdates,
15
+ surfaceFramePose,
16
+ updateSurfaceNode,
17
+ } from '../../../lib/surface-attachment'
12
18
  import usePlacementPreview from '../../../store/use-placement-preview'
13
19
  import { stripTransient } from './placement-math'
14
20
 
15
21
  interface OriginalState {
22
+ surfaceId: string | null
16
23
  position: [number, number, number]
17
24
  rotation: [number, number, number]
18
25
  side: ItemNode['side']
@@ -21,10 +28,12 @@ interface OriginalState {
21
28
  // mid-move, so reverts must restore it alongside parentId.
22
29
  roofSegmentId: ItemNode['roofSegmentId']
23
30
  roofFace: ItemNode['roofFace']
31
+ blockFaceId: ItemNode['blockFaceId']
24
32
  metadata: ItemNode['metadata']
25
33
  }
26
34
 
27
35
  export interface DraftNodeHandle {
36
+ updateSurface: (data: Partial<ItemNode>, surfaceId: string | null) => void
28
37
  /** Current draft item, or null */
29
38
  readonly current: ItemNode | null
30
39
  /** Whether the current draft was adopted (move mode) vs created (create mode) */
@@ -46,7 +55,11 @@ export interface DraftNodeHandle {
46
55
  * commit lands on the surface the cursor pointed at. */
47
56
  commit: (
48
57
  finalUpdate: Partial<ItemNode>,
49
- options?: { supportElevationCap?: number | null },
58
+ options?: {
59
+ supportElevationCap?: number | null
60
+ preferredSupportSlabId?: string | null
61
+ pinSupport?: boolean
62
+ },
50
63
  ) => string | null
51
64
  /** Destroy the current draft. Create mode: delete node. Move mode: restore original state. */
52
65
  destroy: () => void
@@ -107,16 +120,21 @@ export function useDraftNode(): DraftNodeHandle {
107
120
  : {}
108
121
 
109
122
  originalStateRef.current = {
123
+ surfaceId: surfaceAttachmentId(node),
110
124
  position: [...node.position] as [number, number, number],
111
125
  rotation: [...node.rotation] as [number, number, number],
112
126
  side: node.side,
113
127
  parentId: node.parentId,
114
128
  roofSegmentId: node.roofSegmentId,
115
129
  roofFace: node.roofFace,
130
+ blockFaceId: node.blockFaceId,
116
131
  metadata: node.metadata,
117
132
  }
118
133
 
119
- draftRef.current = node
134
+ draftRef.current = {
135
+ ...node,
136
+ ...surfaceFramePose(node.parentId, surfaceAttachmentId(node), node, false),
137
+ }
120
138
  adoptedRef.current = true
121
139
 
122
140
  // Mark as transient so it renders as a draft
@@ -134,11 +152,23 @@ export function useDraftNode(): DraftNodeHandle {
134
152
  const commit = useCallback(
135
153
  (
136
154
  finalUpdate: Partial<ItemNode>,
137
- options?: { supportElevationCap?: number | null },
155
+ options?: {
156
+ supportElevationCap?: number | null
157
+ preferredSupportSlabId?: string | null
158
+ pinSupport?: boolean
159
+ },
138
160
  ): string | null => {
139
161
  const draft = draftRef.current
140
162
  if (!draft) return null
141
163
 
164
+ const surfaceId = surfaceAttachmentId(useScene.getState().nodes[draft.id] ?? draft)
165
+ const stored = surfaceFramePose(
166
+ finalUpdate.parentId ?? draft.parentId,
167
+ surfaceId,
168
+ { ...draft, ...finalUpdate },
169
+ true,
170
+ )
171
+ finalUpdate = { ...finalUpdate, ...stored }
142
172
  if (adoptedRef.current) {
143
173
  // Move mode: update in place (single undoable action)
144
174
  const { parentId: newParentId, ...updateProps } = finalUpdate
@@ -149,15 +179,20 @@ export function useDraftNode(): DraftNodeHandle {
149
179
  const original = originalStateRef.current!
150
180
 
151
181
  // Restore original state while paused — so the undo baseline is clean
152
- useScene.getState().updateNode(draft.id, {
153
- position: original.position,
154
- rotation: original.rotation,
155
- side: original.side,
156
- parentId: original.parentId,
157
- roofSegmentId: original.roofSegmentId,
158
- roofFace: original.roofFace,
159
- metadata: original.metadata,
160
- })
182
+ updateSurfaceNode(
183
+ draft.id,
184
+ {
185
+ position: original.position,
186
+ rotation: original.rotation,
187
+ side: original.side,
188
+ parentId: original.parentId,
189
+ roofSegmentId: original.roofSegmentId,
190
+ roofFace: original.roofFace,
191
+ blockFaceId: original.blockFaceId,
192
+ metadata: original.metadata,
193
+ },
194
+ original.surfaceId,
195
+ )
161
196
 
162
197
  // Resume → tracked update (undo reverts to original)
163
198
  useScene.temporal.getState().resume()
@@ -169,25 +204,32 @@ export function useDraftNode(): DraftNodeHandle {
169
204
  metadata: updateProps.metadata ?? stripTransient(draft.metadata),
170
205
  })
171
206
 
172
- useScene.getState().updateNode(draft.id, {
173
- position: updateProps.position ?? draft.position,
174
- rotation: updateProps.rotation ?? draft.rotation,
175
- side: updateProps.side ?? draft.side,
176
- metadata: updateProps.metadata ?? stripTransient(draft.metadata),
177
- parentId: parentId as string,
178
- // Forward the roof host explicitly: strategies set it on every
179
- // commit (segment id on a roof face, undefined elsewhere), and
180
- // dropping it here strands the item in the roof frame without
181
- // the segment transform.
182
- roofSegmentId: updateProps.roofSegmentId,
183
- roofFace: updateProps.roofFace,
184
- // Only when the strategy decided about wallId (roof commits clear
185
- // it) — floor/ceiling commits never managed the field.
186
- ...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}),
187
- ...resolveSupportSlabPatch(effectiveNode, useScene.getState().nodes, {
188
- maxElevation: options?.supportElevationCap,
189
- }),
190
- })
207
+ updateSurfaceNode(
208
+ draft.id,
209
+ {
210
+ position: updateProps.position ?? draft.position,
211
+ rotation: updateProps.rotation ?? draft.rotation,
212
+ side: updateProps.side ?? draft.side,
213
+ metadata: updateProps.metadata ?? stripTransient(draft.metadata),
214
+ parentId: parentId as string,
215
+ // Forward the roof host explicitly: strategies set it on every
216
+ // commit (segment id on a roof face, undefined elsewhere), and
217
+ // dropping it here strands the item in the roof frame without
218
+ // the segment transform.
219
+ roofSegmentId: updateProps.roofSegmentId,
220
+ roofFace: updateProps.roofFace,
221
+ blockFaceId: updateProps.blockFaceId,
222
+ // Only when the strategy decided about wallId (roof commits clear
223
+ // it) — floor/ceiling commits never managed the field.
224
+ ...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}),
225
+ ...resolveSupportSlabPatch(effectiveNode, useScene.getState().nodes, {
226
+ maxElevation: options?.supportElevationCap,
227
+ preferredSlabId: options?.preferredSupportSlabId,
228
+ pinSupport: options?.pinSupport,
229
+ }),
230
+ },
231
+ surfaceId,
232
+ )
191
233
 
192
234
  useScene.temporal.getState().pause()
193
235
 
@@ -206,7 +248,9 @@ export function useDraftNode(): DraftNodeHandle {
206
248
  const parentId = (newParentId ?? useViewer.getState().selection.levelId) as AnyNodeId
207
249
  if (!parentId) return null
208
250
 
251
+ beginPerfAction('place:item', draft.id)
209
252
  // Delete draft while paused (invisible to undo)
253
+ updateSurfaceNode(draft.id, {}, null)
210
254
  useScene.getState().deleteNode(draft.id)
211
255
  draftRef.current = null
212
256
 
@@ -226,6 +270,7 @@ export function useDraftNode(): DraftNodeHandle {
226
270
  // forwarded explicitly.
227
271
  roofSegmentId: updateProps.roofSegmentId,
228
272
  roofFace: updateProps.roofFace,
273
+ blockFaceId: updateProps.blockFaceId,
229
274
  ...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}),
230
275
  metadata: updateProps.metadata ?? stripTransient(draft.metadata),
231
276
  parentId,
@@ -236,10 +281,17 @@ export function useDraftNode(): DraftNodeHandle {
236
281
  ...resolveSupportSlabPatch(
237
282
  finalNode,
238
283
  { ...nodes, [finalNode.id]: finalNode },
239
- { maxElevation: options?.supportElevationCap },
284
+ {
285
+ maxElevation: options?.supportElevationCap,
286
+ preferredSlabId: options?.preferredSupportSlabId,
287
+ pinSupport: options?.pinSupport,
288
+ },
240
289
  ),
241
290
  })
242
- useScene.getState().createNode(committedNode, parentId)
291
+ useScene.getState().applyNodeChanges({
292
+ create: [{ node: committedNode, parentId }],
293
+ update: surfaceAttachmentUpdates(committedNode.id, parentId, surfaceId),
294
+ })
243
295
  if (usePlacementPreview.getState().node?.id === draft.id) {
244
296
  usePlacementPreview.getState().clear()
245
297
  }
@@ -249,6 +301,7 @@ export function useDraftNode(): DraftNodeHandle {
249
301
 
250
302
  adoptedRef.current = false
251
303
  originalStateRef.current = null
304
+ commitPerfAction()
252
305
  return committedNode.id
253
306
  },
254
307
  [],
@@ -272,6 +325,7 @@ export function useDraftNode(): DraftNodeHandle {
272
325
  const live = useScene.getState().nodes[id as AnyNodeId] as ItemNode | undefined
273
326
  const livePosition = live?.position
274
327
  const externallyMoved =
328
+ !live?.metadata?.isTransient &&
275
329
  !!livePosition &&
276
330
  (livePosition[0] !== original.position[0] ||
277
331
  livePosition[1] !== original.position[1] ||
@@ -286,15 +340,20 @@ export function useDraftNode(): DraftNodeHandle {
286
340
  return
287
341
  }
288
342
 
289
- useScene.getState().updateNode(id, {
290
- position: original.position,
291
- rotation: original.rotation,
292
- side: original.side,
293
- parentId: original.parentId,
294
- roofSegmentId: original.roofSegmentId,
295
- roofFace: original.roofFace,
296
- metadata: original.metadata,
297
- })
343
+ updateSurfaceNode(
344
+ id,
345
+ {
346
+ position: original.position,
347
+ rotation: original.rotation,
348
+ side: original.side,
349
+ parentId: original.parentId,
350
+ roofSegmentId: original.roofSegmentId,
351
+ roofFace: original.roofFace,
352
+ blockFaceId: original.blockFaceId,
353
+ metadata: original.metadata,
354
+ },
355
+ original.surfaceId,
356
+ )
298
357
 
299
358
  // Also reset the Three.js mesh directly — the store update triggers a React
300
359
  // re-render but the mesh position was mutated by useFrame and may not reset
@@ -307,6 +366,7 @@ export function useDraftNode(): DraftNodeHandle {
307
366
  }
308
367
  } else {
309
368
  // Create mode: delete the transient node
369
+ updateSurfaceNode(draftRef.current.id, {}, null)
310
370
  useScene.getState().deleteNode(draftRef.current.id)
311
371
  }
312
372
 
@@ -318,6 +378,17 @@ export function useDraftNode(): DraftNodeHandle {
318
378
  }
319
379
  }, [])
320
380
 
381
+ const updateSurface = useCallback((data: Partial<ItemNode>, surfaceId: string | null) => {
382
+ const draft = draftRef.current
383
+ if (!draft) return
384
+ const pose = { ...draft, ...data }
385
+ updateSurfaceNode(
386
+ draft.id,
387
+ { ...data, ...surfaceFramePose(pose.parentId, surfaceId, pose, true) },
388
+ surfaceId,
389
+ )
390
+ }, [])
391
+
321
392
  return useMemo(
322
393
  () => ({
323
394
  get current() {
@@ -326,11 +397,12 @@ export function useDraftNode(): DraftNodeHandle {
326
397
  get isAdopted() {
327
398
  return adoptedRef.current
328
399
  },
400
+ updateSurface,
329
401
  create,
330
402
  adopt,
331
403
  commit,
332
404
  destroy,
333
405
  }),
334
- [create, adopt, commit, destroy],
406
+ [create, adopt, commit, destroy, updateSurface],
335
407
  )
336
408
  }