update: add a gltf viewer

This commit is contained in:
Tom Boullay
2026-04-03 11:32:56 +02:00
parent a44cd5dab3
commit 7ce3d61110
6 changed files with 924 additions and 21 deletions
+2 -2
View File
@@ -16,9 +16,9 @@ export default function Home() {
<UploadZone />
<footer className="mt-10 text-gray-500 text-xs text-center">
Models: <span className="font-mono text-gray-400">.glb · .gltf · .fbx</span>
Models: <span className="font-mono text-gray-400">.glb · .gltf</span>
<span className="mx-2">·</span>
Textures: <span className="font-mono text-gray-400">.png · .jpg · .webp · .ktx2</span>
Textures: <span className="font-mono text-gray-400">.png · .jpg · .webp</span>
<span className="mx-2">·</span>
Max size: <span className="font-mono text-gray-400">2 GB</span>
</footer>
+47
View File
@@ -0,0 +1,47 @@
'use client'
import { Suspense } from 'react'
import { Canvas } from '@react-three/fiber'
import { Stage, OrbitControls, useGLTF } from '@react-three/drei'
function Model({ url }: { url: string }) {
const { scene } = useGLTF(url)
return <primitive object={scene} />
}
function Loader() {
return (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-gray-500 border-t-gray-300 rounded-full animate-spin" />
</div>
)
}
interface ModelViewerProps {
url: string
filename: string
size: string
}
export default function ModelViewer({ url, filename, size }: ModelViewerProps) {
return (
<div className="w-full h-[450px] bg-black-800 border border-black-700 rounded-xl overflow-hidden relative">
<div className="absolute top-3 left-3 z-10 flex items-center gap-2">
<span className="text-xs text-gray-400 font-mono bg-black-900/60 px-2 py-1 rounded">
{filename}
</span>
<span className="text-xs text-gray-500 bg-black-900/60 px-2 py-1 rounded">
{size}
</span>
</div>
<Canvas shadows dpr={[1, 2]} camera={{ fov: 50 }}>
<Suspense fallback={null}>
<Stage environment="city" intensity={0.6} adjustCamera={1.2}>
<Model url={url} />
</Stage>
</Suspense>
<OrbitControls makeDefault autoRotate autoRotateSpeed={0.5} />
</Canvas>
</div>
)
}
+36 -9
View File
@@ -2,6 +2,9 @@
import { useCallback, useRef, useState } from 'react'
import { useDropzone, FileRejection } from 'react-dropzone'
import dynamic from 'next/dynamic'
const ModelViewer = dynamic(() => import('./ModelViewer'), { ssr: false })
type FileStatus = 'pending' | 'uploading' | 'success' | 'error'
@@ -11,6 +14,7 @@ interface FileEntry {
progress: number
error?: string
filename?: string
previewUrl?: string
}
interface UploadResult {
@@ -25,15 +29,13 @@ interface UploadResult {
const ACCEPTED_FORMATS = {
'model/gltf-binary': ['.glb'],
'model/gltf+json': ['.gltf'],
'application/octet-stream': ['.fbx'],
'image/png': ['.png'],
'image/jpeg': ['.jpg', '.jpeg'],
'image/webp': ['.webp'],
'image/ktx2': ['.ktx2'],
}
const MODEL_EXTENSIONS = ['.glb', '.gltf', '.fbx']
const TEXTURE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp', '.ktx2']
const MODEL_EXTENSIONS = ['.glb', '.gltf']
const TEXTURE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp']
const ALL_EXTENSIONS = [...MODEL_EXTENSIONS, ...TEXTURE_EXTENSIONS]
function formatBytes(bytes: number): string {
@@ -137,10 +139,17 @@ export default function UploadZone() {
const handleCancel = () => { xhrRef.current?.abort() }
const removeFile = (index: number) => {
const file = files[index]
if (file.previewUrl) {
URL.revokeObjectURL(file.previewUrl)
}
setFiles((prev) => prev.filter((_, i) => i !== index))
}
const handleReset = () => {
files.forEach((f) => {
if (f.previewUrl) URL.revokeObjectURL(f.previewUrl)
})
setFiles([])
setGlobalError(null)
setIsUploading(false)
@@ -164,7 +173,14 @@ export default function UploadZone() {
const existingNames = new Set(prev.map((f) => f.file.name))
const newEntries: FileEntry[] = acceptedFiles
.filter((f) => !existingNames.has(f.name))
.map((file) => ({ file, status: 'pending', progress: 0 }))
.map((file) => {
const entry: FileEntry = { file, status: 'pending', progress: 0 }
const type = getFileType(file.name)
if (type === 'model') {
entry.previewUrl = URL.createObjectURL(file)
}
return entry
})
return [...prev, ...newEntries]
})
}, [])
@@ -268,7 +284,7 @@ export default function UploadZone() {
<span className="text-gray-500 font-normal"> or click to browse</span>
</p>
<p className="text-xs text-gray-500 mt-1">
Models: .glb, .gltf, .fbx · Textures: .png, .jpg, .webp, .ktx2
Models: .glb, .gltf · Textures: .png, .jpg, .webp
</p>
</>
)}
@@ -283,8 +299,8 @@ export default function UploadZone() {
{files.map((entry, i) => {
const type = getFileType(entry.file.name)
return (
<div key={`${entry.file.name}-${i}`}
className="flex items-center gap-3 bg-black-800 border border-black-700 rounded-xl px-4 py-3">
<div key={`${entry.file.name}-${i}`}>
<div className="flex items-center gap-3 bg-black-800 border border-black-700 rounded-xl px-4 py-3">
<div className="shrink-0">
{entry.status === 'success' ? (
@@ -355,7 +371,18 @@ export default function UploadZone() {
</button>
)}
</div>
)
{entry.previewUrl && type === 'model' && entry.status !== 'success' && (
<div className="mt-2">
<ModelViewer
url={entry.previewUrl}
filename={entry.file.name}
size={formatBytes(entry.file.size)}
/>
</div>
)}
</div>
);
})}
</div>
)}
+833 -7
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -12,7 +12,10 @@
"next": "^16.1.7",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-dropzone": "^14.2.3"
"react-dropzone": "^14.2.3",
"three": "^0.170.0",
"@react-three/fiber": "^8.17.10",
"@react-three/drei": "^9.117.0"
},
"devDependencies": {
"@types/busboy": "^1.5.4",
+1 -1
View File
@@ -14,7 +14,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{