@pascal-app/editor 0.7.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. package/package.json +12 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +755 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +62 -54
  6. package/src/components/editor/export-manager.tsx +32 -3
  7. package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
  8. package/src/components/editor/first-person/build-collider-world.ts +203 -87
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
  10. package/src/components/editor/first-person-controls.tsx +836 -31
  11. package/src/components/editor/floating-action-menu.tsx +287 -196
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floorplan-panel.tsx +5837 -12006
  14. package/src/components/editor/grid.tsx +51 -17
  15. package/src/components/editor/group-move-handle.tsx +316 -0
  16. package/src/components/editor/group-rotate-handle.tsx +382 -0
  17. package/src/components/editor/group-transform-shared.test.ts +205 -0
  18. package/src/components/editor/group-transform-shared.ts +256 -0
  19. package/src/components/editor/handles/handle-arrow.tsx +523 -0
  20. package/src/components/editor/handles/use-handle-drag.ts +213 -0
  21. package/src/components/editor/index.tsx +234 -190
  22. package/src/components/editor/measurement-pill.tsx +75 -0
  23. package/src/components/editor/node-arrow-handles.tsx +1453 -0
  24. package/src/components/editor/selection-manager.tsx +435 -158
  25. package/src/components/editor/site-edge-labels.tsx +44 -20
  26. package/src/components/editor/slab-hole-highlights.tsx +466 -0
  27. package/src/components/editor/snapshot-capture-overlay.tsx +539 -0
  28. package/src/components/editor/thumbnail-generator.tsx +27 -64
  29. package/src/components/editor/use-floorplan-background-placement.ts +122 -35
  30. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  31. package/src/components/editor/use-floorplan-scene-data.ts +14 -2
  32. package/src/components/editor/wall-measurement-label.tsx +11 -6
  33. package/src/components/editor/wall-move-side-handles.tsx +855 -0
  34. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  35. package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
  36. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
  37. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  38. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  39. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
  41. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  42. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
  43. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  44. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
  45. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  46. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
  47. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  49. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
  50. package/src/components/editor-2d/svg-paths.ts +10 -2
  51. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
  52. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  53. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  54. package/src/components/systems/stair/stair-edit-system.tsx +2 -2
  55. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  56. package/src/components/systems/zone/zone-system.tsx +0 -0
  57. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  58. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  59. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  60. package/src/components/tools/fence/fence-drafting.ts +64 -22
  61. package/src/components/tools/item/move-tool.tsx +40 -107
  62. package/src/components/tools/item/placement-math.test.ts +10 -0
  63. package/src/components/tools/item/placement-math.ts +33 -9
  64. package/src/components/tools/item/placement-strategies.ts +201 -25
  65. package/src/components/tools/item/placement-types.ts +10 -1
  66. package/src/components/tools/item/use-draft-node.ts +20 -2
  67. package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
  68. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  69. package/src/components/tools/roof/roof-tool.tsx +108 -12
  70. package/src/components/tools/select/box-select-state.ts +77 -0
  71. package/src/components/tools/select/box-select-tool.tsx +324 -543
  72. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  73. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  74. package/src/components/tools/select/select-candidates.test.ts +109 -0
  75. package/src/components/tools/select/select-candidates.ts +85 -0
  76. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  77. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  78. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  79. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  80. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  81. package/src/components/tools/shared/placement-box.tsx +123 -0
  82. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  83. package/src/components/tools/shared/segment-angle.ts +57 -1
  84. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  85. package/src/components/tools/site/site-flag-model.ts +18 -0
  86. package/src/components/tools/stair/stair-defaults.ts +1 -1
  87. package/src/components/tools/stair/stair-tool.tsx +269 -35
  88. package/src/components/tools/tool-manager.tsx +172 -56
  89. package/src/components/tools/wall/wall-drafting.ts +98 -98
  90. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  91. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  92. package/src/components/tools/zone/zone-tool.tsx +36 -11
  93. package/src/components/ui/action-menu/action-button.tsx +21 -1
  94. package/src/components/ui/action-menu/camera-actions.tsx +0 -0
  95. package/src/components/ui/action-menu/control-modes.tsx +5 -76
  96. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  97. package/src/components/ui/action-menu/index.tsx +33 -196
  98. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  99. package/src/components/ui/action-menu/view-toggles.tsx +262 -37
  100. package/src/components/ui/command-palette/editor-commands.tsx +24 -17
  101. package/src/components/ui/command-palette/index.tsx +6 -256
  102. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  103. package/src/components/ui/controls/material-picker.tsx +17 -35
  104. package/src/components/ui/controls/metric-control.tsx +33 -11
  105. package/src/components/ui/floating-level-selector.tsx +48 -11
  106. package/src/components/ui/helpers/helper-manager.tsx +21 -17
  107. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  108. package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
  109. package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
  110. package/src/components/ui/level-duplicate-dialog.tsx +5 -6
  111. package/src/components/ui/panels/node-display.ts +1 -0
  112. package/src/components/ui/panels/paint-panel.tsx +66 -41
  113. package/src/components/ui/panels/panel-manager.tsx +33 -74
  114. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  115. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  116. package/src/components/ui/panels/reference-panel.tsx +28 -13
  117. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  118. package/src/components/ui/primitives/number-input.tsx +1 -1
  119. package/src/components/ui/primitives/sidebar.tsx +0 -0
  120. package/src/components/ui/scene-loader.tsx +1 -3
  121. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  122. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  123. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  124. package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  127. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  128. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  129. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  132. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  133. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  134. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  135. package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
  136. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  137. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  138. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  139. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  140. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  141. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  142. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  143. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
  144. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
  145. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
  146. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  147. package/src/components/ui/slider.tsx +1 -1
  148. package/src/components/viewer-overlay.tsx +169 -52
  149. package/src/components/viewer-zone-system.tsx +1 -1
  150. package/src/hooks/use-auto-save.ts +24 -3
  151. package/src/hooks/use-ceiling-events.ts +175 -0
  152. package/src/hooks/use-drag-action.ts +124 -0
  153. package/src/hooks/use-keyboard.ts +125 -62
  154. package/src/hooks/use-selection.ts +64 -0
  155. package/src/index.tsx +243 -4
  156. package/src/lib/constants.ts +6 -2
  157. package/src/lib/editor-api.ts +62 -0
  158. package/src/lib/elevator-support.ts +96 -0
  159. package/src/lib/floorplan/apply-alignment.ts +111 -0
  160. package/src/lib/floorplan/index.ts +7 -0
  161. package/src/lib/floorplan/selection-tool.ts +40 -0
  162. package/src/lib/floorplan/stairs.ts +12 -1
  163. package/src/lib/fresh-planar-placement.test.ts +102 -0
  164. package/src/lib/fresh-planar-placement.ts +63 -0
  165. package/src/lib/history.ts +2 -1
  166. package/src/lib/level-duplication.test.ts +0 -2
  167. package/src/lib/level-duplication.ts +55 -43
  168. package/src/lib/level-name.ts +11 -0
  169. package/src/lib/material-paint.ts +212 -20
  170. package/src/lib/placement-metadata.ts +29 -0
  171. package/src/lib/planar-cursor-placement.test.ts +43 -0
  172. package/src/lib/planar-cursor-placement.ts +42 -0
  173. package/src/lib/roof-duplication.ts +2 -2
  174. package/src/lib/scene-bounds.ts +1 -1
  175. package/src/lib/scene-clipboard.ts +267 -0
  176. package/src/lib/scene.ts +13 -4
  177. package/src/lib/sfx-bus.ts +25 -1
  178. package/src/lib/sfx-player.ts +75 -27
  179. package/src/lib/site-boundary.ts +1 -0
  180. package/src/lib/stair-duplication.ts +2 -2
  181. package/src/lib/stair-levels.test.ts +146 -0
  182. package/src/lib/stair-levels.ts +177 -0
  183. package/src/lib/world-grid-snap.ts +199 -0
  184. package/src/lib/zone-content.ts +130 -0
  185. package/src/store/use-alignment-guides.ts +21 -0
  186. package/src/store/use-editor.tsx +342 -91
  187. package/src/store/use-placement-preview.ts +31 -0
  188. package/src/store/use-wall-move-ghosts.ts +36 -0
  189. package/src/store/use-wall-snap-indicator.ts +32 -0
  190. package/tsconfig.json +2 -1
  191. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  192. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  193. package/src/components/feedback-dialog.tsx +0 -265
  194. package/src/components/pascal-radio.tsx +0 -280
  195. package/src/components/preview-button.tsx +0 -16
  196. package/src/components/tools/building/move-building-tool.tsx +0 -157
  197. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  198. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  199. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  200. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
  201. package/src/components/tools/column/column-tool.tsx +0 -97
  202. package/src/components/tools/column/move-column-tool.tsx +0 -105
  203. package/src/components/tools/door/door-math.ts +0 -110
  204. package/src/components/tools/door/door-tool.tsx +0 -324
  205. package/src/components/tools/door/move-door-tool.tsx +0 -412
  206. package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
  207. package/src/components/tools/fence/fence-tool.tsx +0 -346
  208. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
  209. package/src/components/tools/fence/move-fence-tool.tsx +0 -298
  210. package/src/components/tools/item/item-tool.tsx +0 -26
  211. package/src/components/tools/roof/move-roof-tool.tsx +0 -359
  212. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  213. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  214. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  215. package/src/components/tools/slab/slab-tool.tsx +0 -322
  216. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  217. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  218. package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
  219. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
  220. package/src/components/tools/wall/move-wall-tool.tsx +0 -356
  221. package/src/components/tools/wall/wall-tool.tsx +0 -332
  222. package/src/components/tools/window/move-window-tool.tsx +0 -447
  223. package/src/components/tools/window/window-math.ts +0 -117
  224. package/src/components/tools/window/window-tool.tsx +0 -332
  225. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  226. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  227. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  228. package/src/components/ui/panels/ceiling-panel.tsx +0 -250
  229. package/src/components/ui/panels/column-panel.tsx +0 -715
  230. package/src/components/ui/panels/door-panel.tsx +0 -1292
  231. package/src/components/ui/panels/fence-panel.tsx +0 -227
  232. package/src/components/ui/panels/item-panel.tsx +0 -306
  233. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  234. package/src/components/ui/panels/roof-panel.tsx +0 -230
  235. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  236. package/src/components/ui/panels/slab-panel.tsx +0 -250
  237. package/src/components/ui/panels/spawn-panel.tsx +0 -155
  238. package/src/components/ui/panels/stair-panel.tsx +0 -525
  239. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  240. package/src/components/ui/panels/wall-panel.tsx +0 -177
  241. package/src/components/ui/panels/window-panel.tsx +0 -960
  242. package/src/components/ui/viewer-toolbar.tsx +0 -436
  243. package/src/contexts/presets-context.tsx +0 -121
  244. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
