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()