@pascal-app/editor 0.9.1 → 0.9.2

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 (262) hide show
  1. package/package.json +12 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +63 -25
  5. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  6. package/src/components/editor/editor-layout-v2.tsx +18 -0
  7. package/src/components/editor/export-manager.tsx +71 -63
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
  10. package/src/components/editor/first-person/build-collider-world.ts +32 -5
  11. package/src/components/editor/first-person-controls.tsx +118 -7
  12. package/src/components/editor/floating-action-menu.tsx +410 -33
  13. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +1804 -849
  17. package/src/components/editor/grid.tsx +166 -39
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +102 -64
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +192 -1
  24. package/src/components/editor/group-transform-shared.ts +240 -7
  25. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  26. package/src/components/editor/handles/use-handle-drag.ts +37 -4
  27. package/src/components/editor/index.tsx +248 -27
  28. package/src/components/editor/measurement-pill.tsx +62 -22
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +366 -173
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +812 -466
  36. package/src/components/editor/site-edge-labels.tsx +4 -4
  37. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  38. package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +136 -43
  41. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  42. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  43. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  44. package/src/components/editor/wall-measurement-label.tsx +3 -13
  45. package/src/components/editor/wall-move-side-handles.tsx +36 -21
  46. package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
  47. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  48. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
  49. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  50. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  51. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  52. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  53. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  54. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
  55. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
  56. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  57. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  58. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  59. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
  60. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  61. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
  62. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  63. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
  64. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  65. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  66. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  67. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  68. package/src/components/systems/zone/zone-system.tsx +42 -13
  69. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  70. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  71. package/src/components/tools/fence/fence-drafting.ts +80 -8
  72. package/src/components/tools/item/move-tool.tsx +17 -13
  73. package/src/components/tools/item/placement-math.test.ts +13 -1
  74. package/src/components/tools/item/placement-math.ts +28 -2
  75. package/src/components/tools/item/placement-strategies.ts +246 -5
  76. package/src/components/tools/item/placement-types.ts +13 -1
  77. package/src/components/tools/item/use-draft-node.ts +31 -1
  78. package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
  79. package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
  80. package/src/components/tools/roof/roof-tool.tsx +320 -94
  81. package/src/components/tools/select/box-select-state.ts +18 -7
  82. package/src/components/tools/select/box-select-tool.tsx +176 -40
  83. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  84. package/src/components/tools/select/marquee-geometry.ts +155 -0
  85. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  86. package/src/components/tools/select/select-candidates.ts +1 -1
  87. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  88. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  89. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  90. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  91. package/src/components/tools/shared/placement-box.tsx +183 -1
  92. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  93. package/src/components/tools/site/site-boundary-editor.tsx +88 -36
  94. package/src/components/tools/stair/stair-defaults.ts +1 -0
  95. package/src/components/tools/stair/stair-tool.tsx +82 -11
  96. package/src/components/tools/tool-manager.tsx +86 -25
  97. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  98. package/src/components/tools/wall/wall-drafting.ts +180 -103
  99. package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
  100. package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
  101. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  102. package/src/components/tools/zone/zone-tool.tsx +82 -88
  103. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  104. package/src/components/ui/action-menu/control-modes.tsx +40 -93
  105. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  106. package/src/components/ui/action-menu/index.tsx +2 -4
  107. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  108. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  109. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  110. package/src/components/ui/command-palette/editor-commands.tsx +2 -2
  111. package/src/components/ui/command-palette/index.tsx +4 -3
  112. package/src/components/ui/controls/material-paint-panel.tsx +86 -17
  113. package/src/components/ui/controls/material-picker.tsx +83 -152
  114. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  115. package/src/components/ui/controls/metric-control.tsx +118 -44
  116. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  117. package/src/components/ui/controls/slider-control.tsx +88 -38
  118. package/src/components/ui/floating-level-selector.tsx +1 -1
  119. package/src/components/ui/helpers/building-helper.tsx +10 -23
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  121. package/src/components/ui/helpers/helper-manager.tsx +227 -14
  122. package/src/components/ui/helpers/item-helper.tsx +28 -32
  123. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  124. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  125. package/src/components/ui/icon-ref.tsx +47 -0
  126. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  127. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/panels/node-display.ts +17 -17
  130. package/src/components/ui/panels/panel-manager.tsx +22 -13
  131. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  132. package/src/components/ui/panels/parametric-inspector.tsx +15 -2
  133. package/src/components/ui/panels/reference-panel.tsx +54 -19
  134. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  135. package/src/components/ui/primitives/sidebar.tsx +1 -0
  136. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  137. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  138. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  139. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
  140. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
  141. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  142. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  144. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  145. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  146. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  147. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  148. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  149. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  150. package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
  151. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  152. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  153. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  154. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  155. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  156. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  157. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  158. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  159. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  160. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  161. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  162. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  163. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  164. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  165. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  166. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  167. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  168. package/src/components/ui/snap-target-badge.tsx +88 -0
  169. package/src/components/viewer-overlay.tsx +85 -10
  170. package/src/components/viewer-zone-system.tsx +6 -1
  171. package/src/hooks/use-auto-save.test.ts +14 -0
  172. package/src/hooks/use-auto-save.ts +54 -7
  173. package/src/hooks/use-ceiling-events.ts +3 -2
  174. package/src/hooks/use-drag-action.ts +4 -1
  175. package/src/hooks/use-keyboard.ts +385 -40
  176. package/src/index.tsx +219 -6
  177. package/src/lib/active-placement-surface.ts +35 -0
  178. package/src/lib/ceiling-plan-snap.ts +24 -0
  179. package/src/lib/contextual-help.test.ts +112 -0
  180. package/src/lib/contextual-help.ts +144 -0
  181. package/src/lib/continuation.ts +64 -0
  182. package/src/lib/direct-manipulation.test.ts +192 -0
  183. package/src/lib/direct-manipulation.ts +109 -0
  184. package/src/lib/editor-api.ts +23 -35
  185. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  186. package/src/lib/floorplan/apply-alignment.ts +18 -12
  187. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  188. package/src/lib/floorplan/geometry.ts +53 -0
  189. package/src/lib/floorplan/index.ts +3 -0
  190. package/src/lib/floorplan/items.ts +5 -2
  191. package/src/lib/floorplan/plan-coords.ts +21 -0
  192. package/src/lib/fresh-planar-placement.test.ts +240 -3
  193. package/src/lib/fresh-planar-placement.ts +68 -1
  194. package/src/lib/glb-export.test.ts +441 -0
  195. package/src/lib/glb-export.ts +874 -0
  196. package/src/lib/history.ts +9 -0
  197. package/src/lib/interaction/hot-set.test.ts +133 -0
  198. package/src/lib/interaction/hot-set.ts +67 -0
  199. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  200. package/src/lib/interaction/overlay-policy.ts +59 -0
  201. package/src/lib/interaction/scope.ts +211 -0
  202. package/src/lib/level-selection.ts +2 -2
  203. package/src/lib/material-paint.ts +36 -49
  204. package/src/lib/measurement-kind.test.ts +30 -0
  205. package/src/lib/measurement-kind.ts +19 -0
  206. package/src/lib/measurement-label.test.ts +47 -0
  207. package/src/lib/measurement-label.ts +68 -0
  208. package/src/lib/measurement-parser.ts +116 -0
  209. package/src/lib/measurements.test.ts +183 -0
  210. package/src/lib/measurements.ts +199 -0
  211. package/src/lib/paint-scope.test.ts +454 -0
  212. package/src/lib/paint-scope.ts +461 -0
  213. package/src/lib/placement-drag-release.ts +23 -0
  214. package/src/lib/planar-cursor-placement.test.ts +57 -0
  215. package/src/lib/plugin-panels.test.ts +19 -0
  216. package/src/lib/plugin-panels.ts +91 -0
  217. package/src/lib/quick-measurement.test.ts +77 -0
  218. package/src/lib/quick-measurement.ts +109 -0
  219. package/src/lib/roof-duplication.ts +6 -6
  220. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  221. package/src/lib/roof-wall-hit.ts +164 -0
  222. package/src/lib/scene-clipboard.test.ts +196 -0
  223. package/src/lib/scene-clipboard.ts +73 -8
  224. package/src/lib/scene.ts +28 -5
  225. package/src/lib/selection-routing.test.ts +298 -0
  226. package/src/lib/selection-routing.ts +175 -0
  227. package/src/lib/sfx/index.ts +1 -0
  228. package/src/lib/sfx/movement-tick.test.ts +65 -0
  229. package/src/lib/sfx/movement-tick.ts +18 -0
  230. package/src/lib/sfx-bus.ts +50 -13
  231. package/src/lib/sfx-player.test.ts +124 -0
  232. package/src/lib/sfx-player.ts +111 -66
  233. package/src/lib/slab-plan-snap.test.ts +93 -0
  234. package/src/lib/slab-plan-snap.ts +108 -0
  235. package/src/lib/snapping-mode.test.ts +138 -0
  236. package/src/lib/snapping-mode.ts +179 -0
  237. package/src/lib/stair-duplication.ts +4 -4
  238. package/src/lib/surface-plan-snap.test.ts +71 -0
  239. package/src/lib/surface-plan-snap.ts +256 -0
  240. package/src/lib/use-linear-display.ts +40 -0
  241. package/src/lib/world-grid-snap.ts +37 -5
  242. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  243. package/src/store/use-editor.tsx +484 -114
  244. package/src/store/use-facing-pose.ts +44 -0
  245. package/src/store/use-fence-curve-draft.ts +21 -0
  246. package/src/store/use-floorplan-draft-preview.ts +101 -0
  247. package/src/store/use-floorplan-marquee.ts +50 -0
  248. package/src/store/use-interaction-scope.test.ts +186 -0
  249. package/src/store/use-interaction-scope.ts +132 -0
  250. package/src/store/use-measurement-draft.test.ts +530 -0
  251. package/src/store/use-measurement-draft.ts +543 -0
  252. package/src/store/use-opening-guides.ts +41 -0
  253. package/src/store/use-placement-preview.ts +11 -3
  254. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  255. package/src/store/use-quick-measurement-hud.ts +77 -0
  256. package/src/store/use-segment-draft-chain.ts +28 -0
  257. package/src/store/use-stair-build-preview.ts +43 -0
  258. package/src/store/use-wall-snap-indicator.ts +2 -0
  259. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  260. package/src/components/editor/group-move-handle.tsx +0 -316
  261. package/src/components/ui/panels/paint-panel.tsx +0 -163
  262. package/src/lib/level-name.ts +0 -11
