diff --git a/apps/editor/app/api/presets/[id]/thumbnail/route.ts b/apps/editor/app/api/presets/[id]/thumbnail/route.ts
new file mode 100644
index 00000000..377c883b
--- /dev/null
+++ b/apps/editor/app/api/presets/[id]/thumbnail/route.ts
@@ -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 })
+}
diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx
index ec095031..0fe78eee 100644
--- a/apps/editor/components/editor/index.tsx
+++ b/apps/editor/components/editor/index.tsx
@@ -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) {
+
diff --git a/apps/editor/components/editor/preset-thumbnail-generator.tsx b/apps/editor/components/editor/preset-thumbnail-generator.tsx
new file mode 100644
index 00000000..b55a1f3a
--- /dev/null
+++ b/apps/editor/components/editor/preset-thumbnail-generator.tsx
@@ -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(clones)
+ const snapshot = new Map()
+ 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
+}
diff --git a/apps/editor/components/ui/panels/door-panel.tsx b/apps/editor/components/ui/panels/door-panel.tsx
index ed9f1f40..45ef66ce 100644
--- a/apps/editor/components/ui/panels/door-panel.tsx
+++ b/apps/editor/components/ui/panels/door-panel.tsx
@@ -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) => {
handleUpdate(data as Partial)
diff --git a/apps/editor/components/ui/panels/window-panel.tsx b/apps/editor/components/ui/panels/window-panel.tsx
index e4238aac..ed1b55b1 100644
--- a/apps/editor/components/ui/panels/window-panel.tsx
+++ b/apps/editor/components/ui/panels/window-panel.tsx
@@ -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) => {
handleUpdate(data as Partial)
diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts
index 61250974..8e9b9539 100644
--- a/packages/core/src/events/bus.ts
+++ b/packages/core/src/events/bus.ts
@@ -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()