diff --git a/src/App.tsx b/src/App.tsx index 021f392..638da45 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,6 +31,7 @@ const NewProjectModal = lazy(() => import('./components/NewProjectModal').then(m const EducationPanel = lazy(() => import('./components/EducationPanel').then(m => ({ default: m.EducationPanel }))); const PassportLogin = lazy(() => import('./components/PassportLogin').then(m => ({ default: m.PassportLogin }))); const TranslationPanel = lazy(() => import('./components/TranslationPanel').then(m => ({ default: m.TranslationPanel }))); +const AvatarToolkit = lazy(() => import('./components/AvatarToolkit')); function App() { const [currentCode, setCurrentCode] = useState(''); @@ -41,6 +42,7 @@ function App() { const [showCommandPalette, setShowCommandPalette] = useState(false); const [showSearchInFiles, setShowSearchInFiles] = useState(false); const [showTranslation, setShowTranslation] = useState(false); + const [showAvatarToolkit, setShowAvatarToolkit] = useState(false); const [code, setCode] = useState(''); const [currentPlatform, setCurrentPlatform] = useState('roblox'); const isMobile = useIsMobile(); @@ -476,6 +478,7 @@ end)`, onTemplatesClick={() => setShowTemplates(true)} onPreviewClick={() => setShowPreview(true)} onNewProjectClick={() => setShowNewProject(true)} + onAvatarToolkitClick={() => setShowAvatarToolkit(true)} /> @@ -588,6 +591,14 @@ end)`, /> )} + + {showAvatarToolkit && ( + setShowAvatarToolkit(false)} + /> + )} + diff --git a/src/components/AvatarToolkit.tsx b/src/components/AvatarToolkit.tsx new file mode 100644 index 0000000..3addfea --- /dev/null +++ b/src/components/AvatarToolkit.tsx @@ -0,0 +1,925 @@ +'use client'; + +import { useState, useCallback, useMemo } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Progress } from '@/components/ui/progress'; +import { Separator } from '@/components/ui/separator'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { + Upload, + Download, + Settings, + Wand2, + CheckCircle2, + XCircle, + AlertTriangle, + Info, + ChevronRight, + RefreshCw, + Sparkles, + Users, + Box, + Gamepad2, + Glasses, + PartyPopper, + Globe, + Cpu, + Heart, + Landmark, + Headphones, + ArrowLeftRight, + FileType, + Bone, + Layers, + ImageIcon, + FileBox, + Zap, +} from 'lucide-react'; + +import { + AvatarPlatformId, + avatarPlatforms, + supportedPlatforms, + getConstraintsForPlatform, +} from '@/lib/avatar-platforms'; +import { + AvatarFileFormat, + ParsedAvatar, + AvatarValidationResult, + ExportOptions, + FORMAT_SPECS, + getSupportedImportFormats, + getSupportedExportFormats, + validateForPlatform, + createDemoAvatar, + generateExportFilename, + getOptimizationRecommendations, +} from '@/lib/avatar-formats'; +import { + getConversionPaths, + calculatePlatformCompatibility, +} from '@/lib/avatar-rigging'; +import { + avatarTemplates, + platformPresets, + getTemplatesForPlatform, + getPresetsForPlatform, + AvatarTemplate, + AvatarPreset, +} from '@/lib/templates-avatars'; + +interface AvatarToolkitProps { + isOpen: boolean; + onClose: () => void; +} + +type TabValue = 'import' | 'export' | 'convert' | 'templates' | 'validate'; + +// Platform icon mapping +const platformIcons: Record = { + roblox: , + vrchat: , + recroom: , + spatial: , + sandbox: , + neos: , + resonite: , + chilloutvr: , + decentraland: , + 'meta-horizon': , + universal: , +}; + +export default function AvatarToolkit({ isOpen, onClose }: AvatarToolkitProps) { + const [activeTab, setActiveTab] = useState('import'); + const [selectedPlatform, setSelectedPlatform] = useState('universal'); + const [importedAvatar, setImportedAvatar] = useState(null); + const [validationResult, setValidationResult] = useState(null); + const [isProcessing, setIsProcessing] = useState(false); + const [processingProgress, setProcessingProgress] = useState(0); + const [selectedTemplate, setSelectedTemplate] = useState(null); + const [selectedPreset, setSelectedPreset] = useState(null); + + // Export options state + const [exportOptions, setExportOptions] = useState>({ + optimizeForPlatform: true, + embedTextures: true, + compressTextures: true, + preserveBlendShapes: true, + preserveAnimations: true, + generateLODs: false, + }); + + // Conversion state + const [sourcePlatform, setSourcePlatform] = useState('universal'); + const [targetPlatform, setTargetPlatform] = useState('vrchat'); + + // Handle file import + const handleFileImport = useCallback(async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + setIsProcessing(true); + setProcessingProgress(0); + + // Simulate file processing with progress + const progressInterval = setInterval(() => { + setProcessingProgress((prev) => Math.min(prev + 10, 90)); + }, 200); + + try { + // In a real implementation, this would parse the actual file + // For demo purposes, we create a mock avatar + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const demoAvatar = createDemoAvatar(file.name.replace(/\.[^.]+$/, ''), selectedPlatform); + setImportedAvatar(demoAvatar); + + // Validate against selected platform + const validation = validateForPlatform(demoAvatar, selectedPlatform); + setValidationResult(validation); + + setProcessingProgress(100); + } catch (error) { + console.error('Import error:', error); + } finally { + clearInterval(progressInterval); + setIsProcessing(false); + } + }, [selectedPlatform]); + + // Handle export + const handleExport = useCallback(async () => { + if (!importedAvatar) return; + + setIsProcessing(true); + setProcessingProgress(0); + + const progressInterval = setInterval(() => { + setProcessingProgress((prev) => Math.min(prev + 15, 90)); + }, 200); + + try { + await new Promise((resolve) => setTimeout(resolve, 1500)); + + const format = avatarPlatforms[selectedPlatform].exportFormat as AvatarFileFormat; + const filename = generateExportFilename(importedAvatar.metadata.name, selectedPlatform, format); + + // Create a demo export blob + const exportData = JSON.stringify({ + avatar: importedAvatar, + platform: selectedPlatform, + options: exportOptions, + exportedAt: new Date().toISOString(), + }, null, 2); + + const blob = new Blob([exportData], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename.replace(/\.[^.]+$/, '.json'); // Demo uses JSON + a.click(); + URL.revokeObjectURL(url); + + setProcessingProgress(100); + } catch (error) { + console.error('Export error:', error); + } finally { + clearInterval(progressInterval); + setIsProcessing(false); + } + }, [importedAvatar, selectedPlatform, exportOptions]); + + // Get conversion paths + const conversionPaths = useMemo(() => { + return getConversionPaths(sourcePlatform); + }, [sourcePlatform]); + + // Get templates for selected platform + const platformTemplates = useMemo(() => { + return getTemplatesForPlatform(selectedPlatform); + }, [selectedPlatform]); + + // Get presets for selected platform + const platformPresetsList = useMemo(() => { + return getPresetsForPlatform(selectedPlatform); + }, [selectedPlatform]); + + // Apply preset settings + const applyPreset = useCallback((preset: AvatarPreset) => { + setSelectedPreset(preset); + setExportOptions({ + ...exportOptions, + optimizeForPlatform: true, + preserveBlendShapes: preset.settings.preserveBlendShapes, + generateLODs: preset.settings.generateLODs, + }); + }, [exportOptions]); + + return ( + !open && onClose()}> + + + + + AeThex Avatar Toolkit + + + Import, export, and convert avatars across platforms like Roblox, VRChat, RecRoom, Spatial, and more + + + + setActiveTab(v as TabValue)} className="flex-1 flex flex-col min-h-0"> + + + + Import + + + + Export + + + + Convert + + + + Templates + + + + Validate + + + +
+ {/* Import Tab */} + +
+
+
+ + +
+ +
+ + +
+ + {isProcessing && ( +
+
+ Processing... + {processingProgress}% +
+ +
+ )} + +
+ +
+ {getSupportedImportFormats().map((format) => ( + + {FORMAT_SPECS[format].extension} + + ))} +
+
+
+ +
+

Platform Requirements

+ {selectedPlatform && ( +
+ {(() => { + const constraints = getConstraintsForPlatform(selectedPlatform); + const platform = avatarPlatforms[selectedPlatform]; + return ( + <> +
+ + Max Polygons + + {constraints.maxPolygons.toLocaleString()} +
+
+ + Max Bones + + {constraints.maxBones} +
+
+ + Max Materials + + {constraints.maxMaterials} +
+
+ + Max Texture Size + + {constraints.maxTextureSize}px +
+
+ + Max File Size + + {constraints.maxFileSize}MB +
+ +
+ Supported Features: +
+ {platform.features.map((feature) => ( + + {feature} + + ))} +
+
+ + ); + })()} +
+ )} +
+
+
+ + {/* Export Tab */} + + +
+
+
+ + +
+ +
+ + +
+ + + +
+

Export Options

+ +
+ + setExportOptions({ ...exportOptions, optimizeForPlatform: v })} + /> +
+ +
+ + setExportOptions({ ...exportOptions, embedTextures: v })} + /> +
+ +
+ + setExportOptions({ ...exportOptions, compressTextures: v })} + /> +
+ +
+ + setExportOptions({ ...exportOptions, preserveBlendShapes: v })} + /> +
+ +
+ + setExportOptions({ ...exportOptions, preserveAnimations: v })} + /> +
+ +
+ + setExportOptions({ ...exportOptions, generateLODs: v })} + /> +
+
+
+ +
+ {importedAvatar ? ( + <> +
+

Avatar Preview

+
+
+ Name: + {importedAvatar.metadata.name} +
+
+ Triangles: + {importedAvatar.stats.totalTriangles.toLocaleString()} +
+
+ Bones: + {importedAvatar.stats.totalBones} +
+
+ Materials: + {importedAvatar.stats.totalMaterials} +
+
+ Blend Shapes: + {importedAvatar.stats.totalBlendShapes} +
+
+
+ +
+ +
+ + {isProcessing && ( + + )} + + ) : ( +
+ +

+ Import an avatar first to enable export options +

+ +
+ )} +
+
+
+
+ + {/* Convert Tab */} + +
+
+
+ + +
+ +
+ +
+ +
+ + +
+ +
+
Compatibility Score
+
+ {calculatePlatformCompatibility(sourcePlatform, targetPlatform)}% +
+
+
+ +
+

Conversion Paths from {avatarPlatforms[sourcePlatform].name}