- import { emitter, sceneRegistry, useScene } from '@pascal-app/core'
4
- import { SSGI_PARAMS, snapLevelsToTruePositions, useViewer } from '@pascal-app/viewer'
3
+ import { emitter, sceneRegistry } from '@pascal-app/core'
4
+ import { GRID_LAYER, SSGI_PARAMS, snapLevelsToTruePositions, useViewer } from '@pascal-app/viewer'
5
5
  import type { CameraControls } from '@react-three/drei'
6
6
  import { useThree } from '@react-three/fiber'
7
7
  import { useCallback, useEffect, useRef } from 'react'
@@ -28,7 +28,6 @@ import { EDITOR_LAYER } from '../../lib/constants'
28
28
 
29
29
  const THUMBNAIL_WIDTH = 1920
30
30
  const THUMBNAIL_HEIGHT = 1080
31
- const AUTO_SAVE_DELAY = 10_000
32
31
 
33
32
  export interface SnapshotCameraData {
34
33
  position: [number, number, number]
@@ -49,8 +48,6 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
49
48
  const mainCamera = useThree((state) => state.camera)
50
49
  const controls = useThree((state) => state.controls) as CameraControls | null
51
50
  const isGenerating = useRef(false)
52
- const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
53
- const pendingAutoRef = useRef(false)
54
51
  const onThumbnailCaptureRef = useRef(onThumbnailCapture)
55
52
 
56
53
  const thumbnailCameraRef = useRef<THREE.PerspectiveCamera | null>(null)
@@ -61,10 +58,11 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
61
58
  onThumbnailCaptureRef.current = onThumbnailCapture
62
59
  }, [onThumbnailCapture])
