diff --git a/src/components/AvatarPanel.tsx b/src/components/AvatarPanel.tsx new file mode 100644 index 0000000..6f1c1d8 --- /dev/null +++ b/src/components/AvatarPanel.tsx @@ -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 ( +
+ {/* Left Sidebar - Projects & Templates */} +
+
+

+ + Avatar Studio +

+ + {/* View Selector */} +
+ + + +
+ + +
+ + {/* Projects List */} + +
+

+ {projects.length} project{projects.length !== 1 ? 's' : ''} +

+ {projects.length === 0 ? ( +
+ No avatars yet. +
+ Import one to get started! +
+ ) : ( + projects.map((project) => ( + + )) + )} +
+
+ + {/* Quick Actions */} +
+ +
+
+ + {/* Main Content Area */} +
+ {/* 3D Viewer */} +
+ {view === 'projects' && ( +
+ +
+ )} + + {view === 'import' && ( +
+ + + Import Avatar + + Upload a 3D model to get started. Supported formats: VRM, GLB, GLTF, FBX + + + +
+ + + {isDragActive ? ( +

Drop your avatar file here...

+ ) : ( + <> +

Drag & drop your avatar file

+

or click to browse

+ + + )} +
+ + {uploadProgress && ( +
+
+ {uploadProgress.fileName} + + {uploadProgress.progress}% + +
+
+
+
+
+ )} + +
+ +
+ +

Auto-Rig

+

+ AI-powered rigging +

+
+
+ +
+ +

Multi-Platform

+

+ Export to 5+ platforms +

+
+
+
+ + +
+ )} + + {view === 'export' && currentProject && ( +
+ + + Export to Platforms + + Select which platforms to export {currentProject.name} to + + + + {/* Platform Selection */} +
+ {Object.values(platforms).map((platform) => { + const isSelected = selectedPlatforms.includes(platform.id); + const isSupported = currentProject.supportedPlatforms?.includes(platform.id); + + return ( +
+
+ handlePlatformToggle(platform.id)} + /> + +
+ +
+ {isSupported ? ( + + + Compatible + + ) : ( + + + Needs Optimization + + )} +
+
+ ); + })} +
+ + + + {/* Export Actions */} +
+
+ {selectedPlatforms.length} platform{selectedPlatforms.length !== 1 ? 's' : ''} selected +
+
+ + +
+
+
+
+
+ )} +
+
+ + {/* Right Sidebar - Properties & Stats */} + {currentProject && ( +
+
+

Properties

+
+
+ Name: + {currentProject.name} +
+
+ Format: + {currentProject.sourceFormat.toUpperCase()} +
+
+ Polygons: + {currentProject.polygonCount.toLocaleString()} +
+
+ Textures: + + {currentProject.textureCount} ({currentProject.textureSize.toFixed(1)} MB) + +
+
+ Rig Type: + {currentProject.rigType} +
+ {currentProject.boneCount && ( +
+ Bones: + {currentProject.boneCount} +
+ )} +
+
+ + + +
+

Quick Actions

+
+ + + + + +
+
+
+ )} +
+ ); +} diff --git a/src/components/AvatarViewer3D.tsx b/src/components/AvatarViewer3D.tsx new file mode 100644 index 0000000..8033d8c --- /dev/null +++ b/src/components/AvatarViewer3D.tsx @@ -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(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 ( + + ); +} + +// Fallback model when no file is loaded +function FallbackModel() { + return ( + + + + + ); +} + +// Loading component +function LoadingFallback() { + return ( + + + + + ); +} + +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(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 ( +
+ {/* 3D Canvas */} + onError?.(error as Error)} + > + {/* Lighting */} + + + + + {/* Environment for better reflections */} + + + {/* Grid */} + {showGrid && ( + + )} + + {/* Model */} + }> + {modelUrl ? ( + + ) : ( + + )} + + + {/* Camera Controls */} + + + + {/* Control Panel */} + {showControls && ( +
+ + + + + + +
+ + + + + + +
+ )} + + {/* Info overlay */} + {!modelUrl && ( +
+
+

No Model Loaded

+

Import an avatar to preview it here

+
+
+ )} +
+ ); +} + +// Preload common models +export function preloadModel(url: string) { + useGLTF.preload(url); +} diff --git a/src/components/ui/tabs-extra.tsx b/src/components/ui/tabs-extra.tsx index c7e96b3..64102b3 100644 --- a/src/components/ui/tabs-extra.tsx +++ b/src/components/ui/tabs-extra.tsx @@ -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 }) { Onboarding Profile + Avatars UEFN Preview Translation @@ -32,6 +34,7 @@ export function ExtraTabs({ user }: { user: any }) { {}} /> + diff --git a/src/lib/avatar/types.ts b/src/lib/avatar/types.ts new file mode 100644 index 0000000..bc4b9f0 --- /dev/null +++ b/src/lib/avatar/types.ts @@ -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; +} diff --git a/src/lib/avatar/validators.ts b/src/lib/avatar/validators.ts new file mode 100644 index 0000000..7c3f71c --- /dev/null +++ b/src/lib/avatar/validators.ts @@ -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, + }; +} diff --git a/src/store/avatar-store.ts b/src/store/avatar-store.ts new file mode 100644 index 0000000..99b1838 --- /dev/null +++ b/src/store/avatar-store.ts @@ -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) => void; + deleteProject: (id: string) => void; + + // Export configuration + exportConfigs: Record; // 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) => void; + getActiveJobs: () => ExportJob[]; + + // Validation results + validationResults: Record; // 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) => 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()( + 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, + }), + } + ) +);