@@ -1,7 +1,18 @@
1
1
  'use client'
2
2
 
3
3
  import { emitter, sceneRegistry } from '@pascal-app/core'
4
- import { GRID_LAYER, SSGI_PARAMS, snapLevelsToTruePositions, useViewer } from '@pascal-app/viewer'
4
+ import {
5
+ backdropGradient,
6
+ deepSkyColor,
7
+ GRID_LAYER,
8
+ getSceneTheme,
9
+ horizonHazeColor,
10
+ packNormalToRGB,
11
+ SSGI_PARAMS,
12
+ snapLevelsToTruePositions,
13
+ unpackRGBToNormal,
14
+ useViewer,
15
+ } from '@pascal-app/viewer'
5
16
  import type { CameraControls } from '@react-three/drei'
6
17
  import { useThree } from '@react-three/fiber'
7
18
  import { useCallback, useEffect, useRef } from 'react'
@@ -11,16 +22,18 @@ import { ssgi } from 'three/addons/tsl/display/SSGINode.js'
11
22
  import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js'
12
23
  import { fxaa } from 'three/examples/jsm/tsl/display/FXAANode.js'
13
24
  import {
14
- colorToDirection,
15
25
  convertToTexture,
16
26
  diffuseColor,
17
- directionToColor,
18
27
  float,
28
+ mix,
19
29
  mrt,
20
30
  normalView,
21
31
  output,
22
32
  pass,
23
33
  sample,
34
+ screenUV,
35
+ smoothstep,
36
+ uniform,
24
37
  vec4,
25
38
  } from 'three/tsl'
