Compare commits
1 commit
main
...
claude/cle
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
046f4c4ce1 |
6 changed files with 1589 additions and 0 deletions
427
src/components/AvatarPanel.tsx
Normal file
427
src/components/AvatarPanel.tsx
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
import React, { useState, useCallback } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { useAvatarStore } from '../store/avatar-store';
|
||||
import { AvatarViewer3D } from './AvatarViewer3D';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
import { Checkbox } from './ui/checkbox';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Separator } from './ui/separator';
|
||||
import { Badge } from './ui/badge';
|
||||
import {
|
||||
Upload,
|
||||
FileUp,
|
||||
Sparkles,
|
||||
Download,
|
||||
Settings,
|
||||
FolderOpen,
|
||||
Plus,
|
||||
Trash2,
|
||||
Play,
|
||||
Check,
|
||||
AlertCircle,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
import { platforms, type PlatformId } from '../lib/platforms';
|
||||
import type { AvatarProject } from '../lib/avatar/types';
|
||||
|
||||
interface AvatarPanelProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AvatarPanel({ className = '' }: AvatarPanelProps) {
|
||||
const {
|
||||
currentProject,
|
||||
projects,
|
||||
setCurrentProject,
|
||||
selectedPlatforms,
|
||||
setSelectedPlatforms,
|
||||
uploadProgress,
|
||||
} = useAvatarStore();
|
||||
|
||||
const [view, setView] = useState<'projects' | 'import' | 'export'>('projects');
|
||||
|
||||
// File upload handling
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
const file = acceptedFiles[0];
|
||||
if (file) {
|
||||
handleFileImport(file);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'model/gltf-binary': ['.glb'],
|
||||
'model/gltf+json': ['.gltf'],
|
||||
'model/fbx': ['.fbx'],
|
||||
'application/octet-stream': ['.vrm'],
|
||||
},
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
const handleFileImport = async (file: File) => {
|
||||
console.log('Importing file:', file.name);
|
||||
// TODO: Implement actual file import logic
|
||||
// This will:
|
||||
// 1. Upload file to storage
|
||||
// 2. Parse file format
|
||||
// 3. Extract model statistics
|
||||
// 4. Create AvatarProject
|
||||
// 5. Add to store
|
||||
};
|
||||
|
||||
const handlePlatformToggle = (platformId: PlatformId) => {
|
||||
setSelectedPlatforms(
|
||||
selectedPlatforms.includes(platformId)
|
||||
? selectedPlatforms.filter((p) => p !== platformId)
|
||||
: [...selectedPlatforms, platformId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!currentProject) return;
|
||||
console.log('Exporting to platforms:', selectedPlatforms);
|
||||
// TODO: Implement export logic
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex h-full bg-background ${className}`}>
|
||||
{/* Left Sidebar - Projects & Templates */}
|
||||
<div className="w-64 border-r border-border bg-surface p-4 flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||
<User size={18} />
|
||||
Avatar Studio
|
||||
</h2>
|
||||
|
||||
{/* View Selector */}
|
||||
<div className="flex flex-col gap-1 mb-4">
|
||||
<Button
|
||||
variant={view === 'projects' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setView('projects')}
|
||||
className="justify-start"
|
||||
>
|
||||
<FolderOpen size={16} className="mr-2" />
|
||||
My Avatars
|
||||
</Button>
|
||||
<Button
|
||||
variant={view === 'import' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setView('import')}
|
||||
className="justify-start"
|
||||
>
|
||||
<FileUp size={16} className="mr-2" />
|
||||
Import New
|
||||
</Button>
|
||||
<Button
|
||||
variant={view === 'export' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setView('export')}
|
||||
className="justify-start"
|
||||
disabled={!currentProject}
|
||||
>
|
||||
<Download size={16} className="mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator className="my-3" />
|
||||
</div>
|
||||
|
||||
{/* Projects List */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{projects.length} project{projects.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
{projects.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground text-center py-8">
|
||||
No avatars yet.
|
||||
<br />
|
||||
Import one to get started!
|
||||
</div>
|
||||
) : (
|
||||
projects.map((project) => (
|
||||
<button
|
||||
key={project.id}
|
||||
onClick={() => setCurrentProject(project)}
|
||||
className={`w-full text-left p-3 rounded-lg transition-colors ${
|
||||
currentProject?.id === project.id
|
||||
? 'bg-primary/20 border border-primary'
|
||||
: 'bg-neutral-900/50 hover:bg-neutral-900 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<p className="font-medium text-sm truncate">{project.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{project.polygonCount.toLocaleString()} polys
|
||||
</p>
|
||||
<div className="flex gap-1 mt-2">
|
||||
{project.isRigged && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Rigged
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{project.sourceFormat.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
onClick={() => setView('import')}
|
||||
className="w-full"
|
||||
size="sm"
|
||||
>
|
||||
<Plus size={16} className="mr-2" />
|
||||
New Avatar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* 3D Viewer */}
|
||||
<div className="flex-1 p-4">
|
||||
{view === 'projects' && (
|
||||
<div className="h-full">
|
||||
<AvatarViewer3D
|
||||
modelUrl={currentProject?.sourceFileUrl}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'import' && (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Card className="max-w-lg w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Import Avatar</CardTitle>
|
||||
<CardDescription>
|
||||
Upload a 3D model to get started. Supported formats: VRM, GLB, GLTF, FBX
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`border-2 border-dashed rounded-lg p-12 text-center cursor-pointer transition-colors ${
|
||||
isDragActive
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50'
|
||||
}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<Upload className="mx-auto mb-4 text-muted-foreground" size={48} />
|
||||
{isDragActive ? (
|
||||
<p className="text-lg">Drop your avatar file here...</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-lg mb-2">Drag & drop your avatar file</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">or click to browse</p>
|
||||
<Button variant="secondary">
|
||||
<FileUp size={16} className="mr-2" />
|
||||
Choose File
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{uploadProgress && (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm">{uploadProgress.fileName}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{uploadProgress.progress}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-neutral-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${uploadProgress.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-3">
|
||||
<Card className="p-4">
|
||||
<div className="text-center">
|
||||
<Sparkles className="mx-auto mb-2 text-primary" size={24} />
|
||||
<p className="text-sm font-medium">Auto-Rig</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
AI-powered rigging
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="text-center">
|
||||
<Download className="mx-auto mb-2 text-primary" size={24} />
|
||||
<p className="text-sm font-medium">Multi-Platform</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Export to 5+ platforms
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'export' && currentProject && (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Export to Platforms</CardTitle>
|
||||
<CardDescription>
|
||||
Select which platforms to export {currentProject.name} to
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Platform Selection */}
|
||||
<div className="space-y-3">
|
||||
{Object.values(platforms).map((platform) => {
|
||||
const isSelected = selectedPlatforms.includes(platform.id);
|
||||
const isSupported = currentProject.supportedPlatforms?.includes(platform.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={platform.id}
|
||||
className={`flex items-center justify-between p-4 rounded-lg border ${
|
||||
isSelected ? 'border-primary bg-primary/5' : 'border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id={platform.id}
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handlePlatformToggle(platform.id)}
|
||||
/>
|
||||
<label htmlFor={platform.id} className="flex items-center gap-2 cursor-pointer">
|
||||
<span className="text-2xl">{platform.icon}</span>
|
||||
<div>
|
||||
<p className="font-medium">{platform.displayName}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{platform.language}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isSupported ? (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Check size={12} />
|
||||
Compatible
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<AlertCircle size={12} />
|
||||
Needs Optimization
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Export Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{selectedPlatforms.length} platform{selectedPlatforms.length !== 1 ? 's' : ''} selected
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline">
|
||||
<Settings size={16} className="mr-2" />
|
||||
Configure
|
||||
</Button>
|
||||
<Button onClick={handleExport} disabled={selectedPlatforms.length === 0}>
|
||||
<Play size={16} className="mr-2" />
|
||||
Export All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar - Properties & Stats */}
|
||||
{currentProject && (
|
||||
<div className="w-80 border-l border-border bg-surface p-4 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3">Properties</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Name:</span>
|
||||
<span className="font-medium">{currentProject.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Format:</span>
|
||||
<span className="font-medium">{currentProject.sourceFormat.toUpperCase()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Polygons:</span>
|
||||
<span className="font-medium">{currentProject.polygonCount.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Textures:</span>
|
||||
<span className="font-medium">
|
||||
{currentProject.textureCount} ({currentProject.textureSize.toFixed(1)} MB)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Rig Type:</span>
|
||||
<span className="font-medium capitalize">{currentProject.rigType}</span>
|
||||
</div>
|
||||
{currentProject.boneCount && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Bones:</span>
|
||||
<span className="font-medium">{currentProject.boneCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3">Quick Actions</h3>
|
||||
<div className="space-y-2">
|
||||
<Button variant="outline" size="sm" className="w-full justify-start" disabled={currentProject.isRigged}>
|
||||
<Sparkles size={16} className="mr-2" />
|
||||
Auto-Rig
|
||||
{currentProject.isRigged && <Badge variant="secondary" className="ml-auto text-xs">Done</Badge>}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="w-full justify-start">
|
||||
<Settings size={16} className="mr-2" />
|
||||
Optimize
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="w-full justify-start" onClick={() => setView('export')}>
|
||||
<Download size={16} className="mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
<Separator className="my-2" />
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start text-destructive">
|
||||
<Trash2 size={16} className="mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
241
src/components/AvatarViewer3D.tsx
Normal file
241
src/components/AvatarViewer3D.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import React, { Suspense, useRef, useState } from 'react';
|
||||
import { Canvas, useFrame } from '@react-three/fiber';
|
||||
import { OrbitControls, Grid, useGLTF, PerspectiveCamera, Environment } from '@react-three/drei';
|
||||
import * as THREE from 'three';
|
||||
import { Button } from './ui/button';
|
||||
import { RotateCcw, Grid3x3, Eye, EyeOff, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
|
||||
interface AvatarViewer3DProps {
|
||||
modelUrl?: string;
|
||||
onError?: (error: Error) => void;
|
||||
showControls?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Model component that loads and displays the 3D model
|
||||
function Model({ url, onError }: { url: string; onError?: (error: Error) => void }) {
|
||||
const modelRef = useRef<THREE.Group>(null);
|
||||
const [rotation, setRotation] = useState(0);
|
||||
|
||||
// Load the GLTF/GLB model
|
||||
const { scene } = useGLTF(url, true);
|
||||
|
||||
// Auto-rotate the model slowly
|
||||
useFrame((state, delta) => {
|
||||
if (modelRef.current) {
|
||||
modelRef.current.rotation.y += delta * 0.2;
|
||||
setRotation(modelRef.current.rotation.y);
|
||||
}
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (scene) {
|
||||
// Center the model
|
||||
const box = new THREE.Box3().setFromObject(scene);
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
scene.position.sub(center);
|
||||
|
||||
// Scale to fit in view
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
const scale = 2 / maxDim;
|
||||
scene.scale.setScalar(scale);
|
||||
}
|
||||
}, [scene]);
|
||||
|
||||
return (
|
||||
<primitive
|
||||
ref={modelRef}
|
||||
object={scene}
|
||||
dispose={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback model when no file is loaded
|
||||
function FallbackModel() {
|
||||
return (
|
||||
<mesh>
|
||||
<boxGeometry args={[1, 1, 1]} />
|
||||
<meshStandardMaterial color="#8b5cf6" wireframe />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading component
|
||||
function LoadingFallback() {
|
||||
return (
|
||||
<mesh>
|
||||
<boxGeometry args={[0.5, 0.5, 0.5]} />
|
||||
<meshStandardMaterial color="#666" wireframe />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
export function AvatarViewer3D({
|
||||
modelUrl,
|
||||
onError,
|
||||
showControls = true,
|
||||
className = '',
|
||||
}: AvatarViewer3DProps) {
|
||||
const [showGrid, setShowGrid] = useState(true);
|
||||
const [showWireframe, setShowWireframe] = useState(false);
|
||||
const [autoRotate, setAutoRotate] = useState(true);
|
||||
const controlsRef = useRef<any>(null);
|
||||
|
||||
const handleReset = () => {
|
||||
if (controlsRef.current) {
|
||||
controlsRef.current.reset();
|
||||
}
|
||||
};
|
||||
|
||||
const handleZoomIn = () => {
|
||||
if (controlsRef.current) {
|
||||
controlsRef.current.dollyIn(1.2);
|
||||
}
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
if (controlsRef.current) {
|
||||
controlsRef.current.dollyOut(1.2);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative w-full h-full bg-neutral-950 rounded-lg overflow-hidden ${className}`}>
|
||||
{/* 3D Canvas */}
|
||||
<Canvas
|
||||
shadows
|
||||
camera={{ position: [0, 1.5, 3], fov: 50 }}
|
||||
onError={(error) => onError?.(error as Error)}
|
||||
>
|
||||
{/* Lighting */}
|
||||
<ambientLight intensity={0.5} />
|
||||
<directionalLight
|
||||
position={[5, 5, 5]}
|
||||
intensity={1}
|
||||
castShadow
|
||||
shadow-mapSize-width={2048}
|
||||
shadow-mapSize-height={2048}
|
||||
/>
|
||||
<spotLight position={[-5, 5, 5]} intensity={0.5} />
|
||||
|
||||
{/* Environment for better reflections */}
|
||||
<Environment preset="studio" />
|
||||
|
||||
{/* Grid */}
|
||||
{showGrid && (
|
||||
<Grid
|
||||
args={[10, 10]}
|
||||
cellSize={0.5}
|
||||
cellThickness={0.5}
|
||||
cellColor="#444"
|
||||
sectionSize={1}
|
||||
sectionThickness={1}
|
||||
sectionColor="#666"
|
||||
fadeDistance={20}
|
||||
fadeStrength={1}
|
||||
infiniteGrid
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Model */}
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
{modelUrl ? (
|
||||
<Model url={modelUrl} onError={onError} />
|
||||
) : (
|
||||
<FallbackModel />
|
||||
)}
|
||||
</Suspense>
|
||||
|
||||
{/* Camera Controls */}
|
||||
<OrbitControls
|
||||
ref={controlsRef}
|
||||
enableDamping
|
||||
dampingFactor={0.05}
|
||||
autoRotate={autoRotate}
|
||||
autoRotateSpeed={1}
|
||||
minDistance={1}
|
||||
maxDistance={10}
|
||||
/>
|
||||
</Canvas>
|
||||
|
||||
{/* Control Panel */}
|
||||
{showControls && (
|
||||
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 flex items-center gap-2 bg-neutral-900/90 backdrop-blur-sm rounded-lg p-2 border border-neutral-800">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setShowGrid(!showGrid)}
|
||||
title="Toggle Grid"
|
||||
>
|
||||
<Grid3x3 size={16} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setShowWireframe(!showWireframe)}
|
||||
title="Toggle Wireframe"
|
||||
>
|
||||
{showWireframe ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setAutoRotate(!autoRotate)}
|
||||
title="Toggle Auto-Rotate"
|
||||
className={autoRotate ? 'text-primary' : ''}
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
</Button>
|
||||
|
||||
<div className="w-px h-6 bg-neutral-700 mx-1" />
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleZoomIn}
|
||||
title="Zoom In"
|
||||
>
|
||||
<ZoomIn size={16} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleZoomOut}
|
||||
title="Zoom Out"
|
||||
>
|
||||
<ZoomOut size={16} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleReset}
|
||||
title="Reset Camera"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info overlay */}
|
||||
{!modelUrl && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p className="text-lg font-medium">No Model Loaded</p>
|
||||
<p className="text-sm mt-1">Import an avatar to preview it here</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Preload common models
|
||||
export function preloadModel(url: string) {
|
||||
useGLTF.preload(url);
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import { CertificationPanel } from '../CertificationPanel';
|
|||
import { DesktopAppPanel } from '../DesktopAppPanel';
|
||||
import { TeamCollabPanel } from '../TeamCollabPanel';
|
||||
import { EnterpriseAnalyticsPanel } from '../EnterpriseAnalyticsPanel';
|
||||
import { AvatarPanel } from '../AvatarPanel';
|
||||
|
||||
export function ExtraTabs({ user }: { user: any }) {
|
||||
return (
|
||||
|
|
@ -19,6 +20,7 @@ export function ExtraTabs({ user }: { user: any }) {
|
|||
<TabsList className="w-full rounded-none border-b border-border">
|
||||
<TabsTrigger value="onboarding">Onboarding</TabsTrigger>
|
||||
<TabsTrigger value="profile">Profile</TabsTrigger>
|
||||
<TabsTrigger value="avatars">Avatars</TabsTrigger>
|
||||
<TabsTrigger value="uefn">UEFN</TabsTrigger>
|
||||
<TabsTrigger value="preview">Preview</TabsTrigger>
|
||||
<TabsTrigger value="translation">Translation</TabsTrigger>
|
||||
|
|
@ -32,6 +34,7 @@ export function ExtraTabs({ user }: { user: any }) {
|
|||
</TabsList>
|
||||
<TabsContent value="onboarding"><OnboardingDialog open={true} onClose={() => {}} /></TabsContent>
|
||||
<TabsContent value="profile"><UserProfile user={user} /></TabsContent>
|
||||
<TabsContent value="avatars" className="flex-1"><AvatarPanel /></TabsContent>
|
||||
<TabsContent value="uefn"><UEFNPanel /></TabsContent>
|
||||
<TabsContent value="preview"><GamePreviewPanel /></TabsContent>
|
||||
<TabsContent value="translation"><TranslationPanel /></TabsContent>
|
||||
|
|
|
|||
241
src/lib/avatar/types.ts
Normal file
241
src/lib/avatar/types.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import type { PlatformId } from '../platforms';
|
||||
|
||||
/**
|
||||
* Avatar file format types
|
||||
*/
|
||||
export type AvatarFormat = 'vrm' | 'fbx' | 'glb' | 'gltf' | 'obj';
|
||||
|
||||
/**
|
||||
* Avatar rig types
|
||||
*/
|
||||
export type RigType = 'humanoid' | 'r15' | 'r6' | 'custom' | 'unrigged';
|
||||
|
||||
/**
|
||||
* Auto-rigging service providers
|
||||
*/
|
||||
export type RiggingService = 'accurig' | 'tripo' | 'deepmotion' | 'custom' | 'manual';
|
||||
|
||||
/**
|
||||
* Export job status
|
||||
*/
|
||||
export type ExportStatus = 'idle' | 'validating' | 'processing' | 'completed' | 'failed';
|
||||
|
||||
/**
|
||||
* Avatar project metadata
|
||||
*/
|
||||
export interface AvatarProject {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
// File information
|
||||
sourceFormat: AvatarFormat;
|
||||
sourceFileUrl?: string;
|
||||
sourceFileSize: number; // bytes
|
||||
thumbnailUrl?: string;
|
||||
|
||||
// Model statistics
|
||||
polygonCount: number;
|
||||
vertexCount: number;
|
||||
textureCount: number;
|
||||
textureSize: number; // total MB
|
||||
|
||||
// Rig information
|
||||
rigType: RigType;
|
||||
isRigged: boolean;
|
||||
boneCount?: number;
|
||||
riggingService?: RiggingService;
|
||||
|
||||
// Platform compatibility
|
||||
supportedPlatforms: PlatformId[];
|
||||
exportedPlatforms: PlatformId[];
|
||||
|
||||
// Tags and categorization
|
||||
tags: string[];
|
||||
category?: 'humanoid' | 'stylized' | 'realistic' | 'creature' | 'vehicle' | 'prop';
|
||||
style?: 'anime' | 'realistic' | 'lowpoly' | 'voxel' | 'custom';
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform-specific export configuration
|
||||
*/
|
||||
export interface PlatformExportConfig {
|
||||
platform: PlatformId;
|
||||
enabled: boolean;
|
||||
|
||||
// Optimization settings
|
||||
targetPolygonCount?: number;
|
||||
targetTextureSize?: number;
|
||||
generateLODs?: boolean;
|
||||
lodLevels?: number;
|
||||
|
||||
// Platform-specific settings
|
||||
roblox?: {
|
||||
rigType: 'r15' | 'r6';
|
||||
includeLayeredClothing: boolean;
|
||||
includeAccessories: boolean;
|
||||
};
|
||||
vrchat?: {
|
||||
targetPerformance: 'excellent' | 'good' | 'medium' | 'poor';
|
||||
questCompatible: boolean;
|
||||
questPolyLimit: number;
|
||||
includeVisemes: boolean;
|
||||
includeEyeTracking: boolean;
|
||||
};
|
||||
spatial?: {
|
||||
avatarType: 'universal' | 'world';
|
||||
embedTextures: boolean;
|
||||
};
|
||||
recroom?: {
|
||||
itemType: 'full-avatar' | 'accessory';
|
||||
beanCompatible: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Export validation result
|
||||
*/
|
||||
export interface ValidationResult {
|
||||
platform: PlatformId;
|
||||
isValid: boolean;
|
||||
warnings: ValidationMessage[];
|
||||
errors: ValidationMessage[];
|
||||
|
||||
// Performance metrics
|
||||
performanceRank?: 'excellent' | 'good' | 'medium' | 'poor';
|
||||
estimatedFileSize?: number; // MB
|
||||
|
||||
// Platform-specific checks
|
||||
checks: {
|
||||
polygonCount: boolean;
|
||||
textureSize: boolean;
|
||||
materialCount: boolean;
|
||||
rigStructure: boolean;
|
||||
boneCount: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ValidationMessage {
|
||||
type: 'error' | 'warning' | 'info';
|
||||
message: string;
|
||||
fix?: string; // Suggested fix
|
||||
}
|
||||
|
||||
/**
|
||||
* Export job
|
||||
*/
|
||||
export interface ExportJob {
|
||||
id: string;
|
||||
avatarId: string;
|
||||
platform: PlatformId;
|
||||
status: ExportStatus;
|
||||
progress: number; // 0-100
|
||||
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
|
||||
// Results
|
||||
outputFileUrl?: string;
|
||||
outputFileSize?: number;
|
||||
validationResult?: ValidationResult;
|
||||
|
||||
// Error handling
|
||||
error?: string;
|
||||
retryCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Avatar template for starting new projects
|
||||
*/
|
||||
export interface AvatarTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
thumbnailUrl: string;
|
||||
modelUrl: string;
|
||||
|
||||
category: 'humanoid' | 'stylized' | 'realistic' | 'creature';
|
||||
style: 'anime' | 'realistic' | 'lowpoly' | 'voxel';
|
||||
|
||||
polygonCount: number;
|
||||
isRigged: boolean;
|
||||
rigType: RigType;
|
||||
|
||||
supportedPlatforms: PlatformId[];
|
||||
isPremium: boolean;
|
||||
price?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-rigging request
|
||||
*/
|
||||
export interface RiggingRequest {
|
||||
avatarId: string;
|
||||
service: RiggingService;
|
||||
sourceFileUrl: string;
|
||||
|
||||
options: {
|
||||
quality: 'fast' | 'balanced' | 'high';
|
||||
rigType: 'humanoid' | 'quadruped' | 'custom';
|
||||
preserveBlendShapes?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-rigging result
|
||||
*/
|
||||
export interface RiggingResult {
|
||||
success: boolean;
|
||||
riggedFileUrl?: string;
|
||||
|
||||
rigInfo?: {
|
||||
boneCount: number;
|
||||
rootBone: string;
|
||||
hasFingers: boolean;
|
||||
hasFootIK: boolean;
|
||||
hasFacialRig: boolean;
|
||||
};
|
||||
|
||||
processingTime: number; // seconds
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* File upload progress
|
||||
*/
|
||||
export interface UploadProgress {
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
uploadedBytes: number;
|
||||
progress: number; // 0-100
|
||||
status: 'uploading' | 'processing' | 'completed' | 'failed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Onboarding step
|
||||
*/
|
||||
export interface OnboardingStep {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* User preferences for avatar studio
|
||||
*/
|
||||
export interface AvatarPreferences {
|
||||
defaultExportFormat: AvatarFormat;
|
||||
autoOptimize: boolean;
|
||||
defaultRiggingService: RiggingService;
|
||||
|
||||
// Platform defaults
|
||||
defaultPlatforms: PlatformId[];
|
||||
|
||||
// UI preferences
|
||||
showTutorials: boolean;
|
||||
gridSnap: boolean;
|
||||
wireframeMode: boolean;
|
||||
}
|
||||
422
src/lib/avatar/validators.ts
Normal file
422
src/lib/avatar/validators.ts
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
import type { AvatarProject, ValidationResult, ValidationMessage } from './types';
|
||||
import type { PlatformId } from '../platforms';
|
||||
|
||||
/**
|
||||
* Platform-specific requirements and limits
|
||||
*/
|
||||
const PLATFORM_LIMITS = {
|
||||
roblox: {
|
||||
maxPolygons: 50000,
|
||||
maxTextureSize: 1024,
|
||||
maxTextures: 10,
|
||||
maxBones: 75,
|
||||
requiresRig: true,
|
||||
},
|
||||
vrchat: {
|
||||
pc: {
|
||||
excellent: { maxPolygons: 32000, maxMaterials: 4, maxTextureMemory: 10 },
|
||||
good: { maxPolygons: 70000, maxMaterials: 8, maxTextureMemory: 40 },
|
||||
medium: { maxPolygons: 100000, maxMaterials: 16, maxTextureMemory: 75 },
|
||||
poor: { maxPolygons: 150000, maxMaterials: 32, maxTextureMemory: 150 },
|
||||
},
|
||||
quest: {
|
||||
excellent: { maxPolygons: 7500, maxMaterials: 1, maxTextureMemory: 10 },
|
||||
good: { maxPolygons: 10000, maxMaterials: 2, maxTextureMemory: 20 },
|
||||
medium: { maxPolygons: 15000, maxMaterials: 4, maxTextureMemory: 30 },
|
||||
poor: { maxPolygons: 20000, maxMaterials: 8, maxTextureMemory: 40 },
|
||||
},
|
||||
requiresRig: true,
|
||||
requiresVisemes: false,
|
||||
},
|
||||
spatial: {
|
||||
maxPolygons: 100000,
|
||||
maxTextureSize: 2048,
|
||||
maxTextures: 16,
|
||||
requiresRig: true,
|
||||
requiresHumanoidRig: true,
|
||||
},
|
||||
recroom: {
|
||||
maxPolygons: 15000,
|
||||
maxTextureSize: 1024,
|
||||
maxTextures: 5,
|
||||
requiresRig: false,
|
||||
},
|
||||
uefn: {
|
||||
maxPolygons: 100000,
|
||||
maxTextureSize: 2048,
|
||||
maxTextures: 20,
|
||||
requiresRig: false,
|
||||
},
|
||||
core: {
|
||||
maxPolygons: 50000,
|
||||
maxTextureSize: 1024,
|
||||
maxTextures: 10,
|
||||
requiresRig: false,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Validate avatar for Roblox platform
|
||||
*/
|
||||
function validateRoblox(avatar: AvatarProject): ValidationResult {
|
||||
const errors: ValidationMessage[] = [];
|
||||
const warnings: ValidationMessage[] = [];
|
||||
const limits = PLATFORM_LIMITS.roblox;
|
||||
|
||||
// Check polygon count
|
||||
if (avatar.polygonCount > limits.maxPolygons) {
|
||||
errors.push({
|
||||
type: 'error',
|
||||
message: `Polygon count (${avatar.polygonCount.toLocaleString()}) exceeds Roblox recommended limit (${limits.maxPolygons.toLocaleString()})`,
|
||||
fix: 'Use polygon reduction tool to optimize the model',
|
||||
});
|
||||
} else if (avatar.polygonCount > limits.maxPolygons * 0.8) {
|
||||
warnings.push({
|
||||
type: 'warning',
|
||||
message: `Polygon count is high (${avatar.polygonCount.toLocaleString()}). Consider optimizing for better performance.`,
|
||||
fix: 'Reduce polygon count to improve performance',
|
||||
});
|
||||
}
|
||||
|
||||
// Check texture size
|
||||
if (avatar.textureSize > limits.maxTextures * 1) {
|
||||
warnings.push({
|
||||
type: 'warning',
|
||||
message: `Total texture size (${avatar.textureSize.toFixed(1)} MB) is high`,
|
||||
fix: 'Compress textures or reduce texture resolution',
|
||||
});
|
||||
}
|
||||
|
||||
// Check rig
|
||||
if (!avatar.isRigged) {
|
||||
errors.push({
|
||||
type: 'error',
|
||||
message: 'Roblox requires a rigged model (R15 or R6)',
|
||||
fix: 'Use the Auto-Rig feature to add a skeleton',
|
||||
});
|
||||
} else if (avatar.rigType !== 'r15' && avatar.rigType !== 'r6') {
|
||||
warnings.push({
|
||||
type: 'warning',
|
||||
message: `Rig type (${avatar.rigType}) may need conversion to R15 or R6`,
|
||||
fix: 'Auto-conversion will be applied during export',
|
||||
});
|
||||
}
|
||||
|
||||
// Check bone count
|
||||
if (avatar.boneCount && avatar.boneCount > limits.maxBones) {
|
||||
warnings.push({
|
||||
type: 'warning',
|
||||
message: `Bone count (${avatar.boneCount}) exceeds typical Roblox limit (${limits.maxBones})`,
|
||||
fix: 'Simplify rig structure',
|
||||
});
|
||||
}
|
||||
|
||||
const isValid = errors.length === 0;
|
||||
|
||||
return {
|
||||
platform: 'roblox',
|
||||
isValid,
|
||||
errors,
|
||||
warnings,
|
||||
performanceRank: getPerformanceRank(avatar.polygonCount, limits.maxPolygons),
|
||||
checks: {
|
||||
polygonCount: avatar.polygonCount <= limits.maxPolygons,
|
||||
textureSize: true, // Roblox is flexible with texture sizes
|
||||
materialCount: true,
|
||||
rigStructure: avatar.isRigged,
|
||||
boneCount: !avatar.boneCount || avatar.boneCount <= limits.maxBones,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate avatar for VRChat platform
|
||||
*/
|
||||
function validateVRChat(
|
||||
avatar: AvatarProject,
|
||||
targetPlatform: 'pc' | 'quest' = 'pc'
|
||||
): ValidationResult {
|
||||
const errors: ValidationMessage[] = [];
|
||||
const warnings: ValidationMessage[] = [];
|
||||
const limits = PLATFORM_LIMITS.vrchat;
|
||||
|
||||
// Determine performance rank based on polygon count
|
||||
let performanceRank: 'excellent' | 'good' | 'medium' | 'poor' = 'poor';
|
||||
const platformLimits = limits[targetPlatform];
|
||||
|
||||
if (avatar.polygonCount <= platformLimits.excellent.maxPolygons) {
|
||||
performanceRank = 'excellent';
|
||||
} else if (avatar.polygonCount <= platformLimits.good.maxPolygons) {
|
||||
performanceRank = 'good';
|
||||
} else if (avatar.polygonCount <= platformLimits.medium.maxPolygons) {
|
||||
performanceRank = 'medium';
|
||||
}
|
||||
|
||||
// Check against limits for the determined rank
|
||||
const rankLimits = platformLimits[performanceRank];
|
||||
|
||||
if (avatar.polygonCount > platformLimits.poor.maxPolygons) {
|
||||
errors.push({
|
||||
type: 'error',
|
||||
message: `Polygon count (${avatar.polygonCount.toLocaleString()}) exceeds VRChat ${targetPlatform.toUpperCase()} maximum (${platformLimits.poor.maxPolygons.toLocaleString()})`,
|
||||
fix: 'Use polygon reduction to meet platform limits',
|
||||
});
|
||||
} else if (performanceRank === 'poor') {
|
||||
warnings.push({
|
||||
type: 'warning',
|
||||
message: `Avatar will be rated as "Poor" performance in VRChat ${targetPlatform.toUpperCase()}`,
|
||||
fix: `Reduce polygons to ${platformLimits.medium.maxPolygons.toLocaleString()} for Medium rank`,
|
||||
});
|
||||
}
|
||||
|
||||
// Check texture memory
|
||||
if (avatar.textureSize > rankLimits.maxTextureMemory) {
|
||||
warnings.push({
|
||||
type: 'warning',
|
||||
message: `Texture memory (${avatar.textureSize.toFixed(1)} MB) may affect performance rank`,
|
||||
fix: 'Compress or downsize textures',
|
||||
});
|
||||
}
|
||||
|
||||
// Check rig
|
||||
if (!avatar.isRigged) {
|
||||
errors.push({
|
||||
type: 'error',
|
||||
message: 'VRChat requires a humanoid rig',
|
||||
fix: 'Use Auto-Rig to add Avatar 3.0 compatible skeleton',
|
||||
});
|
||||
}
|
||||
|
||||
const isValid = errors.length === 0;
|
||||
|
||||
return {
|
||||
platform: 'vrchat',
|
||||
isValid,
|
||||
errors,
|
||||
warnings,
|
||||
performanceRank,
|
||||
checks: {
|
||||
polygonCount: avatar.polygonCount <= platformLimits.poor.maxPolygons,
|
||||
textureSize: avatar.textureSize <= rankLimits.maxTextureMemory,
|
||||
materialCount: true, // Will check material count in full export
|
||||
rigStructure: avatar.isRigged && avatar.rigType === 'humanoid',
|
||||
boneCount: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate avatar for Spatial platform
|
||||
*/
|
||||
function validateSpatial(avatar: AvatarProject): ValidationResult {
|
||||
const errors: ValidationMessage[] = [];
|
||||
const warnings: ValidationMessage[] = [];
|
||||
const limits = PLATFORM_LIMITS.spatial;
|
||||
|
||||
// Check polygon count
|
||||
if (avatar.polygonCount > limits.maxPolygons) {
|
||||
errors.push({
|
||||
type: 'error',
|
||||
message: `Polygon count (${avatar.polygonCount.toLocaleString()}) exceeds Spatial limit (${limits.maxPolygons.toLocaleString()})`,
|
||||
fix: 'Reduce polygon count using optimization tool',
|
||||
});
|
||||
}
|
||||
|
||||
// Check texture count and size
|
||||
if (avatar.textureCount > limits.maxTextures) {
|
||||
warnings.push({
|
||||
type: 'warning',
|
||||
message: `Texture count (${avatar.textureCount}) exceeds recommended limit (${limits.maxTextures})`,
|
||||
fix: 'Combine textures or reduce texture usage',
|
||||
});
|
||||
}
|
||||
|
||||
// Check rig
|
||||
if (!avatar.isRigged) {
|
||||
errors.push({
|
||||
type: 'error',
|
||||
message: 'Spatial requires a humanoid rig',
|
||||
fix: 'Use Auto-Rig to add a skeleton',
|
||||
});
|
||||
} else if (avatar.rigType !== 'humanoid') {
|
||||
warnings.push({
|
||||
type: 'warning',
|
||||
message: 'Spatial works best with Unity Mecanim humanoid rigs',
|
||||
fix: 'Rig will be converted to humanoid format during export',
|
||||
});
|
||||
}
|
||||
|
||||
const isValid = errors.length === 0;
|
||||
|
||||
return {
|
||||
platform: 'spatial',
|
||||
isValid,
|
||||
errors,
|
||||
warnings,
|
||||
performanceRank: getPerformanceRank(avatar.polygonCount, limits.maxPolygons),
|
||||
checks: {
|
||||
polygonCount: avatar.polygonCount <= limits.maxPolygons,
|
||||
textureSize: true,
|
||||
materialCount: true,
|
||||
rigStructure: avatar.isRigged,
|
||||
boneCount: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate avatar for Rec Room platform
|
||||
*/
|
||||
function validateRecRoom(avatar: AvatarProject): ValidationResult {
|
||||
const errors: ValidationMessage[] = [];
|
||||
const warnings: ValidationMessage[] = [];
|
||||
const limits = PLATFORM_LIMITS.recroom;
|
||||
|
||||
// Check polygon count
|
||||
if (avatar.polygonCount > limits.maxPolygons) {
|
||||
errors.push({
|
||||
type: 'error',
|
||||
message: `Polygon count (${avatar.polygonCount.toLocaleString()}) exceeds Rec Room limit (${limits.maxPolygons.toLocaleString()})`,
|
||||
fix: 'Reduce polygon count significantly',
|
||||
});
|
||||
}
|
||||
|
||||
// Check textures
|
||||
if (avatar.textureCount > limits.maxTextures) {
|
||||
warnings.push({
|
||||
type: 'warning',
|
||||
message: `Texture count (${avatar.textureCount}) exceeds recommended limit (${limits.maxTextures})`,
|
||||
fix: 'Combine or reduce textures',
|
||||
});
|
||||
}
|
||||
|
||||
const isValid = errors.length === 0;
|
||||
|
||||
return {
|
||||
platform: 'recroom',
|
||||
isValid,
|
||||
errors,
|
||||
warnings,
|
||||
performanceRank: getPerformanceRank(avatar.polygonCount, limits.maxPolygons),
|
||||
checks: {
|
||||
polygonCount: avatar.polygonCount <= limits.maxPolygons,
|
||||
textureSize: true,
|
||||
materialCount: true,
|
||||
rigStructure: true,
|
||||
boneCount: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine performance rank based on polygon count vs limit
|
||||
*/
|
||||
function getPerformanceRank(
|
||||
polygonCount: number,
|
||||
maxPolygons: number
|
||||
): 'excellent' | 'good' | 'medium' | 'poor' {
|
||||
const ratio = polygonCount / maxPolygons;
|
||||
if (ratio <= 0.4) return 'excellent';
|
||||
if (ratio <= 0.7) return 'good';
|
||||
if (ratio <= 0.9) return 'medium';
|
||||
return 'poor';
|
||||
}
|
||||
|
||||
/**
|
||||
* Main validation function - validates avatar for specified platform
|
||||
*/
|
||||
export function validateAvatarForPlatform(
|
||||
avatar: AvatarProject,
|
||||
platformId: PlatformId,
|
||||
options: { questMode?: boolean } = {}
|
||||
): ValidationResult {
|
||||
switch (platformId) {
|
||||
case 'roblox':
|
||||
return validateRoblox(avatar);
|
||||
case 'vrchat':
|
||||
return validateVRChat(avatar, options.questMode ? 'quest' : 'pc');
|
||||
case 'spatial':
|
||||
return validateSpatial(avatar);
|
||||
case 'recroom':
|
||||
return validateRecRoom(avatar);
|
||||
case 'uefn':
|
||||
// UEFN validation - more lenient
|
||||
return {
|
||||
platform: 'uefn',
|
||||
isValid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
performanceRank: 'good',
|
||||
checks: {
|
||||
polygonCount: true,
|
||||
textureSize: true,
|
||||
materialCount: true,
|
||||
rigStructure: true,
|
||||
boneCount: true,
|
||||
},
|
||||
};
|
||||
case 'core':
|
||||
// Core platform validation
|
||||
return {
|
||||
platform: 'core',
|
||||
isValid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
performanceRank: 'good',
|
||||
checks: {
|
||||
polygonCount: true,
|
||||
textureSize: true,
|
||||
materialCount: true,
|
||||
rigStructure: true,
|
||||
boneCount: true,
|
||||
},
|
||||
};
|
||||
default:
|
||||
return {
|
||||
platform: platformId,
|
||||
isValid: false,
|
||||
errors: [{ type: 'error', message: 'Unknown platform' }],
|
||||
warnings: [],
|
||||
checks: {
|
||||
polygonCount: false,
|
||||
textureSize: false,
|
||||
materialCount: false,
|
||||
rigStructure: false,
|
||||
boneCount: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate avatar for multiple platforms at once
|
||||
*/
|
||||
export function validateAvatarForPlatforms(
|
||||
avatar: AvatarProject,
|
||||
platformIds: PlatformId[]
|
||||
): ValidationResult[] {
|
||||
return platformIds.map((platformId) => validateAvatarForPlatform(avatar, platformId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a summary of validation results
|
||||
*/
|
||||
export function getValidationSummary(results: ValidationResult[]): {
|
||||
totalPlatforms: number;
|
||||
validPlatforms: number;
|
||||
totalErrors: number;
|
||||
totalWarnings: number;
|
||||
needsOptimization: boolean;
|
||||
} {
|
||||
const totalErrors = results.reduce((sum, r) => sum + r.errors.length, 0);
|
||||
const totalWarnings = results.reduce((sum, r) => sum + r.warnings.length, 0);
|
||||
const validPlatforms = results.filter((r) => r.isValid).length;
|
||||
|
||||
return {
|
||||
totalPlatforms: results.length,
|
||||
validPlatforms,
|
||||
totalErrors,
|
||||
totalWarnings,
|
||||
needsOptimization: totalErrors > 0 || totalWarnings > 2,
|
||||
};
|
||||
}
|
||||
255
src/store/avatar-store.ts
Normal file
255
src/store/avatar-store.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type {
|
||||
AvatarProject,
|
||||
AvatarTemplate,
|
||||
ExportJob,
|
||||
PlatformExportConfig,
|
||||
ValidationResult,
|
||||
UploadProgress,
|
||||
OnboardingStep,
|
||||
AvatarPreferences,
|
||||
} from '../lib/avatar/types';
|
||||
import type { PlatformId } from '../lib/platforms';
|
||||
|
||||
interface AvatarStore {
|
||||
// Current project state
|
||||
currentProject: AvatarProject | null;
|
||||
setCurrentProject: (project: AvatarProject | null) => void;
|
||||
|
||||
// Project library
|
||||
projects: AvatarProject[];
|
||||
addProject: (project: AvatarProject) => void;
|
||||
updateProject: (id: string, updates: Partial<AvatarProject>) => void;
|
||||
deleteProject: (id: string) => void;
|
||||
|
||||
// Export configuration
|
||||
exportConfigs: Record<string, PlatformExportConfig>; // avatarId -> platform configs
|
||||
updateExportConfig: (avatarId: string, config: PlatformExportConfig) => void;
|
||||
getExportConfig: (avatarId: string, platform: PlatformId) => PlatformExportConfig | undefined;
|
||||
|
||||
// Export jobs
|
||||
exportJobs: ExportJob[];
|
||||
addExportJob: (job: ExportJob) => void;
|
||||
updateExportJob: (id: string, updates: Partial<ExportJob>) => void;
|
||||
getActiveJobs: () => ExportJob[];
|
||||
|
||||
// Validation results
|
||||
validationResults: Record<string, ValidationResult[]>; // avatarId -> results
|
||||
setValidationResults: (avatarId: string, results: ValidationResult[]) => void;
|
||||
|
||||
// Upload state
|
||||
uploadProgress: UploadProgress | null;
|
||||
setUploadProgress: (progress: UploadProgress | null) => void;
|
||||
|
||||
// Templates
|
||||
templates: AvatarTemplate[];
|
||||
setTemplates: (templates: AvatarTemplate[]) => void;
|
||||
|
||||
// UI state
|
||||
isAvatarPanelOpen: boolean;
|
||||
setAvatarPanelOpen: (open: boolean) => void;
|
||||
|
||||
selectedPlatforms: PlatformId[];
|
||||
setSelectedPlatforms: (platforms: PlatformId[]) => void;
|
||||
|
||||
show3DViewer: boolean;
|
||||
setShow3DViewer: (show: boolean) => void;
|
||||
|
||||
// Onboarding
|
||||
onboardingSteps: OnboardingStep[];
|
||||
setOnboardingSteps: (steps: OnboardingStep[]) => void;
|
||||
completeOnboardingStep: (stepId: string) => void;
|
||||
isOnboardingComplete: () => boolean;
|
||||
|
||||
// Preferences
|
||||
preferences: AvatarPreferences;
|
||||
updatePreferences: (updates: Partial<AvatarPreferences>) => void;
|
||||
|
||||
// Actions
|
||||
resetAvatarStore: () => void;
|
||||
}
|
||||
|
||||
const defaultPreferences: AvatarPreferences = {
|
||||
defaultExportFormat: 'glb',
|
||||
autoOptimize: true,
|
||||
defaultRiggingService: 'accurig',
|
||||
defaultPlatforms: ['roblox', 'vrchat', 'spatial'],
|
||||
showTutorials: true,
|
||||
gridSnap: false,
|
||||
wireframeMode: false,
|
||||
};
|
||||
|
||||
const defaultOnboardingSteps: OnboardingStep[] = [
|
||||
{
|
||||
id: 'welcome',
|
||||
title: 'Welcome to Avatar Studio',
|
||||
description: 'Learn how to create cross-platform avatars',
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
id: 'import',
|
||||
title: 'Import Your First Avatar',
|
||||
description: 'Upload a VRM, GLB, or FBX file',
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
id: 'rig',
|
||||
title: 'Auto-Rig Your Avatar',
|
||||
description: 'Add a skeleton to your model automatically',
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
id: 'platforms',
|
||||
title: 'Select Target Platforms',
|
||||
description: 'Choose which platforms to export to',
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
id: 'export',
|
||||
title: 'Export Your Avatar',
|
||||
description: 'Generate platform-specific files',
|
||||
completed: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const useAvatarStore = create<AvatarStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// Initial state
|
||||
currentProject: null,
|
||||
projects: [],
|
||||
exportConfigs: {},
|
||||
exportJobs: [],
|
||||
validationResults: {},
|
||||
uploadProgress: null,
|
||||
templates: [],
|
||||
isAvatarPanelOpen: false,
|
||||
selectedPlatforms: ['roblox', 'vrchat', 'spatial'],
|
||||
show3DViewer: true,
|
||||
onboardingSteps: defaultOnboardingSteps,
|
||||
preferences: defaultPreferences,
|
||||
|
||||
// Project management
|
||||
setCurrentProject: (project) => set({ currentProject: project }),
|
||||
|
||||
addProject: (project) =>
|
||||
set((state) => ({
|
||||
projects: [...state.projects, project],
|
||||
currentProject: project,
|
||||
})),
|
||||
|
||||
updateProject: (id, updates) =>
|
||||
set((state) => ({
|
||||
projects: state.projects.map((p) =>
|
||||
p.id === id ? { ...p, ...updates, updatedAt: new Date().toISOString() } : p
|
||||
),
|
||||
currentProject:
|
||||
state.currentProject?.id === id
|
||||
? { ...state.currentProject, ...updates, updatedAt: new Date().toISOString() }
|
||||
: state.currentProject,
|
||||
})),
|
||||
|
||||
deleteProject: (id) =>
|
||||
set((state) => ({
|
||||
projects: state.projects.filter((p) => p.id !== id),
|
||||
currentProject: state.currentProject?.id === id ? null : state.currentProject,
|
||||
})),
|
||||
|
||||
// Export configuration
|
||||
updateExportConfig: (avatarId, config) =>
|
||||
set((state) => {
|
||||
const avatarConfigs = state.exportConfigs[avatarId] || {};
|
||||
return {
|
||||
exportConfigs: {
|
||||
...state.exportConfigs,
|
||||
[avatarId]: {
|
||||
...avatarConfigs,
|
||||
[config.platform]: config,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
getExportConfig: (avatarId, platform) => {
|
||||
const configs = get().exportConfigs[avatarId];
|
||||
return configs?.[platform];
|
||||
},
|
||||
|
||||
// Export jobs
|
||||
addExportJob: (job) =>
|
||||
set((state) => ({
|
||||
exportJobs: [...state.exportJobs, job],
|
||||
})),
|
||||
|
||||
updateExportJob: (id, updates) =>
|
||||
set((state) => ({
|
||||
exportJobs: state.exportJobs.map((j) => (j.id === id ? { ...j, ...updates } : j)),
|
||||
})),
|
||||
|
||||
getActiveJobs: () =>
|
||||
get().exportJobs.filter((j) => j.status === 'validating' || j.status === 'processing'),
|
||||
|
||||
// Validation
|
||||
setValidationResults: (avatarId, results) =>
|
||||
set((state) => ({
|
||||
validationResults: {
|
||||
...state.validationResults,
|
||||
[avatarId]: results,
|
||||
},
|
||||
})),
|
||||
|
||||
// Upload
|
||||
setUploadProgress: (progress) => set({ uploadProgress: progress }),
|
||||
|
||||
// Templates
|
||||
setTemplates: (templates) => set({ templates }),
|
||||
|
||||
// UI state
|
||||
setAvatarPanelOpen: (open) => set({ isAvatarPanelOpen: open }),
|
||||
setSelectedPlatforms: (platforms) => set({ selectedPlatforms: platforms }),
|
||||
setShow3DViewer: (show) => set({ show3DViewer: show }),
|
||||
|
||||
// Onboarding
|
||||
setOnboardingSteps: (steps) => set({ onboardingSteps: steps }),
|
||||
|
||||
completeOnboardingStep: (stepId) =>
|
||||
set((state) => ({
|
||||
onboardingSteps: state.onboardingSteps.map((step) =>
|
||||
step.id === stepId ? { ...step, completed: true } : step
|
||||
),
|
||||
})),
|
||||
|
||||
isOnboardingComplete: () => get().onboardingSteps.every((step) => step.completed),
|
||||
|
||||
// Preferences
|
||||
updatePreferences: (updates) =>
|
||||
set((state) => ({
|
||||
preferences: { ...state.preferences, ...updates },
|
||||
})),
|
||||
|
||||
// Reset
|
||||
resetAvatarStore: () =>
|
||||
set({
|
||||
currentProject: null,
|
||||
projects: [],
|
||||
exportConfigs: {},
|
||||
exportJobs: [],
|
||||
validationResults: {},
|
||||
uploadProgress: null,
|
||||
selectedPlatforms: ['roblox', 'vrchat', 'spatial'],
|
||||
onboardingSteps: defaultOnboardingSteps,
|
||||
preferences: defaultPreferences,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'aethex-avatar-storage',
|
||||
partialize: (state) => ({
|
||||
projects: state.projects,
|
||||
exportConfigs: state.exportConfigs,
|
||||
preferences: state.preferences,
|
||||
onboardingSteps: state.onboardingSteps,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
Loading…
Reference in a new issue