@pascal-app/editor 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/package.json +6 -6
  2. package/src/components/editor/custom-camera-controls.tsx +2 -1
  3. package/src/components/editor/editor-layout-v2.tsx +4 -3
  4. package/src/components/editor/first-person/build-collider-world.ts +5 -7
  5. package/src/components/editor/first-person/bvh-ecctrl.tsx +119 -54
  6. package/src/components/editor/first-person-controls.tsx +11 -11
  7. package/src/components/editor/floating-action-menu.tsx +0 -0
  8. package/src/components/editor/floorplan-panel.tsx +44 -37
  9. package/src/components/editor/index.tsx +68 -53
  10. package/src/components/editor/selection-manager.tsx +2 -2
  11. package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
  12. package/src/components/editor/thumbnail-generator.tsx +18 -61
  13. package/src/components/editor/use-floorplan-background-placement.ts +3 -3
  14. package/src/components/editor/wall-measurement-label.tsx +0 -0
  15. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
  16. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +6 -1
  17. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +5 -5
  18. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +10 -12
  19. package/src/components/systems/roof/roof-edit-system.tsx +1 -1
  20. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  21. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  22. package/src/components/systems/zone/zone-system.tsx +0 -0
  23. package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
  24. package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
  25. package/src/components/tools/fence/fence-tool.tsx +2 -2
  26. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +11 -8
  27. package/src/components/tools/fence/move-fence-tool.tsx +13 -9
  28. package/src/components/tools/item/move-tool.tsx +3 -6
  29. package/src/components/tools/item/placement-math.ts +2 -4
  30. package/src/components/tools/item/placement-strategies.ts +11 -10
  31. package/src/components/tools/item/use-draft-node.ts +0 -1
  32. package/src/components/tools/item/use-placement-coordinator.tsx +9 -111
  33. package/src/components/tools/roof/move-roof-tool.tsx +7 -2
  34. package/src/components/tools/select/box-select-tool.tsx +12 -17
  35. package/src/components/tools/shared/segment-angle.ts +1 -1
  36. package/src/components/tools/tool-manager.tsx +12 -12
  37. package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
  38. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +11 -8
  39. package/src/components/tools/wall/move-wall-tool.tsx +6 -4
  40. package/src/components/tools/wall/wall-drafting.ts +0 -0
  41. package/src/components/tools/wall/wall-tool.tsx +3 -3
  42. package/src/components/tools/zone/zone-tool.tsx +20 -5
  43. package/src/components/ui/action-menu/camera-actions.tsx +0 -0
  44. package/src/components/ui/action-menu/control-modes.tsx +7 -1
  45. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  46. package/src/components/ui/action-menu/index.tsx +35 -86
  47. package/src/components/ui/action-menu/view-toggles.tsx +19 -31
  48. package/src/components/ui/command-palette/editor-commands.tsx +6 -4
  49. package/src/components/ui/command-palette/index.tsx +4 -255
  50. package/src/components/ui/controls/material-picker.tsx +8 -5
  51. package/src/components/ui/floating-level-selector.tsx +1 -1
  52. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  53. package/src/components/ui/item-catalog/catalog-items.tsx +1742 -315
  54. package/src/components/ui/item-catalog/item-catalog.tsx +88 -46
  55. package/src/components/ui/level-duplicate-dialog.tsx +3 -5
  56. package/src/components/ui/panels/ceiling-panel.tsx +2 -3
  57. package/src/components/ui/panels/column-panel.tsx +62 -18
  58. package/src/components/ui/panels/door-panel.tsx +272 -265
  59. package/src/components/ui/panels/fence-panel.tsx +0 -5
  60. package/src/components/ui/panels/paint-panel.tsx +66 -41
  61. package/src/components/ui/panels/panel-manager.tsx +3 -32
  62. package/src/components/ui/panels/reference-panel.tsx +28 -13
  63. package/src/components/ui/panels/roof-panel.tsx +52 -2
  64. package/src/components/ui/panels/roof-segment-panel.tsx +0 -0
  65. package/src/components/ui/panels/slab-panel.tsx +0 -0
  66. package/src/components/ui/panels/spawn-panel.tsx +10 -4
  67. package/src/components/ui/panels/stair-panel.tsx +66 -14
  68. package/src/components/ui/panels/wall-panel.tsx +97 -1
  69. package/src/components/ui/panels/window-panel.tsx +13 -5
  70. package/src/components/ui/primitives/number-input.tsx +1 -1
  71. package/src/components/ui/primitives/sidebar.tsx +0 -0
  72. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  73. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  74. package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
  75. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -0
  76. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
  77. package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
  78. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
  79. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -7
  80. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +3 -1
  81. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
  82. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
  83. package/src/components/ui/slider.tsx +1 -1
  84. package/src/components/viewer-overlay.tsx +0 -0
  85. package/src/components/viewer-zone-system.tsx +0 -0
  86. package/src/hooks/use-auto-save.ts +14 -0
  87. package/src/hooks/use-keyboard.ts +10 -0
  88. package/src/index.tsx +8 -1
  89. package/src/lib/level-duplication.test.ts +0 -2
  90. package/src/lib/level-duplication.ts +1 -1
  91. package/src/lib/material-paint.ts +1 -1
  92. package/src/lib/roof-duplication.ts +1 -1
  93. package/src/lib/scene-bounds.ts +1 -1
  94. package/src/lib/scene.ts +0 -0
  95. package/src/lib/sfx-bus.ts +2 -0
  96. package/src/lib/sfx-player.ts +5 -5
  97. package/src/lib/stair-duplication.ts +2 -2
  98. package/src/store/use-editor.tsx +27 -59
  99. package/tsconfig.json +2 -1
  100. package/src/components/feedback-dialog.tsx +0 -265
  101. package/src/components/pascal-radio.tsx +0 -280
  102. package/src/components/preview-button.tsx +0 -16
  103. package/src/components/ui/viewer-toolbar.tsx +0 -436