26
39
  import { RenderPipeline, RenderTarget, type WebGPURenderer } from 'three/webgpu'
@@ -54,6 +67,18 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
54
67
  const pipelineRef = useRef<RenderPipeline | null>(null)
55
68
  const renderTargetRef = useRef<RenderTarget | null>(null)
56
69
 
70
+ // Backdrop compositing for scene snapshots (studio renders, project
71
+ // thumbnails): theme background + sky gradient, same world-ray math as the
72
+ // viewport backdrop in viewer's post-processing. Uniform-driven so the one
73
+ // cached pipeline serves both opaque and transparent (preset/item) captures.
74
+ const bgColorUniform = useRef(uniform(new THREE.Color('#ffffff')))
75
+ const bgSkyUniform = useRef(uniform(new THREE.Color('#ffffff')))
76
+ const bgSkyDeepUniform = useRef(uniform(new THREE.Color('#ffffff')))
77
+ const bgHazeUniform = useRef(uniform(new THREE.Color('#ffffff')))
78
+ const bgProjInvUniform = useRef(uniform(new THREE.Matrix4()))
79
+ const bgCamWorldUniform = useRef(uniform(new THREE.Matrix4()))
80
+ const bgMixUniform = useRef(uniform(1))
81
+
57
82
  useEffect(() => {
58
83
  onThumbnailCaptureRef.current = onThumbnailCapture
59
84
  }, [onThumbnailCapture])
@@ -80,7 +105,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
80
105
  mrt({
81
106
  output,
82
107
  diffuseColor,
83
- normal: directionToColor(normalView),
108
+ normal: packNormalToRGB(normalView),
84
109
  }),
85
110
  )
86
111
 
@@ -91,7 +116,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
91
116
  scenePass.getTexture('diffuseColor').type = UnsignedByteType
92
117
  scenePass.getTexture('normal').type = UnsignedByteType
93
118
 
94
- const sceneNormal = sample((uv) => colorToDirection(scenePassNormal.sample(uv)))
119
+ const sceneNormal = sample((uv) => unpackRGBToNormal(scenePassNormal.sample(uv)))
95
120
 
96
121
  const giPass = ssgi(scenePassColor, scenePassDepth, sceneNormal, cam as any)
97
122
  giPass.sliceCount.value = SSGI_PARAMS.sliceCount
@@ -106,14 +131,49 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
106
131
  giPass.useScreenSpaceSampling.value = SSGI_PARAMS.useScreenSpaceSampling
107
132
  giPass.useTemporalFiltering = SSGI_PARAMS.useTemporalFiltering
108
133
 
109
- const giTexture = (giPass as any).getTextureNode()
110
- const aoAsRgb = vec4(giTexture.a, giTexture.a, giTexture.a, float(1))
134
+ // r185: SSGI's AO lives in its own single-channel texture (getAONode)
135
+ // rather than the alpha of one packed rgba texture.
136
+ const aoTexture = (giPass as any).getAONode()
137
+ const aoAsRgb = vec4(aoTexture.r, aoTexture.r, aoTexture.r, float(1))
111
138
  const denoisePass = denoise(aoAsRgb, scenePassDepth, sceneNormal, cam)
112
139
  denoisePass.index.value = 0
113
140
  denoisePass.radius.value = 4
114
141
 