+ +
+ {conversionPaths.map((path) => ( +
setTargetPlatform(path.target)} + > +
+
+ {platformIcons[path.target]} + {avatarPlatforms[path.target].displayName} +
+ = 80 ? 'default' : path.compatibility >= 60 ? 'secondary' : 'outline'}> + {path.compatibility}% compatible + +
+ {path.warnings.length > 0 && ( +
+ {path.warnings.map((warning, i) => ( +
+ + {warning} +
+ ))} +
+ )} +
+ ))} +
+
+
+
+
+ + {/* Templates Tab */} + +
+
+
+ +
+ +
+ + +
+ {(selectedPlatform === 'universal' ? avatarTemplates : platformTemplates).map((template) => ( +
setSelectedTemplate(template)} + > +
+ +
+

{template.name}

+

+ {template.description} +

+
+ {template.style} + {template.polyCount} poly +
+
+ {template.platforms.slice(0, 3).map((p) => ( + + {platformIcons[p]} + + ))} + {template.platforms.length > 3 && ( + +{template.platforms.length - 3} + )} +
+
+ ))} +
+
+ + {selectedTemplate && ( +
+
+
+

{selectedTemplate.name}

+

{selectedTemplate.description}

+
+ +
+
+ {selectedTemplate.features.map((feature) => ( + {feature} + ))} +
+
+ )} +
+
+ + {/* Validate Tab */} + +
+
+
+ + +
+ + {!importedAvatar ? ( +
+ +

+ Import an avatar to validate it against platform requirements +

+ +
+ ) : ( +
+ + + {validationResult && ( +
+
+
+ {validationResult.isValid ? ( + + ) : ( + + )} + + {validationResult.isValid ? 'Compatible' : 'Issues Found'} + +
+ = 80 ? 'default' : validationResult.score >= 50 ? 'secondary' : 'destructive'}> + Score: {validationResult.score}% + +
+ +
+ {Object.entries(validationResult.constraints).map(([key, value]) => ( +
+ {key.replace(/([A-Z])/g, ' $1').trim()} +
+ + {value.current.toLocaleString()} / {value.max.toLocaleString()} + + {value.passed ? ( + + ) : ( + + )} +
+
+ ))} +
+
+ )} +
+ )} +
+ +
+

Validation Results

