@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,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,7 +6,7 @@ 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
12
  import usePlacementPreview from '../../../store/use-placement-preview'
@@ -21,6 +21,7 @@ interface OriginalState {
21
21
  // mid-move, so reverts must restore it alongside parentId.
22
22
  roofSegmentId: ItemNode['roofSegmentId']
23
23
  roofFace: ItemNode['roofFace']
24
+ blockFaceId: ItemNode['blockFaceId']
24
25
  metadata: ItemNode['metadata']
25
26
  }
26
27
 
@@ -46,7 +47,11 @@ export interface DraftNodeHandle {
46
47
  * commit lands on the surface the cursor pointed at. */
47
48
  commit: (
48
49
  finalUpdate: Partial<ItemNode>,
49
- options?: { supportElevationCap?: number | null },
50
+ options?: {
51
+ supportElevationCap?: number | null
52
+ preferredSupportSlabId?: string | null
53
+ pinSupport?: boolean
54
+ },
50
55
  ) => string | null
51
56
  /** Destroy the current draft. Create mode: delete node. Move mode: restore original state. */
52
57
  destroy: () => void
@@ -113,6 +118,7 @@ export function useDraftNode(): DraftNodeHandle {
113
118
  parentId: node.parentId,
114
119
  roofSegmentId: node.roofSegmentId,
115
120
  roofFace: node.roofFace,
121
+ blockFaceId: node.blockFaceId,
116
122
  metadata: node.metadata,
117
123
  }
118
124
 
@@ -134,7 +140,11 @@ export function useDraftNode(): DraftNodeHandle {
134
140
  const commit = useCallback(
135
141
  (
136
142
  finalUpdate: Partial<ItemNode>,
137
- options?: { supportElevationCap?: number | null },
143
+ options?: {
144
+ supportElevationCap?: number | null
145
+ preferredSupportSlabId?: string | null
146
+ pinSupport?: boolean
147
+ },
138
148
  ): string | null => {
139
149
  const draft = draftRef.current
140
150
  if (!draft) return null
@@ -156,6 +166,7 @@ export function useDraftNode(): DraftNodeHandle {
156
166
  parentId: original.parentId,
157
167
  roofSegmentId: original.roofSegmentId,
158
168
  roofFace: original.roofFace,
169
+ blockFaceId: original.blockFaceId,
159
170
  metadata: original.metadata,
160
171
  })
161
172
 
@@ -181,11 +192,14 @@ export function useDraftNode(): DraftNodeHandle {
181
192
  // the segment transform.
182
193
  roofSegmentId: updateProps.roofSegmentId,
183
194
  roofFace: updateProps.roofFace,
195
+ blockFaceId: updateProps.blockFaceId,
184
196
  // Only when the strategy decided about wallId (roof commits clear
185
197
  // it) — floor/ceiling commits never managed the field.
186
198
  ...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}),
187
199
  ...resolveSupportSlabPatch(effectiveNode, useScene.getState().nodes, {
188
200
  maxElevation: options?.supportElevationCap,
201
+ preferredSlabId: options?.preferredSupportSlabId,
202
+ pinSupport: options?.pinSupport,
189
203
  }),
190
204
  })
191
205
 
@@ -206,6 +220,7 @@ export function useDraftNode(): DraftNodeHandle {
206
220
  const parentId = (newParentId ?? useViewer.getState().selection.levelId) as AnyNodeId
207
221
  if (!parentId) return null
208
222
 
223
+ beginPerfAction('place:item', draft.id)
209
224
  // Delete draft while paused (invisible to undo)
210
225
  useScene.getState().deleteNode(draft.id)
211
226
  draftRef.current = null
@@ -226,6 +241,7 @@ export function useDraftNode(): DraftNodeHandle {
226
241
  // forwarded explicitly.
227
242
  roofSegmentId: updateProps.roofSegmentId,
228
243
  roofFace: updateProps.roofFace,
244
+ blockFaceId: updateProps.blockFaceId,
229
245
  ...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}),
230
246
  metadata: updateProps.metadata ?? stripTransient(draft.metadata),
231
247
  parentId,
@@ -236,7 +252,11 @@ export function useDraftNode(): DraftNodeHandle {
236
252
  ...resolveSupportSlabPatch(
237
253
  finalNode,
238
254
  { ...nodes, [finalNode.id]: finalNode },
239
- { maxElevation: options?.supportElevationCap },
255
+ {
256
+ maxElevation: options?.supportElevationCap,
257
+ preferredSlabId: options?.preferredSupportSlabId,
258
+ pinSupport: options?.pinSupport,
259
+ },
240
260
  ),
241
261
  })
242
262
  useScene.getState().createNode(committedNode, parentId)
@@ -249,6 +269,7 @@ export function useDraftNode(): DraftNodeHandle {
249
269
 
250
270
  adoptedRef.current = false
251
271
  originalStateRef.current = null
272
+ commitPerfAction()
252
273
  return committedNode.id
253
274
  },
254
275
  [],
@@ -293,6 +314,7 @@ export function useDraftNode(): DraftNodeHandle {
293
314
  parentId: original.parentId,
294
315
  roofSegmentId: original.roofSegmentId,
295
316
  roofFace: original.roofFace,
317
+ blockFaceId: original.blockFaceId,
296
318
  metadata: original.metadata,
297
319
  })
298
320