115
- const ao = (denoisePass as any).r
116
- const finalOutput = vec4(scenePassColor.rgb.mul(ao), scenePassColor.a)
142
+ // Same far-field AO fade as the viewport pipeline — without it the
143
+ // horizon picks up a visible AO line in captures.
144
+ const aoFarFade = smoothstep(
145
+ float(0.9994),
146
+ float(0.9998),
147
+ scenePassDepth.sample(screenUV).r,
148
+ )
149
+ const ao = mix((denoisePass as any).r, float(1), aoFarFade)
150
+ const sceneRgb = scenePassColor.rgb.mul(ao)
151
+
152
+ // Per-pixel world ray from the capture camera → sky gradient above the
153
+ // horizon (dir.y = 0), flat background below — mirrors the viewport
154
+ // backdrop. bgMix 0 bypasses it and keeps the capture transparent.
155
+ const ndc = vec4(
156
+ screenUV.x.mul(2).sub(1),
157
+ float(1).sub(screenUV.y).mul(2).sub(1),
158
+ 1,
159
+ 1,
160
+ ) as any
161
+ const viewRay = (bgProjInvUniform.current as any).mul(ndc)
162
+ const worldDir = (bgCamWorldUniform.current as any)
163
+ .mul(vec4(viewRay.xyz, 0))
164
+ .xyz.normalize()
165
+ const bgGradient = backdropGradient({
166
+ dirY: worldDir.y,
167
+ background: bgColorUniform.current,
168
+ haze: bgHazeUniform.current,
169
+ sky: bgSkyUniform.current,
170
+ skyDeep: bgSkyDeepUniform.current,
171
+ })
172
+ const alpha = scenePassColor.a
173
+ const finalOutput = vec4(
174
+ mix(sceneRgb, mix(bgGradient, sceneRgb, alpha), bgMixUniform.current),
175
+ mix(alpha, float(1), bgMixUniform.current),
176
+ )
117
177
 
118
178
  // FXAA requires a texture node as input; convertToTexture renders finalOutput
119
179
  // into an intermediate RT so FXAA can sample it with neighbour UV offsets.
@@ -151,7 +211,11 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
151
211
  snapLevels: boolean,
152
212
  captureMode?: 'standard' | 'viewport' | 'area',
153
213
  cropRegion?: { x: number; y: number; width: number; height: number },
214
+ standardSize?: { w: number; h: number },
215
+ transparent = false,
154
216
  ) => {
217
+ const standardW = standardSize?.w ?? THUMBNAIL_WIDTH
218
+ const standardH = standardSize?.h ?? THUMBNAIL_HEIGHT
155
219
  if (isGenerating.current) return
156
220
  if (!onThumbnailCaptureRef.current) return
157
221
 
@@ -173,6 +237,21 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
173
237
  const { width, height } = gl.domElement
174
238
  thumbnailCamera.aspect = width / height
175
239
  thumbnailCamera.updateProjectionMatrix()
240
+ // The capture camera never joins the scene graph, so its matrixWorld
241
+ // is only refreshed by the render itself — too late for the backdrop
242
+ // uniforms below.
243
+ thumbnailCamera.updateMatrixWorld()
244
+
245
+ const theme = getSceneTheme(useViewer.getState().sceneTheme)
246
+ bgColorUniform.current.value.set(theme.background)
247
+ bgSkyUniform.current.value.set(theme.backgroundSky ?? theme.background)
248
+ bgSkyDeepUniform.current.value.set(deepSkyColor(theme.backgroundSky ?? theme.background))
249
+ bgHazeUniform.current.value.set(
250
+ horizonHazeColor(theme.backgroundSky ?? theme.background, theme.appearance),
251
+ )
252
+ bgProjInvUniform.current.value.copy(thumbnailCamera.projectionMatrixInverse)
253
+ bgCamWorldUniform.current.value.copy(thumbnailCamera.matrixWorld)
254
+ bgMixUniform.current.value = transparent ? 0 : 1
176
255
 
177
256
  // Capture camera data for snapshot storage
178
257
  const pos = mainCamera.position
@@ -202,12 +281,14 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
202
281
  restoreLevels = snapLevelsToTruePositions()
203
282
  }
204
283
 