+ + {validationResult ? ( + +
+ {validationResult.issues.length === 0 ? ( +
+
+ + All checks passed! +
+

+ Your avatar is fully compatible with {avatarPlatforms[selectedPlatform].name} +

+
+ ) : ( + validationResult.issues.map((issue, i) => ( +
+
+ {issue.type === 'error' ? ( + + ) : issue.type === 'warning' ? ( + + ) : ( + + )} +
+

{issue.message}

+ {issue.details && ( +

{issue.details}

+ )} + {issue.autoFix && ( + + + Auto-fixable + + )} +
+
+
+ )) + )} + + {validationResult.optimizationSuggestions.length > 0 && ( + <> + +
+
+ + Optimization Suggestions +
+ {validationResult.optimizationSuggestions.map((suggestion, i) => ( +
+ + {suggestion} +
+ ))} +
+ + )} +
+
+ ) : ( +
+

Validation results will appear here

+
+ )} +
+
+
+
+
+
+
+ ); +} diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 7c3c574..b258bf0 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -7,7 +7,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { Copy, FileCode, Download, Info, Play, FolderPlus, User, SignOut, List, ArrowsLeftRight } from '@phosphor-icons/react'; +import { Copy, FileCode, Download, Info, Play, FolderPlus, User, SignOut, List, ArrowsLeftRight, UserCircle } from '@phosphor-icons/react'; import { toast } from 'sonner'; import { useState, useEffect, useCallback, memo } from 'react'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog'; @@ -24,9 +24,10 @@ interface ToolbarProps { currentPlatform: PlatformId; onPlatformChange: (platform: PlatformId) => void; onTranslateClick?: () => void; + onAvatarToolkitClick?: () => void; } -export function Toolbar({ code, onTemplatesClick, onPreviewClick, onNewProjectClick, currentPlatform, onPlatformChange, onTranslateClick }: ToolbarProps) { +export function Toolbar({ code, onTemplatesClick, onPreviewClick, onNewProjectClick, currentPlatform, onPlatformChange, onTranslateClick, onAvatarToolkitClick }: ToolbarProps) { const [showInfo, setShowInfo] = useState(false); const [user, setUser] = useState<{ login: string; avatarUrl: string; email: string } | null>(null); @@ -98,6 +99,25 @@ export function Toolbar({ code, onTemplatesClick, onPreviewClick, onNewProjectCl )} + {/* Avatar Toolkit Button */} + {onAvatarToolkitClick && ( + + + + + Cross-Platform Avatar Toolkit + + )} +
@@ -212,6 +232,12 @@ export function Toolbar({ code, onTemplatesClick, onPreviewClick, onNewProjectCl Templates + {onAvatarToolkitClick && ( + + + Avatar Toolkit + + )} Copy Code diff --git a/src/lib/avatar-formats.ts b/src/lib/avatar-formats.ts new file mode 100644 index 0000000..cb333b4 --- /dev/null +++ b/src/lib/avatar-formats.ts @@ -0,0 +1,577 @@ +/** + * AeThex Avatar Format Handlers + * Import/export handlers for various 3D avatar formats + */ + +import { AvatarPlatformId, avatarPlatforms, AvatarConstraints } from './avatar-platforms'; +import { validateRig, autoMapBones, RigValidationResult } from './avatar-rigging'; + +export type AvatarFileFormat = + | 'glb' + | 'gltf' + | 'fbx' + | 'vrm' + | 'obj' + | 'pmx' + | 'vroid' + | 'aeth' + | 'vox'; + +export interface AvatarMetadata { + name: string; + author?: string; + version?: string; + description?: string; + license?: string; + thumbnail?: string; + createdAt: Date; + updatedAt: Date; + sourcePlatform?: AvatarPlatformId; + tags?: string[]; + customData?: Record; +} + +export interface AvatarMeshInfo { + name: string; + vertexCount: number; + triangleCount: number; + materialIndex: number; + hasNormals: boolean; + hasUVs: boolean; + hasTangents: boolean; + hasVertexColors: boolean; + hasSkinning: boolean; + boneInfluenceCount: number; +} + +export interface AvatarMaterialInfo { + name: string; + type: 'standard' | 'pbr' | 'toon' | 'unlit' | 'custom'; + color?: { r: number; g: number; b: number; a: number }; + metallic?: number; + roughness?: number; + textures: { + diffuse?: string; + normal?: string; + metallic?: string; + roughness?: string; + emission?: string; + occlusion?: string; + }; +} + +export interface AvatarTextureInfo { + name: string; + width: number; + height: number; + format: 'png' | 'jpg' | 'webp' | 'basis' | 'ktx2'; + sizeBytes: number; + mipLevels: number; +} + +export interface AvatarStats { + totalVertices: number; + totalTriangles: number; + totalBones: number; + totalMaterials: number; + totalTextures: number; + totalBlendShapes: number; + fileSizeMB: number; + maxTextureSize: number; +} + +export interface ParsedAvatar { + id: string; + format: AvatarFileFormat; + metadata: AvatarMetadata; + stats: AvatarStats; + meshes: AvatarMeshInfo[]; + materials: AvatarMaterialInfo[]; + textures: AvatarTextureInfo[]; + bones: string[]; + blendShapes: string[]; + animations: string[]; + rawData?: ArrayBuffer; +} + +export interface ValidationIssue { + type: 'error' | 'warning' | 'info'; + code: string; + message: string; + details?: string; + autoFix?: boolean; +} + +export interface AvatarValidationResult { + isValid: boolean; + score: number; + issues: ValidationIssue[]; + rigValidation: RigValidationResult; + constraints: { + polygons: { current: number; max: number; passed: boolean }; + bones: { current: number; max: number; passed: boolean }; + materials: { current: number; max: number; passed: boolean }; + textureSize: { current: number; max: number; passed: boolean }; + fileSize: { current: number; max: number; passed: boolean }; + }; + optimizationSuggestions: string[]; +} + +export interface ExportOptions { + format: AvatarFileFormat; + platform: AvatarPlatformId; + optimizeForPlatform: boolean; + embedTextures: boolean; + compressTextures: boolean; + targetTextureSize?: number; + preserveBlendShapes: boolean; + preserveAnimations: boolean; + generateLODs: boolean; + lodLevels?: number[]; +} + +export interface ImportResult { + success: boolean; + avatar?: ParsedAvatar; + errors: string[]; + warnings: string[]; +} + +export interface ExportResult { + success: boolean; + data?: Blob; + filename: string; + errors: string[]; + warnings: string[]; + stats?: { + originalSize: number; + exportedSize: number; + reductionPercent: number; + }; +} + +// Format specifications +export const FORMAT_SPECS: Record = { + glb: { + name: 'glTF Binary', + extension: '.glb', + mimeType: 'model/gltf-binary', + description: 'Optimized binary format, ideal for web and real-time applications', + supportsAnimations: true, + supportsSkinning: true, + supportsBlendShapes: true, + binary: true, + }, + gltf: { + name: 'glTF', + extension: '.gltf', + mimeType: 'model/gltf+json', + description: 'JSON-based format with external resources', + supportsAnimations: true, + supportsSkinning: true, + supportsBlendShapes: true, + binary: false, + }, + fbx: { + name: 'FBX', + extension: '.fbx', + mimeType: 'application/octet-stream', + description: 'Autodesk format, widely used in game development', + supportsAnimations: true, + supportsSkinning: true, + supportsBlendShapes: true, + binary: true, + }, + vrm: { + name: 'VRM', + extension: '.vrm', + mimeType: 'model/gltf-binary', + description: 'VR avatar format based on glTF with humanoid extensions', + supportsAnimations: true, + supportsSkinning: true, + supportsBlendShapes: true, + binary: true, + }, + obj: { + name: 'Wavefront OBJ', + extension: '.obj', + mimeType: 'text/plain', + description: 'Simple geometry format, no rigging support', + supportsAnimations: false, + supportsSkinning: false, + supportsBlendShapes: false, + binary: false, + }, + pmx: { + name: 'PMX', + extension: '.pmx', + mimeType: 'application/octet-stream', + description: 'MikuMikuDance format for anime-style characters', + supportsAnimations: true, + supportsSkinning: true, + supportsBlendShapes: true, + binary: true, + }, + vroid: { + name: 'VRoid', + extension: '.vroid', + mimeType: 'application/octet-stream', + description: 'VRoid Studio project format', + supportsAnimations: true, + supportsSkinning: true, + supportsBlendShapes: true, + binary: true, + }, + aeth: { + name: 'AeThex Universal', + extension: '.aeth', + mimeType: 'application/x-aethex-avatar', + description: 'AeThex universal avatar format with full platform metadata', + supportsAnimations: true, + supportsSkinning: true, + supportsBlendShapes: true, + binary: true, + }, + vox: { + name: 'MagicaVoxel', + extension: '.vox', + mimeType: 'application/octet-stream', + description: 'Voxel format for blocky/pixelated avatars', + supportsAnimations: false, + supportsSkinning: false, + supportsBlendShapes: false, + binary: true, + }, +}; + +/** + * Detect file format from file extension or MIME type + */ +export function detectFormat(filename: string, mimeType?: string): AvatarFileFormat | null { + const ext = filename.toLowerCase().split('.').pop(); + + for (const [format, spec] of Object.entries(FORMAT_SPECS)) { + if (spec.extension === `.${ext}` || spec.mimeType === mimeType) { + return format as AvatarFileFormat; + } + } + + return null; +} + +/** + * Check if a format is supported for import + */ +export function canImport(format: AvatarFileFormat): boolean { + return ['glb', 'gltf', 'fbx', 'vrm', 'obj', 'pmx', 'aeth'].includes(format); +} + +/** + * Check if a format is supported for export to a specific platform + */ +export function canExport(format: AvatarFileFormat, platform: AvatarPlatformId): boolean { + const platformData = avatarPlatforms[platform]; + return platformData.importFormats.includes(format); +} + +/** + * Get recommended export format for a platform + */ +export function getRecommendedFormat(platform: AvatarPlatformId): AvatarFileFormat { + return avatarPlatforms[platform].exportFormat as AvatarFileFormat; +} + +/** + * Validate avatar against platform constraints + */ +export function validateForPlatform( + avatar: ParsedAvatar, + platform: AvatarPlatformId +): AvatarValidationResult { + const constraints = avatarPlatforms[platform].constraints; + const issues: ValidationIssue[] = []; + const optimizationSuggestions: string[] = []; + + // Polygon check + const polygonsPassed = avatar.stats.totalTriangles <= constraints.maxPolygons; + if (!polygonsPassed) { + issues.push({ + type: 'error', + code: 'POLY_LIMIT', + message: `Triangle count (${avatar.stats.totalTriangles}) exceeds platform limit (${constraints.maxPolygons})`, + details: `Reduce by ${avatar.stats.totalTriangles - constraints.maxPolygons} triangles`, + autoFix: true, + }); + optimizationSuggestions.push( + `Use mesh decimation to reduce triangle count to ${constraints.maxPolygons}` + ); + } + + // Bone check + const bonesPassed = avatar.stats.totalBones <= constraints.maxBones; + if (!bonesPassed) { + issues.push({ + type: 'error', + code: 'BONE_LIMIT', + message: `Bone count (${avatar.stats.totalBones}) exceeds platform limit (${constraints.maxBones})`, + details: `Remove ${avatar.stats.totalBones - constraints.maxBones} bones`, + autoFix: false, + }); + optimizationSuggestions.push( + `Remove non-essential bones or merge small bone chains` + ); + } + + // Material check + const materialsPassed = avatar.stats.totalMaterials <= constraints.maxMaterials; + if (!materialsPassed) { + issues.push({ + type: 'warning', + code: 'MATERIAL_LIMIT', + message: `Material count (${avatar.stats.totalMaterials}) exceeds platform limit (${constraints.maxMaterials})`, + details: `Merge ${avatar.stats.totalMaterials - constraints.maxMaterials} materials`, + autoFix: true, + }); + optimizationSuggestions.push( + `Merge materials using texture atlasing` + ); + } + + // Texture size check + const textureSizePassed = avatar.stats.maxTextureSize <= constraints.maxTextureSize; + if (!textureSizePassed) { + issues.push({ + type: 'warning', + code: 'TEXTURE_SIZE', + message: `Max texture size (${avatar.stats.maxTextureSize}px) exceeds platform limit (${constraints.maxTextureSize}px)`, + autoFix: true, + }); + optimizationSuggestions.push( + `Resize textures to ${constraints.maxTextureSize}x${constraints.maxTextureSize}` + ); + } + + // File size check + const fileSizePassed = avatar.stats.fileSizeMB <= constraints.maxFileSize; + if (!fileSizePassed) { + issues.push({ + type: 'error', + code: 'FILE_SIZE', + message: `File size (${avatar.stats.fileSizeMB.toFixed(2)}MB) exceeds platform limit (${constraints.maxFileSize}MB)`, + autoFix: true, + }); + optimizationSuggestions.push( + `Compress textures and reduce mesh complexity` + ); + } + + // Rig validation + const rigValidation = validateRig(avatar.bones, platform); + + if (!rigValidation.isValid) { + issues.push({ + type: 'error', + code: 'RIG_INVALID', + message: `Rig is missing ${rigValidation.missingRequiredBones.length} required bones`, + details: `Missing: ${rigValidation.missingRequiredBones.join(', ')}`, + autoFix: false, + }); + } + + if (rigValidation.warnings.length > 0) { + for (const warning of rigValidation.warnings) { + issues.push({ + type: 'warning', + code: 'RIG_WARNING', + message: warning, + autoFix: false, + }); + } + } + + // Calculate overall score + const passedChecks = [polygonsPassed, bonesPassed, materialsPassed, textureSizePassed, fileSizePassed] + .filter(Boolean).length; + const constraintScore = (passedChecks / 5) * 50; + const rigScore = rigValidation.score * 0.5; + const overallScore = Math.round(constraintScore + rigScore); + + return { + isValid: polygonsPassed && bonesPassed && fileSizePassed && rigValidation.isValid, + score: overallScore, + issues, + rigValidation, + constraints: { + polygons: { current: avatar.stats.totalTriangles, max: constraints.maxPolygons, passed: polygonsPassed }, + bones: { current: avatar.stats.totalBones, max: constraints.maxBones, passed: bonesPassed }, + materials: { current: avatar.stats.totalMaterials, max: constraints.maxMaterials, passed: materialsPassed }, + textureSize: { current: avatar.stats.maxTextureSize, max: constraints.maxTextureSize, passed: textureSizePassed }, + fileSize: { current: avatar.stats.fileSizeMB, max: constraints.maxFileSize, passed: fileSizePassed }, + }, + optimizationSuggestions, + }; +} + +/** + * Create a mock/demo parsed avatar for testing + */ +export function createDemoAvatar(name: string, platform: AvatarPlatformId): ParsedAvatar { + const platformData = avatarPlatforms[platform]; + + return { + id: `demo-${Date.now()}`, + format: platformData.exportFormat as AvatarFileFormat, + metadata: { + name, + author: 'AeThex Studio', + version: '1.0.0', + description: `Demo avatar for ${platformData.displayName}`, + createdAt: new Date(), + updatedAt: new Date(), + sourcePlatform: platform, + tags: ['demo', platform], + }, + stats: { + totalVertices: Math.floor(platformData.constraints.maxPolygons * 0.6), + totalTriangles: Math.floor(platformData.constraints.maxPolygons * 0.5), + totalBones: Math.floor(platformData.constraints.maxBones * 0.8), + totalMaterials: Math.min(4, platformData.constraints.maxMaterials), + totalTextures: 4, + totalBlendShapes: platformData.skeleton.blendShapeSupport ? 52 : 0, + fileSizeMB: platformData.constraints.maxFileSize * 0.3, + maxTextureSize: platformData.constraints.maxTextureSize, + }, + meshes: [ + { + name: 'Body', + vertexCount: 5000, + triangleCount: 4000, + materialIndex: 0, + hasNormals: true, + hasUVs: true, + hasTangents: true, + hasVertexColors: false, + hasSkinning: true, + boneInfluenceCount: 4, + }, + ], + materials: [ + { + name: 'Skin', + type: 'pbr', + color: { r: 255, g: 224, b: 189, a: 255 }, + metallic: 0, + roughness: 0.8, + textures: { diffuse: 'skin_diffuse.png', normal: 'skin_normal.png' }, + }, + ], + textures: [ + { name: 'skin_diffuse.png', width: 1024, height: 1024, format: 'png', sizeBytes: 512000, mipLevels: 10 }, + { name: 'skin_normal.png', width: 1024, height: 1024, format: 'png', sizeBytes: 512000, mipLevels: 10 }, + ], + bones: platformData.skeleton.bones.map(b => b.name), + blendShapes: platformData.skeleton.blendShapeSupport + ? ['Blink', 'Smile', 'Frown', 'Surprise', 'Angry'] + : [], + animations: ['Idle', 'Walk', 'Run', 'Jump'], + }; +} + +/** + * Generate export filename + */ +export function generateExportFilename( + avatarName: string, + platform: AvatarPlatformId, + format: AvatarFileFormat +): string { + const sanitizedName = avatarName.toLowerCase().replace(/[^a-z0-9]/g, '_'); + const timestamp = new Date().toISOString().slice(0, 10).replace(/-/g, ''); + const extension = FORMAT_SPECS[format].extension; + + return `${sanitizedName}_${platform}_${timestamp}${extension}`; +} + +/** + * Get supported import formats + */ +export function getSupportedImportFormats(): AvatarFileFormat[] { + return ['glb', 'gltf', 'fbx', 'vrm', 'obj', 'pmx', 'aeth']; +} + +/** + * Get supported export formats for a platform + */ +export function getSupportedExportFormats(platform: AvatarPlatformId): AvatarFileFormat[] { + const platformData = avatarPlatforms[platform]; + return platformData.importFormats.filter(f => + ['glb', 'gltf', 'fbx', 'vrm', 'aeth'].includes(f) + ) as AvatarFileFormat[]; +} + +/** + * Estimate export file size based on avatar stats and target format + */ +export function estimateExportSize( + avatar: ParsedAvatar, + targetFormat: AvatarFileFormat, + options: Partial +): number { + let baseSize = avatar.stats.fileSizeMB; + + // Adjust for format + if (targetFormat === 'glb' && avatar.format !== 'glb') { + baseSize *= 0.7; // GLB is usually more compact + } else if (targetFormat === 'fbx') { + baseSize *= 1.2; // FBX can be larger + } + + // Adjust for compression + if (options.compressTextures) { + baseSize *= 0.5; + } + + // Adjust for LODs + if (options.generateLODs) { + baseSize *= 1.3; + } + + return Math.round(baseSize * 100) / 100; +} + +/** + * Get optimization recommendations for an avatar + */ +export function getOptimizationRecommendations( + avatar: ParsedAvatar, + targetPlatform: AvatarPlatformId +): string[] { + const recommendations: string[] = []; + const validation = validateForPlatform(avatar, targetPlatform); + + recommendations.push(...validation.optimizationSuggestions); + + // Additional recommendations + if (avatar.textures.some(t => t.format !== 'png' && t.format !== 'jpg')) { + recommendations.push('Convert textures to PNG or JPG for maximum compatibility'); + } + + if (avatar.meshes.length > 5) { + recommendations.push(`Consider merging ${avatar.meshes.length} meshes to reduce draw calls`); + } + + if (avatar.materials.some(m => m.type !== 'pbr')) { + recommendations.push('Convert materials to PBR for consistent appearance across platforms'); + } + + return recommendations; +} diff --git a/src/lib/avatar-platforms.ts b/src/lib/avatar-platforms.ts new file mode 100644 index 0000000..8c5ab30 --- /dev/null +++ b/src/lib/avatar-platforms.ts @@ -0,0 +1,548 @@ +/** + * AeThex Avatar Platform Configuration + * Cross-platform avatar specifications for Roblox, VRChat, RecRoom, Spatial, Sandbox, and more + */ + +export type AvatarPlatformId = + | 'roblox' + | 'vrchat' + | 'recroom' + | 'spatial' + | 'sandbox' + | 'neos' + | 'resonite' + | 'chilloutvr' + | 'decentraland' + | 'meta-horizon' + | 'universal'; + +export interface BoneMapping { + name: string; + universalName: string; + required: boolean; + parent?: string; + alternateNames?: string[]; +} + +export interface AvatarConstraints { + maxPolygons: number; + maxBones: number; + maxMaterials: number; + maxTextureSize: number; + maxFileSize: number; // in MB + supportedFormats: string[]; + minHeight?: number; + maxHeight?: number; + requiresPhysBones?: boolean; + requiresDynamicBones?: boolean; +} + +export interface SkeletonSpec { + type: 'humanoid' | 'generic' | 'r6' | 'r15' | 'rthro' | 'custom'; + rootBone: string; + bones: BoneMapping[]; + blendShapeSupport: boolean; + maxBlendShapes?: number; + ikSupport: boolean; + fingerTracking: boolean; + eyeTracking: boolean; + fullBodyTracking: boolean; +} + +export interface AvatarPlatform { + id: AvatarPlatformId; + name: string; + displayName: string; + description: string; + color: string; + icon: string; + constraints: AvatarConstraints; + skeleton: SkeletonSpec; + exportFormat: string; + importFormats: string[]; + documentation: string; + status: 'supported' | 'beta' | 'experimental' | 'coming-soon'; + features: string[]; +} + +// Universal humanoid bone names (industry standard) +export const UNIVERSAL_BONES = { + // Root + ROOT: 'Root', + HIPS: 'Hips', + + // Spine + SPINE: 'Spine', + SPINE1: 'Spine1', + SPINE2: 'Spine2', + CHEST: 'Chest', + UPPER_CHEST: 'UpperChest', + NECK: 'Neck', + HEAD: 'Head', + + // Left Arm + LEFT_SHOULDER: 'LeftShoulder', + LEFT_UPPER_ARM: 'LeftUpperArm', + LEFT_LOWER_ARM: 'LeftLowerArm', + LEFT_HAND: 'LeftHand', + + // Left Fingers + LEFT_THUMB_PROXIMAL: 'LeftThumbProximal', + LEFT_THUMB_INTERMEDIATE: 'LeftThumbIntermediate', + LEFT_THUMB_DISTAL: 'LeftThumbDistal', + LEFT_INDEX_PROXIMAL: 'LeftIndexProximal', + LEFT_INDEX_INTERMEDIATE: 'LeftIndexIntermediate', + LEFT_INDEX_DISTAL: 'LeftIndexDistal', + LEFT_MIDDLE_PROXIMAL: 'LeftMiddleProximal', + LEFT_MIDDLE_INTERMEDIATE: 'LeftMiddleIntermediate', + LEFT_MIDDLE_DISTAL: 'LeftMiddleDistal', + LEFT_RING_PROXIMAL: 'LeftRingProximal', + LEFT_RING_INTERMEDIATE: 'LeftRingIntermediate', + LEFT_RING_DISTAL: 'LeftRingDistal', + LEFT_LITTLE_PROXIMAL: 'LeftLittleProximal', + LEFT_LITTLE_INTERMEDIATE: 'LeftLittleIntermediate', + LEFT_LITTLE_DISTAL: 'LeftLittleDistal', + + // Right Arm + RIGHT_SHOULDER: 'RightShoulder', + RIGHT_UPPER_ARM: 'RightUpperArm', + RIGHT_LOWER_ARM: 'RightLowerArm', + RIGHT_HAND: 'RightHand', + + // Right Fingers + RIGHT_THUMB_PROXIMAL: 'RightThumbProximal', + RIGHT_THUMB_INTERMEDIATE: 'RightThumbIntermediate', + RIGHT_THUMB_DISTAL: 'RightThumbDistal', + RIGHT_INDEX_PROXIMAL: 'RightIndexProximal', + RIGHT_INDEX_INTERMEDIATE: 'RightIndexIntermediate', + RIGHT_INDEX_DISTAL: 'RightIndexDistal', + RIGHT_MIDDLE_PROXIMAL: 'RightMiddleProximal', + RIGHT_MIDDLE_INTERMEDIATE: 'RightMiddleIntermediate', + RIGHT_MIDDLE_DISTAL: 'RightMiddleDistal', + RIGHT_RING_PROXIMAL: 'RightRingProximal', + RIGHT_RING_INTERMEDIATE: 'RightRingIntermediate', + RIGHT_RING_DISTAL: 'RightRingDistal', + RIGHT_LITTLE_PROXIMAL: 'RightLittleProximal', + RIGHT_LITTLE_INTERMEDIATE: 'RightLittleIntermediate', + RIGHT_LITTLE_DISTAL: 'RightLittleDistal', + + // Left Leg + LEFT_UPPER_LEG: 'LeftUpperLeg', + LEFT_LOWER_LEG: 'LeftLowerLeg', + LEFT_FOOT: 'LeftFoot', + LEFT_TOES: 'LeftToes', + + // Right Leg + RIGHT_UPPER_LEG: 'RightUpperLeg', + RIGHT_LOWER_LEG: 'RightLowerLeg', + RIGHT_FOOT: 'RightFoot', + RIGHT_TOES: 'RightToes', + + // Eyes + LEFT_EYE: 'LeftEye', + RIGHT_EYE: 'RightEye', + JAW: 'Jaw', +} as const; + +// VRChat skeleton specification +const vrchatSkeleton: SkeletonSpec = { + type: 'humanoid', + rootBone: 'Armature', + blendShapeSupport: true, + maxBlendShapes: 256, + ikSupport: true, + fingerTracking: true, + eyeTracking: true, + fullBodyTracking: true, + bones: [ + { name: 'Hips', universalName: UNIVERSAL_BONES.HIPS, required: true }, + { name: 'Spine', universalName: UNIVERSAL_BONES.SPINE, required: true, parent: 'Hips' }, + { name: 'Chest', universalName: UNIVERSAL_BONES.CHEST, required: true, parent: 'Spine' }, + { name: 'Upper Chest', universalName: UNIVERSAL_BONES.UPPER_CHEST, required: false, parent: 'Chest' }, + { name: 'Neck', universalName: UNIVERSAL_BONES.NECK, required: true, parent: 'Chest' }, + { name: 'Head', universalName: UNIVERSAL_BONES.HEAD, required: true, parent: 'Neck' }, + { name: 'Left Shoulder', universalName: UNIVERSAL_BONES.LEFT_SHOULDER, required: false, parent: 'Chest' }, + { name: 'Left Upper Arm', universalName: UNIVERSAL_BONES.LEFT_UPPER_ARM, required: true, parent: 'Left Shoulder' }, + { name: 'Left Lower Arm', universalName: UNIVERSAL_BONES.LEFT_LOWER_ARM, required: true, parent: 'Left Upper Arm' }, + { name: 'Left Hand', universalName: UNIVERSAL_BONES.LEFT_HAND, required: true, parent: 'Left Lower Arm' }, + { name: 'Right Shoulder', universalName: UNIVERSAL_BONES.RIGHT_SHOULDER, required: false, parent: 'Chest' }, + { name: 'Right Upper Arm', universalName: UNIVERSAL_BONES.RIGHT_UPPER_ARM, required: true, parent: 'Right Shoulder' }, + { name: 'Right Lower Arm', universalName: UNIVERSAL_BONES.RIGHT_LOWER_ARM, required: true, parent: 'Right Upper Arm' }, + { name: 'Right Hand', universalName: UNIVERSAL_BONES.RIGHT_HAND, required: true, parent: 'Right Lower Arm' }, + { name: 'Left Upper Leg', universalName: UNIVERSAL_BONES.LEFT_UPPER_LEG, required: true, parent: 'Hips' }, + { name: 'Left Lower Leg', universalName: UNIVERSAL_BONES.LEFT_LOWER_LEG, required: true, parent: 'Left Upper Leg' }, + { name: 'Left Foot', universalName: UNIVERSAL_BONES.LEFT_FOOT, required: true, parent: 'Left Lower Leg' }, + { name: 'Left Toes', universalName: UNIVERSAL_BONES.LEFT_TOES, required: false, parent: 'Left Foot' }, + { name: 'Right Upper Leg', universalName: UNIVERSAL_BONES.RIGHT_UPPER_LEG, required: true, parent: 'Hips' }, + { name: 'Right Lower Leg', universalName: UNIVERSAL_BONES.RIGHT_LOWER_LEG, required: true, parent: 'Right Upper Leg' }, + { name: 'Right Foot', universalName: UNIVERSAL_BONES.RIGHT_FOOT, required: true, parent: 'Right Lower Leg' }, + { name: 'Right Toes', universalName: UNIVERSAL_BONES.RIGHT_TOES, required: false, parent: 'Right Foot' }, + { name: 'Left Eye', universalName: UNIVERSAL_BONES.LEFT_EYE, required: false, parent: 'Head' }, + { name: 'Right Eye', universalName: UNIVERSAL_BONES.RIGHT_EYE, required: false, parent: 'Head' }, + { name: 'Jaw', universalName: UNIVERSAL_BONES.JAW, required: false, parent: 'Head' }, + ], +}; + +// Roblox R15 skeleton specification +const robloxR15Skeleton: SkeletonSpec = { + type: 'r15', + rootBone: 'HumanoidRootPart', + blendShapeSupport: true, + maxBlendShapes: 50, + ikSupport: true, + fingerTracking: false, + eyeTracking: true, + fullBodyTracking: false, + bones: [ + { name: 'HumanoidRootPart', universalName: UNIVERSAL_BONES.ROOT, required: true }, + { name: 'LowerTorso', universalName: UNIVERSAL_BONES.HIPS, required: true, parent: 'HumanoidRootPart' }, + { name: 'UpperTorso', universalName: UNIVERSAL_BONES.SPINE, required: true, parent: 'LowerTorso' }, + { name: 'Head', universalName: UNIVERSAL_BONES.HEAD, required: true, parent: 'UpperTorso' }, + { name: 'LeftUpperArm', universalName: UNIVERSAL_BONES.LEFT_UPPER_ARM, required: true, parent: 'UpperTorso' }, + { name: 'LeftLowerArm', universalName: UNIVERSAL_BONES.LEFT_LOWER_ARM, required: true, parent: 'LeftUpperArm' }, + { name: 'LeftHand', universalName: UNIVERSAL_BONES.LEFT_HAND, required: true, parent: 'LeftLowerArm' }, + { name: 'RightUpperArm', universalName: UNIVERSAL_BONES.RIGHT_UPPER_ARM, required: true, parent: 'UpperTorso' }, + { name: 'RightLowerArm', universalName: UNIVERSAL_BONES.RIGHT_LOWER_ARM, required: true, parent: 'RightUpperArm' }, + { name: 'RightHand', universalName: UNIVERSAL_BONES.RIGHT_HAND, required: true, parent: 'RightLowerArm' }, + { name: 'LeftUpperLeg', universalName: UNIVERSAL_BONES.LEFT_UPPER_LEG, required: true, parent: 'LowerTorso' }, + { name: 'LeftLowerLeg', universalName: UNIVERSAL_BONES.LEFT_LOWER_LEG, required: true, parent: 'LeftUpperLeg' }, + { name: 'LeftFoot', universalName: UNIVERSAL_BONES.LEFT_FOOT, required: true, parent: 'LeftLowerLeg' }, + { name: 'RightUpperLeg', universalName: UNIVERSAL_BONES.RIGHT_UPPER_LEG, required: true, parent: 'LowerTorso' }, + { name: 'RightLowerLeg', universalName: UNIVERSAL_BONES.RIGHT_LOWER_LEG, required: true, parent: 'RightUpperLeg' }, + { name: 'RightFoot', universalName: UNIVERSAL_BONES.RIGHT_FOOT, required: true, parent: 'RightLowerLeg' }, + ], +}; + +// RecRoom skeleton specification +const recRoomSkeleton: SkeletonSpec = { + type: 'humanoid', + rootBone: 'Root', + blendShapeSupport: true, + maxBlendShapes: 30, + ikSupport: true, + fingerTracking: false, + eyeTracking: false, + fullBodyTracking: false, + bones: [ + { name: 'Root', universalName: UNIVERSAL_BONES.ROOT, required: true }, + { name: 'Hips', universalName: UNIVERSAL_BONES.HIPS, required: true, parent: 'Root' }, + { name: 'Spine', universalName: UNIVERSAL_BONES.SPINE, required: true, parent: 'Hips' }, + { name: 'Chest', universalName: UNIVERSAL_BONES.CHEST, required: true, parent: 'Spine' }, + { name: 'Neck', universalName: UNIVERSAL_BONES.NECK, required: true, parent: 'Chest' }, + { name: 'Head', universalName: UNIVERSAL_BONES.HEAD, required: true, parent: 'Neck' }, + { name: 'LeftArm', universalName: UNIVERSAL_BONES.LEFT_UPPER_ARM, required: true, parent: 'Chest' }, + { name: 'LeftForeArm', universalName: UNIVERSAL_BONES.LEFT_LOWER_ARM, required: true, parent: 'LeftArm' }, + { name: 'LeftHand', universalName: UNIVERSAL_BONES.LEFT_HAND, required: true, parent: 'LeftForeArm' }, + { name: 'RightArm', universalName: UNIVERSAL_BONES.RIGHT_UPPER_ARM, required: true, parent: 'Chest' }, + { name: 'RightForeArm', universalName: UNIVERSAL_BONES.RIGHT_LOWER_ARM, required: true, parent: 'RightArm' }, + { name: 'RightHand', universalName: UNIVERSAL_BONES.RIGHT_HAND, required: true, parent: 'RightForeArm' }, + { name: 'LeftLeg', universalName: UNIVERSAL_BONES.LEFT_UPPER_LEG, required: true, parent: 'Hips' }, + { name: 'LeftKnee', universalName: UNIVERSAL_BONES.LEFT_LOWER_LEG, required: true, parent: 'LeftLeg' }, + { name: 'LeftFoot', universalName: UNIVERSAL_BONES.LEFT_FOOT, required: true, parent: 'LeftKnee' }, + { name: 'RightLeg', universalName: UNIVERSAL_BONES.RIGHT_UPPER_LEG, required: true, parent: 'Hips' }, + { name: 'RightKnee', universalName: UNIVERSAL_BONES.RIGHT_LOWER_LEG, required: true, parent: 'RightLeg' }, + { name: 'RightFoot', universalName: UNIVERSAL_BONES.RIGHT_FOOT, required: true, parent: 'RightKnee' }, + ], +}; + +// Universal/Standard humanoid skeleton +const universalSkeleton: SkeletonSpec = { + type: 'humanoid', + rootBone: 'Armature', + blendShapeSupport: true, + maxBlendShapes: 256, + ikSupport: true, + fingerTracking: true, + eyeTracking: true, + fullBodyTracking: true, + bones: Object.entries(UNIVERSAL_BONES).map(([key, name]) => ({ + name, + universalName: name, + required: ['HIPS', 'SPINE', 'HEAD', 'LEFT_UPPER_ARM', 'LEFT_LOWER_ARM', 'LEFT_HAND', + 'RIGHT_UPPER_ARM', 'RIGHT_LOWER_ARM', 'RIGHT_HAND', 'LEFT_UPPER_LEG', + 'LEFT_LOWER_LEG', 'LEFT_FOOT', 'RIGHT_UPPER_LEG', 'RIGHT_LOWER_LEG', 'RIGHT_FOOT'].includes(key), + })), +}; + +export const avatarPlatforms: Record = { + roblox: { + id: 'roblox', + name: 'Roblox', + displayName: 'Roblox Studio', + description: 'Import/export avatars for Roblox experiences with R6, R15, or Rthro support', + color: '#00A2FF', + icon: 'gamepad-2', + constraints: { + maxPolygons: 10000, + maxBones: 76, + maxMaterials: 1, + maxTextureSize: 1024, + maxFileSize: 30, + supportedFormats: ['fbx', 'obj'], + minHeight: 0.7, + maxHeight: 3.0, + }, + skeleton: robloxR15Skeleton, + exportFormat: 'fbx', + importFormats: ['fbx', 'obj', 'glb', 'gltf', 'vrm'], + documentation: 'https://create.roblox.com/docs/art/characters', + status: 'supported', + features: ['R6', 'R15', 'Rthro', 'Layered Clothing', 'Dynamic Heads', 'Accessories'], + }, + vrchat: { + id: 'vrchat', + name: 'VRChat', + displayName: 'VRChat SDK', + description: 'Create avatars for VRChat with full body tracking, PhysBones, and expressions', + color: '#1FB2A5', + icon: 'glasses', + constraints: { + maxPolygons: 70000, // Poor rating threshold + maxBones: 256, + maxMaterials: 32, + maxTextureSize: 2048, + maxFileSize: 200, + supportedFormats: ['fbx', 'vrm'], + requiresPhysBones: true, + }, + skeleton: vrchatSkeleton, + exportFormat: 'unitypackage', + importFormats: ['fbx', 'glb', 'gltf', 'vrm', 'pmx'], + documentation: 'https://creators.vrchat.com/avatars/', + status: 'supported', + features: ['PhysBones', 'Avatar Dynamics', 'Eye Tracking', 'Face Tracking', 'OSC', 'Full Body'], + }, + recroom: { + id: 'recroom', + name: 'RecRoom', + displayName: 'Rec Room', + description: 'Create fun, stylized avatars for Rec Room social experiences', + color: '#FF6B6B', + icon: 'party-popper', + constraints: { + maxPolygons: 15000, + maxBones: 52, + maxMaterials: 4, + maxTextureSize: 512, + maxFileSize: 20, + supportedFormats: ['fbx'], + }, + skeleton: recRoomSkeleton, + exportFormat: 'fbx', + importFormats: ['fbx', 'glb', 'gltf', 'vrm'], + documentation: 'https://recroom.com/developer', + status: 'supported', + features: ['Stylized Look', 'Props', 'Costumes', 'Expressions'], + }, + spatial: { + id: 'spatial', + name: 'Spatial', + displayName: 'Spatial Creator Toolkit', + description: 'Create avatars for Spatial VR/AR experiences and virtual spaces', + color: '#9B5DE5', + icon: 'globe', + constraints: { + maxPolygons: 50000, + maxBones: 128, + maxMaterials: 8, + maxTextureSize: 2048, + maxFileSize: 50, + supportedFormats: ['glb', 'gltf', 'vrm'], + }, + skeleton: vrchatSkeleton, + exportFormat: 'glb', + importFormats: ['glb', 'gltf', 'vrm', 'fbx'], + documentation: 'https://toolkit.spatial.io/docs/avatars', + status: 'supported', + features: ['Ready Player Me', 'Custom Avatars', 'Emotes', 'Accessories'], + }, + sandbox: { + id: 'sandbox', + name: 'Sandbox', + displayName: 'The Sandbox', + description: 'Create voxel-style or custom avatars for The Sandbox metaverse', + color: '#00D4FF', + icon: 'box', + constraints: { + maxPolygons: 20000, + maxBones: 64, + maxMaterials: 8, + maxTextureSize: 1024, + maxFileSize: 30, + supportedFormats: ['glb', 'gltf', 'vox'], + }, + skeleton: universalSkeleton, + exportFormat: 'glb', + importFormats: ['glb', 'gltf', 'fbx', 'vrm', 'vox'], + documentation: 'https://sandboxgame.gitbook.io/the-sandbox', + status: 'supported', + features: ['Voxel Style', 'LAND Integration', 'NFT Support', 'Equipment'], + }, + neos: { + id: 'neos', + name: 'NeosVR', + displayName: 'NeosVR', + description: 'Create highly customizable avatars for NeosVR', + color: '#F5A623', + icon: 'cpu', + constraints: { + maxPolygons: 100000, + maxBones: 256, + maxMaterials: 32, + maxTextureSize: 4096, + maxFileSize: 300, + supportedFormats: ['fbx', 'glb', 'gltf', 'vrm'], + }, + skeleton: vrchatSkeleton, + exportFormat: 'glb', + importFormats: ['fbx', 'glb', 'gltf', 'vrm', 'obj'], + documentation: 'https://wiki.neos.com/', + status: 'beta', + features: ['LogiX', 'Dynamic Bones', 'Full Customization', 'In-World Editing'], + }, + resonite: { + id: 'resonite', + name: 'Resonite', + displayName: 'Resonite', + description: 'Successor to NeosVR with enhanced avatar capabilities', + color: '#7B68EE', + icon: 'sparkles', + constraints: { + maxPolygons: 100000, + maxBones: 256, + maxMaterials: 32, + maxTextureSize: 4096, + maxFileSize: 300, + supportedFormats: ['fbx', 'glb', 'gltf', 'vrm'], + }, + skeleton: vrchatSkeleton, + exportFormat: 'glb', + importFormats: ['fbx', 'glb', 'gltf', 'vrm', 'obj'], + documentation: 'https://wiki.resonite.com/', + status: 'beta', + features: ['ProtoFlux', 'Dynamic Bones', 'Face Tracking', 'Full Body'], + }, + chilloutvr: { + id: 'chilloutvr', + name: 'ChilloutVR', + displayName: 'ChilloutVR', + description: 'Create avatars for ChilloutVR social platform', + color: '#E91E63', + icon: 'heart', + constraints: { + maxPolygons: 80000, + maxBones: 256, + maxMaterials: 24, + maxTextureSize: 2048, + maxFileSize: 150, + supportedFormats: ['fbx', 'vrm'], + }, + skeleton: vrchatSkeleton, + exportFormat: 'unitypackage', + importFormats: ['fbx', 'glb', 'gltf', 'vrm'], + documentation: 'https://docs.abinteractive.net/', + status: 'beta', + features: ['Advanced Rigging', 'Toggles', 'Gestures', 'Eye/Face Tracking'], + }, + decentraland: { + id: 'decentraland', + name: 'Decentraland', + displayName: 'Decentraland', + description: 'Create Web3-enabled avatars for the Decentraland metaverse', + color: '#FF2D55', + icon: 'landmark', + constraints: { + maxPolygons: 1500, + maxBones: 52, + maxMaterials: 2, + maxTextureSize: 512, + maxFileSize: 2, + supportedFormats: ['glb'], + }, + skeleton: universalSkeleton, + exportFormat: 'glb', + importFormats: ['glb', 'gltf', 'fbx', 'vrm'], + documentation: 'https://docs.decentraland.org/creator/wearables/creating-wearables/', + status: 'supported', + features: ['Wearables', 'NFT Integration', 'Emotes', 'Blockchain'], + }, + 'meta-horizon': { + id: 'meta-horizon', + name: 'Meta Horizon', + displayName: 'Meta Horizon Worlds', + description: 'Create avatars for Meta Horizon Worlds VR platform', + color: '#0668E1', + icon: 'headphones', + constraints: { + maxPolygons: 25000, + maxBones: 70, + maxMaterials: 8, + maxTextureSize: 1024, + maxFileSize: 50, + supportedFormats: ['glb', 'fbx'], + }, + skeleton: universalSkeleton, + exportFormat: 'glb', + importFormats: ['glb', 'gltf', 'fbx', 'vrm'], + documentation: 'https://developer.oculus.com/', + status: 'experimental', + features: ['Hand Tracking', 'Body Estimation', 'Expressions'], + }, + universal: { + id: 'universal', + name: 'Universal', + displayName: 'AeThex Universal Format', + description: 'The AeThex universal avatar format compatible with all platforms', + color: '#00FF88', + icon: 'sparkles', + constraints: { + maxPolygons: 100000, + maxBones: 256, + maxMaterials: 32, + maxTextureSize: 4096, + maxFileSize: 500, + supportedFormats: ['aeth', 'glb', 'gltf', 'vrm', 'fbx'], + }, + skeleton: universalSkeleton, + exportFormat: 'aeth', + importFormats: ['fbx', 'glb', 'gltf', 'vrm', 'obj', 'pmx', 'vroid'], + documentation: 'https://aethex.dev/docs/avatar-format', + status: 'supported', + features: ['All Platforms', 'Lossless Conversion', 'Metadata Preservation', 'Auto-Optimization'], + }, +}; + +export const supportedPlatforms = Object.values(avatarPlatforms).filter( + (p) => p.status === 'supported' || p.status === 'beta' +); + +export function getAvatarPlatform(id: AvatarPlatformId): AvatarPlatform { + return avatarPlatforms[id]; +} + +export function isPlatformSupported(id: AvatarPlatformId): boolean { + return avatarPlatforms[id].status === 'supported'; +} + +export function getConstraintsForPlatform(id: AvatarPlatformId): AvatarConstraints { + return avatarPlatforms[id].constraints; +} + +export function getSkeletonForPlatform(id: AvatarPlatformId): SkeletonSpec { + return avatarPlatforms[id].skeleton; +} + +export function canConvert(from: AvatarPlatformId, to: AvatarPlatformId): boolean { + const fromPlatform = avatarPlatforms[from]; + const toPlatform = avatarPlatforms[to]; + + // Can always convert to universal + if (to === 'universal') return true; + + // Can convert from universal to anything + if (from === 'universal') return true; + + // Check if formats are compatible + const fromFormat = fromPlatform.exportFormat; + return toPlatform.importFormats.includes(fromFormat); +} diff --git a/src/lib/avatar-rigging.ts b/src/lib/avatar-rigging.ts new file mode 100644 index 0000000..d207401 --- /dev/null +++ b/src/lib/avatar-rigging.ts @@ -0,0 +1,488 @@ +/** + * AeThex Avatar Rigging System + * Universal skeleton mapping and auto-rigging for cross-platform avatars + */ + +import { + AvatarPlatformId, + BoneMapping, + SkeletonSpec, + UNIVERSAL_BONES, + avatarPlatforms, + getSkeletonForPlatform, +} from './avatar-platforms'; + +// Common bone name aliases across different software +export const BONE_ALIASES: Record = { + [UNIVERSAL_BONES.HIPS]: ['Hips', 'hips', 'pelvis', 'Pelvis', 'LowerTorso', 'Root', 'Bip01_Pelvis', 'mixamorig:Hips'], + [UNIVERSAL_BONES.SPINE]: ['Spine', 'spine', 'Spine1', 'spine1', 'UpperTorso', 'Bip01_Spine', 'mixamorig:Spine'], + [UNIVERSAL_BONES.SPINE1]: ['Spine1', 'Spine2', 'spine2', 'Bip01_Spine1', 'mixamorig:Spine1'], + [UNIVERSAL_BONES.SPINE2]: ['Spine2', 'Spine3', 'spine3', 'Bip01_Spine2', 'mixamorig:Spine2'], + [UNIVERSAL_BONES.CHEST]: ['Chest', 'chest', 'Ribcage', 'UpperChest', 'Bip01_Spine3', 'mixamorig:Spine2'], + [UNIVERSAL_BONES.NECK]: ['Neck', 'neck', 'Neck1', 'Bip01_Neck', 'mixamorig:Neck'], + [UNIVERSAL_BONES.HEAD]: ['Head', 'head', 'Bip01_Head', 'mixamorig:Head'], + + // Left arm + [UNIVERSAL_BONES.LEFT_SHOULDER]: ['LeftShoulder', 'Left Shoulder', 'L_Shoulder', 'shoulder.L', 'Bip01_L_Clavicle', 'mixamorig:LeftShoulder'], + [UNIVERSAL_BONES.LEFT_UPPER_ARM]: ['LeftUpperArm', 'Left Upper Arm', 'LeftArm', 'L_UpperArm', 'upperarm.L', 'Bip01_L_UpperArm', 'mixamorig:LeftArm'], + [UNIVERSAL_BONES.LEFT_LOWER_ARM]: ['LeftLowerArm', 'Left Lower Arm', 'LeftForeArm', 'L_Forearm', 'forearm.L', 'Bip01_L_Forearm', 'mixamorig:LeftForeArm'], + [UNIVERSAL_BONES.LEFT_HAND]: ['LeftHand', 'Left Hand', 'L_Hand', 'hand.L', 'Bip01_L_Hand', 'mixamorig:LeftHand'], + + // Right arm + [UNIVERSAL_BONES.RIGHT_SHOULDER]: ['RightShoulder', 'Right Shoulder', 'R_Shoulder', 'shoulder.R', 'Bip01_R_Clavicle', 'mixamorig:RightShoulder'], + [UNIVERSAL_BONES.RIGHT_UPPER_ARM]: ['RightUpperArm', 'Right Upper Arm', 'RightArm', 'R_UpperArm', 'upperarm.R', 'Bip01_R_UpperArm', 'mixamorig:RightArm'], + [UNIVERSAL_BONES.RIGHT_LOWER_ARM]: ['RightLowerArm', 'Right Lower Arm', 'RightForeArm', 'R_Forearm', 'forearm.R', 'Bip01_R_Forearm', 'mixamorig:RightForeArm'], + [UNIVERSAL_BONES.RIGHT_HAND]: ['RightHand', 'Right Hand', 'R_Hand', 'hand.R', 'Bip01_R_Hand', 'mixamorig:RightHand'], + + // Left leg + [UNIVERSAL_BONES.LEFT_UPPER_LEG]: ['LeftUpperLeg', 'Left Upper Leg', 'LeftThigh', 'L_Thigh', 'thigh.L', 'Bip01_L_Thigh', 'mixamorig:LeftUpLeg'], + [UNIVERSAL_BONES.LEFT_LOWER_LEG]: ['LeftLowerLeg', 'Left Lower Leg', 'LeftShin', 'LeftKnee', 'L_Calf', 'calf.L', 'Bip01_L_Calf', 'mixamorig:LeftLeg'], + [UNIVERSAL_BONES.LEFT_FOOT]: ['LeftFoot', 'Left Foot', 'L_Foot', 'foot.L', 'Bip01_L_Foot', 'mixamorig:LeftFoot'], + [UNIVERSAL_BONES.LEFT_TOES]: ['LeftToes', 'Left Toes', 'LeftToe', 'L_Toe', 'toe.L', 'Bip01_L_Toe0', 'mixamorig:LeftToeBase'], + + // Right leg + [UNIVERSAL_BONES.RIGHT_UPPER_LEG]: ['RightUpperLeg', 'Right Upper Leg', 'RightThigh', 'R_Thigh', 'thigh.R', 'Bip01_R_Thigh', 'mixamorig:RightUpLeg'], + [UNIVERSAL_BONES.RIGHT_LOWER_LEG]: ['RightLowerLeg', 'Right Lower Leg', 'RightShin', 'RightKnee', 'R_Calf', 'calf.R', 'Bip01_R_Calf', 'mixamorig:RightLeg'], + [UNIVERSAL_BONES.RIGHT_FOOT]: ['RightFoot', 'Right Foot', 'R_Foot', 'foot.R', 'Bip01_R_Foot', 'mixamorig:RightFoot'], + [UNIVERSAL_BONES.RIGHT_TOES]: ['RightToes', 'Right Toes', 'RightToe', 'R_Toe', 'toe.R', 'Bip01_R_Toe0', 'mixamorig:RightToeBase'], + + // Eyes and jaw + [UNIVERSAL_BONES.LEFT_EYE]: ['LeftEye', 'Left Eye', 'L_Eye', 'eye.L', 'EyeLeft'], + [UNIVERSAL_BONES.RIGHT_EYE]: ['RightEye', 'Right Eye', 'R_Eye', 'eye.R', 'EyeRight'], + [UNIVERSAL_BONES.JAW]: ['Jaw', 'jaw', 'Jaw_Joint', 'LowerJaw'], +}; + +export interface RigValidationResult { + isValid: boolean; + missingRequiredBones: string[]; + missingOptionalBones: string[]; + extraBones: string[]; + boneMapping: Map; + warnings: string[]; + errors: string[]; + score: number; // 0-100 compatibility score +} + +export interface ConversionResult { + success: boolean; + warnings: string[]; + errors: string[]; + sourceBoneCount: number; + targetBoneCount: number; + mappedBones: number; + unmappedBones: string[]; + addedBones: string[]; + removedBones: string[]; +} + +export interface BoneTransform { + position: { x: number; y: number; z: number }; + rotation: { x: number; y: number; z: number; w: number }; + scale: { x: number; y: number; z: number }; +} + +export interface AvatarRig { + name: string; + platform: AvatarPlatformId; + bones: Map; + hierarchy: Map; + blendShapes?: string[]; +} + +/** + * Find the universal bone name for a given bone name + */ +export function findUniversalBoneName(boneName: string): string | null { + // Direct match + if (Object.values(UNIVERSAL_BONES).includes(boneName as any)) { + return boneName; + } + + // Search through aliases + for (const [universalName, aliases] of Object.entries(BONE_ALIASES)) { + if (aliases.some(alias => + alias.toLowerCase() === boneName.toLowerCase() || + boneName.toLowerCase().includes(alias.toLowerCase()) || + alias.toLowerCase().includes(boneName.toLowerCase()) + )) { + return universalName; + } + } + + return null; +} + +/** + * Get the platform-specific bone name from universal name + */ +export function getPlatformBoneName(universalName: string, platformId: AvatarPlatformId): string | null { + const skeleton = getSkeletonForPlatform(platformId); + const bone = skeleton.bones.find(b => b.universalName === universalName); + return bone?.name || null; +} + +/** + * Create a bone mapping from source platform to target platform + */ +export function createBoneMapping( + sourcePlatform: AvatarPlatformId, + targetPlatform: AvatarPlatformId +): Map { + const sourceSkeleton = getSkeletonForPlatform(sourcePlatform); + const targetSkeleton = getSkeletonForPlatform(targetPlatform); + const mapping = new Map(); + + for (const sourceBone of sourceSkeleton.bones) { + const targetBone = targetSkeleton.bones.find( + b => b.universalName === sourceBone.universalName + ); + if (targetBone) { + mapping.set(sourceBone.name, targetBone.name); + } + } + + return mapping; +} + +/** + * Validate a rig against a platform's requirements + */ +export function validateRig( + boneNames: string[], + targetPlatform: AvatarPlatformId +): RigValidationResult { + const skeleton = getSkeletonForPlatform(targetPlatform); + const platform = avatarPlatforms[targetPlatform]; + + const result: RigValidationResult = { + isValid: true, + missingRequiredBones: [], + missingOptionalBones: [], + extraBones: [], + boneMapping: new Map(), + warnings: [], + errors: [], + score: 100, + }; + + // Map input bones to universal names + const inputBoneSet = new Set(boneNames); + const mappedUniversalBones = new Set(); + + for (const boneName of boneNames) { + const universalName = findUniversalBoneName(boneName); + if (universalName) { + const platformBoneName = getPlatformBoneName(universalName, targetPlatform); + if (platformBoneName) { + result.boneMapping.set(boneName, platformBoneName); + mappedUniversalBones.add(universalName); + } + } else { + result.extraBones.push(boneName); + } + } + + // Check for missing required and optional bones + for (const bone of skeleton.bones) { + if (!mappedUniversalBones.has(bone.universalName)) { + if (bone.required) { + result.missingRequiredBones.push(bone.name); + result.errors.push(`Missing required bone: ${bone.name}`); + } else { + result.missingOptionalBones.push(bone.name); + result.warnings.push(`Missing optional bone: ${bone.name}`); + } + } + } + + // Calculate score + const requiredBoneCount = skeleton.bones.filter(b => b.required).length; + const foundRequiredCount = requiredBoneCount - result.missingRequiredBones.length; + const baseScore = (foundRequiredCount / requiredBoneCount) * 80; + + const optionalBoneCount = skeleton.bones.filter(b => !b.required).length; + const foundOptionalCount = optionalBoneCount > 0 + ? (optionalBoneCount - result.missingOptionalBones.length) / optionalBoneCount + : 1; + const bonusScore = foundOptionalCount * 20; + + result.score = Math.round(baseScore + bonusScore); + + // Check bone count limit + if (boneNames.length > platform.constraints.maxBones) { + result.warnings.push( + `Bone count (${boneNames.length}) exceeds platform limit (${platform.constraints.maxBones})` + ); + result.score -= 10; + } + + // Determine validity + result.isValid = result.missingRequiredBones.length === 0; + result.score = Math.max(0, Math.min(100, result.score)); + + return result; +} + +/** + * Auto-map bones from an unknown rig to a target platform + */ +export function autoMapBones( + inputBones: string[], + targetPlatform: AvatarPlatformId +): Map { + const skeleton = getSkeletonForPlatform(targetPlatform); + const mapping = new Map(); + + for (const inputBone of inputBones) { + const universalName = findUniversalBoneName(inputBone); + if (universalName) { + const targetBone = skeleton.bones.find(b => b.universalName === universalName); + if (targetBone) { + mapping.set(inputBone, targetBone.name); + } + } + } + + return mapping; +} + +/** + * Suggest fixes for a rig that doesn't meet platform requirements + */ +export function suggestRigFixes( + validationResult: RigValidationResult, + targetPlatform: AvatarPlatformId +): string[] { + const suggestions: string[] = []; + const platform = avatarPlatforms[targetPlatform]; + + if (validationResult.missingRequiredBones.length > 0) { + suggestions.push( + `Add missing required bones: ${validationResult.missingRequiredBones.join(', ')}` + ); + suggestions.push( + `Consider using the AeThex auto-rigger to automatically add missing bones` + ); + } + + if (validationResult.extraBones.length > 5) { + suggestions.push( + `Remove or rename ${validationResult.extraBones.length} unrecognized bones for better compatibility` + ); + } + + if (validationResult.score < 70) { + suggestions.push( + `Consider starting with an ${platform.name} compatible base rig` + ); + } + + return suggestions; +} + +/** + * Convert a rig from one platform to another + */ +export function convertRig( + sourceBones: string[], + sourcePlatform: AvatarPlatformId, + targetPlatform: AvatarPlatformId +): ConversionResult { + const result: ConversionResult = { + success: false, + warnings: [], + errors: [], + sourceBoneCount: sourceBones.length, + targetBoneCount: 0, + mappedBones: 0, + unmappedBones: [], + addedBones: [], + removedBones: [], + }; + + const targetSkeleton = getSkeletonForPlatform(targetPlatform); + const boneMapping = createBoneMapping(sourcePlatform, targetPlatform); + + // Track mapped and unmapped bones + const mappedSourceBones = new Set(); + + for (const sourceBone of sourceBones) { + if (boneMapping.has(sourceBone)) { + mappedSourceBones.add(sourceBone); + result.mappedBones++; + } else { + // Try auto-mapping + const universalName = findUniversalBoneName(sourceBone); + if (universalName) { + const targetBone = getPlatformBoneName(universalName, targetPlatform); + if (targetBone) { + boneMapping.set(sourceBone, targetBone); + mappedSourceBones.add(sourceBone); + result.mappedBones++; + } else { + result.unmappedBones.push(sourceBone); + } + } else { + result.unmappedBones.push(sourceBone); + } + } + } + + // Check for required bones that need to be added + for (const bone of targetSkeleton.bones) { + if (bone.required) { + const isMapped = Array.from(boneMapping.values()).includes(bone.name); + if (!isMapped) { + result.addedBones.push(bone.name); + result.warnings.push(`Will generate required bone: ${bone.name}`); + } + } + } + + // Calculate removed bones (source bones not needed in target) + result.removedBones = sourceBones.filter(b => !mappedSourceBones.has(b)); + + result.targetBoneCount = result.mappedBones + result.addedBones.length; + + // Determine success + const targetRequiredBones = targetSkeleton.bones.filter(b => b.required); + const missingRequired = targetRequiredBones.filter(b => { + const isMapped = Array.from(boneMapping.values()).includes(b.name); + const willBeAdded = result.addedBones.includes(b.name); + return !isMapped && !willBeAdded; + }); + + if (missingRequired.length > 0) { + result.errors.push( + `Cannot generate required bones: ${missingRequired.map(b => b.name).join(', ')}` + ); + } else { + result.success = true; + } + + if (result.unmappedBones.length > 0) { + result.warnings.push( + `${result.unmappedBones.length} bones will not be transferred` + ); + } + + return result; +} + +/** + * Get the bone hierarchy for a platform + */ +export function getBoneHierarchy(platformId: AvatarPlatformId): Map { + const skeleton = getSkeletonForPlatform(platformId); + const hierarchy = new Map(); + + for (const bone of skeleton.bones) { + hierarchy.set(bone.name, bone.parent || null); + } + + return hierarchy; +} + +/** + * Generate T-pose bone transforms for a platform + */ +export function generateTPose(platformId: AvatarPlatformId): Map { + const skeleton = getSkeletonForPlatform(platformId); + const transforms = new Map(); + + // Default T-pose positions (simplified) + const defaultTransform: BoneTransform = { + position: { x: 0, y: 0, z: 0 }, + rotation: { x: 0, y: 0, z: 0, w: 1 }, + scale: { x: 1, y: 1, z: 1 }, + }; + + for (const bone of skeleton.bones) { + transforms.set(bone.name, { ...defaultTransform }); + } + + return transforms; +} + +/** + * Calculate the compatibility between two platforms for avatar conversion + */ +export function calculatePlatformCompatibility( + sourcePlatform: AvatarPlatformId, + targetPlatform: AvatarPlatformId +): number { + const sourceSkeleton = getSkeletonForPlatform(sourcePlatform); + const targetSkeleton = getSkeletonForPlatform(targetPlatform); + + // Count matching bones + let matches = 0; + for (const sourceBone of sourceSkeleton.bones) { + const hasMatch = targetSkeleton.bones.some( + b => b.universalName === sourceBone.universalName + ); + if (hasMatch) matches++; + } + + const totalBones = Math.max(sourceSkeleton.bones.length, targetSkeleton.bones.length); + const boneScore = (matches / totalBones) * 70; + + // Feature compatibility + let featureScore = 0; + if (sourceSkeleton.fingerTracking === targetSkeleton.fingerTracking) featureScore += 10; + if (sourceSkeleton.eyeTracking === targetSkeleton.eyeTracking) featureScore += 10; + if (sourceSkeleton.blendShapeSupport === targetSkeleton.blendShapeSupport) featureScore += 10; + + return Math.round(boneScore + featureScore); +} + +/** + * Get all supported conversion paths from a source platform + */ +export function getConversionPaths(sourcePlatform: AvatarPlatformId): Array<{ + target: AvatarPlatformId; + compatibility: number; + warnings: string[]; +}> { + const paths: Array<{ + target: AvatarPlatformId; + compatibility: number; + warnings: string[]; + }> = []; + + const platforms = Object.keys(avatarPlatforms) as AvatarPlatformId[]; + + for (const targetPlatform of platforms) { + if (targetPlatform === sourcePlatform) continue; + + const compatibility = calculatePlatformCompatibility(sourcePlatform, targetPlatform); + const warnings: string[] = []; + + const sourcePlatformData = avatarPlatforms[sourcePlatform]; + const targetPlatformData = avatarPlatforms[targetPlatform]; + + // Add warnings for feature loss + if (sourcePlatformData.skeleton.fingerTracking && !targetPlatformData.skeleton.fingerTracking) { + warnings.push('Finger tracking will be lost'); + } + if (sourcePlatformData.skeleton.eyeTracking && !targetPlatformData.skeleton.eyeTracking) { + warnings.push('Eye tracking will be lost'); + } + if (sourcePlatformData.skeleton.fullBodyTracking && !targetPlatformData.skeleton.fullBodyTracking) { + warnings.push('Full body tracking will be lost'); + } + if (sourcePlatformData.constraints.maxPolygons > targetPlatformData.constraints.maxPolygons) { + warnings.push(`Mesh may need reduction (${targetPlatformData.constraints.maxPolygons} max polys)`); + } + + paths.push({ + target: targetPlatform, + compatibility, + warnings, + }); + } + + // Sort by compatibility + paths.sort((a, b) => b.compatibility - a.compatibility); + + return paths; +} diff --git a/src/lib/templates-avatars.ts b/src/lib/templates-avatars.ts new file mode 100644 index 0000000..7f6088f --- /dev/null +++ b/src/lib/templates-avatars.ts @@ -0,0 +1,493 @@ +/** + * AeThex Avatar Templates + * Pre-configured avatar presets and styles for different platforms + */ + +import { AvatarPlatformId } from './avatar-platforms'; + +export interface AvatarTemplate { + id: string; + name: string; + description: string; + thumbnail: string; + category: 'humanoid' | 'stylized' | 'anime' | 'robot' | 'creature' | 'custom'; + style: 'realistic' | 'cartoon' | 'anime' | 'lowpoly' | 'voxel' | 'chibi'; + platforms: AvatarPlatformId[]; + features: string[]; + polyCount: 'low' | 'medium' | 'high' | 'very-high'; + rigged: boolean; + animated: boolean; + blendShapes: boolean; + tags: string[]; +} + +export interface AvatarPreset { + id: string; + name: string; + platform: AvatarPlatformId; + description: string; + settings: { + targetPolygons: number; + targetBones: number; + targetMaterials: number; + targetTextureSize: number; + preserveBlendShapes: boolean; + optimizeForVR: boolean; + generateLODs: boolean; + }; +} + +export const avatarTemplates: AvatarTemplate[] = [ + // Universal Templates + { + id: 'universal-humanoid', + name: 'Universal Humanoid', + description: 'Standard humanoid avatar compatible with all platforms', + thumbnail: '/templates/universal-humanoid.png', + category: 'humanoid', + style: 'realistic', + platforms: ['universal', 'vrchat', 'roblox', 'recroom', 'spatial', 'sandbox'], + features: ['Full body', 'Finger bones', 'Face rig', 'Eye tracking ready'], + polyCount: 'medium', + rigged: true, + animated: true, + blendShapes: true, + tags: ['starter', 'universal', 'humanoid'], + }, + { + id: 'universal-stylized', + name: 'Stylized Character', + description: 'Stylized human character with exaggerated proportions', + thumbnail: '/templates/universal-stylized.png', + category: 'stylized', + style: 'cartoon', + platforms: ['universal', 'roblox', 'recroom', 'sandbox'], + features: ['Cartoon proportions', 'Simple rig', 'Expressive face'], + polyCount: 'low', + rigged: true, + animated: true, + blendShapes: true, + tags: ['stylized', 'cartoon', 'beginner'], + }, + { + id: 'universal-anime', + name: 'Anime Character', + description: 'Anime-style humanoid with VRM-ready setup', + thumbnail: '/templates/universal-anime.png', + category: 'anime', + style: 'anime', + platforms: ['universal', 'vrchat', 'spatial', 'neos', 'resonite', 'chilloutvr'], + features: ['Anime shading', 'MToon material', 'VRM expressions', 'Hair physics'], + polyCount: 'medium', + rigged: true, + animated: true, + blendShapes: true, + tags: ['anime', 'vrm', 'vtuber'], + }, + + // VRChat Specific + { + id: 'vrchat-quest-optimized', + name: 'Quest-Optimized Avatar', + description: 'Low-poly avatar optimized for Meta Quest standalone', + thumbnail: '/templates/vrchat-quest.png', + category: 'humanoid', + style: 'lowpoly', + platforms: ['vrchat'], + features: ['Quest compatible', 'Single material', 'Optimized bones', 'Mobile shaders'], + polyCount: 'low', + rigged: true, + animated: true, + blendShapes: true, + tags: ['vrchat', 'quest', 'mobile', 'optimized'], + }, + { + id: 'vrchat-full-body', + name: 'Full Body Tracking Avatar', + description: 'Avatar with full body tracking support and PhysBones', + thumbnail: '/templates/vrchat-fbt.png', + category: 'humanoid', + style: 'realistic', + platforms: ['vrchat'], + features: ['FBT ready', 'PhysBones', 'Eye tracking', 'Face tracking', 'OSC support'], + polyCount: 'high', + rigged: true, + animated: true, + blendShapes: true, + tags: ['vrchat', 'fbt', 'fullbody', 'advanced'], + }, + { + id: 'vrchat-furry', + name: 'Furry/Anthro Base', + description: 'Anthropomorphic character base with digitigrade legs', + thumbnail: '/templates/vrchat-furry.png', + category: 'creature', + style: 'cartoon', + platforms: ['vrchat', 'chilloutvr', 'neos', 'resonite'], + features: ['Digitigrade legs', 'Tail physics', 'Ear physics', 'Custom expressions'], + polyCount: 'medium', + rigged: true, + animated: true, + blendShapes: true, + tags: ['furry', 'anthro', 'creature'], + }, + + // Roblox Specific + { + id: 'roblox-r15', + name: 'Roblox R15 Character', + description: 'Standard Roblox R15 avatar with all body parts', + thumbnail: '/templates/roblox-r15.png', + category: 'humanoid', + style: 'cartoon', + platforms: ['roblox'], + features: ['R15 compatible', 'Layered clothing support', 'Dynamic head ready'], + polyCount: 'low', + rigged: true, + animated: true, + blendShapes: true, + tags: ['roblox', 'r15', 'standard'], + }, + { + id: 'roblox-rthro', + name: 'Roblox Rthro Character', + description: 'Realistic proportioned Roblox avatar', + thumbnail: '/templates/roblox-rthro.png', + category: 'humanoid', + style: 'realistic', + platforms: ['roblox'], + features: ['Rthro proportions', 'Extended skeleton', 'Face animations'], + polyCount: 'medium', + rigged: true, + animated: true, + blendShapes: true, + tags: ['roblox', 'rthro', 'realistic'], + }, + { + id: 'roblox-blocky', + name: 'Classic Blocky Avatar', + description: 'Classic blocky Roblox-style character', + thumbnail: '/templates/roblox-blocky.png', + category: 'stylized', + style: 'voxel', + platforms: ['roblox', 'sandbox'], + features: ['Classic look', 'Simple rig', 'Accessory slots'], + polyCount: 'low', + rigged: true, + animated: false, + blendShapes: false, + tags: ['roblox', 'classic', 'blocky', 'nostalgic'], + }, + + // RecRoom Specific + { + id: 'recroom-standard', + name: 'Rec Room Character', + description: 'Fun, stylized Rec Room avatar', + thumbnail: '/templates/recroom-standard.png', + category: 'stylized', + style: 'cartoon', + platforms: ['recroom'], + features: ['Rec Room style', 'Simple materials', 'Props ready'], + polyCount: 'low', + rigged: true, + animated: true, + blendShapes: true, + tags: ['recroom', 'stylized', 'fun'], + }, + + // Spatial Specific + { + id: 'spatial-professional', + name: 'Professional Avatar', + description: 'Business-ready avatar for Spatial meetings', + thumbnail: '/templates/spatial-professional.png', + category: 'humanoid', + style: 'realistic', + platforms: ['spatial', 'meta-horizon'], + features: ['Professional look', 'Business attire', 'Clean design'], + polyCount: 'medium', + rigged: true, + animated: true, + blendShapes: true, + tags: ['spatial', 'professional', 'business'], + }, + + // Sandbox/Metaverse + { + id: 'sandbox-voxel', + name: 'Sandbox Voxel Character', + description: 'Voxel-style character for The Sandbox', + thumbnail: '/templates/sandbox-voxel.png', + category: 'stylized', + style: 'voxel', + platforms: ['sandbox', 'decentraland'], + features: ['Voxel aesthetic', 'NFT ready', 'Equipment slots'], + polyCount: 'low', + rigged: true, + animated: true, + blendShapes: false, + tags: ['sandbox', 'voxel', 'nft', 'metaverse'], + }, + { + id: 'decentraland-wearable', + name: 'Decentraland Avatar', + description: 'Web3-optimized avatar for Decentraland', + thumbnail: '/templates/decentraland.png', + category: 'humanoid', + style: 'lowpoly', + platforms: ['decentraland'], + features: ['Wearable slots', 'Ultra optimized', 'Blockchain ready'], + polyCount: 'low', + rigged: true, + animated: true, + blendShapes: false, + tags: ['decentraland', 'web3', 'nft', 'optimized'], + }, + + // Specialty + { + id: 'robot-mech', + name: 'Robot/Mech Avatar', + description: 'Mechanical humanoid robot character', + thumbnail: '/templates/robot-mech.png', + category: 'robot', + style: 'realistic', + platforms: ['universal', 'vrchat', 'roblox', 'spatial'], + features: ['Hard surface', 'Mechanical joints', 'LED effects', 'Transform ready'], + polyCount: 'high', + rigged: true, + animated: true, + blendShapes: false, + tags: ['robot', 'mech', 'scifi'], + }, + { + id: 'chibi-cute', + name: 'Chibi Character', + description: 'Super-deformed cute chibi avatar', + thumbnail: '/templates/chibi.png', + category: 'anime', + style: 'chibi', + platforms: ['universal', 'vrchat', 'recroom', 'roblox'], + features: ['Chibi proportions', 'Big head', 'Cute expressions'], + polyCount: 'low', + rigged: true, + animated: true, + blendShapes: true, + tags: ['chibi', 'cute', 'anime', 'kawaii'], + }, +]; + +export const platformPresets: AvatarPreset[] = [ + // VRChat Presets + { + id: 'vrchat-excellent', + name: 'VRChat Excellent', + platform: 'vrchat', + description: 'Optimized for VRChat Excellent performance ranking', + settings: { + targetPolygons: 32000, + targetBones: 75, + targetMaterials: 4, + targetTextureSize: 1024, + preserveBlendShapes: true, + optimizeForVR: true, + generateLODs: true, + }, + }, + { + id: 'vrchat-good', + name: 'VRChat Good', + platform: 'vrchat', + description: 'Balanced quality for VRChat Good performance ranking', + settings: { + targetPolygons: 50000, + targetBones: 150, + targetMaterials: 8, + targetTextureSize: 2048, + preserveBlendShapes: true, + optimizeForVR: true, + generateLODs: true, + }, + }, + { + id: 'vrchat-quest', + name: 'VRChat Quest', + platform: 'vrchat', + description: 'Quest standalone compatible settings', + settings: { + targetPolygons: 10000, + targetBones: 75, + targetMaterials: 2, + targetTextureSize: 512, + preserveBlendShapes: true, + optimizeForVR: true, + generateLODs: false, + }, + }, + + // Roblox Presets + { + id: 'roblox-ugc', + name: 'Roblox UGC', + platform: 'roblox', + description: 'Optimized for Roblox UGC marketplace', + settings: { + targetPolygons: 8000, + targetBones: 76, + targetMaterials: 1, + targetTextureSize: 1024, + preserveBlendShapes: true, + optimizeForVR: false, + generateLODs: false, + }, + }, + { + id: 'roblox-mobile', + name: 'Roblox Mobile', + platform: 'roblox', + description: 'Extra optimization for mobile devices', + settings: { + targetPolygons: 4000, + targetBones: 50, + targetMaterials: 1, + targetTextureSize: 512, + preserveBlendShapes: false, + optimizeForVR: false, + generateLODs: false, + }, + }, + + // RecRoom Presets + { + id: 'recroom-standard', + name: 'RecRoom Standard', + platform: 'recroom', + description: 'Standard RecRoom avatar settings', + settings: { + targetPolygons: 10000, + targetBones: 52, + targetMaterials: 4, + targetTextureSize: 512, + preserveBlendShapes: true, + optimizeForVR: true, + generateLODs: false, + }, + }, + + // Spatial Presets + { + id: 'spatial-quality', + name: 'Spatial Quality', + platform: 'spatial', + description: 'High quality Spatial avatar', + settings: { + targetPolygons: 40000, + targetBones: 100, + targetMaterials: 8, + targetTextureSize: 2048, + preserveBlendShapes: true, + optimizeForVR: true, + generateLODs: true, + }, + }, + + // Sandbox Presets + { + id: 'sandbox-standard', + name: 'Sandbox Standard', + platform: 'sandbox', + description: 'Standard Sandbox metaverse avatar', + settings: { + targetPolygons: 15000, + targetBones: 60, + targetMaterials: 8, + targetTextureSize: 1024, + preserveBlendShapes: false, + optimizeForVR: false, + generateLODs: false, + }, + }, + + // Decentraland Presets + { + id: 'decentraland-wearable', + name: 'Decentraland Wearable', + platform: 'decentraland', + description: 'Ultra-optimized for Decentraland wearables', + settings: { + targetPolygons: 1500, + targetBones: 52, + targetMaterials: 2, + targetTextureSize: 512, + preserveBlendShapes: false, + optimizeForVR: false, + generateLODs: false, + }, + }, + + // Universal Presets + { + id: 'universal-balanced', + name: 'Universal Balanced', + platform: 'universal', + description: 'Balanced settings for cross-platform use', + settings: { + targetPolygons: 30000, + targetBones: 75, + targetMaterials: 4, + targetTextureSize: 1024, + preserveBlendShapes: true, + optimizeForVR: true, + generateLODs: true, + }, + }, + { + id: 'universal-maximum', + name: 'Universal Maximum', + platform: 'universal', + description: 'Maximum quality for archival purposes', + settings: { + targetPolygons: 100000, + targetBones: 256, + targetMaterials: 32, + targetTextureSize: 4096, + preserveBlendShapes: true, + optimizeForVR: false, + generateLODs: true, + }, + }, +]; + +export function getTemplatesForPlatform(platform: AvatarPlatformId): AvatarTemplate[] { + return avatarTemplates.filter(t => t.platforms.includes(platform)); +} + +export function getTemplatesByCategory(category: AvatarTemplate['category']): AvatarTemplate[] { + return avatarTemplates.filter(t => t.category === category); +} + +export function getTemplatesByStyle(style: AvatarTemplate['style']): AvatarTemplate[] { + return avatarTemplates.filter(t => t.style === style); +} + +export function getPresetsForPlatform(platform: AvatarPlatformId): AvatarPreset[] { + return platformPresets.filter(p => p.platform === platform); +} + +export function getTemplateById(id: string): AvatarTemplate | undefined { + return avatarTemplates.find(t => t.id === id); +} + +export function getPresetById(id: string): AvatarPreset | undefined { + return platformPresets.find(p => p.id === id); +} + +export function searchTemplates(query: string): AvatarTemplate[] { + const lowerQuery = query.toLowerCase(); + return avatarTemplates.filter(t => + t.name.toLowerCase().includes(lowerQuery) || + t.description.toLowerCase().includes(lowerQuery) || + t.tags.some(tag => tag.toLowerCase().includes(lowerQuery)) + ); +}