63
60
 
64
- // Build the thumbnail camera, SSGI pipeline, and render target once, reused on every capture.
61
+ // Build the thumbnail camera, SSGI pipeline, and render target once reused on every capture.
65
62
  useEffect(() => {
66
63
  const cam = new THREE.PerspectiveCamera(60, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.1, 1000)
67
64
  cam.layers.disable(EDITOR_LAYER)
65
+ cam.layers.disable(GRID_LAYER)
68
66
  thumbnailCameraRef.current = cam
69
67
 
70
68
  let mounted = true
@@ -75,8 +73,8 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
75
73
  if (!mounted) return
76
74
 
77
75
  // pass() handles MRT internally for all material types, including custom
78
- // shaders, unlike renderer.setMRT() which crashes on non-NodeMaterials.
79
- // pass() also respects camera.layers, so EDITOR_LAYER objects are filtered.
76
+ // shaders unlike renderer.setMRT() which crashes on non-NodeMaterials.
77
+ // pass() also respects camera.layers, so EDITOR_LAYER + GRID_LAYER objects are filtered.
80
78
  const scenePass = pass(scene, cam)
81
79
  scenePass.setMRT(
82
80
  mrt({
@@ -117,15 +115,15 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
117
115
  const ao = (denoisePass as any).r
118
116
  const finalOutput = vec4(scenePassColor.rgb.mul(ao), scenePassColor.a)
119
117
 
120
- // FXAA requires a texture node as input, convertToTexture renders finalOutput
121
- // into an intermediate RT so FXAA can sample it with neighbor UV offsets.
118
+ // FXAA requires a texture node as input; convertToTexture renders finalOutput
119
+ // into an intermediate RT so FXAA can sample it with neighbour UV offsets.
122
120
  const aaOutput = fxaa(convertToTexture(finalOutput))
123
121
 
124
122
  const pipeline = new RenderPipeline(gl as unknown as WebGPURenderer)
125
123
  pipeline.outputNode = aaOutput
126
124
  pipelineRef.current = pipeline
127
125
 
128
- // Dedicated render target, pipeline outputs here instead of the canvas,
126
+ // Dedicated render target pipeline outputs here instead of the canvas,
129
127
  // so R3F's main render loop can never overwrite our capture.
130
128
  const { width, height } = gl.domElement
131
129
  renderTargetRef.current = new RenderTarget(width, height, { depthBuffer: true })
@@ -176,7 +174,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
176
174
  thumbnailCamera.aspect = width / height
177
175
  thumbnailCamera.updateProjectionMatrix()
178
176
 
179
- // Capture camera data for snapshot storage.
177
+ // Capture camera data for snapshot storage
180
178
  const pos = mainCamera.position
181
179
  let tgt: [number, number, number] | null = null
182
180
  if (controls && 'getTarget' in controls) {
@@ -192,7 +190,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
192
190
  ...(isOrtho && { zoom: (mainCamera as THREE.OrthographicCamera).zoom }),
193
191
  }
194
192
 
195
- // For auto-save, snap levels to stacked positions and reset levelMode.
193
+ // For auto-save: snap levels to stacked positions and reset levelMode
196
194
  let restoreLevelMode: (() => void) | null = null
197
195
  let restoreLevels: () => void = () => {}
198
196
  if (snapLevels) {
@@ -205,12 +203,12 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
205
203
  }
206
204
 
207
205
  // Hide scan and guide nodes directly so they are excluded from the
208
- // thumbnail regardless of whether ScanSystem or GuideSystem listeners are
206
+ // thumbnail regardless of whether ScanSystem/GuideSystem listeners are
209
207
  // registered. Returns a function that restores the original visibility.
210
208
  const restoreNodeVisibility = (() => {
211
209
  const saved = new Map<THREE.Object3D, boolean>()
212
210
  for (const type of ['scan', 'guide'] as const) {
213
- const ids = sceneRegistry.byType[type]
211
+ const ids = sceneRegistry.byType[type]!
214
212
  ids.forEach((id) => {
215
213
  const node = sceneRegistry.nodes.get(id)
216
214
  if (node) {
@@ -231,7 +229,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
231
229
  if (pipelineRef.current && renderTargetRef.current) {
232
230
  const rt = renderTargetRef.current
233
231
 
234
- // Resize RT if the canvas dimensions changed.
232
+ // Resize RT if the canvas dimensions changed
235
233
  if (rt.width !== width || rt.height !== height) {
236
234
  rt.setSize(width, height)
237
235
  }
@@ -248,7 +246,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
248
246
  emitter.emit('thumbnail:after-capture', undefined)
249
247
 
250
248
  // Restore level positions, levelMode, and node visibility immediately after the
251
- // render, before the async GPU readback.
249
+ // render before the async GPU readback.
252
250
  restoreLevels()
253
251
  restoreLevelMode?.()
254
252
  restoreNodeVisibility()
@@ -339,6 +337,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
339
337
  offscreen.getContext('2d')!.drawImage(srcCanvas, sx, sy, outW, outH, 0, 0, outW, outH)
340
338
  blob = await offscreen.convertToBlob({ type: 'image/png' })
341
339
  } else {
340
+ // Standard: center-crop to 1920×1080 aspect ratio
342
341
  const srcAspect = width / height
343
342
  const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
344
343
  let sx = 0,
@@ -364,7 +363,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
364
363
  if (captureMode !== undefined) cameraData.captureMode = captureMode
365
364
  cameraData.resolution = { w: outW, h: outH }
366
365
  } else {
367
- // Fallback: plain render directly to the canvas.
366
+ // Fallback: plain render directly to the canvas
368
367
  emitter.emit('thumbnail:before-capture', undefined)
369
368
  gl.render(scene, thumbnailCamera)
370
369
  emitter.emit('thumbnail:after-capture', undefined)
@@ -450,10 +449,11 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
450
449
  )
451
450
 
452
451
  // Thumbnail request via emitter. Two call shapes:
453
- // - user-driven capture: `{ projectId, captureMode, cropRegion }`, captures
452
+ // - user-driven capture: `{ projectId, captureMode, cropRegion }` captures
454
453
  // the current pose with the supplied crop.
455
- // - auto-save capture: `{ projectId, snapLevels: true }`, snaps levels to
456
- // their true positions first for a consistent auto-thumbnail angle.
454
+ // - host-driven auto-save: `{ projectId, snapLevels: true }` snaps levels
455
+ // to their true positions first for a consistent auto-thumbnail angle.
456
+ // The caller owns policy (when to fire, whether the tab is visible).
457
457
  useEffect(() => {
458
458
  if (!onThumbnailCapture) return
459
459
 
@@ -461,6 +461,11 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
461
461
  captureMode?: 'standard' | 'viewport' | 'area'
462
462
  cropRegion?: { x: number; y: number; width: number; height: number }
463
463
  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.
468
+ transparent?: boolean
464
469
  }) => {
465
470
  await generate(event.snapLevels === true, event.captureMode, event.cropRegion)
466
471
  }
@@ -469,49 +474,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
469
474
  return () => emitter.off('camera-controls:generate-thumbnail', handleGenerateThumbnail)
470
475
  }, [generate, onThumbnailCapture])
471
476
 
472
- // OSS adaptation: keep local debounced auto-capture behavior because the
473
- // community host-side autosave hook is not part of this repo.
474
- useEffect(() => {
475
- if (!onThumbnailCapture) return
476
-
477
- const triggerNow = () => {
478
- void generate(true)
479
- }
480
-
481
- const scheduleOrDefer = () => {
482
- if (document.visibilityState === 'visible') {
483
- triggerNow()
484
- } else {
485
- pendingAutoRef.current = true
486
- }
487
- }
488
-
489
- const onSceneChange = () => {
490
- if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
491
- debounceTimerRef.current = setTimeout(scheduleOrDefer, AUTO_SAVE_DELAY)
492
- }
493
-
494
- const onVisibilityChange = () => {
495
- if (document.visibilityState === 'visible' && pendingAutoRef.current) {
496
- pendingAutoRef.current = false
497
- triggerNow()
498
- }
499
- }
500
-
501
- const unsubscribe = useScene.subscribe((state, prevState) => {
502
- if (state.nodes !== prevState.nodes) onSceneChange()
503
- })
504
-
505
- document.addEventListener('visibilitychange', onVisibilityChange)
506
-
507
- return () => {
508
- if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
509
- unsubscribe()
510
- document.removeEventListener('visibilitychange', onVisibilityChange)
511
- }
512
- }, [generate, onThumbnailCapture])
513
-
514
- // Go-to-camera: animate camera to a saved snapshot position or target.
477
+ // Go-to-camera: animate camera to a saved snapshot position/target
515
478
  useEffect(() => {
516
479
  const handler = ({
517
480
  position,
@@ -2,9 +2,13 @@
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 { getPlanPointDistance } from '../../lib/floorplan'
5
+ import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
6
6
  import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
7
- import type { WallPlanPoint } from '../tools/wall/wall-drafting'
7
+ import {
8
+ WALL_FINE_GRID_STEP,
9
+ WALL_GRID_STEP,
10
+ type WallPlanPoint,
11
+ } from '../tools/wall/wall-drafting'
8
12
 
9
13
  type UseFloorplanBackgroundPlacementArgs = {
10
14
  activePolygonDraftPoints: WallPlanPoint[]
@@ -30,11 +34,16 @@ type UseFloorplanBackgroundPlacementArgs = {
30
34
  } | null
31
35
  floorplanOpeningLocalY: number
32
36
  getSnappedFloorplanPoint: (point: WallPlanPoint) => WallPlanPoint
37
+ handleCeilingItemPlacementClick: (
38
+ planPoint: WallPlanPoint,
39
+ nativeEvent: ReactMouseEvent<SVGSVGElement>,
40
+ ) => boolean
33
41
  handleCeilingPlacementPoint: (point: WallPlanPoint) => void
34
42
  handleSlabPlacementPoint: (point: WallPlanPoint) => void
35
- handleWallPlacementPoint: (point: WallPlanPoint) => void
43
+ handleWallPlacementPoint: (point: WallPlanPoint, options?: { singleWall?: boolean }) => void
36
44
  handleZonePlacementPoint: (point: WallPlanPoint) => void
37
45
  isCeilingBuildActive: boolean
46
+ isCeilingItemPlacementActive: boolean
38
47
  isFenceBuildActive: boolean
39
48
  isFloorplanGridInteractionActive: boolean
40
49
  isOpeningPlacementActive: boolean
@@ -53,7 +62,9 @@ type UseFloorplanBackgroundPlacementArgs = {
53
62
  point: WallPlanPoint
54
63
  walls: WallNode[]
55
64
  start?: WallPlanPoint
56
- angleSnap: boolean
65
+ angleSnap?: boolean
66
+ step?: number
67
+ gridSnap?: (point: WallPlanPoint) => WallPlanPoint
57
68
  }) => WallPlanPoint
58
69
  snapPolygonDraftPoint: (args: {
59
70
  point: WallPlanPoint
@@ -62,6 +73,13 @@ type UseFloorplanBackgroundPlacementArgs = {
62
73
  }) => WallPlanPoint
63
74
  toPoint2D: (point: WallPlanPoint) => { x: number; y: number }
64
75
  walls: WallNode[]
76
+ /**
77
+ * Snap a building-local plan point to the world XZ grid at `step`.
78
+ * Injected so the hook doesn't have to know the building's rotation
79
+ * or position — used by wall / fence branches that snap at variable
80
+ * step (Shift = fine).
81
+ */
82
+ worldGridSnap: (point: WallPlanPoint, step: number) => WallPlanPoint
65
83
  }
66
84
 
67
85
  export function useFloorplanBackgroundPlacement({
@@ -75,11 +93,13 @@ export function useFloorplanBackgroundPlacement({
75
93
  findClosestWallPoint,
76
94
  floorplanOpeningLocalY,
77
95
  getSnappedFloorplanPoint,
96
+ handleCeilingItemPlacementClick,
78
97
  handleCeilingPlacementPoint,
79
98
  handleSlabPlacementPoint,
80
99
  handleWallPlacementPoint,
81
100
  handleZonePlacementPoint,
82
101
  isCeilingBuildActive,
102
+ isCeilingItemPlacementActive,
83
103
  isFenceBuildActive,
84
104
  isFloorplanGridInteractionActive,
85
105
  isOpeningPlacementActive,
@@ -98,6 +118,7 @@ export function useFloorplanBackgroundPlacement({
98
118
  snapPolygonDraftPoint,
99
119
  toPoint2D,
100
120
  walls,
121
+ worldGridSnap,
101
122
  }: UseFloorplanBackgroundPlacementArgs) {
102
123
  const handleBackgroundPlacementClick = useCallback(
103
124
  (
@@ -127,43 +148,61 @@ export function useFloorplanBackgroundPlacement({
127
148
  }
128
149
 
129
150
  if (isCeilingBuildActive) {
130
- emitFloorplanGridEvent('click', planPoint, event)
131
-
132
- const snappedPoint = snapPolygonDraftPoint({
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({
133
156
  point: planPoint,
134
157
  start: ceilingDraftPoints[ceilingDraftPoints.length - 1],
135
- angleSnap: ceilingDraftPoints.length > 0 && !shiftPressed,
158
+ angleSnap,
136
159
  })
160
+ if (!angleSnap) {
161
+ snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey })
162
+ }
137
163
 
164
+ emitFloorplanGridEvent('click', snappedPoint, event)
138
165
  handleCeilingPlacementPoint(snappedPoint)
139
166
  return true
140
167
  }
141
168
 
142
169
  if (isRoofBuildActive) {
143
- const snappedPoint = getSnappedFloorplanPoint(planPoint)
170
+ const snappedPoint = alignFloorplanDraftPoint(getSnappedFloorplanPoint(planPoint), {
171
+ bypass: event.altKey,
172
+ })
144
173
  emitFloorplanGridEvent('click', snappedPoint, event)
145
174
  setCursorPoint(snappedPoint)
146
175
 
147
- if (!roofDraftStart) {
176
+ if (roofDraftStart) {
177
+ clearRoofPlacementDraft()
178
+ } else {
148
179
  setRoofDraftStart(snappedPoint)
149
180
  setRoofDraftEnd(snappedPoint)
150
- } else {
151
- clearRoofPlacementDraft()
152
181
  }
153
182
  return true
154
183
  }
155
184
 
156
185
  if (isFenceBuildActive) {
157
- emitFloorplanGridEvent('click', planPoint, event)
158
-
159
- const snappedPoint = snapFenceDraftPoint({
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
191
+ const fenceSnapped = snapFenceDraftPoint({
160
192
  point: planPoint,
161
193
  walls,
162
194
  fences,
163
- start: fenceDraftStart ?? undefined,
164
- angleSnap: Boolean(fenceDraftStart) && !shiftPressed,
195
+ step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
196
+ gridSnap: (p) => worldGridSnap(p, fenceStep),
165
197
  })
198
+ const fenceGridBase = worldGridSnap(planPoint, fenceStep)
199
+ const fenceLocked =
200
+ fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1]
201
+ const snappedPoint = fenceLocked
202
+ ? fenceSnapped
203
+ : alignFloorplanDraftPoint(fenceSnapped, { bypass: event.altKey })
166
204
 
205
+ emitFloorplanGridEvent('click', snappedPoint, event)
167
206
  setCursorPoint(snappedPoint)
168
207
 
169
208
  if (!fenceDraftStart) {
@@ -179,18 +218,28 @@ export function useFloorplanBackgroundPlacement({
179
218
  return true
180
219
  }
181
220
 
182
- if (isFloorplanGridInteractionActive) {
183
- const snappedPoint = emitFloorplanGridEvent('click', planPoint, event)
184
- setCursorPoint(snappedPoint)
185
- return true
186
- }
187
-
221
+ // Slab / zone polygon build — local draft state + grid emit.
222
+ // Must run BEFORE the `isFloorplanGridInteractionActive` catch-all
223
+ // (since slab is registry-driven, the catch-all would otherwise
224
+ // swallow the click and skip local draft state updates — leaving
225
+ // the 2D draft polygon invisible while the 3D tool builds fine).
188
226
  if (isPolygonBuildActive) {
189
- const snappedPoint = snapPolygonDraftPoint({
227
+ const angleSnap = activePolygonDraftPoints.length > 0 && !shiftPressed
228
+ let snappedPoint = snapPolygonDraftPoint({
190
229
  point: planPoint,
191
230
  start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
192
- angleSnap: activePolygonDraftPoints.length > 0 && !shiftPressed,
231
+ angleSnap,
193
232
  })
233
+ if (!angleSnap) {
234
+ snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey })
235
+ }
236
+
237
+ // Emit the grid event so the registry-driven slab tool also
238
+ // sees the click (parity with ceiling / fence / roof branches
239
+ // above). Zone has no registry tool — emit-or-not is irrelevant.
240
+ if (!isZoneBuildActive) {
241
+ emitFloorplanGridEvent('click', snappedPoint, event)
242
+ }
194
243
 
195
244
  if (isZoneBuildActive) {
196
245
  handleZonePlacementPoint(snappedPoint)
@@ -200,19 +249,54 @@ export function useFloorplanBackgroundPlacement({
200
249
  return true
201
250
  }
202
251
 
203
- if (!isWallBuildActive) {
204
- return false
252
+ // Wall placement — local draft state + grid emit. Same reasoning
253
+ // as slab above: wall is registry-driven, so without this branch
254
+ // the catch-all would swallow the click and the local draftStart
255
+ // / draftEnd state in the floor plan would never update, leaving
256
+ // the dashed-line draft preview invisible.
257
+ 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
264
+ const wallSnapped = snapWallDraftPoint({
265
+ point: planPoint,
266
+ walls,
267
+ step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
268
+ gridSnap: (p) => worldGridSnap(p, wallStep),
269
+ })
270
+ const wallGridBase = worldGridSnap(planPoint, wallStep)
271
+ const wallLocked = wallSnapped[0] !== wallGridBase[0] || wallSnapped[1] !== wallGridBase[1]
272
+ const snappedPoint = wallLocked
273
+ ? wallSnapped
274
+ : alignFloorplanDraftPoint(wallSnapped, { bypass: false })
275
+
276
+ emitFloorplanGridEvent('click', snappedPoint, event)
277
+ handleWallPlacementPoint(snappedPoint, { singleWall: event.altKey })
278
+ return true
205
279
  }
206
280
 
207
- const snappedPoint = snapWallDraftPoint({
208
- point: planPoint,
209
- walls,
210
- start: draftStart ?? undefined,
211
- angleSnap: Boolean(draftStart) && !shiftPressed,
212
- })
281
+ // Ceiling-attached item placement (lights, fans). Routes the click
282
+ // through `ceiling:click` instead of `grid:click` so the placement
283
+ // strategy parents the new item to the ceiling at the correct
284
+ // height mirrors the pointer-move handler in `floorplan-panel`.
285
+ if (isCeilingItemPlacementActive) {
286
+ handleCeilingItemPlacementClick(planPoint, event)
287
+ return true
288
+ }
289
+
290
+ // Generic catch-all — registry-driven tool whose kind has no
291
+ // local floor-plan draft handler (column / spawn / shelf / etc.).
292
+ // The tool's `grid:click` subscriber owns the placement.
293
+ if (isFloorplanGridInteractionActive) {
294
+ const snappedPoint = emitFloorplanGridEvent('click', planPoint, event)
295
+ setCursorPoint(snappedPoint)
296
+ return true
297
+ }
213
298
 
214
- handleWallPlacementPoint(snappedPoint)
215
- return true
299
+ return false
216
300
  },
217
301
  [
218
302
  activePolygonDraftPoints,
@@ -225,10 +309,12 @@ export function useFloorplanBackgroundPlacement({
225
309
  findClosestWallPoint,
226
310
  floorplanOpeningLocalY,
227
311
  getSnappedFloorplanPoint,
312
+ handleCeilingItemPlacementClick,
228
313
  handleCeilingPlacementPoint,
229
314
  handleSlabPlacementPoint,
230
315
  handleZonePlacementPoint,
231
316
  isCeilingBuildActive,
317
+ isCeilingItemPlacementActive,
232
318
  isFenceBuildActive,
233
319
  isFloorplanGridInteractionActive,
234
320
  isOpeningPlacementActive,
@@ -248,6 +334,7 @@ export function useFloorplanBackgroundPlacement({
248
334
  toPoint2D,
249
335
  walls,
250
336
  handleWallPlacementPoint,
337
+ worldGridSnap,
251
338
  ],
252
339
  )
253
340
 
@@ -3,7 +3,9 @@
3
3
  import type {
4
4
  AnyNode,
5
5
  CeilingNode,
6
+ ColumnNode,
6
7
  DoorNode,
8
+ ElevatorNode,
7
9
  ItemNode,
8
10
  Point2D,
9
11
  RoofNode,
@@ -46,6 +48,16 @@ type CeilingPolygonEntry = {
46
48
  holes: Point2D[][]
47
49
  }
48
50
 
51
+ type ColumnPolygonEntry = {
52
+ column: ColumnNode
53
+ polygon: Point2D[]
54
+ }
55
+
56
+ type ElevatorPolygonEntry = {
57
+ elevator: ElevatorNode
58
+ polygon: Point2D[]
59
+ }
60
+
49
61
  type FloorplanRoofEntry = {
50
62
  roof: RoofNode
51
63
  segments: Array<{
@@ -72,8 +84,10 @@ type FloorplanStairEntry = {
72
84
 
73
85
  type UseFloorplanHitTestingArgs = {
74
86
  ceilingPolygons: CeilingPolygonEntry[]
87
+ columnPolygons: ColumnPolygonEntry[]
75
88
  displaySlabPolygons: SlabPolygonEntry[]
76
89
  displayWallPolygons: WallPolygonEntry[]
90
+ floorplanElevatorEntries: ElevatorPolygonEntry[]
77
91
  floorplanItemEntries: FloorplanItemEntry[]
78
92
  floorplanOpeningHitTolerance: number
79
93
  floorplanRoofEntries: FloorplanRoofEntry[]
@@ -88,8 +102,10 @@ type UseFloorplanHitTestingArgs = {
88
102
 
89
103
  export function useFloorplanHitTesting({
90
104
  ceilingPolygons,
105
+ columnPolygons,
91
106
  displaySlabPolygons,
92
107
  displayWallPolygons,
108
+ floorplanElevatorEntries,
93
109
  floorplanItemEntries,
94
110
  floorplanOpeningHitTolerance,
95
111
  floorplanRoofEntries,
@@ -113,18 +129,22 @@ export function useFloorplanHitTesting({
113
129
  openings: openingsPolygons,
114
130
  roofs: floorplanRoofEntries,
115
131
  stairs: floorplanStairEntries,
132
+ elevators: floorplanElevatorEntries,
116
133
  walls: displayWallPolygons,
117
134
  slabs: displaySlabPolygons,
118
135
  openingHitTolerance: floorplanOpeningHitTolerance,
119
136
  wallHitTolerance: floorplanWallHitTolerance,
137
+ columns: columnPolygons,
120
138
  getOpeningCenterLine,
121
139
  })
122
140
  },
123
141
  [
124
142
  ceilingPolygons,
143
+ columnPolygons,
125
144
  displaySlabPolygons,
126
145
  displayWallPolygons,
127
146
  floorplanItemEntries,
147
+ floorplanElevatorEntries,
128
148
  floorplanOpeningHitTolerance,
129
149
  floorplanRoofEntries,
130
150
  floorplanStairEntries,
@@ -149,13 +169,17 @@ export function useFloorplanHitTesting({
149
169
  openings: openingsPolygons,
150
170
  roofs: floorplanRoofEntries,
151
171
  slabs: displaySlabPolygons,
172
+ columns: columnPolygons,
173
+ elevators: floorplanElevatorEntries,
152
174
  stairs: floorplanStairEntries,
153
175
  }),
154
176
  [
155
177
  ceilingPolygons,
178
+ columnPolygons,
156
179
  displaySlabPolygons,
157
180
  displayWallPolygons,
158
181
  floorplanItemEntries,
182
+ floorplanElevatorEntries,
159
183
  floorplanRoofEntries,
160
184
  floorplanStairEntries,
161
185
  isFloorplanItemContextActive,
@@ -12,6 +12,7 @@ import {
12
12
  type SiteNode,
13
13
  type SlabNode,
14
14
  type SpawnNode,
15
+ useLiveTransforms,
15
16
  useScene,
16
17
  type WallNode,
17
18
  type WindowNode,
@@ -59,13 +60,22 @@ export function useFloorplanSceneData({
59
60
  ? (levelNode.parentId as BuildingNode['id'])
60
61
  : buildingId
61
62
 
62
- const buildingRotationY = useScene((state) => {
63
+ // Live transform override — when the building is mid-drag (the move
64
+ // tool publishes per-frame pose to useLiveTransforms), the floor-plan
65
+ // follows that pose so the dimmed reference floor tracks the cursor
66
+ // instead of snapping only on commit.
67
+ const buildingLiveTransform = useLiveTransforms((state) =>
68
+ currentBuildingId ? state.transforms.get(currentBuildingId) : undefined,
69
+ )
70
+
71
+ const committedBuildingRotationY = useScene((state) => {
63
72
  if (!currentBuildingId) return 0
64
73
  const node = state.nodes[currentBuildingId]
65
74
  return node?.type === 'building' ? (node.rotation[1] ?? 0) : 0
66
75
  })
76
+ const buildingRotationY = buildingLiveTransform?.rotation ?? committedBuildingRotationY
67
77
 
68
- const buildingPosition = useScene((state) => {
78
+ const committedBuildingPosition = useScene((state) => {
69
79
  if (!currentBuildingId) {
70
80
  return DEFAULT_BUILDING_POSITION
71
81
  }
@@ -75,6 +85,7 @@ export function useFloorplanSceneData({
75
85
  ? (node.position as [number, number, number])
76
86
  : DEFAULT_BUILDING_POSITION
77
87
  })
88
+ const buildingPosition = buildingLiveTransform?.position ?? committedBuildingPosition
78
89
 
79
90
  const site = useScene((state) => {
80
91
  for (const rootNodeId of state.rootNodeIds) {
@@ -170,6 +181,7 @@ export function useFloorplanSceneData({
170
181
 
171
182
  return {
172
183
  buildingPosition,
184
+ committedBuildingPosition,
173
185
  buildingRotationY,
174
186
  currentBuildingId,
175
187
  ceilings,
@@ -18,7 +18,7 @@ import {
18
18
  type WallMiterData,
19
19
  type WallNode,
20
20
  } from '@pascal-app/core'
21
- import { useViewer } from '@pascal-app/viewer'
21
+ import { getSceneTheme, useViewer } from '@pascal-app/viewer'
22
22
  import { Html } from '@react-three/drei'
23
23
  import { createPortal, useFrame } from '@react-three/fiber'
24
24
  import { useMemo, useState } from 'react'
@@ -32,6 +32,12 @@ const HEIGHT_TICK_HALF_LENGTH = 0.14
32
32
  const HEIGHT_GUIDE_OUTSIDE_OFFSET = 0.16
33
33
 
34
34
  const BAR_AXIS = new THREE.Vector3(0, 1, 0)
35
+ // Shared unit cube — each MeasurementBar scales it to BAR_THICKNESS × length
36
+ // × BAR_THICKNESS instead of constructing a fresh BoxGeometry every frame.
37
+ // Per-frame `<boxGeometry args={[..., length, ...]}/>` triggers R3F to
38
+ // rebuild the geometry whenever the wall moves, and the WebGPU backend
39
+ // flags the in-flight buffer churn as "Vertex buffer slot N ... was not set".
40
+ const SHARED_BAR_GEOMETRY = new THREE.BoxGeometry(1, 1, 1)
35
41
 
36
42
  type Vec3 = [number, number, number]
37
43
 
@@ -73,8 +79,7 @@ export function WallMeasurementLabel() {
73
79
 
74
80
  const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
75
81
  const selectedNode = selectedId ? nodes[selectedId as AnyNodeId] : null
76
- const measurableNode =
77
- selectedNode?.type === 'wall' || selectedNode?.type === 'item' ? selectedNode : null
82
+ const measurableNode = selectedNode?.type === 'item' ? selectedNode : null
78
83
 
79
84
  const [objectState, setObjectState] = useState<{
80
85
  id: AnyNodeId
@@ -447,11 +452,12 @@ function MeasurementBar({ start, end, color }: { start: Vec3; end: Vec3; color:
447
452
 
448
453
  return (
449
454
  <mesh
455
+ geometry={SHARED_BAR_GEOMETRY}
450
456
  position={[segment.position.x, segment.position.y, segment.position.z]}
451
457
  quaternion={segment.quaternion}
452
458
  renderOrder={1000}
459
+ scale={[BAR_THICKNESS, segment.length, BAR_THICKNESS]}
453
460
  >
454
- <boxGeometry args={[BAR_THICKNESS, segment.length, BAR_THICKNESS]} />
455
461
  <meshBasicMaterial
456
462
  color={color}
457
463
  depthTest={false}
@@ -515,9 +521,8 @@ function SelectedMeasurementAnnotation({ node }: { node: WallNode | ItemNode })
515
521
 
516
522
  function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
517
523
  const nodes = useScene((state) => state.nodes)
518
- const theme = useViewer((state) => state.theme)
519
524
  const unit = useViewer((state) => state.unit)
520
- const isNight = theme === 'dark'
525
+ const isNight = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
521
526
  const color = isNight ? '#ffffff' : '#111111'
522
527
  const shadowColor = isNight ? '#111111' : '#ffffff'
523
528