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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (283) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +15 -8
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
  6. package/src/components/editor/bake-exporter.tsx +1 -0
  7. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  8. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  9. package/src/components/editor/capture-camera-rig.tsx +59 -0
  10. package/src/components/editor/custom-camera-controls.tsx +20 -11
  11. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  12. package/src/components/editor/editor-layout-v2.tsx +17 -5
  13. package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
  14. package/src/components/editor/export-manager.tsx +175 -33
  15. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  16. package/src/components/editor/first-person-controls.tsx +182 -57
  17. package/src/components/editor/floating-action-menu.tsx +55 -55
  18. package/src/components/editor/floorplan-background-selection.test.ts +31 -4
  19. package/src/components/editor/floorplan-background-selection.ts +27 -7
  20. package/src/components/editor/floorplan-panel.tsx +81 -57
  21. package/src/components/editor/grid.tsx +12 -7
  22. package/src/components/editor/group-actions.ts +34 -50
  23. package/src/components/editor/group-floating-action-menu.tsx +30 -15
  24. package/src/components/editor/group-move-3d.ts +51 -21
  25. package/src/components/editor/group-transform-shared.test.ts +78 -0
  26. package/src/components/editor/group-transform-shared.ts +74 -7
  27. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  28. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  29. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  30. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  31. package/src/components/editor/handles/resize-snap.ts +10 -1
  32. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  33. package/src/components/editor/index.tsx +252 -46
  34. package/src/components/editor/measurement-pill.tsx +12 -12
  35. package/src/components/editor/node-action-menu.tsx +42 -1
  36. package/src/components/editor/node-arrow-handles.tsx +47 -58
  37. package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
  38. package/src/components/editor/selection-manager.tsx +132 -47
  39. package/src/components/editor/site-edge-labels.tsx +2 -1
  40. package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
  41. package/src/components/editor/snapshot-capture.test.ts +422 -0
  42. package/src/components/editor/snapshot-capture.ts +145 -0
  43. package/src/components/editor/thumbnail-generator.tsx +295 -232
  44. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  45. package/src/components/editor/wall-measurement-label.tsx +3 -2
  46. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  47. package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
  48. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  49. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  50. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  51. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  52. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  53. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  54. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +204 -114
  57. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  59. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  60. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  61. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  62. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  63. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  64. package/src/components/systems/selection-affordance-services.ts +19 -0
  65. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  66. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  67. package/src/components/tools/fence/fence-drafting.ts +6 -25
  68. package/src/components/tools/item/draft-creation.test.ts +12 -0
  69. package/src/components/tools/item/draft-creation.ts +10 -0
  70. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  71. package/src/components/tools/item/face-host-commit.ts +56 -0
  72. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  73. package/src/components/tools/item/face-host-preview.ts +82 -0
  74. package/src/components/tools/item/move-tool.tsx +4 -3
  75. package/src/components/tools/item/placement-math.test.ts +11 -1
  76. package/src/components/tools/item/placement-math.ts +0 -20
  77. package/src/components/tools/item/placement-strategies.test.ts +494 -0
  78. package/src/components/tools/item/placement-strategies.ts +192 -24
  79. package/src/components/tools/item/placement-surface.test.ts +61 -0
  80. package/src/components/tools/item/placement-surface.ts +24 -0
  81. package/src/components/tools/item/placement-types.ts +6 -0
  82. package/src/components/tools/item/test-face-host.ts +121 -0
  83. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  84. package/src/components/tools/item/use-draft-node.ts +26 -4
  85. package/src/components/tools/item/use-placement-coordinator.tsx +447 -180
  86. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  87. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  88. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  89. package/src/components/tools/registry-tool-context.tsx +30 -0
  90. package/src/components/tools/select/box-select-tool.tsx +3 -1
  91. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  92. package/src/components/tools/select/marquee-footprint.ts +29 -0
  93. package/src/components/tools/shared/placement-box.tsx +63 -8
  94. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  95. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  96. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  97. package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
  98. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  99. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  100. package/src/components/tools/stair/stair-tool.tsx +127 -74
  101. package/src/components/tools/tool-manager.tsx +25 -8
  102. package/src/components/tools/wall/wall-drafting.test.ts +527 -4
  103. package/src/components/tools/wall/wall-drafting.ts +55 -462
  104. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  105. package/src/components/ui/action-menu/index.tsx +11 -1
  106. package/src/components/ui/command-palette/editor-commands.tsx +12 -13
  107. package/src/components/ui/command-palette/index.tsx +7 -2
  108. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  109. package/src/components/ui/controls/material-picker.tsx +4 -4
  110. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  111. package/src/components/ui/controls/segmented-control.tsx +8 -1
  112. package/src/components/ui/controls/slider-control.tsx +19 -3
  113. package/src/components/ui/controls/toggle-control.tsx +18 -5
  114. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  115. package/src/components/ui/floating-level-selector.tsx +23 -6
  116. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  117. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  118. package/src/components/ui/helpers/item-helper.tsx +1 -1
  119. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  120. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  121. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  122. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  123. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  124. package/src/components/ui/panels/multi-field-value.ts +163 -0
  125. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  126. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  127. package/src/components/ui/panels/multi-selection-panel.tsx +78 -26
  128. package/src/components/ui/panels/node-display.ts +4 -0
  129. package/src/components/ui/panels/panel-manager.tsx +77 -3
  130. package/src/components/ui/panels/panel-wrapper.tsx +228 -12
  131. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  132. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  133. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  134. package/src/components/ui/panels/reference-panel.tsx +31 -1
  135. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  136. package/src/components/ui/scene-loader.tsx +35 -0
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
  138. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  139. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -23
  140. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  142. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  143. package/src/components/ui/sidebar/panels/site-panel/index.tsx +128 -46
  144. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  145. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  146. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  147. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +22 -7
  148. package/src/components/ui/sidebar/tab-bar.tsx +9 -1
  149. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  150. package/src/components/viewer/floorplan-compass-button.tsx +50 -0
  151. package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
  152. package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
  153. package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
  154. package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
  155. package/src/components/viewer/floorplan-preview.tsx +1069 -0
  156. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
  157. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  158. package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
  159. package/src/components/viewer/viewer-stage-modes.ts +49 -0
  160. package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
  161. package/src/components/viewer/viewer-stage.test.tsx +47 -0
  162. package/src/components/viewer/viewer-stage.tsx +221 -0
  163. package/src/components/viewer-overlay.tsx +13 -30
  164. package/src/components/viewer-zone-system.tsx +6 -2
  165. package/src/components/walkthrough-hud.tsx +19 -10
  166. package/src/hooks/use-auto-save.test.ts +122 -1
  167. package/src/hooks/use-auto-save.ts +119 -13
  168. package/src/hooks/use-ceiling-events.test.ts +122 -0
  169. package/src/hooks/use-drag-action.ts +17 -3
  170. package/src/hooks/use-grid-events.test.ts +240 -0
  171. package/src/hooks/use-grid-events.ts +190 -11
  172. package/src/hooks/use-keyboard.test.ts +244 -0
  173. package/src/hooks/use-keyboard.ts +164 -63
  174. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  175. package/src/hooks/use-save-shortcut.ts +33 -0
  176. package/src/index.tsx +109 -4
  177. package/src/lib/active-placement-surface.test.ts +16 -0
  178. package/src/lib/active-placement-surface.ts +14 -0
  179. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  180. package/src/lib/ceiling-surface-raycast.ts +12 -0
  181. package/src/lib/contextual-help-extension.ts +17 -0
  182. package/src/lib/contextual-help.test.ts +8 -0
  183. package/src/lib/contextual-help.ts +8 -0
  184. package/src/lib/continuation.test.ts +11 -0
  185. package/src/lib/continuation.ts +8 -1
  186. package/src/lib/direct-manipulation.test.ts +132 -0
  187. package/src/lib/direct-manipulation.ts +38 -1
  188. package/src/lib/editor-api.ts +6 -3
  189. package/src/lib/elevation-guides.test.ts +28 -1
  190. package/src/lib/elevation-guides.ts +22 -7
  191. package/src/lib/floating-menu-scale.test.ts +29 -0
  192. package/src/lib/floating-menu-scale.ts +14 -0
  193. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  194. package/src/lib/floorplan/apply-alignment.ts +10 -1
  195. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  196. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  197. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  198. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  199. package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
  200. package/src/lib/floorplan/floorplan-readonly.ts +85 -0
  201. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  202. package/src/lib/floorplan-grid-event-point.ts +13 -0
  203. package/src/lib/glb-export.test.ts +1169 -21
  204. package/src/lib/glb-export.ts +629 -101
  205. package/src/lib/grid-event-presentation.ts +16 -0
  206. package/src/lib/history.test.ts +523 -77
  207. package/src/lib/history.ts +133 -14
  208. package/src/lib/host-tree-children.test.ts +27 -0
  209. package/src/lib/host-tree-children.ts +79 -0
  210. package/src/lib/inspector-card-mode.test.ts +92 -0
  211. package/src/lib/inspector-card-mode.ts +60 -0
  212. package/src/lib/interaction/hot-set.test.ts +10 -1
  213. package/src/lib/interaction/hot-set.ts +3 -1
  214. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  215. package/src/lib/interaction/overlay-policy.ts +16 -0
  216. package/src/lib/interaction/registered-drafting.ts +36 -0
  217. package/src/lib/interaction/scope.ts +31 -0
  218. package/src/lib/level-print-export.test.ts +484 -0
  219. package/src/lib/level-print-export.ts +526 -0
  220. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  221. package/src/lib/local-project-presentation-persistence.ts +335 -0
  222. package/src/lib/material-paint.ts +2 -0
  223. package/src/lib/measurement-parser.ts +3 -0
  224. package/src/lib/measurements.test.ts +4 -0
  225. package/src/lib/model-export.ts +29 -0
  226. package/src/lib/paint-preview-owner.ts +71 -0
  227. package/src/lib/paint-scope.test.ts +58 -2
  228. package/src/lib/paint-scope.ts +8 -2
  229. package/src/lib/planar-cursor-placement.test.ts +178 -1
  230. package/src/lib/planar-cursor-placement.ts +61 -5
  231. package/src/lib/plugin-panels.test.ts +92 -1
  232. package/src/lib/plugin-panels.ts +48 -0
  233. package/src/lib/portable-export.test.ts +366 -0
  234. package/src/lib/portable-export.ts +969 -0
  235. package/src/lib/print-3mf.test.ts +86 -0
  236. package/src/lib/print-3mf.ts +161 -0
  237. package/src/lib/print-content-scope.test.ts +107 -0
  238. package/src/lib/print-content-scope.ts +90 -0
  239. package/src/lib/print-export.test.ts +294 -0
  240. package/src/lib/print-export.ts +735 -0
  241. package/src/lib/print-feature-thickness.test.ts +147 -0
  242. package/src/lib/print-feature-thickness.ts +180 -0
  243. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  244. package/src/lib/print-golden-house.test.ts +249 -0
  245. package/src/lib/print-roof-solids.test.ts +133 -0
  246. package/src/lib/print-roof-solids.ts +630 -0
  247. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  248. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  249. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  250. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  251. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  252. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  253. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  254. package/src/lib/print-shell-compiler.ts +273 -0
  255. package/src/lib/print-wall-solids.test.ts +194 -0
  256. package/src/lib/print-wall-solids.ts +328 -0
  257. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  258. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  259. package/src/lib/scene.ts +22 -8
  260. package/src/lib/selection-routing.test.ts +125 -6
  261. package/src/lib/selection-routing.ts +34 -1
  262. package/src/lib/session-groups.test.ts +88 -0
  263. package/src/lib/session-groups.ts +201 -0
  264. package/src/lib/sfx-bus.ts +4 -0
  265. package/src/lib/sfx-player.ts +8 -0
  266. package/src/lib/snapping-mode.test.ts +32 -4
  267. package/src/lib/snapping-mode.ts +11 -7
  268. package/src/lib/structured-clone-fallback.test.ts +45 -0
  269. package/src/lib/structured-clone-fallback.ts +24 -0
  270. package/src/lib/usdz-export.ts +38 -0
  271. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  272. package/src/store/live-draft-preview-stores.test.ts +3 -0
  273. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  274. package/src/store/tool-mode.test.ts +87 -0
  275. package/src/store/use-camera-hint-focus.ts +48 -0
  276. package/src/store/use-editor.tsx +299 -188
  277. package/src/store/use-floorplan-draft-preview.ts +9 -0
  278. package/src/store/use-interaction-scope.test.ts +23 -1
  279. package/src/store/use-interaction-scope.ts +63 -2
  280. package/src/store/use-placement-preview.ts +56 -3
  281. package/src/store/use-session-groups.ts +103 -0
  282. package/src/components/tools/roof/roof-tool.tsx +0 -656
  283. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -0,0 +1,120 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { RoofSegmentNode, sceneRegistry, useLiveNodeOverrides, useScene } from '@pascal-app/core'