@@ -0,0 +1,465 @@
1
+ 'use client'
2
+
3
+ import { emitter } from '@pascal-app/core'
4
+ import { Camera, Check, Crop, Loader2, Maximize2, Monitor, X } from 'lucide-react'
5
+ import { useCallback, useEffect, useRef, useState } from 'react'
6
+ import { useIsMobile } from '../../hooks/use-mobile'
7
+ import { triggerSFX } from '../../lib/sfx-bus'
8
+ import useEditor from '../../store/use-editor'
9
+
10
+ type CaptureMode = 'standard' | 'viewport' | 'area'
11
+ type CaptureState = 'idle' | 'capturing' | 'saved'
12
+
13
+ interface DragPoint {
14
+ x: number
15
+ y: number
16
+ }
17
+
18
+ interface Drag {
19
+ start: DragPoint
20
+ end: DragPoint
21
+ }
22
+
23
+ function getResolution(
24
+ mode: CaptureMode,
25
+ overlayEl: HTMLDivElement | null,
26
+ drag: Drag | null,
27
+ ): { w: number; h: number } | null {
28
+ if (mode === 'standard') return { w: 1920, h: 1080 }
29
+
30
+ if (!overlayEl) return null
31
+ const rect = overlayEl.getBoundingClientRect()
32
+ const dpr = Math.min(window.devicePixelRatio, 1.5)
33
+
34
+ if (mode === 'viewport') {
35
+ return { w: Math.round(rect.width * dpr), h: Math.round(rect.height * dpr) }
36
+ }
37
+
38
+ if (mode === 'area' && drag) {
39
+ const w = Math.abs(drag.end.x - drag.start.x)
40
+ const h = Math.abs(drag.end.y - drag.start.y)
41
+ if (w < 4 || h < 4) return null
42
+ return { w: Math.round(w * dpr), h: Math.round(h * dpr) }
43
+ }
44
+
45
+ return null
46
+ }
47
+
48
+ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
49
+ const isCaptureMode = useEditor((s) => s.isCaptureMode)
50
+ const setCaptureMode = useEditor((s) => s.setCaptureMode)
51
+ const isMobile = useIsMobile()
52
+
53
+ const [mode, setMode] = useState<CaptureMode>('standard')
54
+ const [drag, setDrag] = useState<Drag | null>(null)
55
+ const [isDragging, setIsDragging] = useState(false)
56
+ const [captureState, setCaptureState] = useState<CaptureState>('idle')
57
+ const overlayRef = useRef<HTMLDivElement>(null)
58
+
59
+ // Dismiss on Esc
60
+ useEffect(() => {
61
+ if (!isCaptureMode) return
62
+ const onKey = (e: KeyboardEvent) => {
63
+ if (e.key === 'Escape') setCaptureMode(false)
64
+ }
65
+ window.addEventListener('keydown', onKey)
66
+ return () => window.removeEventListener('keydown', onKey)
67
+ }, [isCaptureMode, setCaptureMode])
68
+
69
+ // Reset local state when entering capture mode
70
+ useEffect(() => {
71
+ if (isCaptureMode) {
72
+ setMode('standard')
73
+ setDrag(null)
74
+ setIsDragging(false)
75
+ setCaptureState('idle')
76
+ }
77
+ }, [isCaptureMode])
78
+
79
+ // Listen for snapshot saved to show feedback then exit
80
+ useEffect(() => {
81
+ const handler = () => {
82
+ setCaptureState('saved')
83
+ setTimeout(() => {
84
+ setCaptureMode(false)
85
+ setCaptureState('idle')
86
+ }, 1500)
87
+ }
88
+ emitter.on('snapshot:saved', handler)
89
+ return () => emitter.off('snapshot:saved', handler)
90
+ }, [setCaptureMode])
91
+
92
+ const dismiss = useCallback(() => setCaptureMode(false), [setCaptureMode])
93
+
94
+ // Tracks whether the active drag is a "move entire rect" gesture
95
+ const moveStartRef = useRef<{ pt: DragPoint; drag: Drag } | null>(null)
96
+
97
+ // Area drag handlers — relative to the overlay container
98
+ const onPointerDown = useCallback(
99
+ (e: React.PointerEvent<HTMLDivElement>) => {
100
+ if (mode !== 'area' || captureState !== 'idle') return
101
+ e.preventDefault()
102
+ const rect = overlayRef.current!.getBoundingClientRect()
103
+ const pt = { x: e.clientX - rect.left, y: e.clientY - rect.top }
104
+
105
+ // If clicking inside an existing selection → move mode
106
+ if (drag) {
107
+ const x0 = Math.min(drag.start.x, drag.end.x)
108
+ const y0 = Math.min(drag.start.y, drag.end.y)
109
+ const x1 = Math.max(drag.start.x, drag.end.x)
110
+ const y1 = Math.max(drag.start.y, drag.end.y)
111
+ if (pt.x >= x0 && pt.x <= x1 && pt.y >= y0 && pt.y <= y1) {
112
+ moveStartRef.current = { pt, drag }
113
+ setIsDragging(true)
114
+ ;(e.target as HTMLElement).setPointerCapture(e.pointerId)
115
+ return
116
+ }
117
+ }
118
+
119
+ // Outside / no selection → start new drag
120
+ moveStartRef.current = null
121
+ setDrag({ start: pt, end: pt })
122
+ setIsDragging(true)
123
+ ;(e.target as HTMLElement).setPointerCapture(e.pointerId)
124
+ },
125
+ [mode, captureState, drag],
126
+ )
127
+
128
+ const onPointerMove = useCallback(
129
+ (e: React.PointerEvent<HTMLDivElement>) => {
130
+ if (!isDragging) return
131
+ const rect = overlayRef.current!.getBoundingClientRect()
132
+ const pt = {
133
+ x: Math.max(0, Math.min(e.clientX - rect.left, rect.width)),
134
+ y: Math.max(0, Math.min(e.clientY - rect.top, rect.height)),
135
+ }
136
+ if (moveStartRef.current) {
137
+ // Move mode: translate the whole rect by the delta
138
+ const { pt: origin, drag: snapshot } = moveStartRef.current
139
+ const dx = pt.x - origin.x
140
+ const dy = pt.y - origin.y
141
+ setDrag({
142
+ start: { x: snapshot.start.x + dx, y: snapshot.start.y + dy },
143
+ end: { x: snapshot.end.x + dx, y: snapshot.end.y + dy },
144
+ })
145
+ } else {
146
+ setDrag((d) => (d ? { start: d.start, end: pt } : null))
147
+ }
148
+ },
149
+ [isDragging],
150
+ )
151
+
152
+ const onPointerUp = useCallback(() => {
153
+ const wasMoving = moveStartRef.current !== null
154
+ setIsDragging(false)
155
+ moveStartRef.current = null
156
+ // Clear the rect if the user just clicked without drawing (not a move gesture)
157
+ if (!wasMoving) {
158
+ setDrag((d) => {
159
+ if (!d) return null
160
+ const w = Math.abs(d.end.x - d.start.x)
161
+ const h = Math.abs(d.end.y - d.start.y)
162
+ return w < 4 && h < 4 ? null : d
163
+ })
164
+ }
165
+ }, [])
166
+
167
+ // Corner-handle resize: re-anchor to the opposite corner then reuse the same drag machinery
168
+ const onCornerPointerDown = useCallback(
169
+ (e: React.PointerEvent<HTMLDivElement>, cornerIndex: number) => {
170
+ if (captureState !== 'idle' || !drag) return
171
+ e.stopPropagation()
172
+ e.preventDefault()
173
+ moveStartRef.current = null
174
+ const x0 = Math.min(drag.start.x, drag.end.x)
175
+ const y0 = Math.min(drag.start.y, drag.end.y)
176
+ const x1 = Math.max(drag.start.x, drag.end.x)
177
+ const y1 = Math.max(drag.start.y, drag.end.y)
178
+ // anchor = opposite corner; dragged = current corner
179
+ const corners: [DragPoint, DragPoint][] = [
180
+ [
181
+ { x: x1, y: y1 },
182
+ { x: x0, y: y0 },
183
+ ], // TL → anchor BR
184
+ [
185
+ { x: x0, y: y1 },
186
+ { x: x1, y: y0 },
187
+ ], // TR → anchor BL
188
+ [
189
+ { x: x1, y: y0 },
190
+ { x: x0, y: y1 },
191
+ ], // BL → anchor TR
192
+ [
193
+ { x: x0, y: y0 },
194
+ { x: x1, y: y1 },
195
+ ], // BR → anchor TL
196
+ ]
197
+ const [anchor, current] = corners[cornerIndex]!
198
+ setDrag({ start: anchor, end: current })
199
+ setIsDragging(true)
200
+ },
201
+ [captureState, drag],
202
+ )
203
+
204
+ const handleCapture = useCallback(() => {
205
+ if (captureState !== 'idle') return
206
+
207
+ let cropRegion: { x: number; y: number; width: number; height: number } | undefined
208
+ if (mode === 'area' && drag && overlayRef.current) {
209
+ const rect = overlayRef.current.getBoundingClientRect()
210
+ const x0 = Math.min(drag.start.x, drag.end.x)
211
+ const y0 = Math.min(drag.start.y, drag.end.y)
212
+ const w = Math.abs(drag.end.x - drag.start.x)
213
+ const h = Math.abs(drag.end.y - drag.start.y)
214
+ cropRegion = {
215
+ x: x0 / rect.width,
216
+ y: y0 / rect.height,
217
+ width: w / rect.width,
218
+ height: h / rect.height,
219
+ }
220
+ }
221
+
222
+ setCaptureState('capturing')
223
+ triggerSFX('sfx:snapshot-capture')
224
+ emitter.emit('camera-controls:generate-thumbnail', {
225
+ projectId,
226
+ captureMode: mode,
227
+ cropRegion,
228
+ })
229
+ }, [captureState, mode, drag, projectId])
230
+
231
+ if (!isCaptureMode) return null
232
+
233
+ const resolution = getResolution(mode, overlayRef.current, drag)
234
+
235
+ // Area selection rect (CSS px, relative to overlay)
236
+ const selectionStyle =
237
+ mode === 'area' && drag
238
+ ? {
239
+ left: Math.min(drag.start.x, drag.end.x),
240
+ top: Math.min(drag.start.y, drag.end.y),
241
+ width: Math.abs(drag.end.x - drag.start.x),
242
+ height: Math.abs(drag.end.y - drag.start.y),
243
+ }
244
+ : null
245
+
246
+ const hasSelection =
247
+ selectionStyle != null && selectionStyle.width > 3 && selectionStyle.height > 3
248
+
249
+ const captureDisabled = captureState !== 'idle' || (mode === 'area' && !hasSelection)
250
+
251
+ return (
252
+ <div className="pointer-events-none absolute inset-0 z-40" ref={overlayRef}>
253
+ {/* Area mode: dim layer + crosshair cursor + drag-to-select */}
254
+ {mode === 'area' && (
255
+ <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' }}
275
+ >
276
+ {/* "No selection" hint */}
277
+ {!selectionStyle && (
278
+ <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
279
+ <span className="rounded-full bg-black/40 px-4 py-2 text-sm text-white backdrop-blur-sm">
280
+ Drag the area you want to capture
281
+ </span>
282
+ </div>
283
+ )}
284
+
285
+ {/* Selection rect */}
286
+ {selectionStyle && (
287
+ <div
288
+ style={{
289
+ position: 'absolute',
290
+ left: selectionStyle.left,
291
+ top: selectionStyle.top,
292
+ width: selectionStyle.width,
293
+ height: selectionStyle.height,
294
+ pointerEvents: 'none',
295
+ boxShadow: '0 0 0 9999px rgba(0,0,0,0.35)',
296
+ border: '2px dashed rgba(255,255,255,0.85)',
297
+ background: 'rgba(255,255,255,0.04)',
298
+ }}
299
+ >
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
+ ))}
325
+ </div>
326
+ )}
327
+ </div>
328
+ )}
329
+
330
+ {/* Top-right dismiss button (icon-only on mobile) */}
331
+ <div className="pointer-events-auto absolute top-4 right-4">
332
+ <button
333
+ aria-label="Close capture mode"
334
+ className="flex items-center gap-1.5 rounded-full border border-white/20 bg-black/60 px-3 py-1.5 text-white/80 text-xs backdrop-blur-sm transition-colors hover:bg-black/80 hover:text-white"
335
+ onClick={dismiss}
336
+ type="button"
337
+ >
338
+ <X className="h-3 w-3" />
339
+ {!isMobile && 'Esc to cancel'}
340
+ </button>
341
+ </div>
342
+
343
+ {/* Bottom-center mode toolbar */}
344
+ <div className="pointer-events-auto absolute bottom-6 left-1/2 -translate-x-1/2">
345
+ {(() => {
346
+ const modeButtons = (
347
+ <>
348
+ <ModeButton
349
+ active={mode === 'standard'}
350
+ badge="16:9"
351
+ icon={<Monitor className="h-3.5 w-3.5" />}
352
+ label="Standard"
353
+ onClick={() => {
354
+ setMode('standard')
355
+ setDrag(null)
356
+ }}
357
+ />
358
+ <ModeButton
359
+ active={mode === 'viewport'}
360
+ icon={<Maximize2 className="h-3.5 w-3.5" />}
361
+ label="Viewport"
362
+ onClick={() => {
363
+ setMode('viewport')
364
+ setDrag(null)
365
+ }}
366
+ />
367
+ <ModeButton
368
+ active={mode === 'area'}
369
+ icon={<Crop className="h-3.5 w-3.5" />}
370
+ label="Area"
371
+ onClick={() => setMode('area')}
372
+ />
373
+ </>
374
+ )
375
+
376
+ const resolutionDisplay = (
377
+ <span className="min-w-[80px] text-center text-white/50 text-xs tabular-nums">
378
+ {resolution ? `${resolution.w} × ${resolution.h}` : '—'}
379
+ </span>
380
+ )
381
+
382
+ const captureButton = (
383
+ <button
384
+ className="flex items-center gap-1.5 rounded-full bg-primary px-4 py-1.5 font-medium text-primary-foreground text-xs transition-opacity disabled:opacity-50"
385
+ disabled={captureDisabled}
386
+ onClick={handleCapture}
387
+ type="button"
388
+ >
389
+ {captureState === 'capturing' ? (
390
+ <>
391
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
392
+ Capturing
393
+ </>
394
+ ) : captureState === 'saved' ? (
395
+ <>
396
+ <Check className="h-3.5 w-3.5" />
397
+ Saved
398
+ </>
399
+ ) : (
400
+ <>
401
+ <Camera className="h-3.5 w-3.5" />
402
+ Capture
403
+ </>
404
+ )}
405
+ </button>
406
+ )
407
+
408
+ if (isMobile) {
409
+ return (
410
+ <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">
413
+ {resolutionDisplay}
414
+ {captureButton}
415
+ </div>
416
+ </div>
417
+ )
418
+ }
419
+
420
+ return (
421
+ <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
+ {modeButtons}
423
+ <div className="mx-1 h-4 w-px bg-white/10" />
424
+ {resolutionDisplay}
425
+ <div className="mx-1 h-4 w-px bg-white/10" />
426
+ {captureButton}
427
+ </div>
428
+ )
429
+ })()}
430
+ </div>
431
+ </div>
432
+ )
433
+ }
434
+
435
+ function ModeButton({
436
+ active,
437
+ icon,
438
+ label,
439
+ badge,
440
+ onClick,
441
+ }: {
442
+ active: boolean
443
+ icon: React.ReactNode
444
+ label: string
445
+ badge?: string
446
+ onClick: () => void
447
+ }) {
448
+ return (
449
+ <button
450
+ className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs transition-colors ${
451
+ active ? 'bg-white/15 text-white ring-1 ring-white/20' : 'text-white/50 hover:text-white/90'
452
+ }`}
453
+ onClick={onClick}
454
+ type="button"
455
+ >
456
+ {icon}
457
+ {label}
458
+ {badge && (
459
+ <span className="rounded-sm bg-white/10 px-1 py-0.5 font-medium text-[10px] text-white/40 leading-none">
460
+ {badge}
461
+ </span>
462
+ )}
463
+ </button>
464
+ )
465
+ }
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { emitter, sceneRegistry, useScene } from '@pascal-app/core'
3
+ import { emitter, sceneRegistry } from '@pascal-app/core'
4
4
  import { 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'
@@ -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,7 +58,7 @@ 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)
@@ -75,7 +72,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
75
72
  if (!mounted) return
76
73
 
77
74
  // pass() handles MRT internally for all material types, including custom
78
- // shaders, unlike renderer.setMRT() which crashes on non-NodeMaterials.
75
+ // shaders unlike renderer.setMRT() which crashes on non-NodeMaterials.
79
76
  // pass() also respects camera.layers, so EDITOR_LAYER objects are filtered.
80
77
  const scenePass = pass(scene, cam)
81
78
  scenePass.setMRT(
@@ -117,15 +114,15 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
117
114
  const ao = (denoisePass as any).r
118
115
  const finalOutput = vec4(scenePassColor.rgb.mul(ao), scenePassColor.a)
119
116
 
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.
117
+ // FXAA requires a texture node as input; convertToTexture renders finalOutput
118
+ // into an intermediate RT so FXAA can sample it with neighbour UV offsets.
122
119
  const aaOutput = fxaa(convertToTexture(finalOutput))
123
120
 
124
121
  const pipeline = new RenderPipeline(gl as unknown as WebGPURenderer)
125
122
  pipeline.outputNode = aaOutput
126
123
  pipelineRef.current = pipeline
127
124
 
128
- // Dedicated render target, pipeline outputs here instead of the canvas,
125
+ // Dedicated render target pipeline outputs here instead of the canvas,
129
126
  // so R3F's main render loop can never overwrite our capture.
130
127
  const { width, height } = gl.domElement
131
128
  renderTargetRef.current = new RenderTarget(width, height, { depthBuffer: true })
@@ -176,7 +173,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
176
173
  thumbnailCamera.aspect = width / height
177
174
  thumbnailCamera.updateProjectionMatrix()
178
175
 
179
- // Capture camera data for snapshot storage.
176
+ // Capture camera data for snapshot storage
180
177
  const pos = mainCamera.position
181
178
  let tgt: [number, number, number] | null = null
182
179
  if (controls && 'getTarget' in controls) {
@@ -192,7 +189,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
192
189
  ...(isOrtho && { zoom: (mainCamera as THREE.OrthographicCamera).zoom }),
193
190
  }
194
191
 
195
- // For auto-save, snap levels to stacked positions and reset levelMode.
192
+ // For auto-save: snap levels to stacked positions and reset levelMode
196
193
  let restoreLevelMode: (() => void) | null = null
197
194
  let restoreLevels: () => void = () => {}
198
195
  if (snapLevels) {
@@ -205,7 +202,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
205
202
  }
206
203
 
207
204
  // Hide scan and guide nodes directly so they are excluded from the
208
- // thumbnail regardless of whether ScanSystem or GuideSystem listeners are
205
+ // thumbnail regardless of whether ScanSystem/GuideSystem listeners are
209
206
  // registered. Returns a function that restores the original visibility.
210
207
  const restoreNodeVisibility = (() => {
211
208
  const saved = new Map<THREE.Object3D, boolean>()
@@ -231,7 +228,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
231
228
  if (pipelineRef.current && renderTargetRef.current) {
232
229
  const rt = renderTargetRef.current
233
230
 
234
- // Resize RT if the canvas dimensions changed.
231
+ // Resize RT if the canvas dimensions changed
235
232
  if (rt.width !== width || rt.height !== height) {
236
233
  rt.setSize(width, height)
237
234
  }
@@ -248,7 +245,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
248
245
  emitter.emit('thumbnail:after-capture', undefined)
249
246
 
250
247
  // Restore level positions, levelMode, and node visibility immediately after the
251
- // render, before the async GPU readback.
248
+ // render before the async GPU readback.
252
249
  restoreLevels()
253
250
  restoreLevelMode?.()
254
251
  restoreNodeVisibility()
@@ -339,6 +336,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
339
336
  offscreen.getContext('2d')!.drawImage(srcCanvas, sx, sy, outW, outH, 0, 0, outW, outH)
340
337
  blob = await offscreen.convertToBlob({ type: 'image/png' })
341
338
  } else {
339
+ // Standard: center-crop to 1920×1080 aspect ratio
342
340
  const srcAspect = width / height
343
341
  const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
344
342
  let sx = 0,
@@ -364,7 +362,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
364
362
  if (captureMode !== undefined) cameraData.captureMode = captureMode
365
363
  cameraData.resolution = { w: outW, h: outH }
366
364
  } else {
367
- // Fallback: plain render directly to the canvas.
365
+ // Fallback: plain render directly to the canvas
368
366
  emitter.emit('thumbnail:before-capture', undefined)
369
367
  gl.render(scene, thumbnailCamera)
370
368
  emitter.emit('thumbnail:after-capture', undefined)
@@ -450,10 +448,11 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
450
448
  )
451
449
 
452
450
  // Thumbnail request via emitter. Two call shapes:
453
- // - user-driven capture: `{ projectId, captureMode, cropRegion }`, captures
451
+ // - user-driven capture: `{ projectId, captureMode, cropRegion }` captures
454
452
  // 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.
453
+ // - host-driven auto-save: `{ projectId, snapLevels: true }` snaps levels
454
+ // to their true positions first for a consistent auto-thumbnail angle.
455
+ // The caller owns policy (when to fire, whether the tab is visible).
457
456
  useEffect(() => {
458
457
  if (!onThumbnailCapture) return
459
458
 
@@ -469,49 +468,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
469
468
  return () => emitter.off('camera-controls:generate-thumbnail', handleGenerateThumbnail)
470
469
  }, [generate, onThumbnailCapture])
471
470
 
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.
471
+ // Go-to-camera: animate camera to a saved snapshot position/target
515
472
  useEffect(() => {
516
473
  const handler = ({
517
474
  position,
@@ -144,11 +144,11 @@ export function useFloorplanBackgroundPlacement({
144
144
  emitFloorplanGridEvent('click', snappedPoint, event)
145
145
  setCursorPoint(snappedPoint)
146
146
 
147
- if (!roofDraftStart) {
147
+ if (roofDraftStart) {
148
+ clearRoofPlacementDraft()
149
+ } else {
148
150
  setRoofDraftStart(snappedPoint)
149
151
  setRoofDraftEnd(snappedPoint)
150
- } else {
151
- clearRoofPlacementDraft()
152
152
  }
153
153
  return true
154
154
  }
File without changes
@@ -72,7 +72,12 @@ export const FloorplanDraftLayer = memo(function FloorplanDraftLayer({
72
72
  )}
73
73
 
74
74
  {polygonDraftPolygonPoints && (
75
- <polygon fill={draftFill} fillOpacity={0.2} points={polygonDraftPolygonPoints} stroke="none" />
75
+ <polygon
76
+ fill={draftFill}
77
+ fillOpacity={0.2}
78
+ points={polygonDraftPolygonPoints}
79
+ stroke="none"
80
+ />
76
81
  )}
77
82
 
78
83
  {polygonDraftPolylinePoints && (