@pascal-app/editor 0.8.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 (223) hide show
  1. package/package.json +8 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +754 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +58 -51
  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 +201 -83
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +8 -8
  10. package/src/components/editor/first-person-controls.tsx +825 -20
  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 +5829 -12005
  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 +178 -149
  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 +433 -156
  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 +136 -62
  28. package/src/components/editor/thumbnail-generator.tsx +9 -3
  29. package/src/components/editor/use-floorplan-background-placement.ts +119 -32
  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-geometry-renderer.tsx +266 -0
  45. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  46. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  47. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  48. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  49. package/src/components/editor-2d/svg-paths.ts +10 -2
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +26 -3
  51. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  52. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  53. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  54. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  55. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  56. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  57. package/src/components/tools/fence/fence-drafting.ts +64 -22
  58. package/src/components/tools/item/move-tool.tsx +41 -105
  59. package/src/components/tools/item/placement-math.test.ts +10 -0
  60. package/src/components/tools/item/placement-math.ts +31 -5
  61. package/src/components/tools/item/placement-strategies.ts +190 -15
  62. package/src/components/tools/item/placement-types.ts +10 -1
  63. package/src/components/tools/item/use-draft-node.ts +20 -1
  64. package/src/components/tools/item/use-placement-coordinator.tsx +694 -191
  65. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  66. package/src/components/tools/roof/roof-tool.tsx +108 -12
  67. package/src/components/tools/select/box-select-state.ts +77 -0
  68. package/src/components/tools/select/box-select-tool.tsx +324 -538
  69. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  70. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  71. package/src/components/tools/select/select-candidates.test.ts +109 -0
  72. package/src/components/tools/select/select-candidates.ts +85 -0
  73. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  74. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  75. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  76. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  77. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  78. package/src/components/tools/shared/placement-box.tsx +123 -0
  79. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  80. package/src/components/tools/shared/segment-angle.ts +56 -0
  81. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  82. package/src/components/tools/site/site-flag-model.ts +18 -0
  83. package/src/components/tools/stair/stair-defaults.ts +1 -1
  84. package/src/components/tools/stair/stair-tool.tsx +269 -35
  85. package/src/components/tools/tool-manager.tsx +172 -56
  86. package/src/components/tools/wall/wall-drafting.ts +98 -98
  87. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  88. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  89. package/src/components/tools/zone/zone-tool.tsx +16 -6
  90. package/src/components/ui/action-menu/action-button.tsx +21 -1
  91. package/src/components/ui/action-menu/control-modes.tsx +5 -82
  92. package/src/components/ui/action-menu/index.tsx +1 -113
  93. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  94. package/src/components/ui/action-menu/view-toggles.tsx +243 -6
  95. package/src/components/ui/command-palette/editor-commands.tsx +18 -13
  96. package/src/components/ui/command-palette/index.tsx +2 -1
  97. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  98. package/src/components/ui/controls/material-picker.tsx +11 -32
  99. package/src/components/ui/controls/metric-control.tsx +33 -11
  100. package/src/components/ui/floating-level-selector.tsx +47 -10
  101. package/src/components/ui/helpers/helper-manager.tsx +16 -17
  102. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  103. package/src/components/ui/item-catalog/catalog-items.tsx +1 -0
  104. package/src/components/ui/item-catalog/item-catalog.tsx +3 -0
  105. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  106. package/src/components/ui/panels/node-display.ts +1 -0
  107. package/src/components/ui/panels/panel-manager.tsx +35 -47
  108. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  109. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  110. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  111. package/src/components/ui/scene-loader.tsx +1 -3
  112. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  113. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  114. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  115. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  116. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  117. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  118. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  119. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  120. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  121. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  122. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  123. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
  125. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  126. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  127. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  128. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -36
  133. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  134. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  135. package/src/components/viewer-overlay.tsx +169 -52
  136. package/src/components/viewer-zone-system.tsx +1 -1
  137. package/src/hooks/use-auto-save.ts +10 -3
  138. package/src/hooks/use-ceiling-events.ts +175 -0
  139. package/src/hooks/use-drag-action.ts +124 -0
  140. package/src/hooks/use-keyboard.ts +115 -62
  141. package/src/hooks/use-selection.ts +64 -0
  142. package/src/index.tsx +237 -5
  143. package/src/lib/constants.ts +6 -2
  144. package/src/lib/editor-api.ts +62 -0
  145. package/src/lib/elevator-support.ts +96 -0
  146. package/src/lib/floorplan/apply-alignment.ts +111 -0
  147. package/src/lib/floorplan/index.ts +7 -0
  148. package/src/lib/floorplan/selection-tool.ts +40 -0
  149. package/src/lib/floorplan/stairs.ts +12 -1
  150. package/src/lib/fresh-planar-placement.test.ts +102 -0
  151. package/src/lib/fresh-planar-placement.ts +63 -0
  152. package/src/lib/history.ts +2 -1
  153. package/src/lib/level-duplication.ts +54 -42
  154. package/src/lib/level-name.ts +11 -0
  155. package/src/lib/material-paint.ts +211 -19
  156. package/src/lib/placement-metadata.ts +29 -0
  157. package/src/lib/planar-cursor-placement.test.ts +43 -0
  158. package/src/lib/planar-cursor-placement.ts +42 -0
  159. package/src/lib/roof-duplication.ts +1 -1
  160. package/src/lib/scene-clipboard.ts +267 -0
  161. package/src/lib/scene.ts +13 -4
  162. package/src/lib/sfx-bus.ts +23 -1
  163. package/src/lib/sfx-player.ts +70 -22
  164. package/src/lib/site-boundary.ts +1 -0
  165. package/src/lib/stair-levels.test.ts +146 -0
  166. package/src/lib/stair-levels.ts +177 -0
  167. package/src/lib/world-grid-snap.ts +199 -0
  168. package/src/lib/zone-content.ts +130 -0
  169. package/src/store/use-alignment-guides.ts +21 -0
  170. package/src/store/use-editor.tsx +320 -37
  171. package/src/store/use-placement-preview.ts +31 -0
  172. package/src/store/use-wall-move-ghosts.ts +36 -0
  173. package/src/store/use-wall-snap-indicator.ts +32 -0
  174. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  175. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  176. package/src/components/tools/building/move-building-tool.tsx +0 -157
  177. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  178. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  179. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  180. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  181. package/src/components/tools/column/column-tool.tsx +0 -97
  182. package/src/components/tools/column/move-column-tool.tsx +0 -105
  183. package/src/components/tools/door/door-math.ts +0 -110
  184. package/src/components/tools/door/door-tool.tsx +0 -324
  185. package/src/components/tools/door/move-door-tool.tsx +0 -412
  186. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  187. package/src/components/tools/fence/fence-tool.tsx +0 -346
  188. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  189. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  190. package/src/components/tools/item/item-tool.tsx +0 -26
  191. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  192. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  193. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  194. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  195. package/src/components/tools/slab/slab-tool.tsx +0 -322
  196. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  197. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  198. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  199. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  200. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  201. package/src/components/tools/wall/wall-tool.tsx +0 -332
  202. package/src/components/tools/window/move-window-tool.tsx +0 -447
  203. package/src/components/tools/window/window-math.ts +0 -117
  204. package/src/components/tools/window/window-tool.tsx +0 -332
  205. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  206. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  207. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  208. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  209. package/src/components/ui/panels/column-panel.tsx +0 -759
  210. package/src/components/ui/panels/door-panel.tsx +0 -1299
  211. package/src/components/ui/panels/fence-panel.tsx +0 -222
  212. package/src/components/ui/panels/item-panel.tsx +0 -306
  213. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  214. package/src/components/ui/panels/roof-panel.tsx +0 -280
  215. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  216. package/src/components/ui/panels/slab-panel.tsx +0 -250
  217. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  218. package/src/components/ui/panels/stair-panel.tsx +0 -577
  219. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  220. package/src/components/ui/panels/wall-panel.tsx +0 -273
  221. package/src/components/ui/panels/window-panel.tsx +0 -968
  222. package/src/contexts/presets-context.tsx +0 -121
  223. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -7,7 +7,11 @@ import { useIsMobile } from '../../hooks/use-mobile'