3
+ import { hideFromScene, showInScene, useViewer } from '@pascal-app/viewer'
4
+ import { _roots, act, createRoot, extend, type Instance, type ThreeEvent } from '@react-three/fiber'
5
+ import { createElement } from 'react'
6
+ import * as THREE from 'three'
7
+ import useInteractionScope from '../../../store/use-interaction-scope'
8
+ import { RoofEditSystem } from './roof-edit-system'
9
+
10
+ extend({ Group: THREE.Group, Mesh: THREE.Mesh, LineSegments: THREE.LineSegments })
11
+
12
+ test('roof trim drag keeps its plane hit over a surface joining and leaving a batch', async () => {
13
+ const previousViewer = useViewer.getState()
14
+ const previousScene = useScene.getState()
15
+ const previousScope = useInteractionScope.getState().scope
16
+ const previousOverrides = useLiveNodeOverrides.getState()
17
+ const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
18
+ const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document')
19
+ const events = Object.assign(new EventTarget(), { setTimeout, clearTimeout })
20
+ Object.defineProperty(globalThis, 'window', { configurable: true, value: events })
21
+ Object.defineProperty(globalThis, 'document', {
22
+ configurable: true,
23
+ value: { body: { style: { cursor: '' } } },
24
+ })
25
+ const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
26
+ const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT
27
+ actGlobal.IS_REACT_ACT_ENVIRONMENT = true
28
+ const canvas = Object.assign(new EventTarget(), {
29
+ getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }),
30
+ }) as unknown as HTMLCanvasElement
31
+ const root = createRoot(canvas)
32
+ const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 100)
33
+ camera.position.set(0, 15, 10)
34
+ camera.lookAt(0, 3, 0)
35
+ camera.updateMatrixWorld()
36
+ const scene = new THREE.Scene()
37
+ const segment = RoofSegmentNode.parse({
38
+ id: 'rseg_batch_drag',
39
+ width: 10,
40
+ depth: 10,
41
+ metadata: { showTrimPlanes: true },
42
+ })
43
+ const source = new THREE.Group()
44
+ const surface = new THREE.Mesh(new THREE.BoxGeometry(30, 0.25, 30), new THREE.MeshBasicMaterial())
45
+ surface.position.y = 2
46
+ scene.add(surface)
47
+ scene.updateMatrixWorld(true)
48
+ try {
49
+ sceneRegistry.nodes.set(segment.id, source)
50
+ useScene.setState({ nodes: { [segment.id]: segment }, readOnly: false })
51
+ useViewer.setState({
52
+ hoveredId: null,
53
+ selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [segment.id] },
54
+ })
55
+ await root.configure({
56
+ gl: {
57
+ domElement: canvas,
58
+ render() {},
59
+ setSize() {},
60
+ setPixelRatio() {},
61
+ } as unknown as THREE.WebGLRenderer,
62
+ camera,
63
+ scene,
64
+ frameloop: 'never',
65
+ dpr: 1,
66
+ size: { width: 100, height: 100, top: 0, left: 0 },
67
+ })
68
+ await act(async () => {
69
+ root.render(createElement(RoofEditSystem))
70
+ })
71
+ let pointerDown: ((event: ThreeEvent<PointerEvent>) => void) | undefined
72
+ scene.traverse((object) => {
73
+ const instance = (object as THREE.Object3D & { __r3f?: Instance }).__r3f
74
+ pointerDown ??= instance?.handlers.onPointerDown
75
+ })
76
+ expect(pointerDown).toBeDefined()
77
+ await act(async () => {
78
+ pointerDown!({
79
+ button: 0,
80
+ clientX: 50,
81
+ clientY: 50,
82
+ stopPropagation() {},
83
+ } as ThreeEvent<PointerEvent>)
84
+ })
85
+ const move = async () => {
86
+ await act(async () => {
87
+ events.dispatchEvent(Object.assign(new Event('pointermove'), { clientX: 60, clientY: 60 }))
88
+ })
89
+ return useLiveNodeOverrides.getState().overrides.get(segment.id)?.trim
90
+ }
91
+ const before = await move()
92
+ expect(before).toBeDefined()
93
+ expect(Object.values(before!).some((value) => (value as number) > 0)).toBe(true)
94
+ hideFromScene(surface, 'batched')
95
+ expect(await move()).toEqual(before)
96
+ showInScene(surface, 'batched')
97
+ expect(await move()).toEqual(before)
98
+ await act(async () => {
99
+ events.dispatchEvent(new Event('pointercancel'))
100
+ })
101
+ expect(useLiveNodeOverrides.getState().overrides.has(segment.id)).toBe(false)
102
+ } finally {
103
+ await act(async () => {
104
+ root.render(null)
105
+ })
106
+ _roots.delete(canvas)
107
+ sceneRegistry.nodes.delete(segment.id)
108
+ surface.geometry.dispose()
109
+ surface.material.dispose()
110
+ useScene.setState(previousScene)
111
+ useViewer.setState(previousViewer)
112
+ useInteractionScope.setState({ scope: previousScope })
113
+ useLiveNodeOverrides.setState(previousOverrides)
114
+ actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct
115
+ if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow)
116
+ else Reflect.deleteProperty(globalThis, 'window')
117
+ if (previousDocument) Object.defineProperty(globalThis, 'document', previousDocument)
118
+ else Reflect.deleteProperty(globalThis, 'document')
119
+ }
120
+ })
@@ -22,6 +22,7 @@ import {
22
22
  csgEvaluator,
23
23
  generateRoofSegmentGeometry,
24
24
  INTERSECTION,
25
+ markPureRaycast,
25
26
  prepareBrushForCSG,
26
27
  useViewer,
27
28
  } from '@pascal-app/viewer'
