thumbnail generation

This commit is contained in:
wass08
2026-03-04 15:13:07 +01:00
parent ba2dfa8caa
commit 23ed54cb6a
6 changed files with 213 additions and 15 deletions
@@ -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 })
}
+2
View File
@@ -25,6 +25,7 @@ import { FloatingActionMenu } from './floating-action-menu'
import { Grid } from './grid' import { Grid } from './grid'
import { SelectionManager } from './selection-manager' import { SelectionManager } from './selection-manager'
import { SiteEdgeLabels } from './site-edge-labels' import { SiteEdgeLabels } from './site-edge-labels'
import { PresetThumbnailGenerator } from './preset-thumbnail-generator'
import { ThumbnailGenerator } from './thumbnail-generator' import { ThumbnailGenerator } from './thumbnail-generator'
@@ -120,6 +121,7 @@ export default function Editor({ projectId }: EditorProps) {
<ToolManager /> <ToolManager />
<CustomCameraControls /> <CustomCameraControls />
<ThumbnailGenerator projectId={projectId} /> <ThumbnailGenerator projectId={projectId} />
<PresetThumbnailGenerator />
<SiteEdgeLabels /> <SiteEdgeLabels />
</Viewer> </Viewer>
</div> </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' '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 { useViewer } from '@pascal-app/viewer'
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import { useCallback } from 'react' import { useCallback } from 'react'
@@ -141,23 +141,29 @@ export function DoorPanel() {
const handleSavePreset = useCallback(async (name: string) => { const handleSavePreset = useCallback(async (name: string) => {
const data = getDoorPresetData() const data = getDoorPresetData()
if (!data) return if (!data || !selectedId) return
await fetch('/api/presets', { const res = await fetch('/api/presets', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'door', name, data }), 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 handleOverwritePreset = useCallback(async (id: string) => {
const data = getDoorPresetData() const data = getDoorPresetData()
if (!data) return if (!data || !selectedId) return
await fetch(`/api/presets/${id}`, { const res = await fetch(`/api/presets/${id}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data }), 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>) => { const handleApplyPreset = useCallback((data: Record<string, unknown>) => {
handleUpdate(data as Partial<DoorNode>) handleUpdate(data as Partial<DoorNode>)
@@ -1,6 +1,6 @@
'use client' '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 { useViewer } from '@pascal-app/viewer'
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import { useCallback } from 'react' import { useCallback } from 'react'
@@ -111,23 +111,29 @@ export function WindowPanel() {
const handleSavePreset = useCallback(async (name: string) => { const handleSavePreset = useCallback(async (name: string) => {
const data = getWindowPresetData() const data = getWindowPresetData()
if (!data) return if (!data || !selectedId) return
await fetch('/api/presets', { const res = await fetch('/api/presets', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'window', name, data }), 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 handleOverwritePreset = useCallback(async (id: string) => {
const data = getWindowPresetData() const data = getWindowPresetData()
if (!data) return if (!data || !selectedId) return
await fetch(`/api/presets/${id}`, { const res = await fetch(`/api/presets/${id}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data }), 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>) => { const handleApplyPreset = useCallback((data: Record<string, unknown>) => {
handleUpdate(data as Partial<WindowNode>) handleUpdate(data as Partial<WindowNode>)
+6 -1
View File
@@ -73,6 +73,10 @@ type ToolEvents = {
'tool:cancel': undefined 'tool:cancel': undefined
} }
type PresetEvents = {
'preset:generate-thumbnail': { presetId: string; nodeId: string }
}
type EditorEvents = GridEvents & type EditorEvents = GridEvents &
NodeEvents<'wall', WallEvent> & NodeEvents<'wall', WallEvent> &
NodeEvents<'item', ItemEvent> & NodeEvents<'item', ItemEvent> &
@@ -86,6 +90,7 @@ type EditorEvents = GridEvents &
NodeEvents<'window', WindowEvent> & NodeEvents<'window', WindowEvent> &
NodeEvents<'door', DoorEvent> & NodeEvents<'door', DoorEvent> &
CameraControlEvents & CameraControlEvents &
ToolEvents ToolEvents &
PresetEvents
export const emitter = mitt<EditorEvents>() export const emitter = mitt<EditorEvents>()