7
7
  import { triggerSFX } from '../../lib/sfx-bus'
8
8
  import useEditor from '../../store/use-editor'
9
9
 
10
- type CaptureMode = 'standard' | 'viewport' | 'area'
10
+ // Local crop-mode enum distinct from `useEditor.captureMode` (which
11
+ // describes *why* a capture is happening, e.g. `preset`). This one says
12
+ // HOW the captured pixels are cropped: full-frame 16:9 (`standard`),
13
+ // raw canvas viewport, or user-dragged area.
14
+ type CropMode = 'standard' | 'viewport' | 'area'
11
15
  type CaptureState = 'idle' | 'capturing' | 'saved'
12
16
 
13
17
  interface DragPoint {
@@ -21,7 +25,7 @@ interface Drag {
21
25
  }
22
26
 
23
27
  function getResolution(
24
- mode: CaptureMode,
28
+ mode: CropMode,
25
29
  overlayEl: HTMLDivElement | null,
26
30
  drag: Drag | null,
27
31
  ): { w: number; h: number } | null {
@@ -47,10 +51,15 @@ function getResolution(
47
51
 
48
52
  export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
49
53
  const isCaptureMode = useEditor((s) => s.isCaptureMode)
54
+ const captureMode = useEditor((s) => s.captureMode)
50
55
  const setCaptureMode = useEditor((s) => s.setCaptureMode)
51
56
  const isMobile = useIsMobile()
57
+ // `preset` capture mode locks the overlay to a square area crop with
58
+ // a transparent background — the user picks framing but not the
59
+ // crop shape. Matches the unified preset-thumbnail capture flow.
60
+ const isPreset = captureMode.mode === 'preset'
52
61
 
53
- const [mode, setMode] = useState<CaptureMode>('standard')
62
+ const [mode, setMode] = useState<CropMode>('standard')
54
63
  const [drag, setDrag] = useState<Drag | null>(null)
55
64
  const [isDragging, setIsDragging] = useState(false)
56
65
  const [captureState, setCaptureState] = useState<CaptureState>('idle')
@@ -66,15 +75,29 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
66
75
  return () => window.removeEventListener('keydown', onKey)
67
76
  }, [isCaptureMode, setCaptureMode])
68
77
 
69
- // Reset local state when entering capture mode
78
+ // Reset local state when entering capture mode. Preset mode also
79
+ // auto-stages a centered square crop sized to ~75% of the shorter
80
+ // viewport dimension so the user can capture immediately — the
81
+ // overlay's pan/move/resize handles still apply if they want to
82
+ // tweak the framing, but they don't have to draw the rect first.
70
83
  useEffect(() => {
71
- if (isCaptureMode) {
72
- setMode('standard')
84
+ if (!isCaptureMode) return
85
+ setMode(isPreset ? 'area' : 'standard')
86
+ setIsDragging(false)
87
+ setCaptureState('idle')
88
+ if (isPreset && overlayRef.current) {
89
+ const rect = overlayRef.current.getBoundingClientRect()
90
+ const side = Math.min(rect.width, rect.height) * 0.75
91
+ const cx = rect.width / 2
92
+ const cy = rect.height / 2
93
+ setDrag({
94
+ start: { x: cx - side / 2, y: cy - side / 2 },
95
+ end: { x: cx + side / 2, y: cy + side / 2 },
96
+ })
97
+ } else {
73
98
  setDrag(null)
74
- setIsDragging(false)
75
- setCaptureState('idle')
76
99
  }
77
- }, [isCaptureMode])
100
+ }, [isCaptureMode, isPreset])
78
101
 
