Initial commit of VIZ 3D project
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# GEMINI_API_KEY: Required for Gemini AI API calls.
|
||||||
|
# AI Studio automatically injects this at runtime from user secrets.
|
||||||
|
# Users configure this via the Secrets panel in the AI Studio UI.
|
||||||
|
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
||||||
|
|
||||||
|
# APP_URL: The URL where this applet is hosted.
|
||||||
|
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
||||||
|
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
||||||
|
APP_URL="MY_APP_URL"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
# Base image
|
||||||
|
FROM node:20-alpine AS base
|
||||||
|
WORKDIR /app
|
||||||
|
# Copy package.json and package-lock.json
|
||||||
|
COPY package*.json ./
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Development stage
|
||||||
|
FROM base AS development
|
||||||
|
# Copy all project files
|
||||||
|
COPY . .
|
||||||
|
# Expose Vite's default port
|
||||||
|
EXPOSE 3000
|
||||||
|
# Run the development server
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
|
|
||||||
|
# Build stage
|
||||||
|
FROM base AS build
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM nginx:alpine AS production
|
||||||
|
# Copy built assets from the build stage to Nginx
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
# Expose port 80 for Nginx
|
||||||
|
EXPOSE 80
|
||||||
|
# Start Nginx
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<div align="center">
|
||||||
|
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
# Run and deploy your AI Studio app
|
||||||
|
|
||||||
|
This contains everything you need to run your app locally.
|
||||||
|
|
||||||
|
View your app in AI Studio: https://ai.studio/apps/ad0ed820-1760-462d-bbc0-fffffb37ffda
|
||||||
|
|
||||||
|
## Run Locally
|
||||||
|
|
||||||
|
**Prerequisites:** Node.js
|
||||||
|
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
`npm install`
|
||||||
|
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||||
|
3. Run the app:
|
||||||
|
`npm run dev`
|
||||||
Binary file not shown.
@@ -0,0 +1,18 @@
|
|||||||
|
services:
|
||||||
|
web:
|
||||||
|
restart: always
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: development
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
# Map the host source code to the container for hot-reloading
|
||||||
|
- .:/app
|
||||||
|
# Prevents the container's node_modules from being overwritten by the host
|
||||||
|
- /app/node_modules
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=development
|
||||||
|
# Optional: ensure container runs smoothly on Windows host
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>viz-3D_hun</title>
|
||||||
|
<script src="http://localhost:8097"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "VIZ 3D",
|
||||||
|
"description": "A professional 3D product visualization studio.",
|
||||||
|
"requestFramePermissions": [],
|
||||||
|
"majorCapabilities": []
|
||||||
|
}
|
||||||
Generated
+5086
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "viz-3D_hun",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --port=3000 --host=0.0.0.0",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"clean": "rm -rf dist",
|
||||||
|
"lint": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fal-ai/client": "^1.10.1",
|
||||||
|
"@google/genai": "^1.29.0",
|
||||||
|
"@react-three/drei": "^10.7.7",
|
||||||
|
"@react-three/fiber": "^9.5.0",
|
||||||
|
"@react-three/postprocessing": "^3.0.4",
|
||||||
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
"@types/three": "^0.183.1",
|
||||||
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"fflate": "^0.8.2",
|
||||||
|
"framer-motion": "^12.38.0",
|
||||||
|
"lucide-react": "^0.546.0",
|
||||||
|
"postprocessing": "^6.39.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"three": "^0.183.2",
|
||||||
|
"three-stdlib": "^2.36.1",
|
||||||
|
"vite": "^6.2.0",
|
||||||
|
"zustand": "^5.0.13"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/node": "^22.14.0",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"tailwindcss": "^4.1.14",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "~5.8.2",
|
||||||
|
"vite": "^6.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
+4775
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
|
}));
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
const puppeteer = require('puppeteer');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const browser = await puppeteer.launch();
|
||||||
|
const page = await browser.newPage();
|
||||||
|
|
||||||
|
page.on('console', msg => {
|
||||||
|
if (msg.type() === 'error') {
|
||||||
|
console.log('PAGE ERROR:', msg.text());
|
||||||
|
} else {
|
||||||
|
console.log('PAGE LOG:', msg.text());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
page.on('pageerror', error => {
|
||||||
|
console.log('PAGE EXCEPTION:', error.message);
|
||||||
|
console.log('STACK:', error.stack);
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('http://localhost:3000', { waitUntil: 'networkidle2' });
|
||||||
|
|
||||||
|
// Find the file input for import (usually has an accept=".glb,.gltf...")
|
||||||
|
const fileInput = await page.$('input[type="file"]');
|
||||||
|
if (fileInput) {
|
||||||
|
console.log('Found file input, uploading...');
|
||||||
|
await fileInput.uploadFile(path.resolve('C:/Users/900250.HCUDT299/Downloads/0.glb'));
|
||||||
|
await new Promise(r => setTimeout(r, 5000));
|
||||||
|
} else {
|
||||||
|
console.log('File input not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"useDefineForClassFields": false,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"allowJs": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
import {defineConfig, loadEnv} from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig(({mode}) => {
|
||||||
|
const env = loadEnv(mode, '.', '');
|
||||||
|
return {
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
define: {
|
||||||
|
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, '.'),
|
||||||
|
'react': path.resolve(__dirname, 'node_modules/react'),
|
||||||
|
'react-dom': path.resolve(__dirname, 'node_modules/react-dom'),
|
||||||
|
},
|
||||||
|
dedupe: ['react', 'react-dom'],
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ['react', 'react-dom', 'three', '@react-three/fiber', '@react-three/drei'],
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||||
|
// Do not modifyâfile watching is disabled to prevent flickering during agent edits.
|
||||||
|
hmr: process.env.DISABLE_HMR !== 'true',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user