Initial commit of VIZ 3D project
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user