thumbnail generation
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { headers } from 'next/headers'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { supabaseAdmin } from '@/lib/supabase/server'
|
||||
|
||||
// POST /api/presets/[id]/thumbnail
|
||||
// Accepts a raw PNG blob, uploads to preset-thumbnails bucket, updates thumbnail_url
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await auth.api.getSession({ headers: await headers() })
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const { data: existing } = await supabaseAdmin
|
||||
.from('presets')
|
||||
.select('user_id')
|
||||
.eq('id', id)
|
||||
.single()
|
||||
|
||||
if (!existing || existing.user_id !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Not found or forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const blob = await req.blob()
|
||||
|
||||
const filename = `${id}/thumbnail.png`
|
||||
const { data: uploadData, error: uploadError } = await supabaseAdmin.storage
|
||||
.from('preset-thumbnails')
|
||||
.upload(filename, blob, {
|
||||
contentType: 'image/png',
|
||||
upsert: true,
|
||||
})
|
||||
|
||||
if (uploadError) {
|
||||
return NextResponse.json({ error: uploadError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const { data: urlData } = supabaseAdmin.storage
|
||||
.from('preset-thumbnails')
|
||||
.getPublicUrl(uploadData.path)
|
||||
|
||||
const thumbnailUrl = `${urlData.publicUrl}?t=${Date.now()}`
|
||||
|
||||
const { error: updateError } = await supabaseAdmin
|
||||
.from('presets')
|
||||
.update({ thumbnail_url: thumbnailUrl })
|
||||
.eq('id', id)
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: updateError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ thumbnail_url: thumbnailUrl })
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { FloatingActionMenu } from './floating-action-menu'
|
||||
import { Grid } from './grid'
|
||||
import { SelectionManager } from './selection-manager'
|
||||
import { SiteEdgeLabels } from './site-edge-labels'
|
||||
import { PresetThumbnailGenerator } from './preset-thumbnail-generator'
|
||||
import { ThumbnailGenerator } from './thumbnail-generator'
|
||||
|
||||
|
||||
@@ -120,6 +121,7 @@ export default function Editor({ projectId }: EditorProps) {
|
||||
<ToolManager />
|
||||
<CustomCameraControls />
|
||||
<ThumbnailGenerator projectId={projectId} />
|
||||
<PresetThumbnailGenerator />
|
||||
<SiteEdgeLabels />
|
||||
</Viewer>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
'use client'
|
||||
|
||||
import { emitter, sceneRegistry } from '@pascal-app/core'
|
||||
import { useThree } from '@react-three/fiber'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import * as THREE from 'three'
|
||||
|
||||
const THUMBNAIL_SIZE = 1080
|
||||
const CAMERA_FOV = 45
|
||||
|
||||
export const PresetThumbnailGenerator = () => {
|
||||
const gl = useThree((state) => state.gl)
|
||||
const scene = useThree((state) => state.scene)
|
||||
|
||||
const generate = useCallback(
|
||||
async ({ presetId, nodeId }: { presetId: string; nodeId: string }) => {
|
||||
const target = sceneRegistry.nodes.get(nodeId)
|
||||
if (!target) {
|
||||
console.error('❌ PresetThumbnail: node not found', nodeId)
|
||||
return
|
||||
}
|
||||
|
||||
// Compute each mesh's transform relative to the target node (cancels world
|
||||
// position/rotation), so the item is always rendered at origin with a known
|
||||
// neutral orientation regardless of where it's placed in the scene.
|
||||
target.updateWorldMatrix(true, true)
|
||||
const targetInverse = new THREE.Matrix4().copy(target.matrixWorld).invert()
|
||||
const relMatrix = new THREE.Matrix4()
|
||||
|
||||
const clones: THREE.Object3D[] = []
|
||||
target.traverse((obj) => {
|
||||
if (!(obj instanceof THREE.Mesh || obj instanceof THREE.Line || obj instanceof THREE.Points)) return
|
||||
const c = obj.clone(false) // shallow clone: copies geometry, material, visible — no children
|
||||
relMatrix.multiplyMatrices(targetInverse, obj.matrixWorld)
|
||||
relMatrix.decompose(c.position, c.quaternion, c.scale)
|
||||
scene.add(c)
|
||||
clones.push(c)
|
||||
})
|
||||
|
||||
if (clones.length === 0) {
|
||||
console.error('❌ PresetThumbnail: no renderable objects found', nodeId)
|
||||
return
|
||||
}
|
||||
|
||||
// Combined bounding box across all clones
|
||||
const box = new THREE.Box3()
|
||||
for (const c of clones) box.expandByObject(c)
|
||||
|
||||
if (box.isEmpty()) {
|
||||
for (const c of clones) scene.remove(c)
|
||||
console.error('❌ PresetThumbnail: empty bounding box', nodeId)
|
||||
return
|
||||
}
|
||||
|
||||
const sphere = new THREE.Sphere()
|
||||
box.getBoundingSphere(sphere)
|
||||
|
||||
// Camera: aspect matches canvas (center-cropped to square after render)
|
||||
const { width, height } = gl.domElement
|
||||
const camera = new THREE.PerspectiveCamera(CAMERA_FOV, width / height, 0.01, 1000)
|
||||
const dir = new THREE.Vector3(-0.5, 0.5, 0.5).normalize()
|
||||
const fovRad = (CAMERA_FOV * Math.PI) / 180
|
||||
const dist = (sphere.radius / Math.tan(fovRad / 2)) * 1.3
|
||||
camera.position.copy(sphere.center).addScaledVector(dir, dist)
|
||||
camera.lookAt(sphere.center)
|
||||
camera.updateProjectionMatrix()
|
||||
|
||||
// Hide all scene geometry except the clones — leave lights, cameras, etc. intact
|
||||
const cloneSet = new Set<THREE.Object3D>(clones)
|
||||
const snapshot = new Map<THREE.Object3D, boolean>()
|
||||
scene.traverse((obj) => {
|
||||
if (cloneSet.has(obj)) return
|
||||
if (!(obj instanceof THREE.Mesh || obj instanceof THREE.Line || obj instanceof THREE.Points)) return
|
||||
snapshot.set(obj, obj.visible)
|
||||
obj.visible = false
|
||||
})
|
||||
|
||||
gl.render(scene, camera)
|
||||
|
||||
// Restore visibility and remove clones
|
||||
snapshot.forEach((wasVisible, obj) => {
|
||||
obj.visible = wasVisible
|
||||
})
|
||||
for (const c of clones) scene.remove(c)
|
||||
|
||||
// Center-crop to square and scale to THUMBNAIL_SIZE
|
||||
const minDim = Math.min(width, height)
|
||||
const sx = Math.round((width - minDim) / 2)
|
||||
const sy = Math.round((height - minDim) / 2)
|
||||
const offscreen = document.createElement('canvas')
|
||||
offscreen.width = THUMBNAIL_SIZE
|
||||
offscreen.height = THUMBNAIL_SIZE
|
||||
const ctx = offscreen.getContext('2d')!
|
||||
ctx.drawImage(gl.domElement, sx, sy, minDim, minDim, 0, 0, THUMBNAIL_SIZE, THUMBNAIL_SIZE)
|
||||
|
||||
offscreen.toBlob(async (blob) => {
|
||||
if (!blob) {
|
||||
console.error('❌ PresetThumbnail: failed to create blob')
|
||||
return
|
||||
}
|
||||
const res = await fetch(`/api/presets/${presetId}/thumbnail`, {
|
||||
method: 'POST',
|
||||
body: blob,
|
||||
headers: { 'Content-Type': 'image/png' },
|
||||
})
|
||||
if (!res.ok) {
|
||||
console.error('❌ PresetThumbnail: upload failed', await res.text())
|
||||
}
|
||||
}, 'image/png')
|
||||
},
|
||||
[gl, scene],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
emitter.on('preset:generate-thumbnail', generate)
|
||||
return () => emitter.off('preset:generate-thumbnail', generate)
|
||||
}, [generate])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNode, type AnyNodeId, DoorNode, useScene } from '@pascal-app/core'
|
||||
import { type AnyNode, type AnyNodeId, DoorNode, emitter, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
@@ -141,23 +141,29 @@ export function DoorPanel() {
|
||||
|
||||
const handleSavePreset = useCallback(async (name: string) => {
|
||||
const data = getDoorPresetData()
|
||||
if (!data) return
|
||||
await fetch('/api/presets', {
|
||||
if (!data || !selectedId) return
|
||||
const res = await fetch('/api/presets', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: 'door', name, data }),
|
||||
})
|
||||
}, [getDoorPresetData])
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
const presetId = json.preset?.id
|
||||
if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: selectedId })
|
||||
}
|
||||
}, [getDoorPresetData, selectedId])
|
||||
|
||||
const handleOverwritePreset = useCallback(async (id: string) => {
|
||||
const data = getDoorPresetData()
|
||||
if (!data) return
|
||||
await fetch(`/api/presets/${id}`, {
|
||||
if (!data || !selectedId) return
|
||||
const res = await fetch(`/api/presets/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data }),
|
||||
})
|
||||
}, [getDoorPresetData])
|
||||
if (res.ok) emitter.emit('preset:generate-thumbnail', { presetId: id, nodeId: selectedId })
|
||||
}, [getDoorPresetData, selectedId])
|
||||
|
||||
const handleApplyPreset = useCallback((data: Record<string, unknown>) => {
|
||||
handleUpdate(data as Partial<DoorNode>)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNode, type AnyNodeId, WindowNode, useScene } from '@pascal-app/core'
|
||||
import { type AnyNode, type AnyNodeId, WindowNode, emitter, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
@@ -111,23 +111,29 @@ export function WindowPanel() {
|
||||
|
||||
const handleSavePreset = useCallback(async (name: string) => {
|
||||
const data = getWindowPresetData()
|
||||
if (!data) return
|
||||
await fetch('/api/presets', {
|
||||
if (!data || !selectedId) return
|
||||
const res = await fetch('/api/presets', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: 'window', name, data }),
|
||||
})
|
||||
}, [getWindowPresetData])
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
const presetId = json.preset?.id
|
||||
if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: selectedId })
|
||||
}
|
||||
}, [getWindowPresetData, selectedId])
|
||||
|
||||
const handleOverwritePreset = useCallback(async (id: string) => {
|
||||
const data = getWindowPresetData()
|
||||
if (!data) return
|
||||
await fetch(`/api/presets/${id}`, {
|
||||
if (!data || !selectedId) return
|
||||
const res = await fetch(`/api/presets/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data }),
|
||||
})
|
||||
}, [getWindowPresetData])
|
||||
if (res.ok) emitter.emit('preset:generate-thumbnail', { presetId: id, nodeId: selectedId })
|
||||
}, [getWindowPresetData, selectedId])
|
||||
|
||||
const handleApplyPreset = useCallback((data: Record<string, unknown>) => {
|
||||
handleUpdate(data as Partial<WindowNode>)
|
||||
|
||||
@@ -73,6 +73,10 @@ type ToolEvents = {
|
||||
'tool:cancel': undefined
|
||||
}
|
||||
|
||||
type PresetEvents = {
|
||||
'preset:generate-thumbnail': { presetId: string; nodeId: string }
|
||||
}
|
||||
|
||||
type EditorEvents = GridEvents &
|
||||
NodeEvents<'wall', WallEvent> &
|
||||
NodeEvents<'item', ItemEvent> &
|
||||
@@ -86,6 +90,7 @@ type EditorEvents = GridEvents &
|
||||
NodeEvents<'window', WindowEvent> &
|
||||
NodeEvents<'door', DoorEvent> &
|
||||
CameraControlEvents &
|
||||
ToolEvents
|
||||
ToolEvents &
|
||||
PresetEvents
|
||||
|
||||
export const emitter = mitt<EditorEvents>()
|
||||
|
||||
Reference in New Issue
Block a user