79
102
  // Listen for snapshot saved to show feedback then exit
80
103
  useEffect(() => {
@@ -142,11 +165,28 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
142
165
  start: { x: snapshot.start.x + dx, y: snapshot.start.y + dy },
143
166
  end: { x: snapshot.end.x + dx, y: snapshot.end.y + dy },
144
167
  })
168
+ } else if (isPreset) {
169
+ // Preset mode locks the rect to a square — use the smaller
170
+ // axis to keep the drag predictable, sign-correct so the user
171
+ // can still drag in any quadrant.
172
+ setDrag((d) => {
173
+ if (!d) return null
174
+ const dx = pt.x - d.start.x
175
+ const dy = pt.y - d.start.y
176
+ const side = Math.min(Math.abs(dx), Math.abs(dy))
177
+ return {
178
+ start: d.start,
179
+ end: {
180
+ x: d.start.x + Math.sign(dx || 1) * side,
181
+ y: d.start.y + Math.sign(dy || 1) * side,
182
+ },
183
+ }
184
+ })
145
185
  } else {
146
186
  setDrag((d) => (d ? { start: d.start, end: pt } : null))
147
187
  }
148
188
  },
149
- [isDragging],
189
+ [isDragging, isPreset],
150
190
  )
151
191
 
152
192
  const onPointerUp = useCallback(() => {
@@ -225,8 +265,12 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
225
265
  projectId,
226
266
  captureMode: mode,
227
267
  cropRegion,
268
+ // In preset mode, the ThumbnailGenerator should keep the alpha
269
+ // channel transparent so the saved preset thumbnail composes
270
+ // cleanly onto any palette background.
271
+ transparent: isPreset,
228
272
  })
