Initial commit of VIZ 3D project

This commit is contained in:
balaji
2026-06-10 16:10:18 +05:30
commit 08aea85c2f
37 changed files with 13118 additions and 0 deletions
+4775
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
error: null
};
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo);
}
public render() {
if (this.state.hasError) {
return this.props.fallback || (
<div className="flex flex-col items-center justify-center p-8 bg-red-50 dark:bg-red-900/20 rounded-2xl border border-red-200 dark:border-red-800 text-center">
<h2 className="text-xl font-bold text-red-600 dark:text-red-400 mb-2">Something went wrong</h2>
<p className="text-sm text-red-500 dark:text-red-300 mb-4">{this.state.error?.message}</p>
<button
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
onClick={() => this.setState({ hasError: false, error: null })}
>
Try again
</button>
</div>
);
}
return this.props.children;
}
}
+688
View File
@@ -0,0 +1,688 @@
import React, { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Upload, Settings as SettingsIcon, Image as ImageIcon, Box, Download, AlertCircle, Loader2, Check } from 'lucide-react';
import { fal } from '@fal-ai/client';
import { cn } from '../lib/utils';
import { Canvas } from '@react-three/fiber';
import { OrbitControls, Stage } from '@react-three/drei';
import { GLTFLoader } from 'three-stdlib';
import * as THREE from 'three';
interface ImageTo3DModalProps {
isOpen: boolean;
onClose: () => void;
onPushToApp: (modelUrl: string) => void;
}
const MODELS = [
{ id: 'fal-ai/hunyuan3d/v2/image-to-3d', name: 'Hunyuan3D-2 (Latest)' },
{ id: 'fal-ai/hunyuan3d/v1', name: 'Hunyuan3D-1' },
{ id: 'fal-ai/meshy/v6/image-to-3d', name: 'Meshy v6' },
{ id: 'fal-ai/tripo3d', name: 'Tripo3D' }
];
const ModelPreview = ({ url }: { url: string }) => {
const [model, setModel] = useState<THREE.Group | null>(null);
useEffect(() => {
const loader = new GLTFLoader();
loader.load(url, (gltf) => {
setModel(gltf.scene);
});
}, [url]);
if (!model) return <div className="absolute inset-0 flex items-center justify-center"><Loader2 className="w-8 h-8 animate-spin text-zinc-500" /></div>;
return (
<Canvas shadows camera={{ position: [0, 1.5, 3], fov: 45 }}>
<Stage environment="studio" intensity={0.5}>
<primitive object={model} />
</Stage>
<OrbitControls autoRotate />
</Canvas>
);
};
export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ isOpen, onClose, onPushToApp }) => {
const [activeTab, setActiveTab] = useState<'generator' | 'settings'>('generator');
const [apiProvider, setApiProvider] = useState<'fal' | 'tripo' | 'meshy'>('fal');
const [apiKey, setApiKey] = useState('');
const [tripoApiKey, setTripoApiKey] = useState('');
const [meshyApiKey, setMeshyApiKey] = useState('');
const [selectedModel, setSelectedModel] = useState(MODELS[0].id);
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
// Meshy specific state
const [meshyModelType, setMeshyModelType] = useState('standard');
const [meshyAiModel, setMeshyAiModel] = useState('latest');
const [meshyShouldTexture, setMeshyShouldTexture] = useState(true);
const [meshyEnablePbr, setMeshyEnablePbr] = useState(false);
const [meshyHdTexture, setMeshyHdTexture] = useState(false);
const [meshyTexturePrompt, setMeshyTexturePrompt] = useState('');
const [meshyTextureImageUrl, setMeshyTextureImageUrl] = useState('');
const [meshyShouldRemesh, setMeshyShouldRemesh] = useState(false);
const [meshyTopology, setMeshyTopology] = useState('triangle');
const [meshyTargetPolycount, setMeshyTargetPolycount] = useState(30000);
const [isGenerating, setIsGenerating] = useState(false);
const [progressMessage, setProgressMessage] = useState('');
const [generatedModelUrl, setGeneratedModelUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isValidatingKey, setIsValidatingKey] = useState(false);
const [keyValidationStatus, setKeyValidationStatus] = useState<'idle' | 'valid' | 'invalid' | 'error'>('idle');
useEffect(() => {
setKeyValidationStatus('idle');
}, [apiKey, tripoApiKey, meshyApiKey, apiProvider]);
const validateKey = async () => {
setIsValidatingKey(true);
setKeyValidationStatus('idle');
try {
if (apiProvider === 'fal') {
const res = await fetch('https://fal.run/', {
headers: { Authorization: `Key ${apiKey}` }
});
if (res.status === 401 || res.status === 403) setKeyValidationStatus('invalid');
else setKeyValidationStatus('valid');
} else if (apiProvider === 'tripo') {
const res = await fetch('https://api.tripo3d.ai/v2/openapi/task', {
headers: { Authorization: `Bearer ${tripoApiKey}` }
});
if (res.status === 401 || res.status === 403) setKeyValidationStatus('invalid');
else setKeyValidationStatus('valid');
} else if (apiProvider === 'meshy') {
const res = await fetch('https://api.meshy.ai/openapi/v1/image-to-3d', {
headers: { Authorization: `Bearer ${meshyApiKey}` }
});
if (res.status === 401 || res.status === 403) setKeyValidationStatus('invalid');
else setKeyValidationStatus('valid');
}
} catch (e) {
setKeyValidationStatus('error');
} finally {
setIsValidatingKey(false);
}
};
useEffect(() => {
const savedProvider = localStorage.getItem('API_PROVIDER') as 'fal' | 'tripo' | 'meshy' | null;
if (savedProvider) setApiProvider(savedProvider);
const savedKey = localStorage.getItem('FAL_API_KEY');
if (savedKey) setApiKey(savedKey);
const savedTripoKey = localStorage.getItem('TRIPO_API_KEY');
if (savedTripoKey) setTripoApiKey(savedTripoKey);
const savedMeshyKey = localStorage.getItem('MESHY_API_KEY');
if (savedMeshyKey) setMeshyApiKey(savedMeshyKey);
const savedModel = localStorage.getItem('FAL_MODEL');
if (savedModel) setSelectedModel(savedModel);
}, []);
const handleSaveSettings = () => {
localStorage.setItem('API_PROVIDER', apiProvider);
localStorage.setItem('FAL_API_KEY', apiKey);
localStorage.setItem('TRIPO_API_KEY', tripoApiKey);
localStorage.setItem('MESHY_API_KEY', meshyApiKey);
localStorage.setItem('FAL_MODEL', selectedModel);
if (apiProvider === 'fal') fal.config({ credentials: apiKey });
setActiveTab('generator');
};
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setImageFile(file);
const url = URL.createObjectURL(file);
setImagePreview(url);
setGeneratedModelUrl(null);
setError(null);
}
};
// Utility to convert file to data URL for Fal API if needed
const fileToDataUrl = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
const generate3DModel = async () => {
if (!imageFile) {
setError("Please upload an image first.");
return;
}
if (apiProvider === 'fal' && !apiKey) {
setError("Fal API Key is required. Please check settings.");
setActiveTab('settings');
return;
}
if (apiProvider === 'tripo' && !tripoApiKey) {
setError("Tripo API Key is required. Please check settings.");
setActiveTab('settings');
return;
}
if (apiProvider === 'meshy' && !meshyApiKey) {
setError("Meshy API Key is required. Please check settings.");
setActiveTab('settings');
return;
}
try {
setIsGenerating(true);
setError(null);
setProgressMessage("Uploading image...");
if (apiProvider === 'fal') {
fal.config({ credentials: apiKey });
const dataUrl = await fileToDataUrl(imageFile);
setProgressMessage("Generating 3D Model... This might take up to a minute.");
let inputPayload: any = { image_url: dataUrl };
if (selectedModel.includes('tripo')) {
inputPayload = { image_url: dataUrl };
}
const result = await fal.subscribe(selectedModel, {
input: inputPayload,
logs: true,
onQueueUpdate: (update) => {
if (update.status === "IN_PROGRESS") {
update.logs?.map((log) => setProgressMessage(log.message));
}
},
});
console.log("Fal API Result:", result);
let modelUrl = null;
if (result.data) {
if (result.data.model_3d && result.data.model_3d.url) {
modelUrl = result.data.model_3d.url; // Hunyuan v2
} else if (result.data.video && result.data.video.url && result.data.video.url.endsWith('.glb')) {
modelUrl = result.data.video.url;
} else if (result.data.mesh_url) {
modelUrl = result.data.mesh_url;
} else if (result.data.model_url) {
modelUrl = result.data.model_url;
}
}
if (modelUrl) {
setGeneratedModelUrl(modelUrl);
setProgressMessage("Done!");
} else {
setError("Failed to extract 3D model from response.");
}
} else if (apiProvider === 'tripo') {
// TRIPO NATIVE API
const formData = new FormData();
formData.append('file', imageFile);
const uploadRes = await fetch('https://api.tripo3d.ai/v2/openapi/upload', {
method: 'POST',
headers: {
'Authorization': `Bearer ${tripoApiKey}`
},
body: formData
});
if (!uploadRes.ok) throw new Error("Tripo API: Upload failed.");
const uploadData = await uploadRes.json();
if (uploadData.code !== 0) throw new Error(`Tripo API Upload Error: ${uploadData.message || 'Unknown'}`);
const imageToken = uploadData.data.image_token;
setProgressMessage("Generating 3D Model with Tripo3D... This might take up to a minute.");
const taskRes = await fetch('https://api.tripo3d.ai/v2/openapi/task', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${tripoApiKey}`
},
body: JSON.stringify({
type: "image_to_model",
file: {
type: imageFile.type.includes('png') ? 'png' : 'jpg',
file_token: imageToken
}
})
});
if (!taskRes.ok) throw new Error("Tripo API: Task creation failed.");
const taskData = await taskRes.json();
if (taskData.code !== 0) throw new Error(`Tripo API Task Error: ${taskData.message || 'Unknown'}`);
const taskId = taskData.data.task_id;
let isDone = false;
while (!isDone) {
await new Promise(r => setTimeout(r, 2000));
const pollRes = await fetch(`https://api.tripo3d.ai/v2/openapi/task/${taskId}`, {
headers: {
'Authorization': `Bearer ${tripoApiKey}`
}
});
if (!pollRes.ok) throw new Error("Tripo API: Polling failed.");
const pollData = await pollRes.json();
if (pollData.code !== 0) throw new Error(`Tripo API Poll Error: ${pollData.message}`);
const status = pollData.data.status;
const progress = pollData.data.progress || 0;
setProgressMessage(`Generating... ${progress}%`);
if (status === 'success') {
const modelUrl = pollData.data.result.model.url || (pollData.data.result.pbr && pollData.data.result.pbr.model && pollData.data.result.pbr.model.url);
if (!modelUrl) throw new Error("Tripo API: Model URL not found in result.");
setGeneratedModelUrl(modelUrl);
setProgressMessage("Done!");
isDone = true;
} else if (status === 'failed' || status === 'cancelled') {
throw new Error(`Tripo API Task ${status}`);
}
}
} else if (apiProvider === 'meshy') {
// MESHY NATIVE API
const dataUrl = await fileToDataUrl(imageFile);
setProgressMessage("Generating 3D Model with Meshy... This might take up to a minute.");
let payload: any = {
image_url: dataUrl,
model_type: meshyModelType,
should_texture: meshyShouldTexture
};
if (meshyModelType !== 'lowpoly') {
payload.ai_model = meshyAiModel;
payload.should_remesh = meshyShouldRemesh;
if (meshyShouldRemesh) {
payload.topology = meshyTopology;
payload.target_polycount = meshyTargetPolycount;
}
}
if (meshyShouldTexture) {
payload.enable_pbr = meshyEnablePbr;
if (meshyAiModel !== 'meshy-5') {
payload.hd_texture = meshyHdTexture;
}
if (meshyTextureImageUrl) {
payload.texture_image_url = meshyTextureImageUrl;
} else if (meshyTexturePrompt) {
payload.texture_prompt = meshyTexturePrompt;
}
}
const taskRes = await fetch('https://api.meshy.ai/openapi/v1/image-to-3d', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${meshyApiKey}`
},
body: JSON.stringify(payload)
});
if (!taskRes.ok) throw new Error("Meshy API: Task creation failed.");
const taskData = await taskRes.json();
const taskId = taskData.result;
let isDone = false;
while (!isDone) {
await new Promise(r => setTimeout(r, 2000));
const pollRes = await fetch(`https://api.meshy.ai/openapi/v1/image-to-3d/${taskId}`, {
headers: {
'Authorization': `Bearer ${meshyApiKey}`
}
});
if (!pollRes.ok) throw new Error("Meshy API: Polling failed.");
const pollData = await pollRes.json();
const status = pollData.status;
const progress = pollData.progress || 0;
setProgressMessage(`Generating... ${progress}%`);
if (status === 'SUCCEEDED') {
const modelUrl = pollData.model_urls?.glb;
if (!modelUrl) throw new Error("Meshy API: Model URL not found in result.");
setGeneratedModelUrl(modelUrl);
setProgressMessage("Done!");
isDone = true;
} else if (status === 'FAILED' || status === 'EXPIRED') {
throw new Error(`Meshy API Task ${status}`);
}
}
}
} catch (err: any) {
console.error(err);
setError(err.message || "An error occurred during generation.");
} finally {
setIsGenerating(false);
}
};
if (!isOpen) return null;
return (
<AnimatePresence>
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="w-full max-w-4xl bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden flex flex-col h-[600px]"
>
{/* Header */}
<div className="h-12 bg-zinc-800 flex items-center justify-between px-6 border-b border-zinc-700">
<div className="flex items-center gap-2 text-blue-400">
<Box size={18} />
<span className="text-sm font-bold text-zinc-200">Image to 3D Generation</span>
</div>
<button onClick={onClose} className="p-1.5 hover:bg-zinc-700 rounded transition-colors">
<X size={16} className="text-zinc-400" />
</button>
</div>
<div className="flex flex-1 overflow-hidden">
{/* Sidebar Tabs */}
<div className="w-48 bg-zinc-950 border-r border-zinc-800 flex flex-col p-2 gap-1">
<button
onClick={() => setActiveTab('generator')}
className={cn(
"flex items-center gap-2 px-4 py-2.5 rounded-lg text-xs font-medium transition-colors",
activeTab === 'generator' ? "bg-zinc-800 text-blue-400" : "text-zinc-500 hover:bg-zinc-800/50 hover:text-zinc-300"
)}
>
<ImageIcon size={16} />
Generator
</button>
<button
onClick={() => setActiveTab('settings')}
className={cn(
"flex items-center gap-2 px-4 py-2.5 rounded-lg text-xs font-medium transition-colors",
activeTab === 'settings' ? "bg-zinc-800 text-blue-400" : "text-zinc-500 hover:bg-zinc-800/50 hover:text-zinc-300"
)}
>
<SettingsIcon size={16} />
API Settings
</button>
</div>
{/* Content Area */}
<div className="flex-1 overflow-y-auto bg-zinc-900/50 p-6">
{activeTab === 'settings' ? (
<div className="flex flex-col gap-6 max-w-lg">
<div>
<h3 className="text-sm font-bold text-zinc-200 mb-1">API Configuration</h3>
<p className="text-xs text-zinc-500">Configure your API credentials to access AI models.</p>
</div>
<div className="flex flex-col gap-2">
<label className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">API Provider</label>
<select
value={apiProvider}
onChange={(e) => setApiProvider(e.target.value as 'fal' | 'tripo' | 'meshy')}
className="bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-2.5 text-sm text-zinc-300 focus:outline-none focus:border-blue-500/50"
>
<option value="fal">Fal.ai</option>
<option value="tripo">Tripo3D (Native)</option>
<option value="meshy">Meshy (Native)</option>
</select>
</div>
{apiProvider === 'fal' ? (
<>
<div className="flex flex-col gap-2">
<label className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">FAL_KEY (API Key)</label>
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="Enter your fal.ai API key"
className="bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-2.5 text-sm text-zinc-300 focus:outline-none focus:border-blue-500/50"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">AI Model</label>
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
className="bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-2.5 text-sm text-zinc-300 focus:outline-none focus:border-blue-500/50"
>
{MODELS.map(m => (
<option key={m.id} value={m.id}>{m.name} ({m.id})</option>
))}
</select>
</div>
</>
) : apiProvider === 'tripo' ? (
<div className="flex flex-col gap-2">
<label className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">TRIPO API KEY</label>
<input
type="password"
value={tripoApiKey}
onChange={(e) => setTripoApiKey(e.target.value)}
placeholder="Enter your Tripo3D API key (starts with tsk_...)"
className="bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-2.5 text-sm text-zinc-300 focus:outline-none focus:border-blue-500/50"
/>
</div>
) : (
<div className="flex flex-col gap-2">
<label className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">MESHY API KEY</label>
<input
type="password"
value={meshyApiKey}
onChange={(e) => setMeshyApiKey(e.target.value)}
placeholder="Enter your Meshy API key (starts with msy_...)"
className="bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-2.5 text-sm text-zinc-300 focus:outline-none focus:border-blue-500/50"
/>
</div>
)}
<div className="flex items-center gap-3 mt-2">
<button
onClick={validateKey}
disabled={isValidatingKey || (apiProvider === 'fal' && !apiKey) || (apiProvider === 'tripo' && !tripoApiKey) || (apiProvider === 'meshy' && !meshyApiKey)}
className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 disabled:opacity-50 text-zinc-300 text-xs font-bold rounded-lg flex items-center gap-2 transition-colors"
>
{isValidatingKey && <Loader2 size={14} className="animate-spin" />}
Test Connection
</button>
{keyValidationStatus === 'valid' && <span className="text-xs text-green-500 font-bold flex items-center gap-1"><Check size={14} /> Valid Key</span>}
{keyValidationStatus === 'invalid' && <span className="text-xs text-red-500 font-bold flex items-center gap-1"><X size={14} /> Invalid Key</span>}
{keyValidationStatus === 'error' && <span className="text-xs text-orange-500 font-bold flex items-center gap-1"><AlertCircle size={14} /> Network Error</span>}
</div>
<button
onClick={handleSaveSettings}
className="mt-4 px-6 py-2.5 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm font-bold shadow-lg shadow-blue-900/20 transition-all w-fit"
>
Save Settings
</button>
</div>
) : (
<div className="grid grid-cols-2 gap-6 h-full">
{/* Left Column: Upload */}
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-bold text-zinc-200">Input Image</h3>
<span className="text-[10px] text-zinc-500 bg-zinc-800 px-2 py-0.5 rounded-full">Front View</span>
</div>
<label className="flex-1 flex flex-col items-center justify-center border-2 border-dashed border-zinc-700 hover:border-zinc-500 rounded-xl bg-zinc-900/50 cursor-pointer transition-colors relative overflow-hidden group">
<input type="file" ref={fileInputRef} accept="image/png, image/jpeg" className="hidden" onChange={handleImageUpload} />
{imagePreview ? (
<>
<img src={imagePreview} alt="Preview" className="absolute inset-0 w-full h-full object-contain p-2" />
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity">
<span className="text-white text-xs font-medium flex items-center gap-2"><Upload size={14}/> Change Image</span>
</div>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setImageFile(null);
setImagePreview(null);
setGeneratedModelUrl(null);
setError(null);
if (fileInputRef.current) fileInputRef.current.value = "";
}}
className="absolute top-2 right-2 p-1.5 bg-red-500/80 hover:bg-red-500 text-white rounded-md backdrop-blur shadow-sm opacity-0 group-hover:opacity-100 transition-all z-20"
title="Remove image"
>
<X size={14} />
</button>
</>
) : (
<div className="flex flex-col items-center text-zinc-500 gap-3 pointer-events-none p-6 text-center">
<div className="w-12 h-12 rounded-full bg-zinc-800 flex items-center justify-center">
<Upload size={20} className="text-zinc-400" />
</div>
<div>
<p className="text-sm font-medium text-zinc-300 mb-1">Click to upload image</p>
<p className="text-xs">PNG, JPG up to 10MB</p>
<p className="text-[10px] mt-2 text-zinc-600">Tip: Use an image with a solid or transparent background.</p>
</div>
</div>
)}
</label>
{apiProvider === 'meshy' && (
<div className="flex flex-col gap-3 mt-2 bg-zinc-900 p-3 rounded-xl border border-zinc-800">
<h4 className="text-[10px] font-bold text-zinc-400 uppercase tracking-wider flex items-center gap-1"><SettingsIcon size={12}/> Meshy Settings</h4>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1">
<label className="text-[10px] text-zinc-500 font-bold">Model Type</label>
<select value={meshyModelType} onChange={e => setMeshyModelType(e.target.value)} className="bg-zinc-950 border border-zinc-800 rounded px-2 py-1.5 text-xs text-zinc-300">
<option value="standard">Standard</option>
<option value="lowpoly">Low Poly</option>
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] text-zinc-500 font-bold">AI Model</label>
<select value={meshyAiModel} onChange={e => setMeshyAiModel(e.target.value)} disabled={meshyModelType === 'lowpoly'} className="bg-zinc-950 border border-zinc-800 rounded px-2 py-1.5 text-xs text-zinc-300 disabled:opacity-50">
<option value="latest">Latest (Meshy 6)</option>
<option value="meshy-6">Meshy 6</option>
<option value="meshy-5">Meshy 5</option>
</select>
</div>
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 text-xs text-zinc-300 cursor-pointer">
<input type="checkbox" checked={meshyShouldTexture} onChange={e => setMeshyShouldTexture(e.target.checked)} className="rounded border-zinc-700 bg-zinc-900 text-blue-500 cursor-pointer" />
Texture
</label>
<label className={cn("flex items-center gap-1.5 text-xs text-zinc-300 cursor-pointer", !meshyShouldTexture && "opacity-50 pointer-events-none")}>
<input type="checkbox" checked={meshyEnablePbr} onChange={e => setMeshyEnablePbr(e.target.checked)} disabled={!meshyShouldTexture} className="rounded border-zinc-700 bg-zinc-900 text-blue-500 cursor-pointer" />
PBR
</label>
<label className={cn("flex items-center gap-1.5 text-xs text-zinc-300 cursor-pointer", (!meshyShouldTexture || meshyAiModel === 'meshy-5') && "opacity-50 pointer-events-none")}>
<input type="checkbox" checked={meshyHdTexture} onChange={e => setMeshyHdTexture(e.target.checked)} disabled={!meshyShouldTexture || meshyAiModel === 'meshy-5'} className="rounded border-zinc-700 bg-zinc-900 text-blue-500 cursor-pointer" />
4K HD
</label>
</div>
{meshyShouldTexture && (
<div className="flex flex-col gap-2">
<input type="text" value={meshyTexturePrompt} onChange={e => setMeshyTexturePrompt(e.target.value)} maxLength={600} placeholder="Texture prompt (optional)..." className="bg-zinc-950 border border-zinc-800 rounded px-2 py-1.5 text-xs text-zinc-300" />
<input type="text" value={meshyTextureImageUrl} onChange={e => setMeshyTextureImageUrl(e.target.value)} placeholder="Texture Image URL (optional)..." className="bg-zinc-950 border border-zinc-800 rounded px-2 py-1.5 text-xs text-zinc-300" />
</div>
)}
<div className="flex items-center gap-3 mt-1">
<label className={cn("flex items-center gap-1.5 text-xs text-zinc-300 cursor-pointer", meshyModelType === 'lowpoly' && "opacity-50 pointer-events-none")}>
<input type="checkbox" checked={meshyShouldRemesh} onChange={e => setMeshyShouldRemesh(e.target.checked)} disabled={meshyModelType === 'lowpoly'} className="rounded border-zinc-700 bg-zinc-900 text-blue-500 cursor-pointer" />
Remesh
</label>
</div>
{meshyShouldRemesh && meshyModelType !== 'lowpoly' && (
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1">
<label className="text-[10px] text-zinc-500 font-bold">Topology</label>
<select value={meshyTopology} onChange={e => setMeshyTopology(e.target.value)} className="bg-zinc-950 border border-zinc-800 rounded px-2 py-1.5 text-xs text-zinc-300">
<option value="triangle">Triangle</option>
<option value="quad">Quad</option>
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] text-zinc-500 font-bold">Polycount</label>
<input type="number" min={100} max={300000} step={1000} value={meshyTargetPolycount} onChange={e => setMeshyTargetPolycount(parseInt(e.target.value) || 30000)} className="bg-zinc-950 border border-zinc-800 rounded px-2 py-1.5 text-xs text-zinc-300" />
</div>
</div>
)}
</div>
)}
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex gap-2 items-start text-red-400 text-xs">
<AlertCircle size={14} className="shrink-0 mt-0.5" />
<p>{error}</p>
</div>
)}
<button
onClick={generate3DModel}
disabled={!imageFile || isGenerating}
className="w-full py-3 bg-blue-600 hover:bg-blue-500 disabled:bg-zinc-800 disabled:text-zinc-500 text-white rounded-xl text-sm font-bold shadow-lg shadow-blue-900/20 transition-all flex items-center justify-center gap-2"
>
{isGenerating ? <Loader2 size={16} className="animate-spin" /> : <Box size={16} />}
{isGenerating ? 'Generating...' : 'Generate 3D Model'}
</button>
</div>
{/* Right Column: Output */}
<div className="flex flex-col gap-4 border-l border-zinc-800 pl-6">
<h3 className="text-sm font-bold text-zinc-200">3D Preview</h3>
<div className="flex-1 bg-zinc-950 border border-zinc-800 rounded-xl relative overflow-hidden flex flex-col items-center justify-center">
{generatedModelUrl ? (
<>
<div className="absolute inset-0">
<ModelPreview url={generatedModelUrl} />
</div>
<div className="absolute top-3 right-3 flex gap-2 z-10">
<a href={generatedModelUrl} download="model.glb" target="_blank" className="p-2 bg-zinc-900/80 hover:bg-zinc-800 backdrop-blur rounded-lg border border-zinc-700 transition-colors text-zinc-300">
<Download size={16} />
</a>
</div>
</>
) : isGenerating ? (
<div className="flex flex-col items-center gap-4 text-zinc-400">
<Loader2 size={32} className="animate-spin text-blue-500" />
<p className="text-xs font-medium animate-pulse">{progressMessage}</p>
</div>
) : (
<div className="text-center text-zinc-600 flex flex-col items-center gap-2">
<Box size={32} className="opacity-20" />
<p className="text-xs">Your 3D model will appear here</p>
</div>
)}
</div>
{generatedModelUrl && (
<button
onClick={() => {
onPushToApp(generatedModelUrl);
onClose();
}}
className="w-full py-3 bg-green-600 hover:bg-green-500 text-white rounded-xl text-sm font-bold shadow-lg shadow-green-900/20 transition-all flex items-center justify-center gap-2"
>
Push to Application Scene
</button>
)}
</div>
</div>
)}
</div>
</div>
</motion.div>
</div>
</AnimatePresence>
);
};
+112
View File
@@ -0,0 +1,112 @@
import { useRef, useMemo, forwardRef } from "react";
import { useFrame } from "@react-three/fiber";
import { RoundedBox, Text } from "@react-three/drei";
import * as THREE from "three";
import { Liquid } from "./LiquidShader";
export const JuiceBox = forwardRef<THREE.Group, any>(({
color = "#4ade80",
roughness = 0.1,
metalness = 0.05,
clearcoat = 1.0,
transmission = 0,
thickness = 0,
ior = 1.5,
sheen = 0,
map = undefined,
uvScale = 1,
showLiquid = true,
liquidColor = "#ff9900",
...props
}, ref) => {
const meshRef = useRef<THREE.Mesh>(null);
// Create a custom texture for the juice box
const customTexture = useMemo(() => {
if (map) return null;
const canvas = document.createElement("canvas");
canvas.width = 512;
canvas.height = 1024;
const ctx = canvas.getContext("2d");
if (ctx) {
// Background
ctx.fillStyle = color;
ctx.fillRect(0, 0, 512, 1024);
// Header
ctx.fillStyle = "#166534";
ctx.fillRect(0, 0, 512, 100);
// Text
ctx.fillStyle = "white";
ctx.font = "bold 80px sans-serif";
ctx.textAlign = "center";
ctx.fillText("MAA", 256, 300);
ctx.font = "bold 60px sans-serif";
ctx.fillStyle = "#be123c";
ctx.fillText("GUAVA", 256, 400);
// Guava illustration (simple circles)
ctx.fillStyle = "#f472b6";
ctx.beginPath();
ctx.arc(256, 650, 150, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "#166534";
ctx.lineWidth = 10;
ctx.stroke();
ctx.fillStyle = "white";
ctx.font = "24px sans-serif";
ctx.fillText("REFRESHING FRUIT DRINK", 256, 150);
}
const tex = new THREE.CanvasTexture(canvas);
tex.needsUpdate = true;
return tex;
}, [color, map]);
const uploadedTexture = useMemo(() => {
if (!map) return null;
const loader = new THREE.TextureLoader();
const tex = loader.load(map);
tex.colorSpace = THREE.SRGBColorSpace;
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(uvScale, uvScale);
return tex;
}, [map, uvScale]);
return (
<group ref={ref} {...props}>
<RoundedBox
ref={meshRef}
args={[1, 2, 0.6]} // Width, Height, Depth
radius={0.05}
smoothness={4}
castShadow
receiveShadow
>
<meshPhysicalMaterial
map={uploadedTexture || customTexture}
roughness={roughness}
metalness={metalness}
clearcoat={clearcoat}
transmission={transmission}
thickness={thickness}
ior={ior}
sheen={sheen}
attenuationDistance={props.attenuationDistance ?? Infinity}
attenuationColor={props.attenuationColor ?? color}
transparent={transmission > 0}
envMapIntensity={1.5}
/>
</RoundedBox>
{showLiquid && (
<group position={[0, 0, 0]}>
<Liquid color={liquidColor} size={[0.9, 1.9, 0.5]} />
</group>
)}
</group>
);
});
+134
View File
@@ -0,0 +1,134 @@
import { useRef, useMemo } from "react";
import { useFrame, useThree, extend, ThreeElement } from "@react-three/fiber";
import * as THREE from "three";
import { shaderMaterial } from "@react-three/drei";
// Type declaration for JSX
declare global {
namespace JSX {
interface IntrinsicElements {
liquidMaterialImpl: any;
}
}
}
const LiquidMaterialImpl = shaderMaterial(
{
uTime: 0,
uColor: new THREE.Color("#ff9900"),
uResolution: new THREE.Vector2(),
uFresnelBias: 0.1,
uFresnelScale: 1.0,
uFresnelPower: 2.0,
uRefractionRatio: 0.98,
uIor: 1.33,
},
// Vertex Shader
`
#include <clipping_planes_pars_vertex>
varying vec3 vNormal;
varying vec3 vViewPosition;
varying vec2 vUv;
varying vec3 vWorldPosition;
void main() {
vUv = uv;
vNormal = normalize(normalMatrix * normal);
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
vWorldPosition = worldPosition.xyz;
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
vViewPosition = -mvPosition.xyz;
#include <clipping_planes_vertex>
gl_Position = projectionMatrix * mvPosition;
}
`,
// Fragment Shader
`
#include <clipping_planes_pars_fragment>
uniform float uTime;
uniform vec3 uColor;
uniform vec2 uResolution;
uniform float uFresnelBias;
uniform float uFresnelScale;
uniform float uFresnelPower;
uniform float uIor;
varying vec3 vNormal;
varying vec3 vViewPosition;
varying vec2 vUv;
varying vec3 vWorldPosition;
// Simple noise function for caustics and imperfections
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash(i + vec2(0.0, 0.0)), hash(i + vec2(1.0, 0.0)), f.x),
mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x), f.y);
}
void main() {
#include <clipping_planes_fragment>
vec3 normal = normalize(vNormal);
vec3 viewDir = normalize(vViewPosition);
// Fresnel effect
float fresnel = uFresnelBias + uFresnelScale * pow(1.0 + dot(viewDir, normal), uFresnelPower);
// Caustics simulation
vec2 causticUv = vWorldPosition.xz * 2.0 + vWorldPosition.y * 0.5;
float caustic = noise(causticUv * 5.0 + uTime * 0.5) * 0.5 + 0.5;
caustic *= noise(causticUv * 10.0 - uTime * 0.3) * 0.5 + 0.5;
caustic = pow(caustic, 3.0) * 1.5;
// Surface imperfections
float imperfection = noise(vUv * 20.0 + uTime * 0.1) * 0.05;
vec3 finalColor = uColor;
finalColor += caustic * 0.2 * uColor;
finalColor -= imperfection;
// Simple refraction-like look by blending with fresnel
vec3 skyColor = vec3(0.8, 0.9, 1.0);
finalColor = mix(finalColor, skyColor, fresnel * 0.3);
gl_FragColor = vec4(finalColor, 0.9);
}
`
);
extend({ LiquidMaterialImpl });
export function Liquid({ color = "#ff9900", size = [0.9, 1.8, 0.5] }) {
const materialRef = useRef<any>(null);
const { size: winSize } = useThree();
useFrame((state) => {
if (materialRef.current) {
const mat = materialRef.current;
if (mat.uTime !== undefined) mat.uTime = state.clock.elapsedTime;
if (mat.uResolution && mat.uResolution.set) {
mat.uResolution.set(winSize.width || 800, winSize.height || 600);
}
}
});
return (
<mesh>
<boxGeometry args={size as any} />
{/* @ts-ignore */}
<liquidMaterialImpl
ref={materialRef}
uColor={new THREE.Color(color)}
transparent
side={THREE.DoubleSide}
clipping={true}
/>
</mesh>
);
}
+271
View File
@@ -0,0 +1,271 @@
import { useRef, useEffect, forwardRef, useMemo, useState } from "react";
import * as THREE from "three";
import { useFrame, useThree, createPortal } from "@react-three/fiber";
import { MaterialFactory } from "../materials/MaterialFactory";
import { useTexturePipeline } from "../hooks/useTexturePipeline";
interface ModelViewerProps {
object: THREE.Object3D | null;
sceneObjects: any[];
}
interface MeshMaterialManagerProps {
mesh: THREE.Mesh;
sceneObj: any;
textureCache: React.MutableRefObject<Map<string, THREE.Texture>>;
}
const MeshMaterialManager = ({ mesh, sceneObj, textureCache }: MeshMaterialManagerProps) => {
useEffect(() => {
if (!mesh || !sceneObj.materialProps) return;
const props = sceneObj.materialProps;
const mappingType = props.mappingType || 'uv';
const uvScale = props.uvScale || 1;
const uvTiling = props.uvTiling || [1, 1];
const uvOffset = props.uvOffset || [0, 0];
const uvRotation = props.uvRotation || 0;
const origMat = (Array.isArray(mesh.material) ? mesh.material[0] : mesh.material) as THREE.MeshPhysicalMaterial;
const baseParams: any = {
color: origMat.color,
roughness: origMat.roughness !== undefined ? origMat.roughness : 0.5,
metalness: origMat.metalness !== undefined ? origMat.metalness : 0,
map: origMat.map,
normalMap: origMat.normalMap,
roughnessMap: origMat.roughnessMap,
metalnessMap: origMat.metalnessMap,
aoMap: origMat.aoMap,
emissiveMap: origMat.emissiveMap,
emissive: origMat.emissive,
emissiveIntensity: origMat.emissiveIntensity,
envMapIntensity: origMat.envMapIntensity,
alphaMap: origMat.alphaMap,
transparent: origMat.transparent,
opacity: origMat.opacity,
side: origMat.side,
alphaTest: origMat.alphaTest,
};
// Use MaterialFactory to get reusable materials
let activeMat: any;
if (mappingType === 'triplanar') {
activeMat = MaterialFactory.getInstance().getTriplanarMaterial(
baseParams,
mesh.uuid
);
activeMat.triplanarUniforms.uTriplanarScale.value = uvScale;
activeMat.triplanarUniforms.uBlendSharpness.value = 5.0; // Default or from props
} else {
activeMat = MaterialFactory.getInstance().getStandardUVMaterial(
baseParams,
mesh.uuid
);
let modeIndex = 0;
if (mappingType === 'planar') modeIndex = 1;
else if (mappingType === 'box') modeIndex = 2;
else if (mappingType === 'cylinder') modeIndex = 3;
activeMat.uvUniforms.uMappingMode.value = modeIndex;
activeMat.updateUVTransform(uvOffset, [uvTiling[0] * uvScale, uvTiling[1] * uvScale], uvRotation * (Math.PI / 180));
}
// Apply basic props
if (props.color) activeMat.color.set(props.color);
if (props.roughness !== undefined) activeMat.roughness = props.roughness;
if (props.metalness !== undefined) activeMat.metalness = props.metalness;
if (props.opacity !== undefined) activeMat.opacity = props.opacity;
if (props.transmission !== undefined) activeMat.transmission = props.transmission;
activeMat.transparent = (props.transmission > 0) || (props.opacity < 1) || !!props.alphaMap;
if (Array.isArray(mesh.material)) {
mesh.material = mesh.material.map(() => activeMat);
} else {
mesh.material = activeMat;
}
// Textures
const textureTypes: ('map' | 'normalMap' | 'specularMap' | 'alphaMap')[] = ['map', 'normalMap', 'specularMap', 'alphaMap'];
textureTypes.forEach(type => {
const url = props[type];
if (url) {
if (!activeMat[type] || activeMat[type]!.name !== url) {
if (textureCache.current.has(url)) {
activeMat[type] = textureCache.current.get(url)!;
if (mappingType === 'triplanar') activeMat.triplanarUniforms['t' + type.charAt(0).toUpperCase() + type.slice(1).replace('Map', '')].value = activeMat[type];
activeMat.needsUpdate = true;
} else {
const loader = new THREE.TextureLoader();
loader.load(url, (tex) => {
tex.name = url;
if (type === 'map') tex.colorSpace = THREE.SRGBColorSpace;
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
textureCache.current.set(url, tex);
activeMat[type] = tex;
if (mappingType === 'triplanar') activeMat.triplanarUniforms['t' + type.charAt(0).toUpperCase() + type.slice(1).replace('Map', '')].value = tex;
activeMat.needsUpdate = true;
});
}
}
} else if (url === null && activeMat[type]) {
activeMat[type] = null;
if (mappingType === 'triplanar') activeMat.triplanarUniforms['t' + type.charAt(0).toUpperCase() + type.slice(1).replace('Map', '')].value = null;
activeMat.needsUpdate = true;
}
});
mesh.visible = sceneObj.visible;
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.frustumCulled = false;
// Sync individual mesh transform
if (sceneObj.transformProps) {
const { position, rotation, scale } = sceneObj.transformProps;
mesh.position.set(position[0], position[1], position[2]);
mesh.rotation.set(rotation[0], rotation[1], rotation[2]);
mesh.scale.set(scale[0], scale[1], scale[2]);
}
}, [sceneObj, mesh]);
return null;
};
const FillMesh = ({ fillProps, originalMesh }: { fillProps: any, originalMesh: THREE.Mesh }) => {
const materialRef = useRef<any>(null);
const [clippingPlane] = useState(() => new THREE.Plane(new THREE.Vector3(0, -1, 0), 0));
const { size: winSize } = useThree();
// Clone geometry and fix interpolation
const geometry = useMemo(() => {
const geo = originalMesh.geometry.clone();
geo.computeVertexNormals();
return geo;
}, [originalMesh.geometry]);
// Update clipping plane based on height
useEffect(() => {
geometry.computeBoundingBox();
const box = geometry.boundingBox || new THREE.Box3().setFromObject(originalMesh);
const minY = box.min.y;
const maxY = box.max.y;
// height is 0 to 1
const clipY = minY + (maxY - minY) * (fillProps.height ?? 0.5);
// Since this plane is applied in world space by Three.js by default,
// wait, localClippingEnabled = true means the plane is in LOCAL space!
// If it's local space, clipY is exact.
clippingPlane.constant = clipY;
}, [geometry, fillProps.height, originalMesh]);
useFrame((state) => {
if (materialRef.current && fillProps.type === 'liquid') {
const mat = materialRef.current;
if (mat.uTime !== undefined) mat.uTime = state.clock.elapsedTime;
if (mat.uResolution && mat.uResolution.set) {
mat.uResolution.set(winSize.width || 800, winSize.height || 600);
}
}
});
const fillNode = (
<mesh geometry={geometry} scale={[0.995, 0.995, 0.995]}>
{fillProps.type === 'liquid' ? (
// @ts-ignore
<liquidMaterialImpl
ref={materialRef}
uColor={new THREE.Color(fillProps.color)}
transparent
side={THREE.DoubleSide}
clippingPlanes={[clippingPlane]}
clipping={true}
/>
) : (
<meshPhysicalMaterial
color={fillProps.color}
side={THREE.DoubleSide}
clippingPlanes={[clippingPlane]}
roughness={0.2}
clearcoat={0.5}
/>
)}
</mesh>
);
return createPortal(fillNode, originalMesh);
};
export const ModelViewer = forwardRef<THREE.Group, ModelViewerProps>(({ object, sceneObjects }, ref) => {
const internalRef = useRef<THREE.Group>(null);
const groupRef = (ref as any) || internalRef;
const textureCache = useRef<Map<string, THREE.Texture>>(new Map());
const meshCache = useRef<Map<string, THREE.Mesh>>(new Map());
useTexturePipeline(object);
// Cleanup textures on unmount
useEffect(() => {
return () => {
textureCache.current.forEach(tex => tex.dispose());
textureCache.current.clear();
meshCache.current.clear();
};
}, []);
// Build mesh cache when object changes
useEffect(() => {
meshCache.current.clear();
if (object) {
let mIdx = 0;
object.traverse((child) => {
if (child instanceof THREE.Mesh) {
const stableId = `mesh-${mIdx}`;
meshCache.current.set(stableId, child);
mIdx++;
}
});
}
}, [object]);
// Main transform update for the core model
useEffect(() => {
if (object && sceneObjects) {
const mainModel = sceneObjects.find(obj => obj.type === 'model');
if (mainModel && mainModel.transformProps && groupRef.current) {
const { position: p, rotation: r, scale: s } = mainModel.transformProps;
groupRef.current.position.set(p[0], p[1], p[2]);
groupRef.current.rotation.set(r[0], r[1], r[2]);
groupRef.current.scale.set(s[0], s[1], s[2]);
}
}
}, [object, sceneObjects]);
if (!object) return null;
const meshObjects = sceneObjects.filter(obj => obj.type === 'mesh');
return (
<group ref={groupRef}>
<primitive object={object} />
{meshObjects.map(obj => {
const mesh = meshCache.current.get(obj.id);
if (!mesh) return null;
return (
<group key={obj.id}>
<MeshMaterialManager
mesh={mesh}
sceneObj={obj}
textureCache={textureCache}
/>
{obj.fillProps?.enabled && (
<FillMesh fillProps={obj.fillProps} originalMesh={mesh} />
)}
</group>
);
})}
</group>
);
});
+252
View File
@@ -0,0 +1,252 @@
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Download, Image as ImageIcon, Layers, Settings, Monitor, Smartphone, Maximize2 } from 'lucide-react';
import { cn } from '../lib/utils';
interface RenderModalProps {
isOpen: boolean;
onClose: () => void;
onRender: (settings: RenderSettings) => void;
}
export interface RenderSettings {
width: number;
height: number;
includeAlpha: boolean;
format: 'png' | 'jpg';
name: string;
folder?: string;
}
const RESOLUTION_PRESETS = [
{ label: '7680 x 4800 (8K)', w: 7680, h: 4800 },
{ label: '3840 x 2400 (4K)', w: 3840, h: 2400 },
{ label: '2560 x 1600 (2K)', w: 2560, h: 1600 },
{ label: '1920 x 1200 (Full HD)', w: 1920, h: 1200 },
{ label: '1600 x 1000', w: 1600, h: 1000 },
{ label: '1280 x 800', w: 1280, h: 800 },
{ label: '1024 x 640', w: 1024, h: 640 },
{ label: '800 x 500', w: 800, h: 500 },
{ label: '640 x 400', w: 640, h: 400 },
];
export const RenderModal: React.FC<RenderModalProps> = ({ isOpen, onClose, onRender }) => {
const [settings, setSettings] = useState<RenderSettings>({
width: 1920,
height: 1200,
includeAlpha: true,
format: 'png',
name: 'Render_1',
folder: 'C:/Users/Render/Documents'
});
const [activeTab, setActiveTab] = useState('Output');
if (!isOpen) return null;
return (
<AnimatePresence>
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="w-full max-w-4xl bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden flex flex-col h-[600px]"
>
{/* Header */}
<div className="h-10 bg-zinc-800 flex items-center justify-between px-4 border-b border-zinc-700">
<span className="text-xs font-bold text-zinc-300">Render</span>
<button onClick={onClose} className="p-1 hover:bg-zinc-700 rounded transition-colors">
<X size={16} className="text-zinc-400" />
</button>
</div>
<div className="flex flex-1 overflow-hidden">
{/* Sidebar */}
<div className="w-48 bg-zinc-900 border-r border-zinc-800 flex flex-col">
{['Output', 'Options', 'Queue'].map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={cn(
"px-4 py-2 text-left text-xs font-medium transition-colors",
activeTab === tab ? "bg-zinc-800 text-blue-400" : "text-zinc-500 hover:bg-zinc-800/50 hover:text-zinc-300"
)}
>
{tab}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 p-6 overflow-y-auto bg-zinc-900/50">
<div className="flex flex-col gap-8 max-w-2xl">
{/* Mode Tabs */}
<div className="flex p-1 bg-zinc-950 rounded-lg border border-zinc-800 self-start">
{['Still Image', 'Animation', 'VIZ3D XR', 'Configurator', 'CMF'].map((mode) => (
<button
key={mode}
className={cn(
"px-4 py-1.5 text-[10px] font-bold rounded transition-all",
mode === 'Still Image' ? "bg-zinc-800 text-blue-400 shadow-lg" : "text-zinc-500 hover:text-zinc-300"
)}
>
{mode}
</button>
))}
</div>
{/* Name & Resolution */}
<div className="grid grid-cols-1 gap-6">
<div className="flex flex-col gap-2">
<label className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">Name</label>
<input
type="text"
value={settings.name}
onChange={(e) => setSettings(prev => ({ ...prev, name: e.target.value }))}
className="bg-zinc-950 border border-zinc-800 rounded px-3 py-1.5 text-xs text-zinc-300 focus:outline-none focus:border-blue-500/50"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">Folder</label>
<div className="flex gap-2">
<input
type="text"
value={settings.folder}
onChange={(e) => setSettings(prev => ({ ...prev, folder: e.target.value }))}
className="flex-1 bg-zinc-950 border border-zinc-800 rounded px-3 py-1.5 text-xs text-zinc-300 focus:outline-none focus:border-blue-500/50"
/>
<button className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded text-[10px] font-bold transition-colors">
Browse...
</button>
</div>
</div>
<div className="flex flex-col gap-4">
<label className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">Format & Quality</label>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-400">Format</span>
<select
value={settings.format}
onChange={(e) => setSettings(prev => ({ ...prev, format: e.target.value as any }))}
className="bg-zinc-950 border border-zinc-800 rounded px-2 py-1 text-xs text-zinc-300"
>
<option value="png">PNG</option>
<option value="jpg">JPG</option>
</select>
</div>
<label className="flex items-center gap-2 cursor-pointer group">
<input
type="checkbox"
checked={settings.includeAlpha}
onChange={(e) => setSettings(prev => ({ ...prev, includeAlpha: e.target.checked }))}
className="hidden"
/>
<div className={cn(
"w-4 h-4 rounded border flex items-center justify-center transition-all",
settings.includeAlpha ? "bg-blue-500 border-blue-500" : "border-zinc-700 bg-zinc-950"
)}>
{settings.includeAlpha && <div className="w-2 h-2 bg-white rounded-sm" />}
</div>
<span className="text-xs text-zinc-400 group-hover:text-zinc-200 transition-colors">Include Alpha (Transparency)</span>
</label>
</div>
</div>
<div className="flex flex-col gap-4">
<label className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">Resolution</label>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-500">W:</span>
<input
type="number"
value={settings.width}
onChange={(e) => setSettings(prev => ({ ...prev, width: parseInt(e.target.value) || 0 }))}
className="w-20 bg-zinc-950 border border-zinc-800 rounded px-2 py-1 text-xs text-zinc-300"
/>
<span className="text-[10px] text-zinc-600">px</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-500">H:</span>
<input
type="number"
value={settings.height}
onChange={(e) => setSettings(prev => ({ ...prev, height: parseInt(e.target.value) || 0 }))}
className="w-20 bg-zinc-950 border border-zinc-800 rounded px-2 py-1 text-xs text-zinc-300"
/>
<span className="text-[10px] text-zinc-600">px</span>
</div>
<select
onChange={(e) => {
const preset = RESOLUTION_PRESETS.find(p => p.label === e.target.value);
if (preset) {
setSettings(prev => ({ ...prev, width: preset.w, height: preset.h }));
}
}}
className="bg-zinc-950 border border-zinc-800 rounded px-2 py-1 text-xs text-zinc-300"
>
<option value="">Presets</option>
{RESOLUTION_PRESETS.map(p => (
<option key={p.label} value={p.label}>{p.label}</option>
))}
</select>
</div>
</div>
</div>
{/* Collapsible Sections */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between p-3 bg-zinc-800/30 rounded border border-zinc-800/50 cursor-pointer hover:bg-zinc-800/50 transition-colors">
<span className="text-xs font-bold text-zinc-400">Layers and Passes</span>
<ChevronDown size={14} className="text-zinc-600" />
</div>
<div className="flex items-center justify-between p-3 bg-zinc-800/30 rounded border border-zinc-800/50 cursor-pointer hover:bg-zinc-800/50 transition-colors">
<div className="flex items-center gap-2">
<div className="w-3 h-3 border border-zinc-700 rounded-sm" />
<span className="text-xs font-bold text-zinc-400">Region</span>
</div>
<ChevronDown size={14} className="text-zinc-600" />
</div>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="h-16 bg-zinc-800/50 border-t border-zinc-800 flex items-center justify-end px-6 gap-3">
<button
onClick={onClose}
className="px-6 py-2 bg-zinc-700 hover:bg-zinc-600 text-zinc-300 rounded text-xs font-bold transition-all"
>
Add to Queue
</button>
<button
onClick={() => onRender(settings)}
className="px-8 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded text-xs font-bold shadow-lg shadow-blue-900/20 transition-all"
>
Render
</button>
</div>
</motion.div>
</div>
</AnimatePresence>
);
};
const ChevronDown = ({ size, className }: { size: number, className?: string }) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="m6 9 6 6 6-6"/>
</svg>
);
+453
View File
@@ -0,0 +1,453 @@
import React, { useState, useRef, useEffect, Suspense, useMemo, useCallback } from 'react';
import { X, Upload, Trash2, RotateCw, Eye, EyeOff } from 'lucide-react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls, Environment } from '@react-three/drei';
import * as THREE from 'three';
import { ModelViewer } from './ModelViewer';
interface Layer {
id: string;
url: string;
img?: HTMLImageElement;
x: number; y: number;
scaleX: number; scaleY: number;
rotation: number;
cropL: number; cropT: number; cropR: number; cropB: number;
visible: boolean;
}
type Handle = 'move' | 'rotate' | 'scaleNW' | 'scaleNE' | 'scaleSW' | 'scaleSE' | 'scaleN' | 'scaleS' | 'scaleE' | 'scaleW';
interface UVEditorProps {
onClose: () => void;
onSave: (dataUrl: string, mappingType: string, layers: any[], bgColor: string, bgTransparent: boolean) => void;
targetObject?: any;
loadedModel?: THREE.Group | null;
sceneObjects?: any[];
}
const CANVAS_SIZE = 1024;
function deg2rad(d: number) { return d * Math.PI / 180; }
export function UVEditor({ onClose, onSave, targetObject, loadedModel, sceneObjects }: UVEditorProps) {
const initBg = targetObject?.materialProps?.uvBackground ?? '#1a1a2e';
const initTrans = targetObject?.materialProps?.uvTransparent ?? false;
const [layers, setLayers] = useState<Layer[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [mappingType, setMappingType] = useState<string>(targetObject?.materialProps?.mappingType || 'uv');
const [bgColor, setBgColor] = useState(initBg);
const [bgTransparent, setBgTransparent] = useState(initTrans);
const containerRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const dragRef = useRef<{ handle: Handle; startX: number; startY: number; layer: Layer } | null>(null);
useEffect(() => {
const saved = targetObject?.materialProps?.uvLayers;
if (!saved?.length) return;
let n = 0;
const init: Layer[] = saved.map((l: any) => ({ ...l }));
init.forEach(layer => {
const img = new Image();
img.onload = () => { layer.img = img; n++; if (n === init.length) setLayers([...init]); };
img.src = layer.url;
});
}, []);
const renderCanvas = useCallback((lrs: Layer[], transparent: boolean, bg: string): string => {
const c = document.createElement('canvas');
c.width = c.height = CANVAS_SIZE;
const ctx = c.getContext('2d')!;
if (!transparent) { ctx.fillStyle = bg; ctx.fillRect(0, 0, CANVAS_SIZE, CANVAS_SIZE); }
lrs.forEach(layer => {
if (!layer.img || layer.visible === false) return;
const iw = layer.img.width, ih = layer.img.height;
const sx = layer.cropL * iw, sy = layer.cropT * ih;
const sw = (1 - layer.cropL - layer.cropR) * iw;
const sh = (1 - layer.cropT - layer.cropB) * ih;
ctx.save();
ctx.translate(layer.x, layer.y);
ctx.rotate(deg2rad(layer.rotation));
ctx.scale(layer.scaleX, layer.scaleY);
ctx.drawImage(layer.img, sx, sy, sw, sh, -sw / 2, -sh / 2, sw, sh);
ctx.restore();
});
return c.toDataURL(transparent ? 'image/png' : 'image/jpeg');
}, []);
useEffect(() => {
setPreviewUrl(renderCanvas(layers, bgTransparent, bgColor));
}, [layers, bgTransparent, bgColor]);
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; if (!file) return;
const reader = new FileReader();
reader.onload = ev => {
const url = ev.target?.result as string;
const img = new Image();
img.onload = () => {
const nl: Layer = { id: `l-${Date.now()}`, url, img, x: CANVAS_SIZE / 2, y: CANVAS_SIZE / 2, scaleX: 1, scaleY: 1, rotation: 0, cropL: 0, cropT: 0, cropR: 0, cropB: 0, visible: true };
setLayers(p => [...p, nl]);
setSelectedId(nl.id);
};
img.src = url;
};
reader.readAsDataURL(file);
e.target.value = '';
};
const updateLayer = (id: string, u: Partial<Layer>) => setLayers(p => p.map(l => l.id === id ? { ...l, ...u } : l));
const selectedLayer = layers.find(l => l.id === selectedId) ?? null;
const getSF = () => {
const r = containerRef.current?.getBoundingClientRect();
return r ? CANVAS_SIZE / r.width : 1;
};
const onHandleDown = (e: React.MouseEvent, handle: Handle, layer: Layer) => {
e.stopPropagation();
setSelectedId(layer.id);
dragRef.current = { handle, startX: e.clientX, startY: e.clientY, layer: { ...layer } };
e.preventDefault();
};
// Snap helper: snap value to nearest multiple of snapSize
const snap = (v: number, snapSize: number) => Math.round(v / snapSize) * snapSize;
const onMouseMove = (e: React.MouseEvent) => {
const d = dragRef.current;
if (!d) return;
const sf = getSF();
const dx = (e.clientX - d.startX) * sf;
const dy = (e.clientY - d.startY) * sf;
const isShift = e.shiftKey;
const SNAP_PX = 16; // grid snap in canvas units
if (d.handle === 'move') {
let nx = d.layer.x + dx;
let ny = d.layer.y + dy;
// Clamp to canvas bounds with soft margin
nx = Math.max(0, Math.min(CANVAS_SIZE, nx));
ny = Math.max(0, Math.min(CANVAS_SIZE, ny));
// Hold Shift to snap to grid
if (isShift) { nx = snap(nx, SNAP_PX); ny = snap(ny, SNAP_PX); }
// Center snapping: snap near canvas center
if (Math.abs(nx - CANVAS_SIZE / 2) < SNAP_PX) nx = CANVAS_SIZE / 2;
if (Math.abs(ny - CANVAS_SIZE / 2) < SNAP_PX) ny = CANVAS_SIZE / 2;
updateLayer(d.layer.id, { x: nx, y: ny });
} else if (d.handle === 'rotate') {
let rot = d.layer.rotation + dx / 2;
// Snap rotation to 15° increments when Shift held
if (isShift) rot = snap(rot, 15);
updateLayer(d.layer.id, { rotation: rot });
} else if (d.handle.startsWith('scale')) {
if (isShift) {
// Shift held: stretch X and Y independently
const sx = Math.max(0.05, d.layer.scaleX + dx / 200);
const sy = Math.max(0.05, d.layer.scaleY + dy / 200);
updateLayer(d.layer.id, { scaleX: sx, scaleY: sy });
} else {
// No shift: uniform scale driven by the larger axis
const delta = (Math.abs(dx) > Math.abs(dy) ? dx : dy) / 200;
const sx = Math.max(0.05, d.layer.scaleX + delta);
const sy = Math.max(0.05, d.layer.scaleY + delta);
updateLayer(d.layer.id, { scaleX: sx, scaleY: sy });
}
}
};
const onMouseUp = () => { dragRef.current = null; };
const handleSave = () => {
if (previewUrl) {
const serialized = layers.map(({ img, ...rest }) => rest);
onSave(previewUrl, mappingType, serialized, bgColor, bgTransparent);
}
onClose();
};
const PreviewModel = () => {
const mockObjs = useMemo(() => {
if (!sceneObjects) return [];
return sceneObjects.map(obj => obj.id === targetObject?.id
? { ...obj, materialProps: { ...obj.materialProps, map: previewUrl, mappingType } }
: obj
);
}, [sceneObjects, previewUrl, mappingType]);
const cloned = useMemo(() => loadedModel?.clone() ?? null, [loadedModel]);
if (!targetObject) return <mesh><boxGeometry /><meshStandardMaterial /></mesh>;
if (targetObject.type === 'cube') {
const tex = previewUrl ? (() => { const t = new THREE.TextureLoader().load(previewUrl); t.colorSpace = THREE.SRGBColorSpace; t.flipY = false; return t; })() : null;
return <mesh><boxGeometry args={[1, 1, 1]} /><meshStandardMaterial map={tex ?? undefined} /></mesh>;
}
return <ModelViewer object={cloned} sceneObjects={mockObjs} />;
};
// Bounding box overlay rendered on canvas div
const BoundingBox = ({ layer }: { layer: Layer }) => {
const iw = (layer.img?.width ?? 200) * (1 - layer.cropL - layer.cropR);
const ih = (layer.img?.height ?? 200) * (1 - layer.cropT - layer.cropB);
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return null;
const pct = (v: number) => `${(v / CANVAS_SIZE) * 100}%`;
const w = (iw * layer.scaleX / CANVAS_SIZE) * 100;
const h = (ih * layer.scaleY / CANVAS_SIZE) * 100;
const lx = (layer.x / CANVAS_SIZE) * 100;
const ly = (layer.y / CANVAS_SIZE) * 100;
const cornerStyle = (cx: string, cy: string, cursor: string): React.CSSProperties => ({
position: 'absolute', left: cx, top: cy, width: 10, height: 10,
background: '#ffffff', border: '2px solid #3b82f6', borderRadius: 2,
transform: 'translate(-50%,-50%)', cursor, zIndex: 10, touchAction: 'none'
});
const edgeStyle = (cx: string, cy: string, cursor: string): React.CSSProperties => ({
position: 'absolute', left: cx, top: cy, width: 8, height: 8,
background: '#3b82f6', borderRadius: '50%',
transform: 'translate(-50%,-50%)', cursor, zIndex: 10
});
return (
<div style={{
position: 'absolute',
left: `${lx}%`, top: `${ly}%`,
width: `${w}%`, height: `${h}%`,
transform: `translate(-50%,-50%) rotate(${layer.rotation}deg)`,
border: '1.5px solid #3b82f6',
pointerEvents: 'none',
zIndex: 5,
}}>
{/* Corners - scale */}
{(['NW','NE','SW','SE'] as const).map(c => (
<div key={c}
style={{
...cornerStyle(
c.includes('W') ? '0%' : '100%',
c.includes('N') ? '0%' : '100%',
c === 'NW' || c === 'SE' ? 'nwse-resize' : 'nesw-resize'
), pointerEvents: 'all'
}}
onMouseDown={e => onHandleDown(e, `scale${c}` as Handle, layer)}
/>
))}
{/* Edge midpoints - scale */}
{[
{ id: 'N', cx: '50%', cy: '0%', cur: 'ns-resize' },
{ id: 'S', cx: '50%', cy: '100%', cur: 'ns-resize' },
{ id: 'E', cx: '100%', cy: '50%', cur: 'ew-resize' },
{ id: 'W', cx: '0%', cy: '50%', cur: 'ew-resize' },
].map(({ id, cx, cy, cur }) => (
<div key={id} style={{ ...edgeStyle(cx, cy, cur), pointerEvents: 'all' }}
onMouseDown={e => onHandleDown(e, `scale${id}` as Handle, layer)} />
))}
{/* Rotate handle - top center above box */}
<div style={{
position: 'absolute', left: '50%', top: -28,
transform: 'translateX(-50%)',
width: 14, height: 14,
background: '#f59e0b', border: '2px solid #fff', borderRadius: '50%',
cursor: 'grab', zIndex: 10, pointerEvents: 'all',
}} onMouseDown={e => onHandleDown(e, 'rotate', layer)} title="Rotate" />
{/* Line from box to rotate handle */}
<div style={{ position: 'absolute', left: '50%', top: -20, width: 2, height: 20, background: '#3b82f6', transform: 'translateX(-50%)', pointerEvents: 'none' }} />
</div>
);
};
return (
<div className="fixed inset-0 z-[200] flex flex-col text-zinc-100" style={{ fontFamily: 'Inter,sans-serif', background: '#0f0f13' }}>
{/* Top Bar */}
<div className="h-11 border-b border-zinc-800 bg-zinc-900/95 flex items-center justify-between px-4 flex-shrink-0 backdrop-blur">
<div className="flex items-center gap-3">
<button onClick={onClose} className="p-1.5 hover:bg-zinc-800 rounded-lg text-zinc-400 hover:text-white transition-colors">
<X size={16} />
</button>
<div className="w-px h-4 bg-zinc-700" />
<span className="text-sm font-semibold">UV Map</span>
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 text-xs text-zinc-400 cursor-pointer select-none">
<input type="checkbox" checked={bgTransparent} onChange={e => setBgTransparent(e.target.checked)} className="accent-blue-500 w-3 h-3" />
Transparent
</label>
{!bgTransparent && (
<label className="relative flex items-center gap-1.5 cursor-pointer">
<div className="w-5 h-5 rounded border border-zinc-600 overflow-hidden relative">
<div className="absolute inset-0" style={{ background: bgColor }} />
<input type="color" value={bgColor} onChange={e => setBgColor(e.target.value)} className="opacity-0 absolute inset-0 w-full h-full cursor-pointer" />
</div>
<span className="text-[10px] font-mono text-zinc-400">{bgColor}</span>
</label>
)}
<button onClick={handleSave} className="px-4 py-1.5 bg-blue-500 hover:bg-blue-600 rounded-lg text-sm font-semibold transition-colors">
Apply to Model
</button>
</div>
</div>
<div className="flex-1 flex overflow-hidden">
{/* Left sidebar */}
<div className="w-60 bg-zinc-900 border-r border-zinc-800 flex flex-col overflow-y-auto flex-shrink-0">
<div className="p-4 flex flex-col gap-3">
<input type="file" ref={fileInputRef} className="hidden" accept=".jpg,.jpeg,.png,.webp,.svg" onChange={handleFileUpload} />
<button onClick={() => fileInputRef.current?.click()}
className="w-full py-2 bg-zinc-800 hover:bg-zinc-700 rounded-lg text-sm font-semibold flex items-center justify-center gap-2 border border-zinc-700 transition-colors">
<Upload size={14} /> Add Image
</button>
<div className="h-px bg-zinc-800" />
<p className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">Layers</p>
{layers.length === 0 && (
<div className="p-5 text-center text-zinc-600 text-xs border-2 border-dashed border-zinc-800 rounded-xl">Upload an image to start</div>
)}
{[...layers].reverse().map((layer, i) => (
<div key={layer.id} onClick={() => setSelectedId(layer.id)}
className={`flex items-center gap-2 p-2 rounded-lg border cursor-pointer transition-all ${selectedId === layer.id ? 'border-blue-500 bg-blue-500/10' : 'border-zinc-800 hover:border-zinc-600 bg-zinc-800/20'} ${!layer.visible ? 'opacity-40' : ''}`}>
<img src={layer.url} alt="" className="w-8 h-8 object-contain rounded bg-zinc-900 border border-zinc-700 flex-shrink-0" />
<span className="text-xs text-zinc-300 flex-1 truncate">Layer {layers.length - i}</span>
<button onClick={e => { e.stopPropagation(); updateLayer(layer.id, { visible: !layer.visible }); }}
className="p-1 text-zinc-600 hover:text-blue-400 rounded" title={layer.visible ? 'Hide layer' : 'Show layer'}>
{layer.visible !== false ? <Eye size={11} /> : <EyeOff size={11} />}
</button>
<button onClick={e => { e.stopPropagation(); setLayers(ls => ls.filter(l => l.id !== layer.id)); if (selectedId === layer.id) setSelectedId(null); }}
className="p-1 text-zinc-600 hover:text-red-400 rounded">
<Trash2 size={11} />
</button>
</div>
))}
{/* Layer props */}
{selectedLayer && (
<div className="mt-1 flex flex-col gap-2 p-3 rounded-xl border border-zinc-800 bg-zinc-800/20">
<p className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">Transform</p>
<div className="grid grid-cols-2 gap-2">
{(['x', 'y'] as const).map(ax => (
<div key={ax}>
<p className="text-[9px] text-zinc-500 uppercase mb-0.5">{ax}</p>
<input type="number" step="1" value={Math.round(selectedLayer[ax])}
onChange={e => updateLayer(selectedLayer.id, { [ax]: +e.target.value })}
className="w-full bg-zinc-900 border border-zinc-700 rounded px-2 py-1 text-[11px] text-blue-400 font-mono outline-none focus:border-blue-500" />
</div>
))}
</div>
<div className="grid grid-cols-2 gap-2">
{(['scaleX', 'scaleY'] as const).map(ax => (
<div key={ax}>
<p className="text-[9px] text-zinc-500 uppercase mb-0.5">{ax === 'scaleX' ? 'Scl X' : 'Scl Y'}</p>
<input type="number" step="0.01" min="0.01" value={selectedLayer[ax].toFixed(2)}
onChange={e => updateLayer(selectedLayer.id, { [ax]: Math.max(0.01, +e.target.value) })}
className="w-full bg-zinc-900 border border-zinc-700 rounded px-2 py-1 text-[11px] text-blue-400 font-mono outline-none focus:border-blue-500" />
</div>
))}
</div>
<div>
<div className="flex justify-between text-[9px] text-zinc-500 uppercase mb-1">
<span>Rotation</span><span className="text-blue-400 font-mono">{Math.round(selectedLayer.rotation)}°</span>
</div>
<input type="range" min="-180" max="180" step="1" value={selectedLayer.rotation}
onChange={e => updateLayer(selectedLayer.id, { rotation: +e.target.value })}
className="w-full h-1 bg-zinc-700 rounded appearance-none cursor-pointer accent-blue-500" />
</div>
<div className="h-px bg-zinc-700 my-1" />
<p className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">Crop</p>
{(['cropL', 'cropT', 'cropR', 'cropB'] as const).map(s => (
<div key={s} className="flex items-center gap-2">
<span className="text-[9px] text-zinc-500 w-6 uppercase">{s.slice(4)}</span>
<input type="range" min="0" max="0.49" step="0.01" value={selectedLayer[s]}
onChange={e => updateLayer(selectedLayer.id, { [s]: +e.target.value })}
className="flex-1 h-1 bg-zinc-700 rounded appearance-none cursor-pointer accent-orange-500" />
<span className="text-[9px] font-mono text-orange-400 w-6 text-right">{Math.round(selectedLayer[s] * 100)}%</span>
</div>
))}
</div>
)}
</div>
</div>
{/* Canvas area */}
<div className="flex-1 flex items-center justify-center overflow-hidden relative"
style={{ background: 'radial-gradient(ellipse at center, #1a1a2e 0%, #0f0f13 100%)' }}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onMouseLeave={onMouseUp}
onClick={() => setSelectedId(null)}>
<div ref={containerRef} className="relative shadow-2xl border border-zinc-700/50"
style={{
width: 'min(68vh, 90%)', aspectRatio: '1/1',
background: bgTransparent
? 'repeating-conic-gradient(#27272a 0% 25%, #18181b 0% 50%) 0 0 / 16px 16px'
: bgColor
}}>
{/* Layers */}
{layers.map(layer => {
if (layer.visible === false) return null;
const iw = (layer.img?.width ?? 200) * (1 - layer.cropL - layer.cropR);
const ih = (layer.img?.height ?? 200) * (1 - layer.cropT - layer.cropB);
const isSelected = selectedId === layer.id;
return (
<div key={layer.id}
onMouseDown={e => { e.stopPropagation(); onHandleDown(e, 'move', layer); }}
className="absolute cursor-move"
style={{
left: `${(layer.x / CANVAS_SIZE) * 100}%`,
top: `${(layer.y / CANVAS_SIZE) * 100}%`,
transform: `translate(-50%,-50%) rotate(${layer.rotation}deg) scale(${layer.scaleX},${layer.scaleY})`,
zIndex: isSelected ? 4 : 2,
transition: 'transform 0.04s linear',
}}>
{layer.img && (
<div style={{ width: iw, height: ih, overflow: 'hidden' }}>
<img src={layer.url} alt=""
style={{ position: 'relative', left: -layer.cropL * layer.img.width, top: -layer.cropT * layer.img.height, width: layer.img.width, height: layer.img.height, display: 'block', pointerEvents: 'none', imageRendering: 'auto' }} />
</div>
)}
</div>
);
})}
{/* Bounding box overlays - only for visible selected layer */}
{layers.map(layer => selectedId === layer.id && layer.visible !== false && <BoundingBox key={`bb-${layer.id}`} layer={layer} />)}
<div className="absolute bottom-2 left-2 bg-black/50 text-[10px] text-zinc-400 px-2 py-0.5 rounded font-mono select-none">
{CANVAS_SIZE}×{CANVAS_SIZE}
</div>
</div>
</div>
{/* Right panel - preview */}
<div className="w-72 bg-zinc-900 border-l border-zinc-800 flex flex-col p-4 gap-4 flex-shrink-0 overflow-y-auto">
<div>
<div className="flex items-center gap-2 mb-2">
<RotateCw size={11} className="text-zinc-500" />
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">3D Preview</span>
</div>
<div className="w-full aspect-square bg-zinc-950 rounded-xl overflow-hidden border border-zinc-800">
<Canvas shadows camera={{ position: [2, 2, 2], fov: 45 }}>
<ambientLight intensity={0.5} />
<directionalLight position={[5, 5, 5]} intensity={1} castShadow />
<Environment preset="studio" />
<Suspense fallback={null}><PreviewModel /></Suspense>
<OrbitControls autoRotate autoRotateSpeed={1.5} />
</Canvas>
</div>
</div>
<div className="p-3 bg-zinc-800/30 rounded-xl border border-zinc-800 text-zinc-500">
<p className="text-[10px] font-bold uppercase tracking-widest mb-2">Legend</p>
<div className="flex flex-col gap-1.5 text-[10px]">
<div className="flex items-center gap-2"><div className="w-3 h-3 rounded-sm bg-white border-2 border-blue-500" /><span>Drag corner/edge uniform scale</span></div>
<div className="flex items-center gap-2"><div className="w-3 h-3 rounded-sm bg-blue-400 border-2 border-blue-500 flex items-center justify-center text-[7px] font-bold text-white"></div><span>Shift + drag free X/Y stretch</span></div>
<div className="flex items-center gap-2"><div className="w-3 h-3 rounded-full bg-amber-400" /><span>Drag top handle rotate</span></div>
<div className="flex items-center gap-2"><div className="w-3 h-3 rounded-sm bg-zinc-600 border border-zinc-500 cursor-move" /><span>Drag layer move (Shift snaps)</span></div>
</div>
</div>
</div>
</div>
</div>
);
}
+176
View File
@@ -0,0 +1,176 @@
import React, { useMemo, useRef, useEffect } from 'react';
import * as THREE from 'three';
import { useTexture } from '@react-three/drei';
// Mapping Types Enum
export const MAPPING_TYPES = {
UV: 0,
PLANAR: 1,
CYLINDER: 2,
SPHERE: 3,
BOX: 4,
};
const vertexShader = `
varying vec2 vUv;
varying vec3 vLocalPos;
varying vec3 vNormal;
varying vec3 vWorldPos;
void main() {
vUv = uv;
vLocalPos = position;
vNormal = normalize(normal);
vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const fragmentShader = `
uniform sampler2D uTexture;
uniform int uMappingType;
uniform float uScale;
uniform vec2 uTiling;
uniform vec2 uOffset;
uniform float uRotation;
uniform vec3 uBBoxMin;
uniform vec3 uBBoxMax;
varying vec2 vUv;
varying vec3 vLocalPos;
varying vec3 vNormal;
#ifndef PI
#define PI 3.14159265359
#endif
vec2 rotateUV(vec2 uv, float rotation) {
float mid = 0.5;
float cosAngle = cos(rotation);
float sinAngle = sin(rotation);
float dx = uv.x - mid;
float dy = uv.y - mid;
return vec2(
cosAngle * dx - sinAngle * dy + mid,
sinAngle * dx + cosAngle * dy + mid
);
}
void main() {
vec2 finalUv = vec2(0.0);
// Bounding box calculations for dynamic projections
vec3 boundsSize = uBBoxMax - uBBoxMin;
vec3 boundsPos = (vLocalPos - uBBoxMin) / (boundsSize + 0.00001);
vec3 center = (uBBoxMax + uBBoxMin) * 0.5;
vec3 dir = vLocalPos - center;
vec3 normDir = normalize(dir + 0.00001);
if (uMappingType == 0) {
// UV: Applies native geometry vUv
finalUv = vUv;
} else if (uMappingType == 1) {
// PLANAR (XZ / Top-Down)
finalUv = boundsPos.xz;
} else if (uMappingType == 2) {
// CYLINDER: u = angle around Y, v = normalized height
vec3 localRel = vLocalPos - center;
float u = (atan(localRel.z, localRel.x) / (2.0 * PI)) + 0.5;
float v = boundsPos.y;
finalUv = vec2(u, v);
} else if (uMappingType == 3) {
// SPHERE
float u = (atan(normDir.z, normDir.x) / (2.0 * PI)) + 0.5;
float v = (asin(clamp(normDir.y, -1.0, 1.0)) / PI) + 0.5;
finalUv = vec2(u, v);
} else if (uMappingType == 4) {
// BOX (Triplanar)
vec3 absNormal = abs(vNormal);
if (absNormal.x > absNormal.y && absNormal.x > absNormal.z) {
finalUv = boundsPos.zy;
} else if (absNormal.y > absNormal.x && absNormal.y > absNormal.z) {
finalUv = boundsPos.xz;
} else {
finalUv = boundsPos.xy;
}
}
// Apply Tiling, Scale, and Offset
finalUv = finalUv * uTiling * uScale + uOffset;
// Apply Rotation
if (uRotation != 0.0) {
finalUv = rotateUV(finalUv, uRotation);
}
vec4 texColor = texture2D(uTexture, finalUv);
gl_FragColor = texColor;
}
`;
export const UnifiedMappingMaterial = ({
textureUrl,
mappingType = 'UV',
uvScale = 1.0,
uvTiling = [1, 1],
uvOffset = [0, 0],
uvRotation = 0,
geometry
}) => {
const materialRef = useRef<THREE.ShaderMaterial>(null);
// Load texture
const texture = useTexture(textureUrl) as THREE.Texture;
useEffect(() => {
if (texture) {
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
texture.needsUpdate = true;
}
}, [texture]);
// Initialize Uniforms
const uniforms = useMemo(() => ({
uTexture: { value: texture },
uMappingType: { value: MAPPING_TYPES[mappingType as keyof typeof MAPPING_TYPES] || 0 },
uScale: { value: uvScale },
uTiling: { value: new THREE.Vector2(uvTiling[0], uvTiling[1]) },
uOffset: { value: new THREE.Vector2(uvOffset[0], uvOffset[1]) },
uRotation: { value: uvRotation * (Math.PI / 180) },
uBBoxMin: { value: new THREE.Vector3(-1, -1, -1) },
uBBoxMax: { value: new THREE.Vector3(1, 1, 1) }
}), [texture]);
// Real-time Uniform Updates
useEffect(() => {
if (materialRef.current) {
const u = materialRef.current.uniforms;
u.uMappingType.value = MAPPING_TYPES[mappingType as keyof typeof MAPPING_TYPES] || 0;
u.uScale.value = uvScale;
u.uTiling.value.set(uvTiling[0], uvTiling[1]);
u.uOffset.value.set(uvOffset[0], uvOffset[1]);
u.uRotation.value = uvRotation * (Math.PI / 180);
}
}, [mappingType, uvScale, uvTiling, uvOffset, uvRotation]);
// Bounding Box Update
useEffect(() => {
if (geometry && materialRef.current) {
geometry.computeBoundingBox();
const box = geometry.boundingBox || new THREE.Box3().setFromObject(new THREE.Mesh(geometry));
materialRef.current.uniforms.uBBoxMin.value.copy(box.min);
materialRef.current.uniforms.uBBoxMax.value.copy(box.max);
}
}, [geometry]);
return (
<shaderMaterial
ref={materialRef}
uniforms={uniforms}
vertexShader={vertexShader}
fragmentShader={fragmentShader}
side={THREE.DoubleSide}
transparent={true}
/>
);
};
+72
View File
@@ -0,0 +1,72 @@
import { UVGenerationContext, computeBoundingBox, normalizeCoordinate } from './uvUtils';
export function generateBoxUVs(context: UVGenerationContext): Float32Array {
const { positions, normals } = context;
const numVertices = positions.length / 3;
const uvs = new Float32Array(numVertices * 2);
const bbox = computeBoundingBox(positions);
if (!normals) {
// Fallback if no normals provided: use position to determine primary axis from center
for (let i = 0; i < numVertices; i++) {
const x = positions[i * 3];
const y = positions[i * 3 + 1];
const z = positions[i * 3 + 2];
const dx = Math.abs(x - bbox.center[0]);
const dy = Math.abs(y - bbox.center[1]);
const dz = Math.abs(z - bbox.center[2]);
let u = 0;
let v = 0;
if (dx >= dy && dx >= dz) {
u = normalizeCoordinate(z, bbox.min[2], bbox.size[2]);
v = normalizeCoordinate(y, bbox.min[1], bbox.size[1]);
} else if (dy >= dx && dy >= dz) {
u = normalizeCoordinate(x, bbox.min[0], bbox.size[0]);
v = normalizeCoordinate(z, bbox.min[2], bbox.size[2]);
} else {
u = normalizeCoordinate(x, bbox.min[0], bbox.size[0]);
v = normalizeCoordinate(y, bbox.min[1], bbox.size[1]);
}
uvs[i * 2] = u;
uvs[i * 2 + 1] = v;
}
return uvs;
}
// Use normals to determine mapping face
for (let i = 0; i < numVertices; i++) {
const x = positions[i * 3];
const y = positions[i * 3 + 1];
const z = positions[i * 3 + 2];
const nx = Math.abs(normals[i * 3]);
const ny = Math.abs(normals[i * 3 + 1]);
const nz = Math.abs(normals[i * 3 + 2]);
let u = 0;
let v = 0;
if (nx >= ny && nx >= nz) {
u = normalizeCoordinate(z, bbox.min[2], bbox.size[2]);
v = normalizeCoordinate(y, bbox.min[1], bbox.size[1]);
if (normals[i * 3] < 0) u = 1 - u; // Flip for negative X
} else if (ny >= nx && ny >= nz) {
u = normalizeCoordinate(x, bbox.min[0], bbox.size[0]);
v = normalizeCoordinate(z, bbox.min[2], bbox.size[2]);
if (normals[i * 3 + 1] < 0) u = 1 - u; // Flip for negative Y
} else {
u = normalizeCoordinate(x, bbox.min[0], bbox.size[0]);
v = normalizeCoordinate(y, bbox.min[1], bbox.size[1]);
if (normals[i * 3 + 2] < 0) u = 1 - u; // Flip for negative Z
}
uvs[i * 2] = u;
uvs[i * 2 + 1] = v;
}
return uvs;
}
+42
View File
@@ -0,0 +1,42 @@
import { UVGenerationContext, computeBoundingBox, normalizeCoordinate } from './uvUtils';
export function generateCylindricalUVs(context: UVGenerationContext, axis: 'x' | 'y' | 'z' = 'y'): Float32Array {
const { positions } = context;
const numVertices = positions.length / 3;
const uvs = new Float32Array(numVertices * 2);
const bbox = computeBoundingBox(positions);
for (let i = 0; i < numVertices; i++) {
const x = positions[i * 3];
const y = positions[i * 3 + 1];
const z = positions[i * 3 + 2];
const cx = x - bbox.center[0];
const cy = y - bbox.center[1];
const cz = z - bbox.center[2];
let u = 0;
let v = 0;
switch (axis) {
case 'x':
u = (Math.atan2(cy, cz) + Math.PI) / (2 * Math.PI);
v = normalizeCoordinate(x, bbox.min[0], bbox.size[0]);
break;
case 'z':
u = (Math.atan2(cy, cx) + Math.PI) / (2 * Math.PI);
v = normalizeCoordinate(z, bbox.min[2], bbox.size[2]);
break;
case 'y':
default:
u = (Math.atan2(cz, cx) + Math.PI) / (2 * Math.PI);
v = normalizeCoordinate(y, bbox.min[1], bbox.size[1]);
break;
}
uvs[i * 2] = u;
uvs[i * 2 + 1] = v;
}
return uvs;
}
+38
View File
@@ -0,0 +1,38 @@
import { UVGenerationContext, computeBoundingBox, normalizeCoordinate } from './uvUtils';
export function generatePlanarUVs(context: UVGenerationContext, axis: 'x' | 'y' | 'z' = 'z'): Float32Array {
const { positions } = context;
const numVertices = positions.length / 3;
const uvs = new Float32Array(numVertices * 2);
const bbox = computeBoundingBox(positions);
for (let i = 0; i < numVertices; i++) {
const x = positions[i * 3];
const y = positions[i * 3 + 1];
const z = positions[i * 3 + 2];
let u = 0;
let v = 0;
switch (axis) {
case 'x':
u = normalizeCoordinate(z, bbox.min[2], bbox.size[2]);
v = normalizeCoordinate(y, bbox.min[1], bbox.size[1]);
break;
case 'y':
u = normalizeCoordinate(x, bbox.min[0], bbox.size[0]);
v = normalizeCoordinate(z, bbox.min[2], bbox.size[2]);
break;
case 'z':
default:
u = normalizeCoordinate(x, bbox.min[0], bbox.size[0]);
v = normalizeCoordinate(y, bbox.min[1], bbox.size[1]);
break;
}
uvs[i * 2] = u;
uvs[i * 2 + 1] = v;
}
return uvs;
}
+50
View File
@@ -0,0 +1,50 @@
export interface UVGenerationContext {
positions: Float32Array;
normals?: Float32Array;
indices?: Uint32Array | Uint16Array;
}
export interface BoundingBox {
min: [number, number, number];
max: [number, number, number];
size: [number, number, number];
center: [number, number, number];
}
export function computeBoundingBox(positions: Float32Array): BoundingBox {
let minX = Infinity, minY = Infinity, minZ = Infinity;
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
for (let i = 0; i < positions.length; i += 3) {
const x = positions[i];
const y = positions[i + 1];
const z = positions[i + 2];
if (x < minX) minX = x;
if (y < minY) minY = y;
if (z < minZ) minZ = z;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
if (z > maxZ) maxZ = z;
}
const size: [number, number, number] = [maxX - minX, maxY - minY, maxZ - minZ];
const center: [number, number, number] = [
minX + size[0] / 2,
minY + size[1] / 2,
minZ + size[2] / 2,
];
return {
min: [minX, minY, minZ],
max: [maxX, maxY, maxZ],
size,
center,
};
}
export function normalizeCoordinate(value: number, min: number, size: number): number {
if (size === 0) return 0;
return (value - min) / size;
}
+94
View File
@@ -0,0 +1,94 @@
import { useEffect, useRef, useState } from 'react';
import * as THREE from 'three';
import { ProjectionType, UVWorkerMessage, UVWorkerResponse } from '../workers/uvWorker';
export function useTexturePipeline(scene: THREE.Object3D | null) {
const [isProcessing, setIsProcessing] = useState(false);
const workerRef = useRef<Worker | null>(null);
useEffect(() => {
workerRef.current = new Worker(new URL('../workers/uvWorker.ts', import.meta.url), {
type: 'module',
});
return () => {
workerRef.current?.terminate();
};
}, []);
useEffect(() => {
if (!scene || !workerRef.current) return;
const processMesh = async (mesh: THREE.Mesh) => {
const geometry = mesh.geometry;
if (!geometry.attributes.position) return;
const positions = geometry.attributes.position.array as Float32Array;
const normals = geometry.attributes.normal?.array as Float32Array | undefined;
const generateUVs = (projection: ProjectionType, axis?: 'x'|'y'|'z'): Promise<Float32Array> => {
return new Promise((resolve, reject) => {
if (!workerRef.current) return reject('No worker');
const id = Math.random().toString(36).substring(7);
const handleMessage = (e: MessageEvent<UVWorkerResponse>) => {
if (e.data.id === id) {
workerRef.current?.removeEventListener('message', handleMessage);
if (e.data.error) reject(new Error(e.data.error));
else resolve(e.data.uvs);
}
};
workerRef.current.addEventListener('message', handleMessage);
workerRef.current.postMessage({
id,
positions,
normals,
projection,
axis
} as UVWorkerMessage);
});
};
try {
const numVertices = positions.length / 3;
// Add dummy attributes synchronously to prevent shader compile errors
if (!geometry.attributes.uvPlanar) geometry.setAttribute('uvPlanar', new THREE.BufferAttribute(new Float32Array(numVertices * 2), 2));
if (!geometry.attributes.uvBox) geometry.setAttribute('uvBox', new THREE.BufferAttribute(new Float32Array(numVertices * 2), 2));
if (!geometry.attributes.uvCylinder) geometry.setAttribute('uvCylinder', new THREE.BufferAttribute(new Float32Array(numVertices * 2), 2));
const uvPlanar = await generateUVs('planar', 'z');
(geometry.attributes.uvPlanar as THREE.BufferAttribute).array.set(uvPlanar);
geometry.attributes.uvPlanar.needsUpdate = true;
const uvBox = await generateUVs('box');
(geometry.attributes.uvBox as THREE.BufferAttribute).array.set(uvBox);
geometry.attributes.uvBox.needsUpdate = true;
const uvCylinder = await generateUVs('cylindrical', 'y');
(geometry.attributes.uvCylinder as THREE.BufferAttribute).array.set(uvCylinder);
geometry.attributes.uvCylinder.needsUpdate = true;
} catch (err) {
console.error("UV Generation error", err);
}
};
const processAllMeshes = async () => {
setIsProcessing(true);
const meshes: THREE.Mesh[] = [];
scene.traverse((child) => {
if ((child as THREE.Mesh).isMesh) {
meshes.push(child as THREE.Mesh);
}
});
// Process in parallel
await Promise.all(meshes.map(processMesh));
setIsProcessing(false);
};
processAllMeshes();
}, [scene]);
return { isProcessing };
}
+113
View File
@@ -0,0 +1,113 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&family=Outfit:wght@400;500;600;700&display=swap');
@import "tailwindcss";
@theme {
--font-sans: "Outfit", "Inter", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
/* Theme Colors */
--color-primary: #4DA3FF;
--color-accent: #FF2E88;
--color-glass-bg: rgba(246, 249, 255, 0.8);
--color-glass-card: rgba(255, 255, 255, 0.7);
/* Shadows */
--shadow-glass: 0 8px 32px 0 rgba(31, 38, 135, 0.15);
--shadow-emboss: inset 2px 2px 4px #dbe6ff, inset -2px -2px 4px #ffffff;
}
@layer base {
body {
@apply bg-[#F6F9FF] text-gray-900 antialiased overflow-hidden transition-colors duration-500;
font-feature-settings: "ss01", "ss02", "cv01", "cv11";
}
body.dark {
@apply bg-[#0a0a0a] text-gray-100;
}
}
@layer components {
.glass-panel {
@apply bg-white/70 backdrop-blur-xl border border-white/40 shadow-glass transition-all duration-300;
}
.dark .glass-panel {
@apply bg-white/5 border-white/10 shadow-2xl;
}
.glass-card {
@apply bg-white/50 backdrop-blur-md border border-white/40 hover:bg-white/80 transition-all duration-300 shadow-sm;
}
.dark .glass-card {
@apply bg-white/5 border-white/10 hover:bg-white/10;
}
.emboss-btn {
@apply shadow-glass border border-white/40 transition-all duration-200 active:shadow-emboss;
box-shadow: 4px 4px 8px rgba(0,0,0,0.05), -4px -4px 8px rgba(255,255,255,0.8);
}
.dark .emboss-btn {
@apply border-white/10 shadow-none active:bg-white/10;
box-shadow: none;
}
.sidebar-item {
@apply flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 cursor-pointer;
}
.sidebar-item-active {
@apply bg-primary/20 text-primary border border-primary/30;
}
.sidebar-item:hover:not(.sidebar-item-active) {
@apply bg-white/20;
}
.dark .sidebar-item:hover:not(.sidebar-item-active) {
@apply bg-white/5;
}
.neon-glow:hover {
box-shadow: 0 0 15px rgba(255, 46, 136, 0.4);
}
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
@apply bg-transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
@apply bg-black/10 rounded-full hover:bg-black/20;
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
@apply bg-white/10 hover:bg-white/20;
}
}
/* Animation for the background glow */
@keyframes float {
0% { transform: translate(0, 0) scale(1); }
33% { transform: translate(2%, 2%) scale(1.1); }
66% { transform: translate(-1%, 3%) scale(0.9); }
100% { transform: translate(0, 0) scale(1); }
}
.animate-float {
animation: float 20s ease-in-out infinite;
}
.animate-float-delayed {
animation: float 25s ease-in-out infinite reverse;
}
/* Hide TransformControls UI when rendering */
.rendering-hide {
display: none !important;
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+10
View File
@@ -0,0 +1,10 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
+52
View File
@@ -0,0 +1,52 @@
import * as THREE from 'three';
import { StandardUVMaterial } from './StandardUVMaterial';
import { TriplanarMaterial } from './TriplanarMaterial';
type MaterialCacheKey = string;
export class MaterialFactory {
private static instance: MaterialFactory;
private cache: Map<MaterialCacheKey, THREE.Material> = new Map();
private constructor() {}
public static getInstance(): MaterialFactory {
if (!MaterialFactory.instance) {
MaterialFactory.instance = new MaterialFactory();
}
return MaterialFactory.instance;
}
public getStandardUVMaterial(
parameters: THREE.MeshPhysicalMaterialParameters,
key: string
): StandardUVMaterial {
const cacheKey = `standard_uv_${key}`;
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey) as StandardUVMaterial;
}
const material = new StandardUVMaterial(parameters);
this.cache.set(cacheKey, material);
return material;
}
public getTriplanarMaterial(
parameters: THREE.MeshPhysicalMaterialParameters,
key: string
): TriplanarMaterial {
const cacheKey = `triplanar_${key}`;
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey) as TriplanarMaterial;
}
const material = new TriplanarMaterial(parameters);
this.cache.set(cacheKey, material);
return material;
}
public clearCache() {
this.cache.forEach((material) => material.dispose());
this.cache.clear();
}
}
+95
View File
@@ -0,0 +1,95 @@
import * as THREE from 'three';
export class StandardUVMaterial extends THREE.MeshPhysicalMaterial {
public uvUniforms: {
uMappingMode: { value: number }; // 0: imported, 1: planar, 2: box, 3: cylindrical
uUVTransform: { value: THREE.Matrix3 };
};
constructor(parameters?: THREE.MeshPhysicalMaterialParameters) {
super(parameters);
this.uvUniforms = {
uMappingMode: { value: 0 },
uUVTransform: { value: new THREE.Matrix3() },
};
this.customProgramCacheKey = () => `standard_uv_v2`;
this.onBeforeCompile = (shader) => {
shader.uniforms.uMappingMode = this.uvUniforms.uMappingMode;
shader.uniforms.uUVTransform = this.uvUniforms.uUVTransform;
shader.vertexShader = `
attribute vec2 uvPlanar;
attribute vec2 uvBox;
attribute vec2 uvCylinder;
uniform int uMappingMode;
uniform mat3 uUVTransform;
varying vec2 vCustomUV;
` + shader.vertexShader;
shader.vertexShader = shader.vertexShader.replace(
`#include <uv_vertex>`,
`
#include <uv_vertex>
vec2 selectedUV = vec2(0.0);
#ifdef USE_UV
selectedUV = uv;
#endif
if (uMappingMode == 1) {
selectedUV = uvPlanar;
} else if (uMappingMode == 2) {
selectedUV = uvBox;
} else if (uMappingMode == 3) {
selectedUV = uvCylinder;
}
vCustomUV = (uUVTransform * vec3(selectedUV, 1.0)).xy;
`
);
shader.fragmentShader = `
varying vec2 vCustomUV;
` + shader.fragmentShader;
const mainStartIndex = shader.fragmentShader.indexOf('void main() {');
if (mainStartIndex !== -1) {
const prefix = shader.fragmentShader.substring(0, mainStartIndex);
let mainBody = shader.fragmentShader.substring(mainStartIndex);
mainBody = mainBody.replace(/\bvUv\b/g, 'vCustomUV');
mainBody = mainBody.replace(/\bvMapUv\b/g, 'vCustomUV');
mainBody = mainBody.replace(/\bvNormalMapUv\b/g, 'vCustomUV');
mainBody = mainBody.replace(/\bvRoughnessMapUv\b/g, 'vCustomUV');
mainBody = mainBody.replace(/\bvMetalnessMapUv\b/g, 'vCustomUV');
mainBody = mainBody.replace(/\bvAlphaMapUv\b/g, 'vCustomUV');
mainBody = mainBody.replace(/\bvEmissiveMapUv\b/g, 'vCustomUV');
mainBody = mainBody.replace(/\bvAoMapUv\b/g, 'vCustomUV');
mainBody = mainBody.replace(/\bvClearcoatMapUv\b/g, 'vCustomUV');
mainBody = mainBody.replace(/\bvClearcoatNormalMapUv\b/g, 'vCustomUV');
mainBody = mainBody.replace(/\bvClearcoatRoughnessMapUv\b/g, 'vCustomUV');
mainBody = mainBody.replace(/\bvTransmissionMapUv\b/g, 'vCustomUV');
mainBody = mainBody.replace(/\bvThicknessMapUv\b/g, 'vCustomUV');
mainBody = mainBody.replace(/\bvSpecularMapUv\b/g, 'vCustomUV');
mainBody = mainBody.replace(/\bvSpecularIntensityMapUv\b/g, 'vCustomUV');
mainBody = mainBody.replace(/\bvSpecularColorMapUv\b/g, 'vCustomUV');
shader.fragmentShader = prefix + mainBody;
}
};
}
public updateUVTransform(offset: [number, number], repeat: [number, number], rotation: number) {
this.uvUniforms.uUVTransform.value.setUvTransform(
offset[0], offset[1],
repeat[0], repeat[1],
rotation,
0.5, 0.5
);
}
}
+134
View File
@@ -0,0 +1,134 @@
import * as THREE from 'three';
export class TriplanarMaterial extends THREE.MeshPhysicalMaterial {
public triplanarUniforms: {
tAlbedo: { value: THREE.Texture | null };
tNormal: { value: THREE.Texture | null };
tRoughness: { value: THREE.Texture | null };
tAo: { value: THREE.Texture | null };
uTriplanarScale: { value: number };
uBlendSharpness: { value: number };
};
constructor(parameters?: THREE.MeshPhysicalMaterialParameters) {
super(parameters);
this.triplanarUniforms = {
tAlbedo: { value: null },
tNormal: { value: null },
tRoughness: { value: null },
tAo: { value: null },
uTriplanarScale: { value: 1.0 },
uBlendSharpness: { value: 5.0 },
};
this.customProgramCacheKey = () => `triplanar_mat_v1`;
this.onBeforeCompile = (shader) => {
// Inject Uniforms
shader.uniforms.tAlbedo = this.triplanarUniforms.tAlbedo;
shader.uniforms.tNormal = this.triplanarUniforms.tNormal;
shader.uniforms.tRoughness = this.triplanarUniforms.tRoughness;
shader.uniforms.tAo = this.triplanarUniforms.tAo;
shader.uniforms.uTriplanarScale = this.triplanarUniforms.uTriplanarScale;
shader.uniforms.uBlendSharpness = this.triplanarUniforms.uBlendSharpness;
// Vertex Shader Injections
shader.vertexShader = `
varying vec3 vTriplanarPosition;
varying vec3 vTriplanarNormal;
` + shader.vertexShader;
shader.vertexShader = shader.vertexShader.replace(
`#include <worldpos_vertex>`,
`
#include <worldpos_vertex>
vTriplanarPosition = position; // Object space
vTriplanarNormal = normal; // Object space normal
`
);
// Fragment Shader Injections
shader.fragmentShader = `
uniform sampler2D tAlbedo;
uniform sampler2D tNormal;
uniform sampler2D tRoughness;
uniform sampler2D tAo;
uniform float uTriplanarScale;
uniform float uBlendSharpness;
varying vec3 vTriplanarPosition;
varying vec3 vTriplanarNormal;
// Triplanar blending logic
vec4 getTriplanarBlend(sampler2D tex, vec3 pos, vec3 norm) {
vec3 blendWeights = abs(norm);
blendWeights = pow(blendWeights, vec3(uBlendSharpness));
blendWeights /= dot(blendWeights, vec3(1.0)); // Normalize
vec2 uvX = pos.yz * uTriplanarScale;
vec2 uvY = pos.zx * uTriplanarScale;
vec2 uvZ = pos.xy * uTriplanarScale;
vec4 colX = texture2D(tex, uvX);
vec4 colY = texture2D(tex, uvY);
vec4 colZ = texture2D(tex, uvZ);
return colX * blendWeights.x + colY * blendWeights.y + colZ * blendWeights.z;
}
` + shader.fragmentShader;
// Replace map chunks
shader.fragmentShader = shader.fragmentShader.replace(
`#include <map_fragment>`,
`
#ifdef USE_MAP
vec4 texelColor = getTriplanarBlend(tAlbedo, vTriplanarPosition, vTriplanarNormal);
texelColor = mapTexelToLinear(texelColor);
diffuseColor *= texelColor;
#endif
`
);
shader.fragmentShader = shader.fragmentShader.replace(
`#include <roughnessmap_fragment>`,
`
float roughnessFactor = roughness;
#ifdef USE_ROUGHNESSMAP
vec4 texelRoughness = getTriplanarBlend(tRoughness, vTriplanarPosition, vTriplanarNormal);
roughnessFactor *= texelRoughness.g;
#endif
`
);
shader.fragmentShader = shader.fragmentShader.replace(
`#include <normal_fragment_maps>`,
`
#ifdef USE_NORMALMAP
vec4 texelNormal = getTriplanarBlend(tNormal, vTriplanarPosition, vTriplanarNormal);
// Simplified triplanar normal calculation
vec3 n = texelNormal.rgb * 2.0 - 1.0;
normal = normalize(normalMatrix * n);
#elif defined( USE_BUMPMAP )
// Bump map omitted for brevity
#endif
`
);
shader.fragmentShader = shader.fragmentShader.replace(
`#include <aomap_fragment>`,
`
#ifdef USE_AOMAP
vec4 texelAo = getTriplanarBlend(tAo, vTriplanarPosition, vTriplanarNormal);
float ambientOcclusion = ( texelAo.r - 1.0 ) * aoMapIntensity + 1.0;
reflectedLight.indirectDiffuse *= ambientOcclusion;
#if defined( USE_ENVMAP ) && defined( STANDARD )
float dotNV = saturate( dot( geometry.normal, geometry.viewDir ) );
reflectedLight.indirectSpecular *= computeSpecularOcclusion( dotNV, ambientOcclusion, material.roughness );
#endif
#endif
`
);
};
}
}
+54
View File
@@ -0,0 +1,54 @@
import { create } from 'zustand';
export type MappingMode = 'imported' | 'planar' | 'box' | 'cylindrical' | 'triplanar';
export interface UVTransforms {
scale: [number, number];
repeat: [number, number];
offset: [number, number];
rotation: number;
}
export interface TriplanarTransforms {
scale: number;
blendSharpness: number;
rotation: [number, number, number];
worldSpace: boolean;
}
interface ConfiguratorState {
mappingMode: MappingMode;
uvTransforms: UVTransforms;
triplanarTransforms: TriplanarTransforms;
projectionAxis: 'x' | 'y' | 'z';
// Actions
setMappingMode: (mode: MappingMode) => void;
setUVTransforms: (transforms: Partial<UVTransforms>) => void;
setTriplanarTransforms: (transforms: Partial<TriplanarTransforms>) => void;
setProjectionAxis: (axis: 'x' | 'y' | 'z') => void;
}
export const useConfiguratorStore = create<ConfiguratorState>((set) => ({
mappingMode: 'imported',
projectionAxis: 'z',
uvTransforms: {
scale: [1, 1],
repeat: [1, 1],
offset: [0, 0],
rotation: 0,
},
triplanarTransforms: {
scale: 1,
blendSharpness: 5.0,
rotation: [0, 0, 0],
worldSpace: false,
},
setMappingMode: (mode) => set({ mappingMode: mode }),
setUVTransforms: (transforms) =>
set((state) => ({ uvTransforms: { ...state.uvTransforms, ...transforms } })),
setTriplanarTransforms: (transforms) =>
set((state) => ({ triplanarTransforms: { ...state.triplanarTransforms, ...transforms } })),
setProjectionAxis: (axis) => set({ projectionAxis: axis }),
}));
+71
View File
@@ -0,0 +1,71 @@
export interface UVQualityReport {
missingUVs: boolean;
extremeStretching: boolean;
overlappingUVs: boolean;
highComplexity: boolean;
recommendTriplanar: boolean;
details: {
vertexCount: number;
uvRange: [number, number];
};
}
export function analyzeUVQuality(
positions: Float32Array,
uvs?: Float32Array,
indices?: Uint32Array | Uint16Array
): UVQualityReport {
const vertexCount = positions.length / 3;
const highComplexity = vertexCount > 50000; // Arbitrary threshold for complex geometry
if (!uvs || uvs.length === 0) {
return {
missingUVs: true,
extremeStretching: false,
overlappingUVs: false,
highComplexity,
recommendTriplanar: true,
details: {
vertexCount,
uvRange: [0, 0]
}
};
}
let minU = Infinity, maxU = -Infinity;
let minV = Infinity, maxV = -Infinity;
for (let i = 0; i < uvs.length; i += 2) {
const u = uvs[i];
const v = uvs[i + 1];
if (u < minU) minU = u;
if (u > maxU) maxU = u;
if (v < minV) minV = v;
if (v > maxV) maxV = v;
}
const uRange = maxU - minU;
const vRange = maxV - minV;
// Basic heuristic: if UVs are heavily clamped or barely cover any area
const extremeStretching = (uRange < 0.01 && uRange > 0) || (vRange < 0.01 && vRange > 0);
// Overlapping detection is computationally expensive, skipping deep check for now
// A robust check would require comparing triangle areas in UV space vs World space
const overlappingUVs = false;
const recommendTriplanar = extremeStretching || highComplexity;
return {
missingUVs: false,
extremeStretching,
overlappingUVs,
highComplexity,
recommendTriplanar,
details: {
vertexCount,
uvRange: [uRange, vRange]
}
};
}
+49
View File
@@ -0,0 +1,49 @@
import { generatePlanarUVs } from '../core/uv/planarProjection';
import { generateBoxUVs } from '../core/uv/boxProjection';
import { generateCylindricalUVs } from '../core/uv/cylindricalProjection';
export type ProjectionType = 'planar' | 'box' | 'cylindrical';
export interface UVWorkerMessage {
id: string;
positions: Float32Array;
normals?: Float32Array;
projection: ProjectionType;
axis?: 'x' | 'y' | 'z';
}
export interface UVWorkerResponse {
id: string;
uvs: Float32Array;
error?: string;
}
self.onmessage = (event: MessageEvent<UVWorkerMessage>) => {
const { id, positions, normals, projection, axis } = event.data;
try {
const context = { positions, normals };
let uvs: Float32Array;
switch (projection) {
case 'planar':
uvs = generatePlanarUVs(context, axis || 'z');
break;
case 'box':
uvs = generateBoxUVs(context);
break;
case 'cylindrical':
uvs = generateCylindricalUVs(context, axis || 'y');
break;
default:
throw new Error(`Unsupported projection: ${projection}`);
}
// Transfer the ArrayBuffer back to main thread for performance
self.postMessage({ id, uvs } as UVWorkerResponse, {
transfer: [uvs.buffer],
});
} catch (error) {
self.postMessage({ id, error: String(error) } as UVWorkerResponse);
}
};