@@ -707,7 +708,7 @@ function makeExpandedTrimRaycast(
707
708
  const halfX = Math.max(0.5, hitScale[0] / Math.max(visualScale[0], 1e-6) / 2)
708
709
  const halfY = Math.max(0.5, hitScale[1] / Math.max(visualScale[1], 1e-6) / 2)
709
710
  const halfZ = Math.max(0.5, hitScale[2] / Math.max(visualScale[2], 1e-6) / 2)
710
- return function expandedTrimRaycast(
711
+ return markPureRaycast(function expandedTrimRaycast(
711
712
  this: THREE.Mesh,
712
713
  raycaster: THREE.Raycaster,
713
714
  intersects: THREE.Intersection[],
@@ -722,7 +723,7 @@ function makeExpandedTrimRaycast(
722
723
  const distance = raycaster.ray.origin.distanceTo(point)
723
724
  if (distance < raycaster.near || distance > raycaster.far) return
724
725
  intersects.push({ distance, point, object: this })
725
- }
726
+ })
726
727
  }
727
728
 
728
729
  function trimEquals(a: RoofSegmentTrim, b: RoofSegmentTrim): boolean {
@@ -1788,16 +1789,14 @@ export const RoofEditSystem = () => {
1788
1789
  useEffect(() => {
1789
1790
  const nodes = useScene.getState().nodes
1790
1791
 
1791
- // Roofs where a segment itself is selected -> full edit mode (hide
1792
- // merged, show wrapper).
1792
+ // Roofs where a segment itself is selected enter full edit mode.
1793
1793
  const activeRoofIds = new Set<string>()
1794
1794
  // Roofs where an accessory (dormer/chimney/etc.) is selected -> only
1795
1795
  // reveal the wrapper so handle portals into the segment mesh become
1796
1796
  // visible. Merged stays on.
1797
1797
  const revealRoofIds = new Set<string>()
1798
- // Roofs whose selected segment is currently being moved in 3D. During this
1799
- // transient state we reveal the wrapper so the moving segment mesh is
1800
- // visible and hide the merged roof to avoid the duplicate shell fighting it.
1798
+ // Roofs whose selected segment is currently being moved in 3D. The merged
1799
+ // roof remains the visual source and rebuilds from the live move override.
1801
1800
  const movingRoofIds = new Set<string>()
1802
1801
 
1803
1802
  for (const id of selectedIds) {
@@ -1842,14 +1841,8 @@ export const RoofEditSystem = () => {
1842
1841
  const isMoving = movingRoofIds.has(roofId)
1843
1842
  const isReveal = revealRoofIds.has(roofId)
1844
1843
 
1845
- // Keep the clean merged shell visible during trim editing too (not just
1846
- // when deselected). The merged shell rebuilds live from each segment's
1847
- // trim override (RoofSystem reads getEffectiveNode), so the dragged
1848
- // cutaway matches the commit. Showing the individual per-segment meshes
1849
- // instead would expose their abutting end-cap faces (the white planes the
1850
- // merged union removes) — exactly what the commit doesn't show.
1851
- if (mergedMesh) mergedMesh.visible = !isMoving
1852
- if (segmentsWrapper) segmentsWrapper.visible = isReveal || isMoving
1844
+ if (mergedMesh) mergedMesh.visible = true
1845
+ if (segmentsWrapper) segmentsWrapper.visible = isReveal
1853
1846
 
1854
1847
  const roofNode = nodes[roofId as AnyNodeId] as RoofNode | undefined
1855
1848
  if (roofNode?.children?.length) {
@@ -1857,10 +1850,8 @@ export const RoofEditSystem = () => {
1857
1850
  const wasMoving = prevMovingRoofIds.current.has(roofId)
1858
1851
  const wasReveal = prevRevealRoofIds.current.has(roofId)
1859
1852
  if (isActive !== wasActive || isMoving !== wasMoving) {
1860
- // Entering / exiting full edit mode: rebuild segment / merged
1861
- // geometries. Segment-move reveal uses the same rebuild so any
1862
- // wrapper mesh previously stripped to an empty placeholder is
1863
- // restored before the drag begins.
1853
+ // Entering or exiting edit and move modes rebuilds the merged shell
1854
+ // from the current segment values.
1864
1855
  const { markDirty } = useScene.getState()
1865
1856
  for (const childId of roofNode.children) {
1866
1857
  markDirty(childId as AnyNodeId)
@@ -1,9 +1,19 @@
1
1
  'use client'
2
2
 
3
- import { type AnyNodeId, useScene } from '@pascal-app/core'
3
+ import {
4
+ type AnyNodeId,
5
+ createSceneApi,
6
+ runAsSingleSceneHistoryStep,
7
+ useScene,
8
+ } from '@pascal-app/core'
4
9
  import { useViewer } from '@pascal-app/viewer'
5
10
  import { type ComponentType, Suspense, useMemo } from 'react'
6
11
  import { getRegistryAffordanceTool } from '../tools/shared/affordance-dispatch'
12
+ import type {
13
+ SelectionAffordanceHistoryApi,
14
+ SelectionAffordanceInteractionApi,
15
+ SelectionAffordanceProps,
16
+ } from './selection-affordance-services'
7
17
 
8
18
  /**
9
19
  * Editor-mounted dispatcher for a kind's selection-time editing UI.
@@ -19,20 +29,60 @@ import { getRegistryAffordanceTool } from '../tools/shared/affordance-dispatch'
19
29
  */
20
30
  export function SelectionAffordanceManager() {
21
31
  const selectedIds = useViewer((s) => s.selection.selectedIds)
22
- const selectedKind = useScene((s) => {
32
+ const selectedNode = useScene((s) => {
23
33
  if (selectedIds.length !== 1) return null
24
- return s.nodes[selectedIds[0] as AnyNodeId]?.type ?? null
34
+ return s.nodes[selectedIds[0] as AnyNodeId] ?? null
25
35
  })
36
+ const readOnly = useScene((s) => s.readOnly)
37
+ const sceneApi = useMemo(() => createSceneApi(useScene), [])
38
+ const historyApi = useMemo<SelectionAffordanceHistoryApi>(
39
+ () => ({
40
+ depth: () => useScene.temporal.getState().pastStates.length,
41
+ replaceLatest: (expectedDepth, replace) => {
42
+ if (useScene.temporal.getState().pastStates.length !== expectedDepth) return false
43
+ let replaced = false
44
+ runAsSingleSceneHistoryStep(useScene, () => {
45
+ useScene.temporal.getState().undo()
46
+ replaced = replace()
47
+ if (!replaced) useScene.temporal.getState().redo()
48
+ })
49
+ return replaced
50
+ },
51
+ }),
52
+ [],
53
+ )
54
+ const interactionApi = useMemo<SelectionAffordanceInteractionApi>(
55
+ () => ({
56
+ beginInputDrag: () => {
57
+ const previous = useViewer.getState().inputDragging
58
+ let restored = false
59
+ useViewer.getState().setInputDragging(true)
60
+ return () => {
61
+ if (restored) return
62
+ restored = true
63
+ useViewer.getState().setInputDragging(previous)
64
+ }
65
+ },
66
+ clearSelection: () => useViewer.getState().setSelection({ selectedIds: [] }),
67
+ }),
68
+ [],
69
+ )
26
70
 
27
- const Component = useMemo<ComponentType | null>(() => {
28
- if (!selectedKind) return null
29
- return getRegistryAffordanceTool(selectedKind, 'selection')
30
- }, [selectedKind])
71
+ const Component = useMemo<ComponentType<SelectionAffordanceProps> | null>(() => {
72
+ if (!selectedNode) return null
73
+ return getRegistryAffordanceTool(selectedNode.type, 'selection')
74
+ }, [selectedNode])
31
75
 
32
- if (!Component) return null
76
+ if (!(Component && selectedNode)) return null
33
77
  return (
34
78
  <Suspense fallback={null}>
35
- <Component />
79
+ <Component
80
+ historyApi={historyApi}
81
+ interactionApi={interactionApi}
82
+ node={selectedNode}
83
+ readOnly={readOnly}
84
+ sceneApi={sceneApi}
85
+ />
36
86
  </Suspense>
37
87
  )
38
88
  }
@@ -0,0 +1,19 @@
1
+ import type { AnyNode, SceneApi } from '@pascal-app/core'
2
+
3
+ export type SelectionAffordanceHistoryApi = {
4
+ depth: () => number
5
+ replaceLatest: (expectedDepth: number, replace: () => boolean) => boolean
6
+ }
7
+
8
+ export type SelectionAffordanceInteractionApi = {
9
+ beginInputDrag: () => () => void
10
+ clearSelection: () => void
11
+ }
12
+
13
+ export type SelectionAffordanceProps = {
14
+ historyApi: SelectionAffordanceHistoryApi
15
+ interactionApi: SelectionAffordanceInteractionApi
16
+ node: AnyNode
17
+ readOnly: boolean
18
+ sceneApi: SceneApi
19
+ }
@@ -67,9 +67,12 @@ export const StairEditSystem = () => {
67
67
  const mergedMesh = group.getObjectByName('merged-stair')
68
68
  const segmentsWrapper = group.getObjectByName('segments-wrapper')
69
69
  const isActive = activeStairIds.has(stairId)
70
+ // A straight stair with no segment children has an empty wrapper, so
71
+ // edit mode would hide the merged body and leave nothing on screen.
72
+ const isEditable = !isCurved && (stairNode?.children?.length ?? 0) > 0
70
73
 
71
- if (mergedMesh) mergedMesh.visible = !(isActive || isCurved)
72
- if (segmentsWrapper) segmentsWrapper.visible = isActive && !isCurved
74
+ if (mergedMesh) mergedMesh.visible = !((isActive && isEditable) || isCurved)
75
+ if (segmentsWrapper) segmentsWrapper.visible = isActive && isEditable
73
76
 
74
77
  if (stairNode?.children?.length) {
75
78
  const wasActive = prevActiveStairIds.current.has(stairId)
@@ -0,0 +1,100 @@
1
+ import { beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeId,
5
+ GROUND_SUPPORT_ID,
6
+ type SlabNode,
7
+ spatialGridManager,
8
+ useScene,
9
+ } from '@pascal-app/core'
10
+ import { useViewer } from '@pascal-app/viewer'
11
+ import useEditor from '../../../store/use-editor'
12
+ import { createFenceOnCurrentLevel } from './fence-drafting'
13
+
14
+ const LEVEL_ID = 'level_test' as AnyNodeId
15
+
16
+ function seedLevel(extraNodes: AnyNode[] = []) {
17
+ useScene.setState({
18
+ nodes: Object.fromEntries([
19
+ [
20
+ LEVEL_ID,
21
+ {
22
+ id: LEVEL_ID,
23
+ type: 'level',
24
+ object: 'node',
25
+ parentId: null,
26
+ visible: true,
27
+ metadata: {},
28
+ children: extraNodes.map((node) => node.id),
29
+ level: 0,
30
+ } as AnyNode,
31
+ ],
32
+ ...extraNodes.map((node) => [node.id, node] as const),
33
+ ]),
34
+ rootNodeIds: [LEVEL_ID],
35
+ dirtyNodes: new Set(),
36
+ collections: {},
37
+ } as never)
38
+ }
39
+
40
+ describe('createFenceOnCurrentLevel', () => {
41
+ beforeEach(() => {
42
+ spatialGridManager.clear()
43
+ useViewer.setState({
44
+ selection: {
45
+ buildingId: null,
46
+ levelId: LEVEL_ID,
47
+ zoneId: null,
48
+ selectedIds: [],
49
+ },
50
+ } as never)
51
+ useEditor.getState().setToolDefaults('fence', null)
52
+ seedLevel()
53
+ })
54
+
55
+ test('freezes a block top above its underlying slab', () => {
56
+ const slab = {
57
+ id: 'slab_low',
58
+ type: 'slab',
59
+ object: 'node',
60
+ parentId: LEVEL_ID,
61
+ visible: true,
62
+ metadata: {},
63
+ children: [],
64
+ polygon: [
65
+ [-2, -2],
66
+ [2, -2],
67
+ [2, 2],
68
+ [-2, 2],
69
+ ],
70
+ holes: [],
71
+ holeMetadata: [],
72
+ elevation: 0.25,
73
+ thickness: 0.25,
74
+ recessed: false,
75
+ autoFromWalls: false,
76
+ } as SlabNode
77
+ seedLevel([slab as AnyNode])
78
+ spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID)
79
+
80
+ const fence = createFenceOnCurrentLevel([-1, 0], [1, 0], {
81
+ supportCap: 2,
82
+ preferredSupportSlabId: slab.id,
83
+ constructionElevation: 2,
84
+ })
85
+
86
+ expect(fence?.supportSlabId).toBe(slab.id)
87
+ expect(fence?.supportOffset).toBeCloseTo(1.75)
88
+ })
89
+
90
+ test('pins ground beneath a block top when no slab exists', () => {
91
+ const fence = createFenceOnCurrentLevel([-1, 0], [1, 0], {
92
+ supportCap: 2,
93
+ preferredSupportSlabId: null,
94
+ constructionElevation: 2,
95
+ })
96
+
97
+ expect(fence?.supportSlabId).toBe(GROUND_SUPPORT_ID)
98
+ expect(fence?.supportOffset).toBeCloseTo(2)
99
+ })
100
+ })
@@ -1,11 +1,12 @@
1
1
  import {
2
2
  DEFAULT_ANGLE_STEP,
3
+ type FenceConstructionOptions as FenceCommitOptions,
3
4
  FenceNode,
4
5
  getTwoPointFenceCurveTangents,
5
6
  getWallCurveFrameAt,
6
7
  getWallCurveLength,
7
8
  isCurvedWall,
8
- resolveFenceSupportSlabPatch,
9
+ resolveFenceConstructionSupport,
9
10
  snapPointAlongAngleRay,
10
11
  useScene,
11
12
  type WallNode,
@@ -188,18 +189,6 @@ export function snapFenceDraftPoint(args: {
188
189
  return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint
189
190
  }
190
191
 
191
- export type FenceCommitOptions = {
192
- /**
193
- * Pointer-decided support cap (level-local Y) from
194
- * `resolvePointerSupportSurface` — the 3D tool passes the elevation of
195
- * the surface the commit click actually aimed at, so a fence drawn on a
196
- * deck top persists the deck as its lift host while one drawn at the
197
- * floor underneath stays grounded. Omitted by 2D floor-plan commits (no
198
- * camera ray): those keep the uncapped max election.
199
- */
200
- supportCap?: number | null
201
- }
202
-
203
192
  export function createFenceOnCurrentLevel(
204
193
  start: FencePlanPoint,
205
194
  end: FencePlanPoint,
@@ -217,7 +206,7 @@ export function createFenceOnCurrentLevel(
217
206
  // spacing, …) merge in first; `name`/`start`/`end` always win. The
218
207
  // schema parse validates and drops anything unexpected.
219
208
  const defaults = useEditor.getState().toolDefaults.fence ?? {}
220
- const fence = FenceNode.parse({
209
+ const authoredFence = FenceNode.parse({
221
210
  ...defaults,
222
211
  name: `Fence ${fenceCount + 1}`,
223
212
  start,
@@ -225,11 +214,7 @@ export function createFenceOnCurrentLevel(
225
214
  })
226
215
  // Fences run no per-frame support election — the persisted host IS the
227
216
  // lift (absent = level floor), so elect it at commit, pointer-capped.
228
- fence.supportSlabId = resolveFenceSupportSlabPatch(
229
- { ...fence, parentId: currentLevelId },
230
- nodes,
231
- { maxElevation: options?.supportCap ?? null },
232
- ).supportSlabId
217
+ const fence = resolveFenceConstructionSupport(authoredFence, currentLevelId, nodes, options)
233
218
 
234
219
  createNode(fence, currentLevelId)
235
220
  sfxEmitter.emit('sfx:structure-build')
@@ -264,7 +249,7 @@ export function createSplineFenceOnCurrentLevel(
264
249
 
265
250
  const fenceCount = Object.values(nodes).filter((node) => node.type === 'fence').length
266
251
  const defaults = useEditor.getState().toolDefaults.fence ?? {}
267
- const fence = FenceNode.parse({
252
+ const authoredFence = FenceNode.parse({
268
253
  ...defaults,
269
254
  name: `Fence ${fenceCount + 1}`,
270
255
  start,
@@ -272,11 +257,7 @@ export function createSplineFenceOnCurrentLevel(
272
257
  path,
273
258
  tangents,
274
259
  })
275
- fence.supportSlabId = resolveFenceSupportSlabPatch(
276
- { ...fence, parentId: currentLevelId },
277
- nodes,
278
- { maxElevation: options?.supportCap ?? null },
279
- ).supportSlabId
260
+ const fence = resolveFenceConstructionSupport(authoredFence, currentLevelId, nodes, options)
280
261
 
281
262
  createNode(fence, currentLevelId)
282
263
  sfxEmitter.emit('sfx:structure-build')
@@ -0,0 +1,12 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { shouldCreateFloorDraft } from './draft-creation'
3
+
4
+ describe('shouldCreateFloorDraft', () => {
5
+ test('does not create a level-hosted draft while block-face placement is active', () => {
6
+ expect(shouldCreateFloorDraft(null, undefined, 'block-face')).toBe(false)
7
+ })
8
+
9
+ test('creates a draft for an unmounted floor placement', () => {
10
+ expect(shouldCreateFloorDraft(null, undefined, 'floor')).toBe(true)
11
+ })
12
+ })
@@ -0,0 +1,10 @@
1
+ import type { AssetInput, ItemNode } from '@pascal-app/core'
2
+ import type { PlacementState } from './placement-types'
3
+
4
+ export function shouldCreateFloorDraft(
5
+ draft: ItemNode | null,
6
+ attachTo: AssetInput['attachTo'],
7
+ surface: PlacementState['surface'],
8
+ ): boolean {
9
+ return draft === null && attachTo === undefined && surface === 'floor'
10
+ }
@@ -0,0 +1,65 @@
1
+ import { beforeEach, describe, expect, test } from 'bun:test'
2
+ import { BlockNode, ItemNode, type LevelNode, useScene } from '@pascal-app/core'
3
+ import { Vector3 } from 'three'
4
+ import { resolveFaceHostPreviewCommit } from './face-host-commit'
5
+ import type { PlacementContext } from './placement-types'
6
+ import { registerTestBlockFaceHost } from './test-face-host'
7
+
8
+ const BLOCK_ID = 'block_self-click'
9
+ const LEVEL_ID = 'level_self-click' as LevelNode['id']
10
+
11
+ beforeEach(() => {
12
+ registerTestBlockFaceHost()
13
+ useScene.setState((state) => ({
14
+ ...state,
15
+ nodes: {
16
+ ...state.nodes,
17
+ [BLOCK_ID]: BlockNode.parse({ id: BLOCK_ID, parentId: LEVEL_ID }),
18
+ },
19
+ }))
20
+ })
21
+
22
+ describe('resolveFaceHostPreviewCommit', () => {
23
+ test('commits an intercepted draft click to its active block face', () => {
24
+ const asset = {
25
+ id: 'cactus',
26
+ category: 'decor',
27
+ name: 'Cactus',
28
+ thumbnail: '/cactus.png',
29
+ src: '/cactus.glb',
30
+ dimensions: [0.5, 0.39, 0.5] as [number, number, number],
31
+ }
32
+ const context: PlacementContext = {
33
+ asset,
34
+ levelId: LEVEL_ID,
35
+ draftItem: ItemNode.parse({
36
+ id: 'item_self-click',
37
+ asset,
38
+ parentId: BLOCK_ID,
39
+ position: [0.5, -0.5, 0],
40
+ rotation: [Math.PI / 2, 0, 0],
41
+ blockFaceId: 'face-top',
42
+ metadata: { isTransient: true },
43
+ }),
44
+ gridPosition: new Vector3(0.5, -0.5, 0),
45
+ state: {
46
+ surface: 'block-face',
47
+ blockId: BLOCK_ID,
48
+ wallId: null,
49
+ roofSegmentId: null,
50
+ ceilingId: null,
51
+ surfaceItemId: null,
52
+ shelfId: null,
53
+ },
54
+ currentCursorRotationY: 0,
55
+ }
56
+
57
+ expect(resolveFaceHostPreviewCommit(context)?.nodeUpdate).toMatchObject({
58
+ parentId: BLOCK_ID,
59
+ position: [0.5, -0.5, 0],
60
+ rotation: [Math.PI / 2, 0, 0],
61
+ blockFaceId: 'face-top',
62
+ metadata: {},
63
+ })
64
+ })
65
+ })
@@ -0,0 +1,56 @@
1
+ import type { ItemNode, NodeEvent } from '@pascal-app/core'
2
+ import { nodeRegistry, useScene } from '@pascal-app/core'
3
+ import { stripTransient } from './placement-math'
4
+ import { faceHostStrategy } from './placement-strategies'
5
+ import type { CommitResult, PlacementContext } from './placement-types'
6
+
7
+ export type FaceHostClickCommitOutcome = {
8
+ committedId: string | null
9
+ wasAdopted: boolean
10
+ }
11
+
12
+ export function commitFaceHostClick({
13
+ commitDraft,
14
+ enterFaceHost,
15
+ event,
16
+ getContext,
17
+ }: {
18
+ commitDraft: (nodeUpdate: Partial<ItemNode>) => FaceHostClickCommitOutcome
19
+ enterFaceHost: (event: NodeEvent) => boolean
20
+ event: NodeEvent
21
+ getContext: () => PlacementContext
22
+ }): FaceHostClickCommitOutcome | null {
23
+ let result = faceHostStrategy.click(getContext(), event)
24
+ if (!result && enterFaceHost(event)) {
25
+ result = faceHostStrategy.click(getContext(), event)
26
+ }
27
+ if (!result) return null
28
+ const outcome = commitDraft(result.nodeUpdate)
29
+ event.stopPropagation()
30
+ return outcome
31
+ }
32
+
33
+ export function resolveFaceHostPreviewCommit(context: PlacementContext): CommitResult | null {
34
+ const { draftItem, gridPosition, state } = context
35
+ if (state.surface !== 'block-face' || !state.blockId || !draftItem) {
36
+ return null
37
+ }
38
+ const host = useScene.getState().nodes[state.blockId]
39
+ const faceHost = host ? nodeRegistry.get(host.type)?.capabilities.faceHost : undefined
40
+ if (!(host && faceHost)) return null
41
+ const nodeUpdate = faceHost?.storedPlacementPatch({
42
+ host,
43
+ item: draftItem,
44
+ position: [gridPosition.x, gridPosition.y, gridPosition.z],
45
+ })
46
+ if (!nodeUpdate) return null
47
+
48
+ return {
49
+ nodeUpdate: {
50
+ ...nodeUpdate,
51
+ metadata: stripTransient(draftItem.metadata),
52
+ },
53
+ stopPropagation: true,
54
+ dirtyNodeId: null,
55
+ }
56
+ }