229
- }, [captureState, mode, drag, projectId])
273
+ }, [captureState, mode, drag, projectId, isPreset])
230
274
 
231
275
  if (!isCaptureMode) return null
232
276
 
@@ -250,31 +294,47 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
250
294
 
251
295
  return (
252
296
  <div className="pointer-events-none absolute inset-0 z-40" ref={overlayRef}>
253
- {/* Area mode: dim layer + crosshair cursor + drag-to-select */}
297
+ {/* Area mode: dim layer + crosshair cursor + drag-to-select.
298
+ *
299
+ * Preset mode reuses the same DOM but stays click-through: the
300
+ * crop frame is auto-staged and locked, so the user adjusts the
301
+ * camera (orbit / pan / zoom) instead of dragging the rect. The
302
+ * dim letterbox + dashed border still render via the inline
303
+ * `box-shadow` on the selection rect — they're cosmetic. */}
254
304
  {mode === 'area' && (
255
305
  <div
256
- className="pointer-events-auto absolute inset-0 bg-black/30"
257
- onPointerDown={onPointerDown}
258
- onPointerMove={(e) => {
259
- onPointerMove(e)
260
- // Update cursor: 'move' when hovering inside an existing selection
261
- if (!isDragging && drag && overlayRef.current) {
262
- const rect = overlayRef.current.getBoundingClientRect()
263
- const px = e.clientX - rect.left
264
- const py = e.clientY - rect.top
265
- const x0 = Math.min(drag.start.x, drag.end.x)
266
- const y0 = Math.min(drag.start.y, drag.end.y)
267
- const x1 = Math.max(drag.start.x, drag.end.x)
268
- const y1 = Math.max(drag.start.y, drag.end.y)
269
- e.currentTarget.style.cursor =
270
- px >= x0 && px <= x1 && py >= y0 && py <= y1 ? 'move' : 'crosshair'
271
- }
272
- }}
273
- onPointerUp={onPointerUp}
274
- style={{ cursor: 'crosshair' }}
306
+ className={
307
+ isPreset
308
+ ? 'pointer-events-none absolute inset-0'
309
+ : 'pointer-events-auto absolute inset-0 bg-black/30'
310
+ }
311
+ onPointerDown={isPreset ? undefined : onPointerDown}
312
+ onPointerMove={
313
+ isPreset
314
+ ? undefined
315
+ : (e) => {
316
+ onPointerMove(e)
317
+ // Update cursor: 'move' when hovering inside an existing selection
318
+ if (!isDragging && drag && overlayRef.current) {
319
+ const rect = overlayRef.current.getBoundingClientRect()
320
+ const px = e.clientX - rect.left
321
+ const py = e.clientY - rect.top
322
+ const x0 = Math.min(drag.start.x, drag.end.x)
323
+ const y0 = Math.min(drag.start.y, drag.end.y)
324
+ const x1 = Math.max(drag.start.x, drag.end.x)
325
+ const y1 = Math.max(drag.start.y, drag.end.y)
326
+ e.currentTarget.style.cursor =
327
+ px >= x0 && px <= x1 && py >= y0 && py <= y1 ? 'move' : 'crosshair'
328
+ }
329
+ }
330
+ }
331
+ onPointerUp={isPreset ? undefined : onPointerUp}
332
+ style={isPreset ? undefined : { cursor: 'crosshair' }}
275
333
  >
276
- {/* "No selection" hint */}
277
- {!selectionStyle && (
334
+ {/* "No selection" hint — only when the user has to draw the
335
+ area themselves (`standard` capture). Preset mode always
336
+ has a pre-staged square, so we never show it there. */}
337
+ {!selectionStyle && !isPreset && (
278
338
  <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
279
339
  <span className="rounded-full bg-black/40 px-4 py-2 text-sm text-white backdrop-blur-sm">
280
340
  Drag the area you want to capture
@@ -297,31 +357,34 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
297
357
  background: 'rgba(255,255,255,0.04)',
298
358
  }}
299
359
  >
300
- {/* Corner handles */}
301
- {(
302
- [
303
- { pos: { top: -5, left: -5 }, cursor: 'nwse-resize' },
304
- { pos: { top: -5, right: -5 }, cursor: 'nesw-resize' },
305
- { pos: { bottom: -5, left: -5 }, cursor: 'nesw-resize' },
306
- { pos: { bottom: -5, right: -5 }, cursor: 'nwse-resize' },
307
- ] as const
308
- ).map(({ pos, cursor }, i) => (
309
- <div
310
- key={i}
311
- onPointerDown={(e) => onCornerPointerDown(e, i)}
312
- style={{
313
- position: 'absolute',
314
- width: 10,
315
- height: 10,
316
- borderRadius: '50%',
317
- background: 'white',
318
- boxShadow: '0 1px 4px rgba(0,0,0,0.4)',
319
- pointerEvents: 'auto',
320
- cursor,
321
- ...pos,
322
- }}
323
- />
324
- ))}
360
+ {/* Corner handles — preset mode locks the frame to the
361
+ auto-staged centered square; the user adjusts the
362
+ camera instead. */}
363
+ {!isPreset &&
364
+ (
365
+ [
366
+ { pos: { top: -5, left: -5 }, cursor: 'nwse-resize' },
367
+ { pos: { top: -5, right: -5 }, cursor: 'nesw-resize' },
368
+ { pos: { bottom: -5, left: -5 }, cursor: 'nesw-resize' },
369
+ { pos: { bottom: -5, right: -5 }, cursor: 'nwse-resize' },
370
+ ] as const
371
+ ).map(({ pos, cursor }, i) => (
372
+ <div
373
+ key={i}
374
+ onPointerDown={(e) => onCornerPointerDown(e, i)}
375
+ style={{
376
+ position: 'absolute',
377
+ width: 10,
378
+ height: 10,
379
+ borderRadius: '50%',
380
+ background: 'white',
381
+ boxShadow: '0 1px 4px rgba(0,0,0,0.4)',
382
+ pointerEvents: 'auto',
383
+ cursor,
384
+ ...pos,
385
+ }}
386
+ />
387
+ ))}
325
388
  </div>
326
389
  )}
327
390
  </div>
@@ -343,7 +406,10 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
343
406
  {/* Bottom-center mode toolbar */}
344
407
  <div className="pointer-events-auto absolute bottom-6 left-1/2 -translate-x-1/2">
345
408
  {(() => {
346
- const modeButtons = (
409
+ // Preset capture mode locks both the crop shape (square) and
410
+ // the transparent-background output — hide the per-shape
411
+ // mode buttons so the user has nothing to second-guess.
412
+ const modeButtons = isPreset ? null : (
347
413
  <>
348
414
  <ModeButton
349
415
  active={mode === 'standard'}
@@ -408,8 +474,16 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
408
474
  if (isMobile) {
409
475
  return (
410
476
  <div className="flex flex-col items-stretch gap-2 rounded-2xl border border-white/10 bg-neutral-900/95 px-2 py-2 shadow-xl backdrop-blur-md">
411
- <div className="flex items-center justify-center gap-1">{modeButtons}</div>
412
- <div className="flex items-center justify-center gap-2 border-white/10 border-t pt-2">
477
+ {modeButtons && (
478
+ <div className="flex items-center justify-center gap-1">{modeButtons}</div>
479
+ )}
480
+ <div
481
+ className={
482
+ modeButtons
483
+ ? 'flex items-center justify-center gap-2 border-white/10 border-t pt-2'
484
+ : 'flex items-center justify-center gap-2'
485
+ }
486
+ >
413
487
  {resolutionDisplay}
414
488
  {captureButton}
415
489
  </div>
@@ -420,7 +494,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
420
494
  return (
421
495
  <div className="flex items-center gap-1 rounded-full border border-white/10 bg-neutral-900/95 px-2 py-2 shadow-xl backdrop-blur-md">
422
496
  {modeButtons}
423
- <div className="mx-1 h-4 w-px bg-white/10" />
497
+ {modeButtons && <div className="mx-1 h-4 w-px bg-white/10" />}
424
498
  {resolutionDisplay}
425
499
  <div className="mx-1 h-4 w-px bg-white/10" />
426
500
  {captureButton}
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { emitter, sceneRegistry } from '@pascal-app/core'
4
- import { SSGI_PARAMS, snapLevelsToTruePositions, useViewer } from '@pascal-app/viewer'
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'
@@ -62,6 +62,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
62
62
  useEffect(() => {
63
63
  const cam = new THREE.PerspectiveCamera(60, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.1, 1000)
64
64
  cam.layers.disable(EDITOR_LAYER)
65
+ cam.layers.disable(GRID_LAYER)
65
66
  thumbnailCameraRef.current = cam
66
67
 
67
68
  let mounted = true
@@ -73,7 +74,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
73
74
 
74
75
  // pass() handles MRT internally for all material types, including custom
75
76
  // shaders — unlike renderer.setMRT() which crashes on non-NodeMaterials.
76
- // pass() also respects camera.layers, so EDITOR_LAYER objects are filtered.
77
+ // pass() also respects camera.layers, so EDITOR_LAYER + GRID_LAYER objects are filtered.
77
78
  const scenePass = pass(scene, cam)
78
79
  scenePass.setMRT(
79
80
  mrt({
@@ -207,7 +208,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
207
208
  const restoreNodeVisibility = (() => {
208
209
  const saved = new Map<THREE.Object3D, boolean>()
209
210
  for (const type of ['scan', 'guide'] as const) {
210
- const ids = sceneRegistry.byType[type]
211
+ const ids = sceneRegistry.byType[type]!
211
212
  ids.forEach((id) => {
212
213
  const node = sceneRegistry.nodes.get(id)
213
214
  if (node) {
@@ -460,6 +461,11 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
460
461
  captureMode?: 'standard' | 'viewport' | 'area'
461
462
  cropRegion?: { x: number; y: number; width: number; height: number }
462
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
463
469
  }) => {
464
470
  await generate(event.snapLevels === true, event.captureMode, event.cropRegion)
465
471
  }
@@ -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,20 +148,28 @@ 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
 
@@ -154,16 +183,26 @@ export function useFloorplanBackgroundPlacement({
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