@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.
- package/package.json +6 -6
- package/src/components/editor/custom-camera-controls.tsx +2 -1
- package/src/components/editor/editor-layout-v2.tsx +4 -3
- package/src/components/editor/first-person/build-collider-world.ts +5 -7
- package/src/components/editor/first-person/bvh-ecctrl.tsx +119 -54
- package/src/components/editor/first-person-controls.tsx +11 -11
- package/src/components/editor/floating-action-menu.tsx +0 -0
- package/src/components/editor/floorplan-panel.tsx +44 -37
- package/src/components/editor/index.tsx +68 -53
- package/src/components/editor/selection-manager.tsx +2 -2
- package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
- package/src/components/editor/thumbnail-generator.tsx +18 -61
- package/src/components/editor/use-floorplan-background-placement.ts +3 -3
- package/src/components/editor/wall-measurement-label.tsx +0 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +6 -1
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +5 -5
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +10 -12
- package/src/components/systems/roof/roof-edit-system.tsx +1 -1
- package/src/components/systems/stair/stair-edit-system.tsx +1 -1
- package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
- package/src/components/systems/zone/zone-system.tsx +0 -0
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
- package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
- package/src/components/tools/fence/fence-tool.tsx +2 -2
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +11 -8
- package/src/components/tools/fence/move-fence-tool.tsx +13 -9
- package/src/components/tools/item/move-tool.tsx +3 -6
- package/src/components/tools/item/placement-math.ts +2 -4
- package/src/components/tools/item/placement-strategies.ts +11 -10
- package/src/components/tools/item/use-draft-node.ts +0 -1
- package/src/components/tools/item/use-placement-coordinator.tsx +9 -111
- package/src/components/tools/roof/move-roof-tool.tsx +7 -2
- package/src/components/tools/select/box-select-tool.tsx +12 -17
- package/src/components/tools/shared/segment-angle.ts +1 -1
- package/src/components/tools/tool-manager.tsx +12 -12
- package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +11 -8
- package/src/components/tools/wall/move-wall-tool.tsx +6 -4
- package/src/components/tools/wall/wall-drafting.ts +0 -0
- package/src/components/tools/wall/wall-tool.tsx +3 -3
- package/src/components/tools/zone/zone-tool.tsx +20 -5
- package/src/components/ui/action-menu/camera-actions.tsx +0 -0
- package/src/components/ui/action-menu/control-modes.tsx +7 -1
- package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
- package/src/components/ui/action-menu/index.tsx +35 -86
- package/src/components/ui/action-menu/view-toggles.tsx +19 -31
- package/src/components/ui/command-palette/editor-commands.tsx +6 -4
- package/src/components/ui/command-palette/index.tsx +4 -255
- package/src/components/ui/controls/material-picker.tsx +8 -5
- package/src/components/ui/floating-level-selector.tsx +1 -1
- package/src/components/ui/helpers/helper-manager.tsx +5 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1742 -315
- package/src/components/ui/item-catalog/item-catalog.tsx +88 -46
- package/src/components/ui/level-duplicate-dialog.tsx +3 -5
- package/src/components/ui/panels/ceiling-panel.tsx +2 -3
- package/src/components/ui/panels/column-panel.tsx +62 -18
- package/src/components/ui/panels/door-panel.tsx +272 -265
- package/src/components/ui/panels/fence-panel.tsx +0 -5
- package/src/components/ui/panels/paint-panel.tsx +66 -41
- package/src/components/ui/panels/panel-manager.tsx +3 -32
- package/src/components/ui/panels/reference-panel.tsx +28 -13
- package/src/components/ui/panels/roof-panel.tsx +52 -2
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -0
- package/src/components/ui/panels/slab-panel.tsx +0 -0
- package/src/components/ui/panels/spawn-panel.tsx +10 -4
- package/src/components/ui/panels/stair-panel.tsx +66 -14
- package/src/components/ui/panels/wall-panel.tsx +97 -1
- package/src/components/ui/panels/window-panel.tsx +13 -5
- package/src/components/ui/primitives/number-input.tsx +1 -1
- package/src/components/ui/primitives/sidebar.tsx +0 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
- package/src/components/ui/sidebar/icon-rail.tsx +0 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -0
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -7
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
- package/src/components/ui/slider.tsx +1 -1
- package/src/components/viewer-overlay.tsx +0 -0
- package/src/components/viewer-zone-system.tsx +0 -0
- package/src/hooks/use-auto-save.ts +14 -0
- package/src/hooks/use-keyboard.ts +10 -0
- package/src/index.tsx +8 -1
- package/src/lib/level-duplication.test.ts +0 -2
- package/src/lib/level-duplication.ts +1 -1
- package/src/lib/material-paint.ts +1 -1
- package/src/lib/roof-duplication.ts +1 -1
- package/src/lib/scene-bounds.ts +1 -1
- package/src/lib/scene.ts +0 -0
- package/src/lib/sfx-bus.ts +2 -0
- package/src/lib/sfx-player.ts +5 -5
- package/src/lib/stair-duplication.ts +2 -2
- package/src/store/use-editor.tsx +27 -59
- package/tsconfig.json +2 -1
- package/src/components/feedback-dialog.tsx +0 -265
- package/src/components/pascal-radio.tsx +0 -280
- package/src/components/preview-button.tsx +0 -16
- 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
|
|
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
|
|
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
|
|
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
|
|
121
|
-
// into an intermediate RT so FXAA can sample it with
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 }
|
|
451
|
+
// - user-driven capture: `{ projectId, captureMode, cropRegion }` — captures
|
|
454
452
|
// the current pose with the supplied crop.
|
|
455
|
-
// - auto-save
|
|
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
|
-
//
|
|
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 (
|
|
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
|
|
75
|
+
<polygon
|
|
76
|
+
fill={draftFill}
|
|
77
|
+
fillOpacity={0.2}
|
|
78
|
+
points={polygonDraftPolygonPoints}
|
|
79
|
+
stroke="none"
|
|
80
|
+
/>
|
|
76
81
|
)}
|
|
77
82
|
|
|
78
83
|
{polygonDraftPolylinePoints && (
|