205
- // Hide scan and guide nodes directly so they are excluded from the
206
- // thumbnail regardless of whether ScanSystem/GuideSystem listeners are
207
- // registered. Returns a function that restores the original visibility.
284
+ // Hide scan, guide, and spawn nodes directly so they are excluded from
285
+ // the thumbnail regardless of whether ScanSystem/GuideSystem listeners
286
+ // are registered. Spawn renders on SCENE_LAYER for occlusion, so the
287
+ // thumbnail camera's layer mask can't filter it either. Returns a
288
+ // function that restores the original visibility.
208
289
  const restoreNodeVisibility = (() => {
209
290
  const saved = new Map<THREE.Object3D, boolean>()
210
- for (const type of ['scan', 'guide'] as const) {
291
+ for (const type of ['scan', 'guide', 'spawn'] as const) {
211
292
  const ids = sceneRegistry.byType[type]!
212
293
  ids.forEach((id) => {
213
294
  const node = sceneRegistry.nodes.get(id)
@@ -238,18 +319,21 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
238
319
 
239
320
  // Notify other systems (wall cutouts, selection manager) to restore
240
321
  // their overrides before capture and re-apply them after.
241
- emitter.emit('thumbnail:before-capture', undefined)
242
- ;(renderer as any).setClearAlpha(0)
243
- renderer.setRenderTarget(rt)
244
- pipelineRef.current.render()
245
- renderer.setRenderTarget(null)
246
- emitter.emit('thumbnail:after-capture', undefined)
247
-
248
- // Restore level positions, levelMode, and node visibility immediately after the
249
- // render before the async GPU readback.
250
- restoreLevels()
251
- restoreLevelMode?.()
252
- restoreNodeVisibility()
322
+ try {
323
+ emitter.emit('thumbnail:before-capture', undefined)
324
+ ;(renderer as any).setClearAlpha(0)
325
+ renderer.setRenderTarget(rt)
326
+ pipelineRef.current.render()
327
+ } finally {
328
+ // Restore level positions, levelMode, and node visibility immediately
329
+ // after the render before the async GPU readback. Runs in `finally`
330
+ // so a render failure can't leave helpers permanently hidden.
331
+ renderer.setRenderTarget(null)
332
+ emitter.emit('thumbnail:after-capture', undefined)
333
+ restoreLevels()
334
+ restoreLevelMode?.()
335
+ restoreNodeVisibility()
336
+ }
253
337
 
254
338
  // Read pixels from the RT asynchronously.
255
339
  // WebGPU copyTextureToBuffer aligns each row to 256 bytes, so we must
@@ -337,9 +421,9 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
337
421
  offscreen.getContext('2d')!.drawImage(srcCanvas, sx, sy, outW, outH, 0, 0, outW, outH)
338
422
  blob = await offscreen.convertToBlob({ type: 'image/png' })
339
423
  } else {
340
- // Standard: center-crop to 1920×1080 aspect ratio
424
+ // Standard: center-crop to the requested aspect (default 1920×1080)
341
425
  const srcAspect = width / height
342
- const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
426
+ const dstAspect = standardW / standardH
343
427
  let sx = 0,
344
428
  sy = 0,
345
429
  sWidth = width,
@@ -351,8 +435,8 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
351
435
  sHeight = Math.round(width / dstAspect)
352
436
  sy = Math.round((height - sHeight) / 2)
353
437
  }
354
- outW = THUMBNAIL_WIDTH
355
- outH = THUMBNAIL_HEIGHT
438
+ outW = standardW
439
+ outH = standardH
356
440
  const offscreen = new OffscreenCanvas(outW, outH)
357
441
  offscreen
358
442
  .getContext('2d')!
@@ -364,12 +448,15 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
364
448
  cameraData.resolution = { w: outW, h: outH }
365
449
  } else {
366
450
  // Fallback: plain render directly to the canvas
367
- emitter.emit('thumbnail:before-capture', undefined)
368
- gl.render(scene, thumbnailCamera)
369
- emitter.emit('thumbnail:after-capture', undefined)
370
- restoreLevels()
371
- restoreLevelMode?.()
372
- restoreNodeVisibility()
451
+ try {
452
+ emitter.emit('thumbnail:before-capture', undefined)
453
+ gl.render(scene, thumbnailCamera)
454
+ } finally {
455
+ emitter.emit('thumbnail:after-capture', undefined)
456
+ restoreLevels()
457
+ restoreLevelMode?.()
458
+ restoreNodeVisibility()
459
+ }
373
460
 
374
461
  let outW: number
375
462
  let outH: number
@@ -406,7 +493,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
406
493
  )
407
494
  } else {
408
495
  const srcAspect = width / height
409
- const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
496
+ const dstAspect = standardW / standardH
410
497
  let sx = 0,
411
498
  sy = 0,
412
499
  sWidth = width,
@@ -418,8 +505,8 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
418
505
  sHeight = Math.round(width / dstAspect)
419
506
  sy = Math.round((height - sHeight) / 2)
420
507
  }
421
- outW = THUMBNAIL_WIDTH
422
- outH = THUMBNAIL_HEIGHT
508
+ outW = standardW
509
+ outH = standardH
423
510
  const offscreen = document.createElement('canvas')
424
511
  offscreen.width = outW
425
512
  offscreen.height = outH
@@ -460,14 +547,20 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
460
547
  const handleGenerateThumbnail = async (event: {
461
548
  captureMode?: 'standard' | 'viewport' | 'area'
462
549
  cropRegion?: { x: number; y: number; width: number; height: number }
550
+ standardSize?: { w: number; h: number }
463
551
  snapLevels?: boolean
464
- // `transparent` is informational here the render pipeline already
465
- // captures with alpha (see `setClearAlpha(0)` above)the flag is
466
- // forwarded so future tweaks (suppressing the ground occluder, theme
467
- // background bits) can branch on it without touching the emitter.
552
+ // Preset/item captures keep the alpha channel (their thumbnails compose
553
+ // onto arbitrary palette backgrounds); scene snapshots studio renders
554
+ // and project thumbnails composite the theme backdrop + sky.
468
555
  transparent?: boolean
469
556
  }) => {
470
- await generate(event.snapLevels === true, event.captureMode, event.cropRegion)
557
+ await generate(
558
+ event.snapLevels === true,
559
+ event.captureMode,
560
+ event.cropRegion,
561
+ event.standardSize,
562
+ event.transparent === true,
563
+ )
471
564
  }
472
565
 
473
566
  emitter.on('camera-controls:generate-thumbnail', handleGenerateThumbnail)
@@ -2,24 +2,27 @@
2
2
 
3
3
  import { emitter, type FenceNode, isCurvedWall, type WallNode } from '@pascal-app/core'
4
4
  import { type MouseEvent as ReactMouseEvent, useCallback } from 'react'
5
+ import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
5
6
  import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
7
+ import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
8
+ import useAlignmentGuides from '../../store/use-alignment-guides'
9
+ import useEditor, { isAngleSnapActive, isMagneticSnapActive } from '../../store/use-editor'
10
+ import usePlacementPreview from '../../store/use-placement-preview'
11
+ import useSegmentDraftChain from '../../store/use-segment-draft-chain'
6
12
  import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
7
- import {
8
- WALL_FINE_GRID_STEP,
9
- WALL_GRID_STEP,
10
- type WallPlanPoint,
11
- } from '../tools/wall/wall-drafting'
13
+ import { getSegmentGridStep, type WallPlanPoint } from '../tools/wall/wall-drafting'
12
14
 
13
15
  type UseFloorplanBackgroundPlacementArgs = {
14
16
  activePolygonDraftPoints: WallPlanPoint[]
15
17
  ceilingDraftPoints: WallPlanPoint[]
16
18
  clearFencePlacementDraft: () => void
17
19
  clearRoofPlacementDraft: () => void
20
+ clearWallPlacementDraft: () => void
18
21
  emitFloorplanGridEvent: (
19
22
  type: 'click' | 'double-click' | 'move',
20
23
  planPoint: WallPlanPoint,
21
24
  event: ReactMouseEvent<SVGSVGElement>,
22
- ) => WallPlanPoint
25
+ ) => void
23
26
  fenceDraftStart: WallPlanPoint | null
24
27
  fences: FenceNode[]
25
28
  findClosestWallPoint: (
@@ -40,7 +43,7 @@ type UseFloorplanBackgroundPlacementArgs = {
40
43
  ) => boolean
41
44
  handleCeilingPlacementPoint: (point: WallPlanPoint) => void
42
45
  handleSlabPlacementPoint: (point: WallPlanPoint) => void
43
- handleWallPlacementPoint: (point: WallPlanPoint, options?: { singleWall?: boolean }) => void
46
+ handleWallPlacementPoint: (point: WallPlanPoint) => void
44
47
  handleZonePlacementPoint: (point: WallPlanPoint) => void
45
48
  isCeilingBuildActive: boolean
46
49
  isCeilingItemPlacementActive: boolean
@@ -51,18 +54,19 @@ type UseFloorplanBackgroundPlacementArgs = {
51
54
  isRoofBuildActive: boolean
52
55
  isWallBuildActive: boolean
53
56
  isZoneBuildActive: boolean
57
+ levelId: string | null
54
58
  roofDraftStart: WallPlanPoint | null
55
59
  setCursorPoint: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
56
60
  setFenceDraftEnd: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
57
61
  setFenceDraftStart: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
58
62
  setRoofDraftEnd: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
59
63
  setRoofDraftStart: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
60
- shiftPressed: boolean
61
64
  snapWallDraftPoint: (args: {
62
65
  point: WallPlanPoint
63
66
  walls: WallNode[]
64
67
  start?: WallPlanPoint
65
68
  angleSnap?: boolean
69
+ bypassSnap?: boolean
66
70
  step?: number
67
71
  gridSnap?: (point: WallPlanPoint) => WallPlanPoint
68
72
  }) => WallPlanPoint
@@ -77,7 +81,7 @@ type UseFloorplanBackgroundPlacementArgs = {
77
81
  * Snap a building-local plan point to the world XZ grid at `step`.
78
82
  * Injected so the hook doesn't have to know the building's rotation
79
83
  * or position — used by wall / fence branches that snap at variable
80
- * step (Shift = fine).
84
+ * step.
81
85
  */
82
86
  worldGridSnap: (point: WallPlanPoint, step: number) => WallPlanPoint
83
87
  }
@@ -87,6 +91,7 @@ export function useFloorplanBackgroundPlacement({
87
91
  ceilingDraftPoints,
88
92
  clearFencePlacementDraft,
89
93
  clearRoofPlacementDraft,
94
+ clearWallPlacementDraft,
90
95
  emitFloorplanGridEvent,
91
96
  fenceDraftStart,
92
97
  fences,
@@ -107,13 +112,13 @@ export function useFloorplanBackgroundPlacement({
107
112
  isRoofBuildActive,
108
113
  isWallBuildActive,
109
114
  isZoneBuildActive,
115
+ levelId,
110
116
  roofDraftStart,
111
117
  setCursorPoint,
112
118
  setFenceDraftEnd,
113
119
  setFenceDraftStart,
114
120
  setRoofDraftEnd,
115
121
  setRoofDraftStart,
116
- shiftPressed,
117
122
  snapWallDraftPoint,
118
123
  snapPolygonDraftPoint,
119
124
  toPoint2D,
@@ -144,22 +149,29 @@ export function useFloorplanBackgroundPlacement({
144
149
  stopPropagation: () => {},
145
150
  } as any)
146
151
  }
152
+ // Drop the off-wall ghost on commit so it doesn't linger at the
153
+ // just-placed spot before the next pointer move re-evaluates.
154
+ usePlacementPreview.getState().clear()
147
155
  return true
148
156
  }
149
157
 
150
158
  if (isCeilingBuildActive) {
151
- // Align the committed vertex the same way the move-preview did, so
152
- // the placed point matches what the user saw. Skip when angle snap
153
- // owns the vertex (matches the move branch).
154
- const angleSnap = ceilingDraftPoints.length > 0 && !shiftPressed
155
- let snappedPoint = snapPolygonDraftPoint({
159
+ // Align the committed vertex the same way the move-preview did, so the
160
+ // placed point matches what the user saw mode-driven (the chip):
161
+ // `grid` quantizes, `angles` locks 15° rays, `lines` snaps onto walls /
162
+ // alignment, `off` is free. Alt remains force/free at commit time.
163
+ const angleSnap = ceilingDraftPoints.length > 0 && isAngleSnapActive()
164
+ const fallbackPoint = snapPolygonDraftPoint({
156
165
  point: planPoint,
157
166
  start: ceilingDraftPoints[ceilingDraftPoints.length - 1],
158
167
  angleSnap,
159
168
  })
160
- if (!angleSnap) {
161
- snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey })
162
- }
169
+ const snappedPoint = resolveCeilingPlanPointSnap({
170
+ rawPoint: planPoint,
171
+ fallbackPoint,
172
+ levelId,
173
+ align: !angleSnap,
174
+ }).point
163
175
 
164
176
  emitFloorplanGridEvent('click', snappedPoint, event)
165
177
  handleCeilingPlacementPoint(snappedPoint)
@@ -167,8 +179,11 @@ export function useFloorplanBackgroundPlacement({
167
179
  }
168
180
 
169
181
  if (isRoofBuildActive) {
182
+ // Footprint placement (polygon context: grid / lines / off, no angle),
183
+ // mode-driven to match the chip. Alt is force/free at commit time;
184
+ // alignment display/pull follows the active magnetic mode.
170
185
  const snappedPoint = alignFloorplanDraftPoint(getSnappedFloorplanPoint(planPoint), {
171
- bypass: event.altKey,
186
+ applySnap: isMagneticSnapActive(),
172
187
  })
173
188
  emitFloorplanGridEvent('click', snappedPoint, event)
174
189
  setCursorPoint(snappedPoint)
@@ -183,16 +198,19 @@ export function useFloorplanBackgroundPlacement({
183
198
  }
184
199
 
185
200
  if (isFenceBuildActive) {
186
- // Fence draft: grid snap (+ existing-wall/fence endpoint snap), then
187
- // Figma alignment endpoint snap wins (same precedence as move).
188
- // `gridSnap` keeps the snap on the world XZ grid even when the
189
- // building is rotated.
190
- const fenceStep = shiftPressed ? WALL_FINE_GRID_STEP : WALL_GRID_STEP
201
+ // Fence draft: mode-driven (matches the chip), same as the move
202
+ // preview. `grid` snaps to the world XZ grid (rotation-safe via the
203
+ // `gridSnap` callback), `angles` locks 15° rays from the start, `lines`
204
+ // pulls onto walls / fences / alignment, `off` is free.
205
+ const fenceStep = getSegmentGridStep()
206
+ const fenceAngleSnap = fenceDraftStart !== null && isAngleSnapActive()
191
207
  const fenceSnapped = snapFenceDraftPoint({
192
208
  point: planPoint,
193
209
  walls,
194
210
  fences,
195
- step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
211
+ start: fenceDraftStart ?? undefined,
212
+ angleSnap: fenceAngleSnap,
213
+ magnetic: isMagneticSnapActive(),
196
214
  gridSnap: (p) => worldGridSnap(p, fenceStep),
197
215
  })
198
216
  const fenceGridBase = worldGridSnap(planPoint, fenceStep)
@@ -200,18 +218,42 @@ export function useFloorplanBackgroundPlacement({
200
218
  fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1]
201
219
  const snappedPoint = fenceLocked
202
220
  ? fenceSnapped
203
- : alignFloorplanDraftPoint(fenceSnapped, { bypass: event.altKey })
221
+ : alignFloorplanDraftPoint(fenceSnapped, {
222
+ applySnap: isMagneticSnapActive() && !fenceAngleSnap,
223
+ })
204
224
 
205
225
  emitFloorplanGridEvent('click', snappedPoint, event)
206
226
  setCursorPoint(snappedPoint)
207
227
 
228
+ // Double-click finishes the chain. The emit above already made the
229
+ // 3D fence tool stopDrafting (its detail >= 2 guard), so close the
230
+ // 2D draft too — leaving it open desyncs the two views.
231
+ if (fenceDraftStart && event.detail >= 2) {
232
+ clearFencePlacementDraft()
233
+ return true
234
+ }
235
+
208
236
  if (!fenceDraftStart) {
209
237
  setFenceDraftStart(snappedPoint)
210
238
  setFenceDraftEnd(snappedPoint)
211
239
  } else if (
212
240
  getPlanPointDistance(toPoint2D(fenceDraftStart), toPoint2D(snappedPoint)) >= 0.01
213
241
  ) {
214
- clearFencePlacementDraft()
242
+ // Single mode commits one segment per click: the same emit above
243
+ // already made the 3D fence tool stopDrafting, so close the 2D
244
+ // draft too instead of chaining.
245
+ if (useEditor.getState().getContinuation('fence') === 'single') {
246
+ clearFencePlacementDraft()
247
+ setCursorPoint(snappedPoint)
248
+ return true
249
+ }
250
+ // The 3D fence tool owns creation and keeps chaining from the
251
+ // committed fence's resolved end — chain the 2D draft from the
252
+ // same published point so both views draft the next segment
253
+ // from the same start.
254
+ const nextStart = useSegmentDraftChain.getState().fence ?? snappedPoint
255
+ setFenceDraftStart(nextStart)
256
+ setFenceDraftEnd(nextStart)
215
257
  } else {
216
258
  setFenceDraftEnd(snappedPoint)
217
259
  }
@@ -224,15 +266,20 @@ export function useFloorplanBackgroundPlacement({
224
266
  // swallow the click and skip local draft state updates — leaving
225
267
  // the 2D draft polygon invisible while the 3D tool builds fine).
226
268
  if (isPolygonBuildActive) {
227
- const angleSnap = activePolygonDraftPoints.length > 0 && !shiftPressed
228
- let snappedPoint = snapPolygonDraftPoint({
269
+ const angleSnap = activePolygonDraftPoints.length > 0 && isAngleSnapActive()
270
+ const fallbackPoint = snapPolygonDraftPoint({
229
271
  point: planPoint,
230
272
  start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
231
273
  angleSnap,
232
274
  })
233
- if (!angleSnap) {
234
- snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey })
235
- }
275
+ // Zone shares the slab surface snap (wall corners / midpoints /
276
+ // crossings + alignment) it's the same polygon-on-a-level draw.
277
+ const snappedPoint = resolveSlabPlanPointSnap({
278
+ rawPoint: planPoint,
279
+ fallbackPoint,
280
+ levelId,
281
+ align: !angleSnap,
282
+ }).point
236
283
 
237
284
  // Emit the grid event so the registry-driven slab tool also
238
285
  // sees the click (parity with ceiling / fence / roof branches
@@ -255,26 +302,45 @@ export function useFloorplanBackgroundPlacement({
255
302
  // / draftEnd state in the floor plan would never update, leaving
256
303
  // the dashed-line draft preview invisible.
257
304
  if (isWallBuildActive) {
258
- // Wall draft: grid snap (+ existing-wall endpoint/join snap), then
259
- // Figma alignment endpoint/join snap wins (same precedence as the
260
- // move-preview branch), so committing onto a corner still works.
261
- // `gridSnap` keeps the snap on the world XZ grid even when the
262
- // building is rotated.
263
- const wallStep = shiftPressed ? WALL_FINE_GRID_STEP : WALL_GRID_STEP
305
+ // Wall draft: mode-driven (matches the chip + the move-preview branch).
306
+ // `grid` snaps to the world XZ grid (rotation-safe via `gridSnap`),
307
+ // `angles` locks 15° rays from the start, `lines` pulls the endpoint
308
+ // onto existing wall corners / edges + alignment, `off` is free.
309
+ const wallStep = getSegmentGridStep()
310
+ const wallAngleSnap = draftStart !== null && isAngleSnapActive()
264
311
  const wallSnapped = snapWallDraftPoint({
265
312
  point: planPoint,
266
313
  walls,
267
- step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
314
+ start: draftStart ?? undefined,
315
+ angleSnap: wallAngleSnap,
268
316
  gridSnap: (p) => worldGridSnap(p, wallStep),
269
317
  })
270
318
  const wallGridBase = worldGridSnap(planPoint, wallStep)
271
319
  const wallLocked = wallSnapped[0] !== wallGridBase[0] || wallSnapped[1] !== wallGridBase[1]
272
- const snappedPoint = wallLocked
273
- ? wallSnapped
274
- : alignFloorplanDraftPoint(wallSnapped, { bypass: false })
320
+ let snappedPoint = wallSnapped
321
+ if (wallLocked) {
322
+ useAlignmentGuides.getState().clear()
323
+ } else {
324
+ // Alignment lines are shown in every mode; the pull applies only when
325
+ // magnetic ('lines') and the segment isn't angle-locked.
326
+ snappedPoint = alignFloorplanDraftPoint(wallSnapped, {
327
+ applySnap: isMagneticSnapActive() && !wallAngleSnap,
328
+ })
329
+ }
275
330
 
276
331
  emitFloorplanGridEvent('click', snappedPoint, event)
277
- handleWallPlacementPoint(snappedPoint, { singleWall: event.altKey })
332
+
333
+ // Double-click finishes the chain. The emit above already made the
334
+ // 3D wall tool stopDrafting (its detail >= 2 guard), so close the
335
+ // 2D draft too — otherwise it stays open against a closed 3D tool
336
+ // and the next previewed segment is silently never created.
337
+ if (draftStart && event.detail >= 2) {
338
+ clearWallPlacementDraft()
339
+ setCursorPoint(snappedPoint)
340
+ return true
341
+ }
342
+
343
+ handleWallPlacementPoint(snappedPoint)
278
344
  return true
279
345
  }
280
346
 
@@ -291,7 +357,8 @@ export function useFloorplanBackgroundPlacement({
291
357
  // local floor-plan draft handler (column / spawn / shelf / etc.).
292
358
  // The tool's `grid:click` subscriber owns the placement.
293
359
  if (isFloorplanGridInteractionActive) {
294
- const snappedPoint = emitFloorplanGridEvent('click', planPoint, event)
360
+ const snappedPoint = getSnappedFloorplanPoint(planPoint)
361
+ emitFloorplanGridEvent('click', snappedPoint, event)
295
362
  setCursorPoint(snappedPoint)
296
363
  return true
297
364
  }
@@ -303,6 +370,7 @@ export function useFloorplanBackgroundPlacement({
303
370
  ceilingDraftPoints,
304
371
  clearFencePlacementDraft,
305
372
  clearRoofPlacementDraft,
373
+ clearWallPlacementDraft,
306
374
  emitFloorplanGridEvent,
307
375
  fenceDraftStart,
308
376
  fences,
@@ -322,13 +390,13 @@ export function useFloorplanBackgroundPlacement({
322
390
  isRoofBuildActive,
323
391
  isWallBuildActive,
324
392
  isZoneBuildActive,
393
+ levelId,
325
394
  roofDraftStart,
326
395
  setCursorPoint,
327
396
  setFenceDraftEnd,
328
397
  setFenceDraftStart,
329
398
  setRoofDraftEnd,
330
399
  setRoofDraftStart,
331
- shiftPressed,
332
400
  snapWallDraftPoint,
333
401
  snapPolygonDraftPoint,
334
402
  toPoint2D,