chore: flatten repository to completely erase 255MB history

This commit is contained in:
AI Bot
2026-08-27 17:29:57 +05:30
commit d13ecbbdb9
103 changed files with 17799 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.git
.env
.DS_Store
+9
View File
@@ -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"
+11
View File
@@ -0,0 +1,11 @@
node_modules/
build/
dist/
coverage/
.DS_Store
*.log
.env*
!.env.example
backend/node_modules/
backend/data/
backend/uploads/
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1017 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 732 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 741 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 903 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 840 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 MiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 MiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

+32
View File
@@ -0,0 +1,32 @@
# 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
# Copy custom nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port 80 for Nginx
EXPOSE 80
# Start Nginx
CMD ["nginx", "-g", "daemon off;"]
+46
View File
@@ -0,0 +1,46 @@
# VIZ 3D - Product Configurator
An advanced, web-based 3D Product Configurator and Scene Editor built with React, Three.js, and React Three Fiber.
## Core Features
- **3D Scene Editor**: Complete control over 3D scenes, including object transforms, lighting, and environments.
- **Advanced Material & Texture Pipeline**: Features a sophisticated material management system with Triplanar, Planar, Box, and Cylindrical UV mapping projections.
- **AI Image-to-3D Generator**: Native integrations with leading AI generative models (Hunyuan3D, Tripo3D, and Meshy) to seamlessly generate `.glb` 3D assets directly from uploaded reference images.
- **UV Editor**: Powerful 2D UV coordinate editor with live 3D syncing to precisely adjust texture mapping and placement.
- **Drag-and-Drop Imports**: Support for a variety of 3D model formats including `.glb`, `.gltf`, `.fbx`, `.obj`, and `.usdz`.
## Tech Stack
- **Framework**: React (Vite)
- **3D Rendering**: Three.js, `@react-three/fiber`, `@react-three/drei`, `@react-three/postprocessing`
- **Styling**: TailwindCSS
- **State Management**: Zustand
- **AI Integrations**: Tripo3D (v2 API), Meshy (v1 API), Fal.ai (Hunyuan 3D)
## Getting Started
### Prerequisites
- Node.js 18+
- npm or yarn
### Installation
1. Clone the repository:
```bash
git clone https://scm.ckdigital.in/digifox/cia_product_configurator/cia_product_configurator_frontend_react.git
cd cia_product_configurator_frontend_react
```
2. Install dependencies:
```bash
npm install
```
3. Start the development server:
```bash
npm run dev
```
### API Keys
If you plan to use the AI Image-to-3D generation features, you will need to supply API keys for the respective services directly within the application settings UI. These are securely stored in your browser's `localStorage`.
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta http-equiv="refresh" content="0;url='https://deploy.digifox.live/login'" />
<title>Redirecting to https://deploy.digifox.live/login</title>
</head>
<body>
Redirecting to <a href="https://deploy.digifox.live/login">https://deploy.digifox.live/login</a>.
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
CMD ["npm", "start"]
+87
View File
@@ -0,0 +1,87 @@
const { Pool } = require('pg');
require('dotenv').config();
// Coolify uses DATABASE_URL
// If not set, we can fallback to a local postgres or just let it fail
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
pool.on('error', (err, client) => {
console.error('Unexpected error on idle client', err);
process.exit(-1);
});
const initDB = async () => {
try {
const client = await pool.connect();
console.log('Connected to the PostgreSQL database.');
await client.query(`
CREATE TABLE IF NOT EXISTS profiles (
id TEXT PRIMARY KEY,
email TEXT UNIQUE,
password_hash TEXT,
role TEXT DEFAULT 'user',
api_credits INTEGER DEFAULT 10,
storage_limit_mb INTEGER DEFAULT 500,
is_active BOOLEAN DEFAULT false
)
`);
// In Postgres, ALTER TABLE ADD COLUMN IF NOT EXISTS requires PG >= 9.6
// So we can catch the error if column exists just like sqlite logic
try {
await client.query(`ALTER TABLE profiles ADD COLUMN is_active BOOLEAN DEFAULT false`);
} catch (err) {
// Ignore if it already exists
}
await client.query(`
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
user_id TEXT REFERENCES profiles(id),
name TEXT,
scene_data TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
client.release();
} catch (err) {
console.error('Error initializing database:', err);
}
};
initDB();
// Wrapper functions to simulate the old sqlite API but backed by pg
const run = async (sql, params = []) => {
// convert ? to $1, $2, etc.
let paramIndex = 1;
const pgSql = sql.replace(/\?/g, () => `$${paramIndex++}`);
const res = await pool.query(pgSql, params);
// `run` in sqlite returns { id: this.lastID, changes: this.changes }
// Postgres doesn't return lastID unless RETURNING is used, but for uuids it doesn't matter.
return { changes: res.rowCount };
};
const get = async (sql, params = []) => {
let paramIndex = 1;
const pgSql = sql.replace(/\?/g, () => `$${paramIndex++}`);
const res = await pool.query(pgSql, params);
return res.rows[0];
};
const all = async (sql, params = []) => {
let paramIndex = 1;
const pgSql = sql.replace(/\?/g, () => `$${paramIndex++}`);
const res = await pool.query(pgSql, params);
return res.rows;
};
module.exports = { db: pool, run, get, all };
+46
View File
@@ -0,0 +1,46 @@
const nodemailer = require('nodemailer');
require('dotenv').config();
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || 'smtp.office365.com',
port: parseInt(process.env.SMTP_PORT || '587', 10),
secure: process.env.SMTP_SECURE === 'true', // true for 465, false for other ports (587 uses STARTTLS)
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
tls: {
ciphers: 'SSLv3', // sometimes required for Office365
rejectUnauthorized: false
}
});
/**
* Sends an email using the configured SMTP server.
* @param {string} to - Recipient email address
* @param {string} subject - Email subject
* @param {string} text - Plain text body
* @param {string} html - HTML body (optional)
* @returns {Promise<any>} Info object from nodemailer
*/
const sendMail = async (to, subject, text, html = '') => {
try {
const info = await transporter.sendMail({
from: process.env.SMTP_FROM,
to,
subject,
text,
html
});
console.log(`Email sent to ${to}: ${info.messageId}`);
return info;
} catch (error) {
console.error('Error sending email:', error);
throw error;
}
};
module.exports = {
sendMail,
transporter
};
+1914
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "titan3d-backend",
"version": "1.0.0",
"description": "Local API for Titan3d",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"bcrypt": "^5.1.1",
"cors": "^2.8.5",
"dotenv": "^17.4.2",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"nodemailer": "^9.0.5",
"pg": "^8.23.0",
"uuid": "^9.0.1"
}
}
+222
View File
@@ -0,0 +1,222 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { db, run, get, all } = require('./database');
const { sendMail } = require('./mail');
const app = express();
app.use(cors());
app.use(express.json({ limit: '500mb' }));
app.use(express.urlencoded({ limit: '500mb', extended: true }));
const JWT_SECRET = 'viz-3d-local-secret-key-1234';
// Ensure uploads dir exists
const uploadsDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
app.use('/uploads', express.static(uploadsDir));
// Multer storage
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, uploadsDir);
},
filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, uniqueSuffix + '-' + file.originalname);
}
});
const upload = multer({ storage: storage });
// --- AUTH MIDDLEWARE ---
const authenticate = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader) return res.status(401).json({ error: 'No token provided' });
const token = authHeader.split(' ')[1];
jwt.verify(token, JWT_SECRET, (err, decoded) => {
if (err) return res.status(401).json({ error: 'Invalid token' });
req.user = decoded;
next();
});
};
// --- ROUTES ---
// 1. Auth Register
app.post('/api/auth/register', async (req, res) => {
const { email, password } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
try {
const existing = await get('SELECT * FROM profiles WHERE email = ?', [email]);
if (existing) return res.status(400).json({ error: 'User already exists' });
const hash = await bcrypt.hash(password, 10);
const id = uuidv4();
// First user becomes admin and is automatically active
const countRow = await get('SELECT COUNT(*) as count FROM profiles');
const role = parseInt(countRow.count) === 0 ? 'admin' : 'user';
const is_active = parseInt(countRow.count) === 0 ? true : false;
await run(
'INSERT INTO profiles (id, email, password_hash, role, api_credits, storage_limit_mb, is_active) VALUES (?, ?, ?, ?, ?, ?, ?)',
[id, email, hash, role, 10, 500, is_active]
);
if (is_active === false) {
return res.json({ status: 'pending', message: 'Account created. Please contact an administrator to activate your account.' });
}
const token = jwt.sign({ id, email, role }, JWT_SECRET, { expiresIn: '7d' });
const profile = await get('SELECT id, email, role, api_credits, storage_limit_mb, is_active FROM profiles WHERE id = ?', [id]);
res.json({ status: 'active', token, profile });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 2. Auth Login
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
try {
const user = await get('SELECT * FROM profiles WHERE email = ?', [email]);
if (!user) return res.status(400).json({ error: 'Invalid credentials' });
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) return res.status(400).json({ error: 'Invalid credentials' });
if (user.is_active === false) {
return res.status(403).json({ error: 'Account pending admin approval. Please contact an administrator.' });
}
const token = jwt.sign({ id: user.id, email: user.email, role: user.role }, JWT_SECRET, { expiresIn: '7d' });
const profile = { id: user.id, email: user.email, role: user.role, api_credits: user.api_credits, storage_limit_mb: user.storage_limit_mb, is_active: user.is_active };
res.json({ token, profile });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 3. Auth Me
app.get('/api/auth/me', authenticate, async (req, res) => {
try {
const profile = await get('SELECT id, email, role, api_credits, storage_limit_mb, is_active FROM profiles WHERE id = ?', [req.user.id]);
res.json({ profile });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 4. Get all projects for user
app.get('/api/projects', authenticate, async (req, res) => {
try {
const projects = await all('SELECT id, name FROM projects WHERE user_id = ?', [req.user.id]);
res.json({ projects });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 5. Get project by ID
app.get('/api/projects/:id', authenticate, async (req, res) => {
try {
const project = await get('SELECT * FROM projects WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
if (!project) return res.status(404).json({ error: 'Project not found' });
if (project.scene_data) {
project.scene_data = JSON.parse(project.scene_data);
}
res.json({ project });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 6. Save (create or update) project
app.post('/api/projects', authenticate, async (req, res) => {
const { id, name, scene_data } = req.body;
const projectId = id || uuidv4();
try {
const existing = await get('SELECT * FROM projects WHERE id = ?', [projectId]);
if (existing) {
if (existing.user_id !== req.user.id) return res.status(403).json({ error: 'Forbidden' });
await run('UPDATE projects SET name = ?, scene_data = ? WHERE id = ?', [name, JSON.stringify(scene_data), projectId]);
} else {
await run('INSERT INTO projects (id, user_id, name, scene_data) VALUES (?, ?, ?, ?)', [projectId, req.user.id, name, JSON.stringify(scene_data)]);
}
res.json({ success: true, id: projectId });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.delete('/api/projects/:id', authenticate, async (req, res) => {
const { id } = req.params;
try {
const existing = await get('SELECT * FROM projects WHERE id = ?', [id]);
if (!existing) return res.status(404).json({ error: 'Not found' });
if (existing.user_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
}
await run('DELETE FROM projects WHERE id = ?', [id]);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 7. Get users (Admin only)
app.get('/api/users', authenticate, async (req, res) => {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Forbidden' });
try {
const users = await all('SELECT id, email, role, api_credits, storage_limit_mb, is_active FROM profiles');
res.json({ users });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 8. Update user (Admin only)
app.put('/api/users/:id', authenticate, async (req, res) => {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Forbidden' });
const { role, api_credits, storage_limit_mb, is_active } = req.body;
try {
await run('UPDATE profiles SET role = ?, api_credits = ?, storage_limit_mb = ?, is_active = ? WHERE id = ?', [role, api_credits, storage_limit_mb, is_active !== undefined ? is_active : true, req.params.id]);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 9. Deduct API credit
app.post('/api/users/deduct-credit', authenticate, async (req, res) => {
try {
const user = await get('SELECT api_credits FROM profiles WHERE id = ?', [req.user.id]);
if (user.api_credits <= 0) return res.status(400).json({ error: 'Not enough credits' });
await run('UPDATE profiles SET api_credits = api_credits - 1 WHERE id = ?', [req.user.id]);
const updated = await get('SELECT api_credits FROM profiles WHERE id = ?', [req.user.id]);
res.json({ api_credits: updated.api_credits });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 10. Upload file
app.post('/api/upload', authenticate, upload.single('file'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const url = `${req.protocol}://${req.get('host')}/uploads/${req.file.filename}`;
res.json({ url });
});
const PORT = process.env.PORT || 3005;
app.listen(PORT, () => {
console.log(`Backend server running on port ${PORT}`);
});
BIN
View File
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
services:
web:
restart: always
build:
context: .
target: production
expose:
- "80"
environment:
- NODE_ENV=production
labels:
- "coolify.endpoint=true"
api:
restart: always
build:
context: ./backend
volumes:
- uploads-volume:/app/uploads
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://titan_user:titan_secure_pass@db:5432/titan_db
depends_on:
- db
labels:
- "traefik.enable=false"
db:
image: postgres:16-alpine
restart: always
environment:
- POSTGRES_USER=titan_user
- POSTGRES_PASSWORD=titan_secure_pass
- POSTGRES_DB=titan_db
volumes:
- db-data:/var/lib/postgresql/data
labels:
- "traefik.enable=false"
volumes:
uploads-volume:
db-data:
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Titan - Product configurator.</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
BIN
View File
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
{
"name": "VIZ 3D",
"description": "A professional 3D product visualization studio.",
"requestFramePermissions": [],
"majorCapabilities": []
}
+30
View File
@@ -0,0 +1,30 @@
server {
listen 80;
server_name localhost;
client_max_body_size 500M;
# Serve static assets
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
# Proxy API requests to backend
location /api/ {
proxy_pass http://api:3005;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_cache_bypass $http_upgrade;
}
# Proxy /uploads to backend
location /uploads/ {
proxy_pass http://api:3005;
proxy_set_header Host $host;
}
}
+5144
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
{
"name": "titan3d",
"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",
"react-router-dom": "^7.18.2",
"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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 642 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 773 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 935 KiB

+51
View File
@@ -0,0 +1,51 @@
import { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { useAuthStore } from './store/authStore';
import { ProtectedRoute } from './components/ProtectedRoute';
// Pages
import Editor from './pages/Editor';
import Home from './pages/Home';
import Dashboard from './pages/Dashboard';
import Admin from './pages/Admin';
import { AuthModal } from './components/AuthModal';
export default function App() {
const { initialize, isLoading } = useAuthStore();
useEffect(() => {
initialize();
}, [initialize]);
if (isLoading) {
return (
<div className="flex h-screen w-screen items-center justify-center bg-zinc-950 text-white">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
);
}
return (
<BrowserRouter>
<AuthModal />
<Routes>
{/* Public Routes */}
<Route path="/" element={<Home />} />
{/* Protected Routes (User) */}
<Route element={<ProtectedRoute allowedRoles={['user', 'admin']} />}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/editor/:projectId?" element={<Editor />} />
</Route>
{/* Protected Routes (Admin) */}
<Route element={<ProtectedRoute allowedRoles={['admin']} />}>
<Route path="/admin" element={<Admin />} />
</Route>
{/* Fallback */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
);
}
+173
View File
@@ -0,0 +1,173 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Settings, Key, Sparkles, Image as ImageIcon, Lightbulb, Box } from 'lucide-react';
import { cn } from '../lib/utils';
interface ApiSettingsModalProps {
isOpen: boolean;
onClose: () => void;
}
export const ApiSettingsModal: React.FC<ApiSettingsModalProps> = ({ isOpen, onClose }) => {
const [falApiKey, setFalApiKey] = useState('');
const [tripoApiKey, setTripoApiKey] = useState('');
const [meshyApiKey, setMeshyApiKey] = useState('');
const [stabilityApiKey, setStabilityApiKey] = useState('');
const [openAiKey, setOpenAiKey] = useState('');
useEffect(() => {
if (isOpen) {
setFalApiKey(localStorage.getItem('FAL_API_KEY') || '');
setTripoApiKey(localStorage.getItem('TRIPO_API_KEY') || '');
setMeshyApiKey(localStorage.getItem('MESHY_API_KEY') || '');
setStabilityApiKey(localStorage.getItem('stability_api_key') || '');
setOpenAiKey(localStorage.getItem('openai_api_key') || '');
}
}, [isOpen]);
const saveKeys = () => {
localStorage.setItem('FAL_API_KEY', falApiKey);
localStorage.setItem('TRIPO_API_KEY', tripoApiKey);
localStorage.setItem('MESHY_API_KEY', meshyApiKey);
localStorage.setItem('stability_api_key', stabilityApiKey);
localStorage.setItem('openai_api_key', openAiKey);
onClose();
};
if (!isOpen) return null;
return (
<AnimatePresence>
<div className="fixed inset-0 z-[150] 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-2xl bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden flex flex-col h-[80vh]"
>
{/* Header */}
<div className="h-14 bg-zinc-950 flex items-center justify-between px-6 border-b border-zinc-800 shrink-0">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-tr from-zinc-700 to-zinc-600 flex items-center justify-center">
<Settings size={16} className="text-white" />
</div>
<div>
<h2 className="text-sm font-bold text-zinc-100">Global API Settings</h2>
<p className="text-[10px] text-zinc-400">Configure your API keys for AI generation features</p>
</div>
</div>
<button onClick={onClose} className="p-2 hover:bg-zinc-800 rounded-lg transition-colors">
<X size={18} className="text-zinc-400" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 bg-zinc-900/50 flex flex-col gap-6 custom-scrollbar">
{/* 3D Generation */}
<div className="flex flex-col gap-4">
<h3 className="text-xs font-bold text-zinc-500 uppercase tracking-widest flex items-center gap-2">
<Box size={14} /> Image-to-3D Providers
</h3>
<div className="grid grid-cols-1 gap-4 pl-2 border-l-2 border-zinc-800">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<label className="text-sm font-bold text-zinc-300">Fal.ai Key</label>
<span className="text-[10px] text-zinc-500">For Hunyuan3D</span>
</div>
<input
type="password"
placeholder="Enter Fal.ai API Key..."
value={falApiKey}
onChange={(e) => setFalApiKey(e.target.value)}
className="bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-2 text-sm text-white focus:outline-none focus:border-zinc-500"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<label className="text-sm font-bold text-zinc-300">Tripo3D Key</label>
<span className="text-[10px] text-zinc-500">For Native Tripo3D</span>
</div>
<input
type="password"
placeholder="Enter Tripo3D API Key..."
value={tripoApiKey}
onChange={(e) => setTripoApiKey(e.target.value)}
className="bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-2 text-sm text-white focus:outline-none focus:border-zinc-500"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<label className="text-sm font-bold text-zinc-300">Meshy.ai Key</label>
<span className="text-[10px] text-zinc-500">For Native Meshy</span>
</div>
<input
type="password"
placeholder="Enter Meshy API Key..."
value={meshyApiKey}
onChange={(e) => setMeshyApiKey(e.target.value)}
className="bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-2 text-sm text-white focus:outline-none focus:border-zinc-500"
/>
</div>
</div>
</div>
<div className="h-px bg-zinc-800/50 w-full" />
{/* Viz AI (Image-to-Image & LLM) */}
<div className="flex flex-col gap-4">
<h3 className="text-xs font-bold text-zinc-500 uppercase tracking-widest flex items-center gap-2">
<Sparkles size={14} /> Titan3d AI & Mockup Generation
</h3>
<div className="grid grid-cols-1 gap-4 pl-2 border-l-2 border-zinc-800">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<label className="text-sm font-bold text-blue-400">Stability AI Key</label>
<span className="text-[10px] text-zinc-500">For Scene rendering (Image-to-Image)</span>
</div>
<input
type="password"
placeholder="sk-..."
value={stabilityApiKey}
onChange={(e) => setStabilityApiKey(e.target.value)}
className="bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-2 text-sm text-white focus:outline-none focus:border-blue-500/50"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<label className="text-sm font-bold text-green-400">OpenAI Key</label>
<span className="text-[10px] text-zinc-500">For AI Idea Prompt Generation</span>
</div>
<input
type="password"
placeholder="sk-..."
value={openAiKey}
onChange={(e) => setOpenAiKey(e.target.value)}
className="bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-2 text-sm text-white focus:outline-none focus:border-green-500/50"
/>
</div>
</div>
</div>
</div>
<div className="p-6 bg-zinc-950 border-t border-zinc-800 shrink-0">
<button
onClick={saveKeys}
className="w-full py-3 bg-white hover:bg-zinc-200 text-black font-bold rounded-xl transition-colors flex items-center justify-center gap-2"
>
<Key size={16} />
Save Keys
</button>
</div>
</motion.div>
</div>
</AnimatePresence>
);
};
+154
View File
@@ -0,0 +1,154 @@
import React, { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { api } from '../lib/api';
import { useAuthStore } from '../store/authStore';
import { Box, Mail, Lock, Loader2, X } from 'lucide-react';
export function AuthModal() {
const { isAuthModalOpen, closeAuthModal, authMode, openAuthModal, setSession } = useAuthStore();
const navigate = useNavigate();
const location = useLocation();
const isRegister = authMode === 'register';
const from = location.state?.from?.pathname || '/dashboard';
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
setSuccess('');
try {
if (isRegister) {
const res = await api.register(email, password);
if (res.status === 'pending') {
setSuccess(res.message || 'Account created successfully! Please contact an admin to activate your account.');
setEmail('');
setPassword('');
} else {
setSession(res.token, res.profile);
closeAuthModal();
navigate(from, { replace: true });
}
} else {
const { token, profile } = await api.login(email, password);
setSession(token, profile);
closeAuthModal();
navigate(from, { replace: true });
}
} catch (err: any) {
setError(err.message || 'An error occurred during authentication.');
} finally {
setLoading(false);
}
};
return (
<AnimatePresence>
{isAuthModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={closeAuthModal}
className="absolute inset-0 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="relative w-full max-w-md bg-zinc-900/90 backdrop-blur-xl border border-zinc-700/50 rounded-3xl p-8 shadow-2xl overflow-hidden"
>
{/* Glowing orb background effect */}
<div className="absolute -top-32 -right-32 w-64 h-64 bg-primary/20 rounded-full blur-3xl pointer-events-none" />
<button
onClick={closeAuthModal}
className="absolute top-4 right-4 text-zinc-400 hover:text-white transition-colors"
>
<X size={20} />
</button>
<div className="flex justify-center mb-8 relative z-10">
<div className="w-12 h-12 rounded-xl bg-primary/20 flex items-center justify-center shadow-[0_0_20px_rgba(var(--primary-rgb),0.3)]">
<Box size={24} className="text-primary" />
</div>
</div>
<h2 className="text-2xl font-bold text-white text-center mb-2 relative z-10">
{isRegister ? 'Create an Account' : 'Welcome Back'}
</h2>
<p className="text-zinc-400 text-sm text-center mb-8 relative z-10">
{isRegister ? 'Sign up to start building 3D scenes.' : 'Sign in to access your projects.'}
</p>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 p-3 rounded-xl text-sm mb-6 text-center relative z-10">
{error}
</div>
)}
{success && (
<div className="bg-green-500/10 border border-green-500/20 text-green-400 p-3 rounded-xl text-sm mb-6 text-center relative z-10">
{success}
</div>
)}
<form onSubmit={handleSubmit} className="flex flex-col gap-4 relative z-10">
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={18} />
<input
type="email"
placeholder="Email address"
required
autoComplete="username"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full bg-zinc-950/50 border border-zinc-700/50 rounded-xl py-3 pl-10 pr-4 text-white placeholder-zinc-500 focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
/>
</div>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={18} />
<input
type="password"
placeholder="Password"
required
autoComplete={isRegister ? "new-password" : "current-password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-zinc-950/50 border border-zinc-700/50 rounded-xl py-3 pl-10 pr-4 text-white placeholder-zinc-500 focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-primary hover:bg-primary/90 text-white font-bold py-3 rounded-xl mt-2 transition-all flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed shadow-[0_0_20px_rgba(var(--primary-rgb),0.3)] hover:shadow-[0_0_30px_rgba(var(--primary-rgb),0.5)]"
>
{loading && <Loader2 size={18} className="animate-spin" />}
{isRegister ? 'Sign Up' : 'Sign In'}
</button>
</form>
<div className="mt-8 text-center text-sm text-zinc-400 relative z-10">
{isRegister ? (
<p>Already have an account? <button onClick={() => openAuthModal('login')} className="text-primary hover:underline font-medium">Sign in</button></p>
) : (
<p>Don't have an account? <button onClick={() => openAuthModal('register')} className="text-primary hover:underline font-medium">Sign up</button></p>
)}
</div>
</motion.div>
</div>
)}
</AnimatePresence>
);
}
+45
View File
@@ -0,0 +1,45 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
error: null
};
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo);
}
public render() {
if (this.state.hasError) {
return this.props.fallback || (
<div className="flex flex-col items-center justify-center p-8 bg-red-50 dark:bg-red-900/20 rounded-2xl border border-red-200 dark:border-red-800 text-center">
<h2 className="text-xl font-bold text-red-600 dark:text-red-400 mb-2">Something went wrong</h2>
<p className="text-sm text-red-500 dark:text-red-300 mb-4">{this.state.error?.message}</p>
<button
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
onClick={() => this.setState({ hasError: false, error: null })}
>
Try again
</button>
</div>
);
}
return this.props.children;
}
}
+617
View File
@@ -0,0 +1,617 @@
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 { useAuthStore } from '../store/authStore';
import { api } from '../lib/api';
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 { profile, setProfile } = useAuthStore();
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('clear text');
const [meshyTextureImageUrl, setMeshyTextureImageUrl] = useState('');
const [meshyEnhanceImage, setMeshyEnhanceImage] = useState(true);
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 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 (!profile || profile.api_credits <= 0) {
setError("Not enough AI Credits. Please contact an administrator.");
return;
}
if (apiProvider === 'fal' && !apiKey) {
setError("Fal API Key is required. Please set it in Tools > API Settings.");
return;
}
if (apiProvider === 'tripo' && !tripoApiKey) {
setError("Tripo API Key is required. Please set it in Tools > API Settings.");
return;
}
if (apiProvider === 'meshy' && !meshyApiKey) {
setError("Meshy API Key is required. Please set it in Tools > API Settings.");
return;
}
const deductCredit = async () => {
if (profile) {
try {
const { api_credits } = await api.deductCredit();
setProfile({ ...profile, api_credits });
} catch (e) {
console.error("Credit deduction failed", e);
}
}
};
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!");
deductCredit();
} 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;
deductCredit();
} 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,
should_enhance_image: meshyEnhanceImage
};
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;
deductCredit();
} 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">
{/* Content Area */}
<div className="flex-1 overflow-y-auto bg-zinc-900/50 p-6">
<div className="grid grid-cols-2 gap-6 h-full">
{/* Left Column: Upload */}
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">Provider & Model</label>
<div className="flex gap-2">
<select
value={apiProvider}
onChange={(e) => {
const val = e.target.value as 'fal' | 'tripo' | 'meshy';
setApiProvider(val);
localStorage.setItem('API_PROVIDER', val);
}}
className="flex-1 bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-xs text-zinc-300 focus:outline-none focus:border-blue-500/50"
>
<option value="fal">Fal.ai</option>
<option value="tripo">Tripo3D</option>
<option value="meshy">Meshy</option>
</select>
{apiProvider === 'fal' && (
<select
value={selectedModel}
onChange={(e) => {
setSelectedModel(e.target.value);
localStorage.setItem('FAL_MODEL', e.target.value);
}}
className="flex-1 bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-xs text-zinc-300 focus:outline-none focus:border-blue-500/50"
>
{MODELS.map(m => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
)}
</div>
</div>
<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>
<label className="flex items-center gap-1.5 text-xs text-zinc-300 cursor-pointer" title="Keep enabled to prevent flat text/logos from becoming embossed geometry">
<input type="checkbox" checked={meshyEnhanceImage} onChange={e => setMeshyEnhanceImage(e.target.checked)} className="rounded border-zinc-700 bg-zinc-900 text-blue-500 cursor-pointer" />
Enhance Image
</label>
</div>
{meshyShouldRemesh && meshyModelType !== 'lowpoly' && (
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1">
<label className="text-[10px] text-zinc-500 font-bold">Topology</label>
<select value={meshyTopology} onChange={e => setMeshyTopology(e.target.value)} className="bg-zinc-950 border border-zinc-800 rounded px-2 py-1.5 text-xs text-zinc-300">
<option value="triangle">Triangle</option>
<option value="quad">Quad</option>
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] text-zinc-500 font-bold">Polycount</label>
<input type="number" min={100} max={300000} step={1000} value={meshyTargetPolycount} onChange={e => setMeshyTargetPolycount(parseInt(e.target.value) || 30000)} className="bg-zinc-950 border border-zinc-800 rounded px-2 py-1.5 text-xs text-zinc-300" />
</div>
</div>
)}
</div>
)}
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex gap-2 items-start text-red-400 text-xs">
<AlertCircle size={14} className="shrink-0 mt-0.5" />
<p>{error}</p>
</div>
)}
<button
onClick={generate3DModel}
disabled={!imageFile || isGenerating}
className="w-full py-3 bg-blue-600 hover:bg-blue-500 disabled:bg-zinc-800 disabled:text-zinc-500 text-white rounded-xl text-sm font-bold shadow-lg shadow-blue-900/20 transition-all flex items-center justify-center gap-2"
>
{isGenerating ? <Loader2 size={16} className="animate-spin" /> : <Box size={16} />}
{isGenerating ? 'Generating...' : 'Generate 3D Model'}
</button>
</div>
{/* Right Column: Output */}
<div className="flex flex-col gap-4 border-l border-zinc-800 pl-6">
<h3 className="text-sm font-bold text-zinc-200">3D Preview</h3>
<div className="flex-1 bg-zinc-950 border border-zinc-800 rounded-xl relative overflow-hidden flex flex-col items-center justify-center">
{generatedModelUrl ? (
<>
<div className="absolute inset-0">
<ModelPreview url={generatedModelUrl} />
</div>
<div className="absolute top-3 right-3 flex gap-2 z-10">
<a href={generatedModelUrl} download="model.glb" target="_blank" className="p-2 bg-zinc-900/80 hover:bg-zinc-800 backdrop-blur rounded-lg border border-zinc-700 transition-colors text-zinc-300">
<Download size={16} />
</a>
</div>
</>
) : isGenerating ? (
<div className="flex flex-col items-center gap-4 text-zinc-400">
<Loader2 size={32} className="animate-spin text-blue-500" />
<p className="text-xs font-medium animate-pulse">{progressMessage}</p>
</div>
) : (
<div className="text-center text-zinc-600 flex flex-col items-center gap-2">
<Box size={32} className="opacity-20" />
<p className="text-xs">Your 3D model will appear here</p>
</div>
)}
</div>
{generatedModelUrl && (
<button
onClick={() => {
onPushToApp(generatedModelUrl);
onClose();
}}
className="w-full py-3 bg-green-600 hover:bg-green-500 text-white rounded-xl text-sm font-bold shadow-lg shadow-green-900/20 transition-all flex items-center justify-center gap-2"
>
Push to Application Scene
</button>
)}
</div>
</div>
</div>
</div>
</motion.div>
</div>
</AnimatePresence>
);
};
+112
View File
@@ -0,0 +1,112 @@
import { useRef, useMemo, forwardRef } from "react";
import { useFrame } from "@react-three/fiber";
import { RoundedBox, Text } from "@react-three/drei";
import * as THREE from "three";
import { Liquid } from "./LiquidShader";
export const JuiceBox = forwardRef<THREE.Group, any>(({
color = "#4ade80",
roughness = 0.1,
metalness = 0.05,
clearcoat = 1.0,
transmission = 0,
thickness = 0,
ior = 1.5,
sheen = 0,
map = undefined,
uvScale = 1,
showLiquid = true,
liquidColor = "#ff9900",
...props
}, ref) => {
const meshRef = useRef<THREE.Mesh>(null);
// Create a custom texture for the juice box
const customTexture = useMemo(() => {
if (map) return null;
const canvas = document.createElement("canvas");
canvas.width = 512;
canvas.height = 1024;
const ctx = canvas.getContext("2d");
if (ctx) {
// Background
ctx.fillStyle = color;
ctx.fillRect(0, 0, 512, 1024);
// Header
ctx.fillStyle = "#166534";
ctx.fillRect(0, 0, 512, 100);
// Text
ctx.fillStyle = "white";
ctx.font = "bold 80px sans-serif";
ctx.textAlign = "center";
ctx.fillText("MAA", 256, 300);
ctx.font = "bold 60px sans-serif";
ctx.fillStyle = "#be123c";
ctx.fillText("GUAVA", 256, 400);
// Guava illustration (simple circles)
ctx.fillStyle = "#f472b6";
ctx.beginPath();
ctx.arc(256, 650, 150, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "#166534";
ctx.lineWidth = 10;
ctx.stroke();
ctx.fillStyle = "white";
ctx.font = "24px sans-serif";
ctx.fillText("REFRESHING FRUIT DRINK", 256, 150);
}
const tex = new THREE.CanvasTexture(canvas);
tex.needsUpdate = true;
return tex;
}, [color, map]);
const uploadedTexture = useMemo(() => {
if (!map) return null;
const loader = new THREE.TextureLoader();
const tex = loader.load(map);
tex.colorSpace = THREE.SRGBColorSpace;
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(uvScale, uvScale);
return tex;
}, [map, uvScale]);
return (
<group ref={ref} {...props}>
<RoundedBox
ref={meshRef}
args={[1, 2, 0.6]} // Width, Height, Depth
radius={0.05}
smoothness={4}
castShadow
receiveShadow
>
<meshPhysicalMaterial
map={uploadedTexture || customTexture}
roughness={roughness}
metalness={metalness}
clearcoat={clearcoat}
transmission={transmission}
thickness={thickness}
ior={ior}
sheen={sheen}
attenuationDistance={props.attenuationDistance ?? Infinity}
attenuationColor={props.attenuationColor ?? color}
transparent={transmission > 0}
envMapIntensity={1.5}
/>
</RoundedBox>
{showLiquid && (
<group position={[0, 0, 0]}>
<Liquid color={liquidColor} size={[0.9, 1.9, 0.5]} />
</group>
)}
</group>
);
});
+134
View File
@@ -0,0 +1,134 @@
import { useRef, useMemo } from "react";
import { useFrame, useThree, extend, ThreeElement } from "@react-three/fiber";
import * as THREE from "three";
import { shaderMaterial } from "@react-three/drei";
// Type declaration for JSX
declare global {
namespace JSX {
interface IntrinsicElements {
liquidMaterialImpl: any;
}
}
}
const LiquidMaterialImpl = shaderMaterial(
{
uTime: 0,
uColor: new THREE.Color("#ff9900"),
uResolution: new THREE.Vector2(),
uFresnelBias: 0.1,
uFresnelScale: 1.0,
uFresnelPower: 2.0,
uRefractionRatio: 0.98,
uIor: 1.33,
},
// Vertex Shader
`
#include <clipping_planes_pars_vertex>
varying vec3 vNormal;
varying vec3 vViewPosition;
varying vec2 vUv;
varying vec3 vWorldPosition;
void main() {
vUv = uv;
vNormal = normalize(normalMatrix * normal);
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
vWorldPosition = worldPosition.xyz;
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
vViewPosition = -mvPosition.xyz;
#include <clipping_planes_vertex>
gl_Position = projectionMatrix * mvPosition;
}
`,
// Fragment Shader
`
#include <clipping_planes_pars_fragment>
uniform float uTime;
uniform vec3 uColor;
uniform vec2 uResolution;
uniform float uFresnelBias;
uniform float uFresnelScale;
uniform float uFresnelPower;
uniform float uIor;
varying vec3 vNormal;
varying vec3 vViewPosition;
varying vec2 vUv;
varying vec3 vWorldPosition;
// Simple noise function for caustics and imperfections
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash(i + vec2(0.0, 0.0)), hash(i + vec2(1.0, 0.0)), f.x),
mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x), f.y);
}
void main() {
#include <clipping_planes_fragment>
vec3 normal = normalize(vNormal);
vec3 viewDir = normalize(vViewPosition);
// Fresnel effect
float fresnel = uFresnelBias + uFresnelScale * pow(1.0 + dot(viewDir, normal), uFresnelPower);
// Caustics simulation
vec2 causticUv = vWorldPosition.xz * 2.0 + vWorldPosition.y * 0.5;
float caustic = noise(causticUv * 5.0 + uTime * 0.5) * 0.5 + 0.5;
caustic *= noise(causticUv * 10.0 - uTime * 0.3) * 0.5 + 0.5;
caustic = pow(caustic, 3.0) * 1.5;
// Surface imperfections
float imperfection = noise(vUv * 20.0 + uTime * 0.1) * 0.05;
vec3 finalColor = uColor;
finalColor += caustic * 0.2 * uColor;
finalColor -= imperfection;
// Simple refraction-like look by blending with fresnel
vec3 skyColor = vec3(0.8, 0.9, 1.0);
finalColor = mix(finalColor, skyColor, fresnel * 0.3);
gl_FragColor = vec4(finalColor, 0.9);
}
`
);
extend({ LiquidMaterialImpl });
export function Liquid({ color = "#ff9900", size = [0.9, 1.8, 0.5] }) {
const materialRef = useRef<any>(null);
const { size: winSize } = useThree();
useFrame((state) => {
if (materialRef.current) {
const mat = materialRef.current;
if (mat.uTime !== undefined) mat.uTime = state.clock.elapsedTime;
if (mat.uResolution && mat.uResolution.set) {
mat.uResolution.set(winSize.width || 800, winSize.height || 600);
}
}
});
return (
<mesh>
<boxGeometry args={size as any} />
{/* @ts-ignore */}
<liquidMaterialImpl
ref={materialRef}
uColor={new THREE.Color(color)}
transparent
side={THREE.DoubleSide}
clipping={true}
/>
</mesh>
);
}
+271
View File
@@ -0,0 +1,271 @@
import { useRef, useEffect, forwardRef, useMemo, useState } from "react";
import * as THREE from "three";
import { useFrame, useThree, createPortal } from "@react-three/fiber";
import { MaterialFactory } from "../materials/MaterialFactory";
import { useTexturePipeline } from "../hooks/useTexturePipeline";
interface ModelViewerProps {
object: THREE.Object3D | null;
sceneObjects: any[];
}
interface MeshMaterialManagerProps {
mesh: THREE.Mesh;
sceneObj: any;
textureCache: React.MutableRefObject<Map<string, THREE.Texture>>;
}
const MeshMaterialManager = ({ mesh, sceneObj, textureCache }: MeshMaterialManagerProps) => {
useEffect(() => {
if (!mesh || !sceneObj.materialProps) return;
const props = sceneObj.materialProps;
const mappingType = props.mappingType || 'uv';
const uvScale = props.uvScale || 1;
const uvTiling = props.uvTiling || [1, 1];
const uvOffset = props.uvOffset || [0, 0];
const uvRotation = props.uvRotation || 0;
const origMat = (Array.isArray(mesh.material) ? mesh.material[0] : mesh.material) as THREE.MeshPhysicalMaterial;
const baseParams: any = {
color: origMat.color,
roughness: origMat.roughness !== undefined ? origMat.roughness : 0.5,
metalness: origMat.metalness !== undefined ? origMat.metalness : 0,
map: origMat.map,
normalMap: origMat.normalMap,
roughnessMap: origMat.roughnessMap,
metalnessMap: origMat.metalnessMap,
aoMap: origMat.aoMap,
emissiveMap: origMat.emissiveMap,
emissive: origMat.emissive,
emissiveIntensity: origMat.emissiveIntensity,
envMapIntensity: origMat.envMapIntensity,
alphaMap: origMat.alphaMap,
transparent: origMat.transparent,
opacity: origMat.opacity,
side: origMat.side,
alphaTest: origMat.alphaTest,
};
// Use MaterialFactory to get reusable materials
let activeMat: any;
if (mappingType === 'triplanar') {
activeMat = MaterialFactory.getInstance().getTriplanarMaterial(
baseParams,
mesh.uuid
);
activeMat.triplanarUniforms.uTriplanarScale.value = uvScale;
activeMat.triplanarUniforms.uBlendSharpness.value = 5.0; // Default or from props
} else {
activeMat = MaterialFactory.getInstance().getStandardUVMaterial(
baseParams,
mesh.uuid
);
let modeIndex = 0;
if (mappingType === 'planar') modeIndex = 1;
else if (mappingType === 'box') modeIndex = 2;
else if (mappingType === 'cylinder') modeIndex = 3;
activeMat.uvUniforms.uMappingMode.value = modeIndex;
activeMat.updateUVTransform(uvOffset, [uvTiling[0] * uvScale, uvTiling[1] * uvScale], uvRotation * (Math.PI / 180));
}
// Apply basic props
if (props.color) activeMat.color.set(props.color);
if (props.roughness !== undefined) activeMat.roughness = props.roughness;
if (props.metalness !== undefined) activeMat.metalness = props.metalness;
if (props.opacity !== undefined) activeMat.opacity = props.opacity;
if (props.transmission !== undefined) activeMat.transmission = props.transmission;
activeMat.transparent = (props.transmission > 0) || (props.opacity < 1) || !!props.alphaMap;
if (Array.isArray(mesh.material)) {
mesh.material = mesh.material.map(() => activeMat);
} else {
mesh.material = activeMat;
}
// Textures
const textureTypes: ('map' | 'normalMap' | 'specularMap' | 'alphaMap')[] = ['map', 'normalMap', 'specularMap', 'alphaMap'];
textureTypes.forEach(type => {
const url = props[type];
if (url) {
if (!activeMat[type] || activeMat[type]!.name !== url) {
if (textureCache.current.has(url)) {
activeMat[type] = textureCache.current.get(url)!;
if (mappingType === 'triplanar') activeMat.triplanarUniforms['t' + type.charAt(0).toUpperCase() + type.slice(1).replace('Map', '')].value = activeMat[type];
activeMat.needsUpdate = true;
} else {
const loader = new THREE.TextureLoader();
loader.load(url, (tex) => {
tex.name = url;
if (type === 'map') tex.colorSpace = THREE.SRGBColorSpace;
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
textureCache.current.set(url, tex);
activeMat[type] = tex;
if (mappingType === 'triplanar') activeMat.triplanarUniforms['t' + type.charAt(0).toUpperCase() + type.slice(1).replace('Map', '')].value = tex;
activeMat.needsUpdate = true;
});
}
}
} else if (url === null && activeMat[type]) {
activeMat[type] = null;
if (mappingType === 'triplanar') activeMat.triplanarUniforms['t' + type.charAt(0).toUpperCase() + type.slice(1).replace('Map', '')].value = null;
activeMat.needsUpdate = true;
}
});
mesh.visible = sceneObj.visible;
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.frustumCulled = false;
// Sync individual mesh transform
if (sceneObj.transformProps) {
const { position, rotation, scale } = sceneObj.transformProps;
mesh.position.set(position[0], position[1], position[2]);
mesh.rotation.set(rotation[0], rotation[1], rotation[2]);
mesh.scale.set(scale[0], scale[1], scale[2]);
}
}, [sceneObj, mesh]);
return null;
};
const FillMesh = ({ fillProps, originalMesh }: { fillProps: any, originalMesh: THREE.Mesh }) => {
const materialRef = useRef<any>(null);
const [clippingPlane] = useState(() => new THREE.Plane(new THREE.Vector3(0, -1, 0), 0));
const { size: winSize } = useThree();
// Clone geometry and fix interpolation
const geometry = useMemo(() => {
const geo = originalMesh.geometry.clone();
geo.computeVertexNormals();
return geo;
}, [originalMesh.geometry]);
// Update clipping plane based on height
useEffect(() => {
geometry.computeBoundingBox();
const box = geometry.boundingBox || new THREE.Box3().setFromObject(originalMesh);
const minY = box.min.y;
const maxY = box.max.y;
// height is 0 to 1
const clipY = minY + (maxY - minY) * (fillProps.height ?? 0.5);
// Since this plane is applied in world space by Three.js by default,
// wait, localClippingEnabled = true means the plane is in LOCAL space!
// If it's local space, clipY is exact.
clippingPlane.constant = clipY;
}, [geometry, fillProps.height, originalMesh]);
useFrame((state) => {
if (materialRef.current && fillProps.type === 'liquid') {
const mat = materialRef.current;
if (mat.uTime !== undefined) mat.uTime = state.clock.elapsedTime;
if (mat.uResolution && mat.uResolution.set) {
mat.uResolution.set(winSize.width || 800, winSize.height || 600);
}
}
});
const fillNode = (
<mesh geometry={geometry} scale={[0.995, 0.995, 0.995]}>
{fillProps.type === 'liquid' ? (
// @ts-ignore
<liquidMaterialImpl
ref={materialRef}
uColor={new THREE.Color(fillProps.color)}
transparent
side={THREE.DoubleSide}
clippingPlanes={[clippingPlane]}
clipping={true}
/>
) : (
<meshPhysicalMaterial
color={fillProps.color}
side={THREE.DoubleSide}
clippingPlanes={[clippingPlane]}
roughness={0.2}
clearcoat={0.5}
/>
)}
</mesh>
);
return createPortal(fillNode, originalMesh);
};
export const ModelViewer = forwardRef<THREE.Group, ModelViewerProps>(({ object, sceneObjects }, ref) => {
const internalRef = useRef<THREE.Group>(null);
const groupRef = (ref as any) || internalRef;
const textureCache = useRef<Map<string, THREE.Texture>>(new Map());
const meshCache = useRef<Map<string, THREE.Mesh>>(new Map());
useTexturePipeline(object);
// Cleanup textures on unmount
useEffect(() => {
return () => {
textureCache.current.forEach(tex => tex.dispose());
textureCache.current.clear();
meshCache.current.clear();
};
}, []);
// Build mesh cache when object changes
useEffect(() => {
meshCache.current.clear();
if (object) {
let mIdx = 0;
object.traverse((child) => {
if (child instanceof THREE.Mesh) {
const stableId = `mesh-${mIdx}`;
meshCache.current.set(stableId, child);
mIdx++;
}
});
}
}, [object]);
// Main transform update for the core model
useEffect(() => {
if (object && sceneObjects) {
const mainModel = sceneObjects.find(obj => obj.type === 'model');
if (mainModel && mainModel.transformProps && groupRef.current) {
const { position: p, rotation: r, scale: s } = mainModel.transformProps;
groupRef.current.position.set(p[0], p[1], p[2]);
groupRef.current.rotation.set(r[0], r[1], r[2]);
groupRef.current.scale.set(s[0], s[1], s[2]);
}
}
}, [object, sceneObjects]);
if (!object) return null;
const meshObjects = sceneObjects.filter(obj => obj.type === 'mesh');
return (
<group ref={groupRef}>
<primitive object={object} />
{meshObjects.map(obj => {
const mesh = meshCache.current.get(obj.id);
if (!mesh) return null;
return (
<group key={obj.id}>
<MeshMaterialManager
mesh={mesh}
sceneObj={obj}
textureCache={textureCache}
/>
{obj.fillProps?.enabled && (
<FillMesh fillProps={obj.fillProps} originalMesh={mesh} />
)}
</group>
);
})}
</group>
);
});
+23
View File
@@ -0,0 +1,23 @@
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
interface ProtectedRouteProps {
allowedRoles?: ('user' | 'admin')[];
}
export const ProtectedRoute = ({ allowedRoles = ['user', 'admin'] }: ProtectedRouteProps) => {
const { user, profile } = useAuthStore();
const location = useLocation();
if (!user) {
// Redirect to login but save the attempted url
return <Navigate to="/auth" state={{ from: location }} replace />;
}
if (profile && !allowedRoles.includes(profile.role)) {
// Role not authorized, go to dashboard
return <Navigate to="/dashboard" replace />;
}
return <Outlet />;
};
+252
View File
@@ -0,0 +1,252 @@
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Download, Image as ImageIcon, Layers, Settings, Monitor, Smartphone, Maximize2 } from 'lucide-react';
import { cn } from '../lib/utils';
interface RenderModalProps {
isOpen: boolean;
onClose: () => void;
onRender: (settings: RenderSettings) => void;
}
export interface RenderSettings {
width: number;
height: number;
includeAlpha: boolean;
format: 'png' | 'jpg';
name: string;
folder?: string;
}
const RESOLUTION_PRESETS = [
{ label: '7680 x 4800 (8K)', w: 7680, h: 4800 },
{ label: '3840 x 2400 (4K)', w: 3840, h: 2400 },
{ label: '2560 x 1600 (2K)', w: 2560, h: 1600 },
{ label: '1920 x 1200 (Full HD)', w: 1920, h: 1200 },
{ label: '1600 x 1000', w: 1600, h: 1000 },
{ label: '1280 x 800', w: 1280, h: 800 },
{ label: '1024 x 640', w: 1024, h: 640 },
{ label: '800 x 500', w: 800, h: 500 },
{ label: '640 x 400', w: 640, h: 400 },
];
export const RenderModal: React.FC<RenderModalProps> = ({ isOpen, onClose, onRender }) => {
const [settings, setSettings] = useState<RenderSettings>({
width: 1920,
height: 1200,
includeAlpha: true,
format: 'png',
name: 'Render_1',
folder: 'C:/Users/Render/Documents'
});
const [activeTab, setActiveTab] = useState('Output');
if (!isOpen) return null;
return (
<AnimatePresence>
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="w-full max-w-4xl bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden flex flex-col h-[600px]"
>
{/* Header */}
<div className="h-10 bg-zinc-800 flex items-center justify-between px-4 border-b border-zinc-700">
<span className="text-xs font-bold text-zinc-300">Render</span>
<button onClick={onClose} className="p-1 hover:bg-zinc-700 rounded transition-colors">
<X size={16} className="text-zinc-400" />
</button>
</div>
<div className="flex flex-1 overflow-hidden">
{/* Sidebar */}
<div className="w-48 bg-zinc-900 border-r border-zinc-800 flex flex-col">
{['Output', 'Options', 'Queue'].map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={cn(
"px-4 py-2 text-left text-xs font-medium transition-colors",
activeTab === tab ? "bg-zinc-800 text-blue-400" : "text-zinc-500 hover:bg-zinc-800/50 hover:text-zinc-300"
)}
>
{tab}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 p-6 overflow-y-auto bg-zinc-900/50">
<div className="flex flex-col gap-8 max-w-2xl">
{/* Mode Tabs */}
<div className="flex p-1 bg-zinc-950 rounded-lg border border-zinc-800 self-start">
{['Still Image', 'Animation', 'Titan3d 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>
);
+515
View File
@@ -0,0 +1,515 @@
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[];
}
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 [canvasW, setCanvasW] = useState(1024);
const [canvasH, setCanvasH] = useState(1024);
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) {
setCanvasW(1024);
setCanvasH(1024);
let n = 0;
const init: Layer[] = saved.map((l: any) => ({ ...l }));
init.forEach(layer => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => { layer.img = img; n++; if (n === init.length) setLayers([...init]); };
img.src = layer.url;
});
} else if (targetObject?.materialProps?.map) {
// If no saved layers but there's a base map texture, load it as the initial layer
const url = targetObject.materialProps.map;
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
// Reduced MAX size from 2048 to 1024 to prevent huge base64 strings
// that trigger the 1MB Nginx reverse proxy 413 Payload Too Large limit.
const MAX = 1024;
let scale = 1;
if (img.width > MAX || img.height > MAX) {
scale = MAX / Math.max(img.width, img.height);
}
const cw = Math.round(img.width * scale);
const ch = Math.round(img.height * scale);
setCanvasW(cw);
setCanvasH(ch);
const scaleX = cw / img.width;
const scaleY = ch / img.height;
const layer: Layer = {
id: `l-base`,
url,
img,
x: cw / 2,
y: ch / 2,
scaleX,
scaleY,
rotation: 0,
cropL: 0,
cropT: 0,
cropR: 0,
cropB: 0,
visible: true
};
setLayers([layer]);
setSelectedId(layer.id);
};
img.src = url;
}
}, [targetObject]);
const renderCanvas = useCallback((lrs: Layer[], transparent: boolean, bg: string): string => {
const c = document.createElement('canvas');
c.width = canvasW; c.height = canvasH;
const ctx = c.getContext('2d')!;
if (!transparent) { ctx.fillStyle = bg; ctx.fillRect(0, 0, canvasW, canvasH); }
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();
});
try {
// Use JPEG with 0.75 quality to heavily compress the image size
// to avoid hitting the 1MB Nginx reverse proxy limit.
return c.toDataURL(transparent ? 'image/png' : 'image/jpeg', 0.75);
} catch (e) {
console.error("Canvas render error (likely CORS taint):", e);
return "";
}
}, [canvasW, canvasH]);
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: canvasW / 2, y: canvasH / 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 ? canvasW / 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(canvasW, nx));
ny = Math.max(0, Math.min(canvasH, 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 - canvasW / 2) < SNAP_PX) nx = canvasW / 2;
if (Math.abs(ny - canvasH / 2) < SNAP_PX) ny = canvasH / 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 w = (iw * layer.scaleX / canvasW) * 100;
const h = (ih * layer.scaleY / canvasH) * 100;
const lx = (layer.x / canvasW) * 100;
const ly = (layer.y / canvasH) * 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: `${canvasW}/${canvasH}`,
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 / canvasW) * 100}%`,
top: `${(layer.y / canvasH) * 100}%`,
width: `${(iw / canvasW) * 100}%`,
height: `${(ih / canvasH) * 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: '100%', height: '100%', overflow: 'hidden' }}>
<img src={layer.url} alt=""
style={{
position: 'relative',
left: `${-layer.cropL * 100}%`,
top: `${-layer.cropT * 100}%`,
width: `${(1 / (1 - layer.cropL - layer.cropR)) * 100}%`,
height: `${(1 / (1 - layer.cropT - layer.cropB)) * 100}%`,
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">
{canvasW}x{canvasH}
</div>
</div>
</div>
{/* Right panel - preview */}
<div className="w-72 bg-zinc-900 border-l border-zinc-800 flex flex-col p-4 gap-4 flex-shrink-0 overflow-y-auto">
<div>
<div className="flex items-center gap-2 mb-2">
<RotateCw size={11} className="text-zinc-500" />
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">3D Preview</span>
</div>
<div className="w-full aspect-square bg-zinc-950 rounded-xl overflow-hidden border border-zinc-800">
<Canvas shadows camera={{ position: [2, 2, 2], fov: 45 }}>
<ambientLight intensity={0.5} />
<directionalLight position={[5, 5, 5]} intensity={1} castShadow />
<Environment preset="studio" />
<Suspense fallback={null}><PreviewModel /></Suspense>
<OrbitControls autoRotate autoRotateSpeed={1.5} />
</Canvas>
</div>
</div>
<div className="p-3 bg-zinc-800/30 rounded-xl border border-zinc-800 text-zinc-500">
<p className="text-[10px] font-bold uppercase tracking-widest mb-2">Legend</p>
<div className="flex flex-col gap-1.5 text-[10px]">
<div className="flex items-center gap-2"><div className="w-3 h-3 rounded-sm bg-white border-2 border-blue-500" /><span>Drag corner/edge — uniform scale</span></div>
<div className="flex items-center gap-2"><div className="w-3 h-3 rounded-sm bg-blue-400 border-2 border-blue-500 flex items-center justify-center text-[7px] font-bold text-white">⇧</div><span>Shift + drag — free X/Y stretch</span></div>
<div className="flex items-center gap-2"><div className="w-3 h-3 rounded-full bg-amber-400" /><span>Drag top handle — rotate</span></div>
<div className="flex items-center gap-2"><div className="w-3 h-3 rounded-sm bg-zinc-600 border border-zinc-500 cursor-move" /><span>Drag layer — move (Shift snaps)</span></div>
</div>
</div>
</div>
</div>
</div>
);
}
+176
View File
@@ -0,0 +1,176 @@
import React, { useMemo, useRef, useEffect } from 'react';
import * as THREE from 'three';
import { useTexture } from '@react-three/drei';
// Mapping Types Enum
export const MAPPING_TYPES = {
UV: 0,
PLANAR: 1,
CYLINDER: 2,
SPHERE: 3,
BOX: 4,
};
const vertexShader = `
varying vec2 vUv;
varying vec3 vLocalPos;
varying vec3 vNormal;
varying vec3 vWorldPos;
void main() {
vUv = uv;
vLocalPos = position;
vNormal = normalize(normal);
vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const fragmentShader = `
uniform sampler2D uTexture;
uniform int uMappingType;
uniform float uScale;
uniform vec2 uTiling;
uniform vec2 uOffset;
uniform float uRotation;
uniform vec3 uBBoxMin;
uniform vec3 uBBoxMax;
varying vec2 vUv;
varying vec3 vLocalPos;
varying vec3 vNormal;
#ifndef PI
#define PI 3.14159265359
#endif
vec2 rotateUV(vec2 uv, float rotation) {
float mid = 0.5;
float cosAngle = cos(rotation);
float sinAngle = sin(rotation);
float dx = uv.x - mid;
float dy = uv.y - mid;
return vec2(
cosAngle * dx - sinAngle * dy + mid,
sinAngle * dx + cosAngle * dy + mid
);
}
void main() {
vec2 finalUv = vec2(0.0);
// Bounding box calculations for dynamic projections
vec3 boundsSize = uBBoxMax - uBBoxMin;
vec3 boundsPos = (vLocalPos - uBBoxMin) / (boundsSize + 0.00001);
vec3 center = (uBBoxMax + uBBoxMin) * 0.5;
vec3 dir = vLocalPos - center;
vec3 normDir = normalize(dir + 0.00001);
if (uMappingType == 0) {
// UV: Applies native geometry vUv
finalUv = vUv;
} else if (uMappingType == 1) {
// PLANAR (XZ / Top-Down)
finalUv = boundsPos.xz;
} else if (uMappingType == 2) {
// CYLINDER: u = angle around Y, v = normalized height
vec3 localRel = vLocalPos - center;
float u = (atan(localRel.z, localRel.x) / (2.0 * PI)) + 0.5;
float v = boundsPos.y;
finalUv = vec2(u, v);
} else if (uMappingType == 3) {
// SPHERE
float u = (atan(normDir.z, normDir.x) / (2.0 * PI)) + 0.5;
float v = (asin(clamp(normDir.y, -1.0, 1.0)) / PI) + 0.5;
finalUv = vec2(u, v);
} else if (uMappingType == 4) {
// BOX (Triplanar)
vec3 absNormal = abs(vNormal);
if (absNormal.x > absNormal.y && absNormal.x > absNormal.z) {
finalUv = boundsPos.zy;
} else if (absNormal.y > absNormal.x && absNormal.y > absNormal.z) {
finalUv = boundsPos.xz;
} else {
finalUv = boundsPos.xy;
}
}
// Apply Tiling, Scale, and Offset
finalUv = finalUv * uTiling * uScale + uOffset;
// Apply Rotation
if (uRotation != 0.0) {
finalUv = rotateUV(finalUv, uRotation);
}
vec4 texColor = texture2D(uTexture, finalUv);
gl_FragColor = texColor;
}
`;
export const UnifiedMappingMaterial = ({
textureUrl,
mappingType = 'UV',
uvScale = 1.0,
uvTiling = [1, 1],
uvOffset = [0, 0],
uvRotation = 0,
geometry
}) => {
const materialRef = useRef<THREE.ShaderMaterial>(null);
// Load texture
const texture = useTexture(textureUrl) as THREE.Texture;
useEffect(() => {
if (texture) {
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
texture.needsUpdate = true;
}
}, [texture]);
// Initialize Uniforms
const uniforms = useMemo(() => ({
uTexture: { value: texture },
uMappingType: { value: MAPPING_TYPES[mappingType as keyof typeof MAPPING_TYPES] || 0 },
uScale: { value: uvScale },
uTiling: { value: new THREE.Vector2(uvTiling[0], uvTiling[1]) },
uOffset: { value: new THREE.Vector2(uvOffset[0], uvOffset[1]) },
uRotation: { value: uvRotation * (Math.PI / 180) },
uBBoxMin: { value: new THREE.Vector3(-1, -1, -1) },
uBBoxMax: { value: new THREE.Vector3(1, 1, 1) }
}), [texture]);
// Real-time Uniform Updates
useEffect(() => {
if (materialRef.current) {
const u = materialRef.current.uniforms;
u.uMappingType.value = MAPPING_TYPES[mappingType as keyof typeof MAPPING_TYPES] || 0;
u.uScale.value = uvScale;
u.uTiling.value.set(uvTiling[0], uvTiling[1]);
u.uOffset.value.set(uvOffset[0], uvOffset[1]);
u.uRotation.value = uvRotation * (Math.PI / 180);
}
}, [mappingType, uvScale, uvTiling, uvOffset, uvRotation]);
// Bounding Box Update
useEffect(() => {
if (geometry && materialRef.current) {
geometry.computeBoundingBox();
const box = geometry.boundingBox || new THREE.Box3().setFromObject(new THREE.Mesh(geometry));
materialRef.current.uniforms.uBBoxMin.value.copy(box.min);
materialRef.current.uniforms.uBBoxMax.value.copy(box.max);
}
}, [geometry]);
return (
<shaderMaterial
ref={materialRef}
uniforms={uniforms}
vertexShader={vertexShader}
fragmentShader={fragmentShader}
side={THREE.DoubleSide}
transparent={true}
/>
);
};
+350
View File
@@ -0,0 +1,350 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Sparkles, Image as ImageIcon, Loader2, Download, Settings, Wand2, Lightbulb } from 'lucide-react';
import { cn } from '../lib/utils';
import { useAuthStore } from '../store/authStore';
import { api } from '../lib/api';
interface VizAiModalProps {
isOpen: boolean;
onClose: () => void;
baseImage: string | null;
}
const dataURLtoBlob = (dataurl: string) => {
const arr = dataurl.split(',');
const mime = arr[0].match(/:(.*?);/)?.[1];
const bstr = atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while(n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new Blob([u8arr], {type:mime});
};
export const VizAiModal: React.FC<VizAiModalProps> = ({ isOpen, onClose, baseImage }) => {
const { profile, setProfile } = useAuthStore();
// API Keys
const [stabilityApiKey, setStabilityApiKey] = useState('');
const [openAiKey, setOpenAiKey] = useState('');
// Prompt Generation State (AI Idea)
const [productName, setProductName] = useState('');
const [ingredients, setIngredients] = useState('');
const [imageTone, setImageTone] = useState('');
const [packDesign, setPackDesign] = useState('');
const [isGeneratingPrompt, setIsGeneratingPrompt] = useState(false);
// Image Generation State
const [prompt, setPrompt] = useState('');
const [strength, setStrength] = useState(0.85);
const [isGeneratingImage, setIsGeneratingImage] = useState(false);
const [generatedImage, setGeneratedImage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (isOpen) {
setStabilityApiKey(localStorage.getItem('stability_api_key') || '');
setOpenAiKey(localStorage.getItem('openai_api_key') || '');
}
}, [isOpen]);
const generateAiIdea = async () => {
if (!openAiKey) {
setError("Please set your OpenAI API key in Tools > API Settings first.");
return;
}
setIsGeneratingPrompt(true);
setError(null);
try {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${openAiKey}`
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [{
role: 'system',
content: 'You are an expert prompt engineer for Stable Diffusion. Your goal is to take product specifications and write a single, highly descriptive prompt for generating a beautiful, professional product mockup scene. Describe the lighting, the background, and the aesthetic vividly.'
}, {
role: 'user',
content: `Create a single, highly descriptive image generation prompt based on these details:
Product Name: ${productName || 'Unspecified Product'}
Ingredients/Elements: ${ingredients || 'None specified'}
Image Tone/Mood: ${imageTone || 'Professional, studio lighting'}
Pack Design/Shape: ${packDesign || 'Standard'}
Return ONLY the prompt string (under 100 words) without any conversational text or quotes.`
}]
})
});
const data = await response.json();
if (data.error) throw new Error(data.error.message);
const generated = data.choices?.[0]?.message?.content?.trim() || "";
// Strip quotes if they were included
setPrompt(generated.replace(/^"|"$/g, ''));
} catch (err: any) {
console.error(err);
setError(err.message || "Failed to generate AI Idea.");
} finally {
setIsGeneratingPrompt(false);
}
};
const generateImage = async () => {
if (!stabilityApiKey) {
setError("Please set your Stability AI API key in Tools > API Settings first.");
return;
}
if (!baseImage) {
setError("No base image captured from the scene.");
return;
}
if (!prompt) {
setError("Please enter a prompt or use AI Idea.");
return;
}
if (!profile || profile.api_credits <= 0) {
setError("Not enough AI Credits. Please contact an administrator.");
return;
}
setIsGeneratingImage(true);
setError(null);
try {
const formData = new FormData();
formData.append('image', dataURLtoBlob(baseImage), 'base.png');
formData.append('prompt', prompt);
formData.append('strength', strength.toString());
formData.append('mode', 'image-to-image');
formData.append('model', 'sd3.5-large');
formData.append('output_format', 'png');
const response = await fetch('https://api.stability.ai/v2beta/stable-image/generate/sd3', {
method: 'POST',
headers: {
'Authorization': `Bearer ${stabilityApiKey}`,
'Accept': 'image/*'
},
body: formData
});
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
throw new Error(errData.message || `Stability API error: ${response.status}`);
}
const blob = await response.blob();
const imageUrl = URL.createObjectURL(blob);
setGeneratedImage(imageUrl);
// Deduct credit
if (profile) {
const { api_credits } = await api.deductCredit();
setProfile({ ...profile, api_credits });
}
} catch (err: any) {
console.error(err);
setError(err.message || "Failed to generate image.");
} finally {
setIsGeneratingImage(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-5xl bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden flex flex-col h-[80vh]"
>
{/* Header */}
<div className="h-14 bg-zinc-950 flex items-center justify-between px-6 border-b border-zinc-800 shrink-0">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-tr from-purple-600 to-blue-500 flex items-center justify-center">
<Sparkles size={16} className="text-white" />
</div>
<div>
<h2 className="text-sm font-bold text-zinc-100">Titan3d AI Generator</h2>
<p className="text-[10px] text-zinc-400">Generate stunning product mockups directly from your 3D scene</p>
</div>
</div>
<div className="flex items-center gap-2">
<button onClick={onClose} className="p-2 hover:bg-zinc-800 rounded-lg transition-colors">
<X size={18} className="text-zinc-400" />
</button>
</div>
</div>
<div className="flex-1 overflow-hidden flex bg-zinc-900/50">
<div className="flex w-full h-full">
{/* Left Panel: Inputs */}
<div className="w-[400px] border-r border-zinc-800 flex flex-col overflow-y-auto custom-scrollbar">
{/* AI Idea Box */}
<div className="p-5 border-b border-zinc-800 bg-zinc-950/30">
<div className="flex items-center gap-2 mb-4">
<Lightbulb size={16} className="text-yellow-500" />
<h3 className="text-xs font-bold text-zinc-300 uppercase tracking-wider">AI Idea Generator</h3>
</div>
<div className="flex flex-col gap-3">
<input
type="text"
placeholder="Product Name (e.g. Tetra Juice)"
value={productName}
onChange={(e) => setProductName(e.target.value)}
className="w-full bg-zinc-900 border border-zinc-800 rounded px-3 py-2 text-xs text-white placeholder-zinc-600 focus:border-yellow-500/50 outline-none"
/>
<input
type="text"
placeholder="Ingredients (e.g. Oranges, Mint)"
value={ingredients}
onChange={(e) => setIngredients(e.target.value)}
className="w-full bg-zinc-900 border border-zinc-800 rounded px-3 py-2 text-xs text-white placeholder-zinc-600 focus:border-yellow-500/50 outline-none"
/>
<input
type="text"
placeholder="Image Tone (e.g. Fresh, Bright, Morning)"
value={imageTone}
onChange={(e) => setImageTone(e.target.value)}
className="w-full bg-zinc-900 border border-zinc-800 rounded px-3 py-2 text-xs text-white placeholder-zinc-600 focus:border-yellow-500/50 outline-none"
/>
<input
type="text"
placeholder="Pack Design (e.g. Sleek, Modern)"
value={packDesign}
onChange={(e) => setPackDesign(e.target.value)}
className="w-full bg-zinc-900 border border-zinc-800 rounded px-3 py-2 text-xs text-white placeholder-zinc-600 focus:border-yellow-500/50 outline-none"
/>
<button
onClick={generateAiIdea}
disabled={isGeneratingPrompt}
className="w-full mt-2 py-2 bg-yellow-500/10 hover:bg-yellow-500/20 text-yellow-500 border border-yellow-500/30 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-2"
>
{isGeneratingPrompt ? <Loader2 size={14} className="animate-spin" /> : <Wand2 size={14} />}
{isGeneratingPrompt ? 'Generating Idea...' : 'Suggest Prompt'}
</button>
</div>
</div>
{/* Main Generation Settings */}
<div className="p-5 flex flex-col gap-6">
<div className="flex flex-col gap-2">
<label className="text-xs font-bold text-zinc-300">Prompt</label>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Describe the background and lighting..."
className="w-full h-32 bg-zinc-900 border border-zinc-800 rounded-lg p-3 text-sm text-white placeholder-zinc-600 focus:border-blue-500/50 outline-none resize-none"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex justify-between">
<label className="text-xs font-bold text-zinc-400">Creative Strength</label>
<span className="text-xs text-blue-400 font-mono">{strength.toFixed(2)}</span>
</div>
<input
type="range"
min="0" max="1" step="0.05"
value={strength}
onChange={(e) => setStrength(parseFloat(e.target.value))}
className="w-full accent-blue-500"
/>
<div className="flex justify-between text-[10px] text-zinc-600">
<span>Similar to Base</span>
<span>Highly Creative</span>
</div>
</div>
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-xs text-red-400">
{error}
</div>
)}
<button
onClick={generateImage}
disabled={isGeneratingImage}
className="w-full py-4 mt-auto bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-500 hover:to-blue-500 text-white font-bold rounded-xl shadow-lg transition-all flex items-center justify-center gap-2"
>
{isGeneratingImage ? <Loader2 size={18} className="animate-spin" /> : <Sparkles size={18} />}
{isGeneratingImage ? 'Generating Scene...' : 'Generate Image'}
</button>
</div>
</div>
{/* Right Panel: Previews */}
<div className="flex-1 p-6 bg-zinc-950 flex flex-col gap-4 relative overflow-y-auto">
<div className="grid grid-cols-2 gap-6 h-full">
{/* Base Image */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-zinc-500 uppercase tracking-widest">Base Render</span>
</div>
<div className="flex-1 bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden flex items-center justify-center relative shadow-inner">
{baseImage ? (
<img src={baseImage} alt="Base Render" className="w-full h-full object-contain" />
) : (
<div className="text-zinc-600 flex flex-col items-center gap-2">
<ImageIcon size={32} />
<span className="text-xs">No scene captured</span>
</div>
)}
</div>
</div>
{/* Generated Image */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-blue-500 uppercase tracking-widest">AI Result</span>
{generatedImage && (
<a
href={generatedImage}
download="viz_ai_mockup.png"
className="text-xs text-zinc-400 hover:text-white flex items-center gap-1"
>
<Download size={14} /> Download
</a>
)}
</div>
<div className="flex-1 bg-zinc-900 border border-blue-500/20 rounded-xl overflow-hidden flex items-center justify-center relative shadow-lg">
{isGeneratingImage ? (
<div className="flex flex-col items-center gap-4 text-blue-500">
<Loader2 size={32} className="animate-spin" />
<span className="text-sm font-medium animate-pulse">Rendering via Stability AI...</span>
</div>
) : generatedImage ? (
<img src={generatedImage} alt="AI Result" className="w-full h-full object-contain" />
) : (
<div className="text-zinc-600 flex flex-col items-center gap-2">
<Sparkles size={32} className="opacity-50" />
<span className="text-xs">Result will appear here</span>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
</motion.div>
</div>
</AnimatePresence>
);
};
+83
View File
@@ -0,0 +1,83 @@
import { useRef, useMemo } from 'react';
import { useFrame } from '@react-three/fiber';
import { useScroll, useFBX } from '@react-three/drei';
import * as THREE from 'three';
export function Models() {
const scroll = useScroll();
// Load the actual FBX models from the public folder
const heroFBX = useFBX('/3dmodels/MANUAL/Cavins/GEE_TIN.fbx');
const featureFBX = useFBX('/3dmodels/MANUAL/MAA_Bottle/MAA_Bottle.fbx');
const ctaFBX = useFBX('/3dmodels/MANUAL/Fairever/Carton.fbx');
// Clone them so we don't mutate the cached originals
const heroModel = useMemo(() => heroFBX.clone(), [heroFBX]);
const featureModel = useMemo(() => featureFBX.clone(), [featureFBX]);
const ctaModel = useMemo(() => ctaFBX.clone(), [ctaFBX]);
const heroRef = useRef<THREE.Group>(null);
const featureRef = useRef<THREE.Group>(null);
const ctaRef = useRef<THREE.Group>(null);
useFrame((state, delta) => {
const offset = scroll.offset; // 0 to 1
if (heroRef.current) {
heroRef.current.rotation.y += delta * 0.3;
// Fade out and move left as we scroll down
heroRef.current.position.y = THREE.MathUtils.lerp(0, 10, offset * 3);
heroRef.current.position.x = THREE.MathUtils.lerp(3, -5, offset * 3);
// GEE_TIN increased by 300% (0.05 -> 0.15)
const scaleBase = 0.15;
heroRef.current.scale.setScalar(THREE.MathUtils.lerp(scaleBase, 0, offset * 3));
}
if (featureRef.current) {
featureRef.current.rotation.y += delta * 0.4;
const featureProgress = Math.max(0, Math.min(1, (offset - 0.2) * 2));
featureRef.current.position.y = THREE.MathUtils.lerp(-10, 0, featureProgress);
featureRef.current.position.x = THREE.MathUtils.lerp(-5, -2, featureProgress);
// MAA_Bottle increased by 300% (0.05 -> 0.15)
const scaleBase = 0.15;
if (offset > 0.6) {
const exitProgress = (offset - 0.6) * 3;
featureRef.current.position.y = THREE.MathUtils.lerp(0, 10, exitProgress);
featureRef.current.scale.setScalar(THREE.MathUtils.lerp(scaleBase, 0, exitProgress));
} else {
featureRef.current.scale.setScalar(scaleBase);
}
}
if (ctaRef.current) {
ctaRef.current.rotation.y -= delta * 0.3;
const ctaProgress = Math.max(0, Math.min(1, (offset - 0.6) * 3));
ctaRef.current.position.y = THREE.MathUtils.lerp(-10, -1, ctaProgress);
ctaRef.current.position.x = THREE.MathUtils.lerp(5, 2, ctaProgress);
// Carton decreased by 70% (0.05 -> 0.015)
const scaleBase = 0.015;
ctaRef.current.scale.setScalar(scaleBase);
}
});
return (
<group>
<group ref={heroRef} position={[3, 0, -2]}>
<primitive object={heroModel} />
</group>
<group ref={featureRef} position={[-5, -10, -3]}>
<primitive object={featureModel} />
</group>
<group ref={ctaRef} position={[5, -10, -2]}>
<primitive object={ctaModel} />
</group>
</group>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { Scroll, Image } from '@react-three/drei';
import { useAuthStore } from '../../store/authStore';
import { Box, Layers, Zap, Globe, Sparkles } from 'lucide-react';
export function Overlay() {
const { openAuthModal } = useAuthStore();
return (
<>
{/* 3D Background Images Parallax Layer */}
<Scroll>
<Image url="/assets/images/bg_hero.png" scale={[16, 10]} position={[0, 0, -5]} transparent opacity={0.6} />
<Image url="/assets/images/bg_features.png" scale={[16, 10]} position={[0, -10, -5]} transparent opacity={0.5} />
<Image url="/assets/images/bg_cta.png" scale={[16, 10]} position={[0, -20, -5]} transparent opacity={0.6} />
</Scroll>
{/* HTML UI Layer */}
<Scroll html style={{ width: '100%', height: '100%' }}>
{/* Navigation */}
<nav className="fixed top-0 left-0 w-full p-6 flex justify-between items-center z-50 mix-blend-difference text-white">
<div className="flex items-center gap-2 font-bold text-2xl tracking-tighter">
<Box className="text-primary" /> Titan<span className="text-primary">3d</span>
</div>
<button
onClick={() => openAuthModal('login')}
className="px-6 py-2 bg-white/10 hover:bg-white/20 backdrop-blur-md rounded-full font-medium transition-all"
>
Sign In
</button>
</nav>
{/* Section 1: Hero */}
<section className="w-screen h-screen flex flex-col justify-center items-start px-12 md:px-32 text-white">
<div className="max-w-4xl p-10 rounded-[2.5rem] bg-black/30 backdrop-blur-xl border border-white/10 shadow-2xl shadow-black/50">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/10 border border-white/20 backdrop-blur-md mb-8">
<Sparkles size={16} className="text-primary" />
<span className="text-sm font-medium">Next-Gen 3D Configurator</span>
</div>
<h1 className="text-6xl md:text-8xl font-extrabold tracking-tighter leading-tight mb-6 bg-clip-text text-transparent bg-gradient-to-r from-white via-zinc-200 to-zinc-500">
Build The Future in 3D.
</h1>
<p className="text-xl md:text-2xl text-zinc-300 max-w-2xl mb-10 font-light">
An ultra-premium, AI-driven platform to visualize, construct, and deploy stunning 3D assets directly in your browser.
</p>
<button
onClick={() => openAuthModal('register')}
className="px-8 py-4 bg-primary hover:bg-primary/90 rounded-full font-bold text-lg shadow-[0_0_40px_rgba(var(--primary-rgb),0.5)] hover:scale-105 transition-all"
>
Start Building Free
</button>
</div>
</section>
{/* Section 2: Features */}
<section className="w-screen h-screen flex flex-col justify-center items-end px-12 md:px-32 text-white text-right">
<div className="p-10 rounded-[2.5rem] bg-black/30 backdrop-blur-xl border border-white/10 shadow-2xl shadow-black/50 flex flex-col items-end">
<h2 className="text-5xl md:text-7xl font-bold tracking-tighter mb-12 max-w-3xl">
Unleash Unprecedented <span className="text-blue-400">Power</span>.
</h2>
<div className="flex flex-col gap-6 max-w-xl">
<div className="flex items-center justify-end gap-6 p-6 rounded-2xl bg-white/10 border border-white/20 backdrop-blur-md hover:bg-white/15 transition-all cursor-default">
<div className="text-left">
<h3 className="text-2xl font-bold mb-2">Real-time Rendering</h3>
<p className="text-zinc-300">Experience photorealistic materials and lighting instantly.</p>
</div>
<div className="w-16 h-16 rounded-full bg-blue-500/20 border border-blue-500/30 flex items-center justify-center shrink-0">
<Layers className="text-blue-400" size={32} />
</div>
</div>
<div className="flex items-center justify-end gap-6 p-6 rounded-2xl bg-white/10 border border-white/20 backdrop-blur-md hover:bg-white/15 transition-all cursor-default">
<div className="text-left">
<h3 className="text-2xl font-bold mb-2">AI Asset Generation</h3>
<p className="text-zinc-300">Turn text prompts into high-fidelity 3D models in seconds.</p>
</div>
<div className="w-16 h-16 rounded-full bg-purple-500/20 border border-purple-500/30 flex items-center justify-center shrink-0">
<Zap className="text-purple-400" size={32} />
</div>
</div>
</div>
</div>
</section>
{/* Section 3: Call to Action */}
<section className="w-screen h-screen flex flex-col justify-center items-center text-center px-12 text-white relative">
<div className="absolute inset-0 bg-gradient-to-t from-zinc-950 via-transparent to-transparent pointer-events-none" />
<div className="relative z-10 p-16 rounded-[3rem] bg-black/30 backdrop-blur-xl border border-white/10 shadow-2xl shadow-black/50 flex flex-col items-center">
<Globe size={64} className="text-emerald-400 mb-8 animate-pulse drop-shadow-[0_0_15px_rgba(52,211,153,0.5)]" />
<h2 className="text-6xl md:text-8xl font-bold tracking-tighter mb-6">
Ready to Deploy?
</h2>
<p className="text-2xl text-zinc-300 max-w-2xl mb-12">
Join elite creators building the next generation of spatial computing experiences.
</p>
<button
onClick={() => openAuthModal('register')}
className="px-10 py-5 bg-white text-black hover:bg-zinc-200 rounded-full font-bold text-xl transition-all hover:scale-105 shadow-xl"
>
Create Your Workspace
</button>
</div>
</section>
</Scroll>
</>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import { Environment, Float, Sparkles } from '@react-three/drei';
import { EffectComposer, Bloom, Vignette } from '@react-three/postprocessing';
import { Models } from './Models';
export function Scene() {
const group = useRef<THREE.Group>(null);
useFrame((state) => {
if (group.current) {
// Gentle floating animation for the entire scene group
group.current.rotation.y = Math.sin(state.clock.elapsedTime * 0.1) * 0.2;
}
});
return (
<>
<color attach="background" args={['#09090b']} />
{/* Lighting */}
<ambientLight intensity={0.5} />
<directionalLight position={[10, 10, 5]} intensity={1.5} color="#8b5cf6" />
<directionalLight position={[-10, -10, -5]} intensity={1} color="#3b82f6" />
<pointLight position={[0, 0, 0]} intensity={0.5} color="#ffffff" />
<Environment preset="city" />
{/* Main Content */}
<group ref={group}>
<Float speed={1.5} rotationIntensity={0.5} floatIntensity={0.5}>
<Models />
</Float>
{/* Floating Particles */}
<Sparkles count={300} scale={15} size={3} speed={0.4} opacity={0.3} color="#a78bfa" />
</group>
{/* Post Processing for Premium Aesthetic */}
<EffectComposer disableNormalPass>
<Bloom luminanceThreshold={0.2} mipmapBlur intensity={1.5} radius={0.8} />
<Vignette eskil={false} offset={0.1} darkness={1.1} />
</EffectComposer>
</>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { UVGenerationContext, computeBoundingBox, normalizeCoordinate } from './uvUtils';
export function generateBoxUVs(context: UVGenerationContext): Float32Array {
const { positions, normals } = context;
const numVertices = positions.length / 3;
const uvs = new Float32Array(numVertices * 2);
const bbox = computeBoundingBox(positions);
if (!normals) {
// Fallback if no normals provided: use position to determine primary axis from center
for (let i = 0; i < numVertices; i++) {
const x = positions[i * 3];
const y = positions[i * 3 + 1];
const z = positions[i * 3 + 2];
const dx = Math.abs(x - bbox.center[0]);
const dy = Math.abs(y - bbox.center[1]);
const dz = Math.abs(z - bbox.center[2]);
let u = 0;
let v = 0;
if (dx >= dy && dx >= dz) {
u = normalizeCoordinate(z, bbox.min[2], bbox.size[2]);
v = normalizeCoordinate(y, bbox.min[1], bbox.size[1]);
} else if (dy >= dx && dy >= dz) {
u = normalizeCoordinate(x, bbox.min[0], bbox.size[0]);
v = normalizeCoordinate(z, bbox.min[2], bbox.size[2]);
} else {
u = normalizeCoordinate(x, bbox.min[0], bbox.size[0]);
v = normalizeCoordinate(y, bbox.min[1], bbox.size[1]);
}
uvs[i * 2] = u;
uvs[i * 2 + 1] = v;
}
return uvs;
}
// Use normals to determine mapping face
for (let i = 0; i < numVertices; i++) {
const x = positions[i * 3];
const y = positions[i * 3 + 1];
const z = positions[i * 3 + 2];
const nx = Math.abs(normals[i * 3]);
const ny = Math.abs(normals[i * 3 + 1]);
const nz = Math.abs(normals[i * 3 + 2]);
let u = 0;
let v = 0;
if (nx >= ny && nx >= nz) {
u = normalizeCoordinate(z, bbox.min[2], bbox.size[2]);
v = normalizeCoordinate(y, bbox.min[1], bbox.size[1]);
if (normals[i * 3] < 0) u = 1 - u; // Flip for negative X
} else if (ny >= nx && ny >= nz) {
u = normalizeCoordinate(x, bbox.min[0], bbox.size[0]);
v = normalizeCoordinate(z, bbox.min[2], bbox.size[2]);
if (normals[i * 3 + 1] < 0) u = 1 - u; // Flip for negative Y
} else {
u = normalizeCoordinate(x, bbox.min[0], bbox.size[0]);
v = normalizeCoordinate(y, bbox.min[1], bbox.size[1]);
if (normals[i * 3 + 2] < 0) u = 1 - u; // Flip for negative Z
}
uvs[i * 2] = u;
uvs[i * 2 + 1] = v;
}
return uvs;
}
+42
View File
@@ -0,0 +1,42 @@
import { UVGenerationContext, computeBoundingBox, normalizeCoordinate } from './uvUtils';
export function generateCylindricalUVs(context: UVGenerationContext, axis: 'x' | 'y' | 'z' = 'y'): Float32Array {
const { positions } = context;
const numVertices = positions.length / 3;
const uvs = new Float32Array(numVertices * 2);
const bbox = computeBoundingBox(positions);
for (let i = 0; i < numVertices; i++) {
const x = positions[i * 3];
const y = positions[i * 3 + 1];
const z = positions[i * 3 + 2];
const cx = x - bbox.center[0];
const cy = y - bbox.center[1];
const cz = z - bbox.center[2];
let u = 0;
let v = 0;
switch (axis) {
case 'x':
u = (Math.atan2(cy, cz) + Math.PI) / (2 * Math.PI);
v = normalizeCoordinate(x, bbox.min[0], bbox.size[0]);
break;
case 'z':
u = (Math.atan2(cy, cx) + Math.PI) / (2 * Math.PI);
v = normalizeCoordinate(z, bbox.min[2], bbox.size[2]);
break;
case 'y':
default:
u = (Math.atan2(cz, cx) + Math.PI) / (2 * Math.PI);
v = normalizeCoordinate(y, bbox.min[1], bbox.size[1]);
break;
}
uvs[i * 2] = u;
uvs[i * 2 + 1] = v;
}
return uvs;
}
+38
View File
@@ -0,0 +1,38 @@
import { UVGenerationContext, computeBoundingBox, normalizeCoordinate } from './uvUtils';
export function generatePlanarUVs(context: UVGenerationContext, axis: 'x' | 'y' | 'z' = 'z'): Float32Array {
const { positions } = context;
const numVertices = positions.length / 3;
const uvs = new Float32Array(numVertices * 2);
const bbox = computeBoundingBox(positions);
for (let i = 0; i < numVertices; i++) {
const x = positions[i * 3];
const y = positions[i * 3 + 1];
const z = positions[i * 3 + 2];
let u = 0;
let v = 0;
switch (axis) {
case 'x':
u = normalizeCoordinate(z, bbox.min[2], bbox.size[2]);
v = normalizeCoordinate(y, bbox.min[1], bbox.size[1]);
break;
case 'y':
u = normalizeCoordinate(x, bbox.min[0], bbox.size[0]);
v = normalizeCoordinate(z, bbox.min[2], bbox.size[2]);
break;
case 'z':
default:
u = normalizeCoordinate(x, bbox.min[0], bbox.size[0]);
v = normalizeCoordinate(y, bbox.min[1], bbox.size[1]);
break;
}
uvs[i * 2] = u;
uvs[i * 2 + 1] = v;
}
return uvs;
}
+50
View File
@@ -0,0 +1,50 @@
export interface UVGenerationContext {
positions: Float32Array;
normals?: Float32Array;
indices?: Uint32Array | Uint16Array;
}
export interface BoundingBox {
min: [number, number, number];
max: [number, number, number];
size: [number, number, number];
center: [number, number, number];
}
export function computeBoundingBox(positions: Float32Array): BoundingBox {
let minX = Infinity, minY = Infinity, minZ = Infinity;
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
for (let i = 0; i < positions.length; i += 3) {
const x = positions[i];
const y = positions[i + 1];
const z = positions[i + 2];
if (x < minX) minX = x;
if (y < minY) minY = y;
if (z < minZ) minZ = z;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
if (z > maxZ) maxZ = z;
}
const size: [number, number, number] = [maxX - minX, maxY - minY, maxZ - minZ];
const center: [number, number, number] = [
minX + size[0] / 2,
minY + size[1] / 2,
minZ + size[2] / 2,
];
return {
min: [minX, minY, minZ],
max: [maxX, maxY, maxZ],
size,
center,
};
}
export function normalizeCoordinate(value: number, min: number, size: number): number {
if (size === 0) return 0;
return (value - min) / size;
}
+94
View File
@@ -0,0 +1,94 @@
import { useEffect, useRef, useState } from 'react';
import * as THREE from 'three';
import { ProjectionType, UVWorkerMessage, UVWorkerResponse } from '../workers/uvWorker';
export function useTexturePipeline(scene: THREE.Object3D | null) {
const [isProcessing, setIsProcessing] = useState(false);
const workerRef = useRef<Worker | null>(null);
useEffect(() => {
workerRef.current = new Worker(new URL('../workers/uvWorker.ts', import.meta.url), {
type: 'module',
});
return () => {
workerRef.current?.terminate();
};
}, []);
useEffect(() => {
if (!scene || !workerRef.current) return;
const processMesh = async (mesh: THREE.Mesh) => {
const geometry = mesh.geometry;
if (!geometry.attributes.position) return;
const positions = geometry.attributes.position.array as Float32Array;
const normals = geometry.attributes.normal?.array as Float32Array | undefined;
const generateUVs = (projection: ProjectionType, axis?: 'x'|'y'|'z'): Promise<Float32Array> => {
return new Promise((resolve, reject) => {
if (!workerRef.current) return reject('No worker');
const id = Math.random().toString(36).substring(7);
const handleMessage = (e: MessageEvent<UVWorkerResponse>) => {
if (e.data.id === id) {
workerRef.current?.removeEventListener('message', handleMessage);
if (e.data.error) reject(new Error(e.data.error));
else resolve(e.data.uvs);
}
};
workerRef.current.addEventListener('message', handleMessage);
workerRef.current.postMessage({
id,
positions,
normals,
projection,
axis
} as UVWorkerMessage);
});
};
try {
const numVertices = positions.length / 3;
// Add dummy attributes synchronously to prevent shader compile errors
if (!geometry.attributes.uvPlanar) geometry.setAttribute('uvPlanar', new THREE.BufferAttribute(new Float32Array(numVertices * 2), 2));
if (!geometry.attributes.uvBox) geometry.setAttribute('uvBox', new THREE.BufferAttribute(new Float32Array(numVertices * 2), 2));
if (!geometry.attributes.uvCylinder) geometry.setAttribute('uvCylinder', new THREE.BufferAttribute(new Float32Array(numVertices * 2), 2));
const uvPlanar = await generateUVs('planar', 'z');
(geometry.attributes.uvPlanar as THREE.BufferAttribute).array.set(uvPlanar);
geometry.attributes.uvPlanar.needsUpdate = true;
const uvBox = await generateUVs('box');
(geometry.attributes.uvBox as THREE.BufferAttribute).array.set(uvBox);
geometry.attributes.uvBox.needsUpdate = true;
const uvCylinder = await generateUVs('cylindrical', 'y');
(geometry.attributes.uvCylinder as THREE.BufferAttribute).array.set(uvCylinder);
geometry.attributes.uvCylinder.needsUpdate = true;
} catch (err) {
console.error("UV Generation error", err);
}
};
const processAllMeshes = async () => {
setIsProcessing(true);
const meshes: THREE.Mesh[] = [];
scene.traverse((child) => {
if ((child as THREE.Mesh).isMesh) {
meshes.push(child as THREE.Mesh);
}
});
// Process in parallel
await Promise.all(meshes.map(processMesh));
setIsProcessing(false);
};
processAllMeshes();
}, [scene]);
return { isProcessing };
}
+113
View File
@@ -0,0 +1,113 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&family=Outfit:wght@400;500;600;700&display=swap');
@import "tailwindcss";
@theme {
--font-sans: "Outfit", "Inter", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
/* Theme Colors */
--color-primary: #4DA3FF;
--color-accent: #FF2E88;
--color-glass-bg: rgba(246, 249, 255, 0.8);
--color-glass-card: rgba(255, 255, 255, 0.7);
/* Shadows */
--shadow-glass: 0 8px 32px 0 rgba(31, 38, 135, 0.15);
--shadow-emboss: inset 2px 2px 4px #dbe6ff, inset -2px -2px 4px #ffffff;
}
@layer base {
body {
@apply bg-[#F6F9FF] text-gray-900 antialiased overflow-hidden transition-colors duration-500;
font-feature-settings: "ss01", "ss02", "cv01", "cv11";
}
body.dark {
@apply bg-[#0a0a0a] text-gray-100;
}
}
@layer components {
.glass-panel {
@apply bg-white/70 backdrop-blur-xl border border-white/40 shadow-glass transition-all duration-300;
}
.dark .glass-panel {
@apply bg-white/5 border-white/10 shadow-2xl;
}
.glass-card {
@apply bg-white/50 backdrop-blur-md border border-white/40 hover:bg-white/80 transition-all duration-300 shadow-sm;
}
.dark .glass-card {
@apply bg-white/5 border-white/10 hover:bg-white/10;
}
.emboss-btn {
@apply shadow-glass border border-white/40 transition-all duration-200 active:shadow-emboss;
box-shadow: 4px 4px 8px rgba(0,0,0,0.05), -4px -4px 8px rgba(255,255,255,0.8);
}
.dark .emboss-btn {
@apply border-white/10 shadow-none active:bg-white/10;
box-shadow: none;
}
.sidebar-item {
@apply flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 cursor-pointer;
}
.sidebar-item-active {
@apply bg-primary/20 text-primary border border-primary/30;
}
.sidebar-item:hover:not(.sidebar-item-active) {
@apply bg-white/20;
}
.dark .sidebar-item:hover:not(.sidebar-item-active) {
@apply bg-white/5;
}
.neon-glow:hover {
box-shadow: 0 0 15px rgba(255, 46, 136, 0.4);
}
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
@apply bg-transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
@apply bg-black/10 rounded-full hover:bg-black/20;
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
@apply bg-white/10 hover:bg-white/20;
}
}
/* Animation for the background glow */
@keyframes float {
0% { transform: translate(0, 0) scale(1); }
33% { transform: translate(2%, 2%) scale(1.1); }
66% { transform: translate(-1%, 3%) scale(0.9); }
100% { transform: translate(0, 0) scale(1); }
}
.animate-float {
animation: float 20s ease-in-out infinite;
}
.animate-float-delayed {
animation: float 25s ease-in-out infinite reverse;
}
/* Hide TransformControls UI when rendering */
.rendering-hide {
display: none !important;
}
+130
View File
@@ -0,0 +1,130 @@
const API_URL = import.meta.env.VITE_API_URL || (import.meta.env.PROD ? '/api' : 'http://localhost:3005/api');
const getHeaders = () => {
const token = localStorage.getItem('auth_token');
return {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
};
};
export const api = {
// Auth
register: async (email, password) => {
const res = await fetch(`${API_URL}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
return data; // returns { status: 'pending' | 'active', token, profile }
},
login: async (email, password) => {
const res = await fetch(`${API_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
return data;
},
getMe: async () => {
const res = await fetch(`${API_URL}/auth/me`, { headers: getHeaders() });
const data = await res.json();
if (!res.ok) throw new Error(data.error);
return data;
},
// Projects
getProjects: async () => {
const res = await fetch(`${API_URL}/projects`, { headers: getHeaders() });
const data = await res.json();
if (!res.ok) throw new Error(data.error);
return data;
},
getProject: async (id) => {
const res = await fetch(`${API_URL}/projects/${id}`, { headers: getHeaders() });
const data = await res.json();
if (!res.ok) throw new Error(data.error);
return data;
},
saveProject: async (projectData) => {
const res = await fetch(`${API_URL}/projects`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(projectData),
});
const text = await res.text();
let data;
try {
data = JSON.parse(text);
} catch(e) {
throw new Error(`Server returned non-JSON (${res.status}): ${text.substring(0, 100)}`);
}
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
return data;
},
deleteProject: async (id) => {
const res = await fetch(`${API_URL}/projects/${id}`, {
method: 'DELETE',
headers: getHeaders(),
});
if (!res.ok) throw new Error('Failed to delete project');
return res.json();
},
// Admin
getUsers: async () => {
const res = await fetch(`${API_URL}/users`, { headers: getHeaders() });
const data = await res.json();
if (!res.ok) throw new Error(data.error);
return data;
},
updateUser: async (id, updateData) => {
const res = await fetch(`${API_URL}/users/${id}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(updateData)
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
return data;
},
// Credits
deductCredit: async () => {
const res = await fetch(`${API_URL}/users/deduct-credit`, {
method: 'POST',
headers: getHeaders()
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
return data;
},
// Storage
uploadAsset: async (fileBlob) => {
const formData = new FormData();
formData.append('file', fileBlob);
const token = localStorage.getItem('auth_token');
const headers = token ? { Authorization: `Bearer ${token}` } : {};
const res = await fetch(`${API_URL}/upload`, {
method: 'POST',
headers,
body: formData
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
return data.url;
}
};
+81
View File
@@ -0,0 +1,81 @@
/**
* Utility functions for API requests with exponential backoff and polling.
*/
export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
export interface FetchOptions extends RequestInit {
maxRetries?: number;
baseDelayMs?: number;
}
export async function fetchWithRetry(url: string, options: FetchOptions = {}): Promise<Response> {
const { maxRetries = 3, baseDelayMs = 1000, ...fetchOptions } = options;
let retries = 0;
while (true) {
try {
const response = await fetch(url, fetchOptions);
if (response.ok) {
return response;
}
// Don't retry on 4xx errors (client errors)
if (response.status >= 400 && response.status < 500 && response.status !== 429) {
throw new Error(`HTTP Error: ${response.status} ${response.statusText}`);
}
// If it's a 429 (Rate Limit) or 5xx, we throw to trigger a retry
throw new Error(`HTTP Error: ${response.status} ${response.statusText}`);
} catch (error) {
if (retries >= maxRetries) {
throw error;
}
retries++;
// Exponential backoff with some jitter
const waitTime = (baseDelayMs * Math.pow(2, retries - 1)) + (Math.random() * 500);
console.warn(`Request failed, retrying in ${Math.round(waitTime)}ms... (Attempt ${retries}/${maxRetries})`);
await delay(waitTime);
}
}
}
export interface PollOptions {
intervalMs?: number;
maxAttempts?: number;
onProgress?: (progress: number) => void;
}
export async function pollStatus<T>(
pollFn: () => Promise<{ status: 'processing' | 'success' | 'failed', data?: T, progress?: number }>,
options: PollOptions = {}
): Promise<T> {
const { intervalMs = 2000, maxAttempts = 150, onProgress } = options; // Default to 5 minutes total polling
let attempts = 0;
while (attempts < maxAttempts) {
try {
const result = await pollFn();
if (result.status === 'success' && result.data) {
return result.data;
}
if (result.status === 'failed') {
throw new Error("Task failed during processing on the remote server.");
}
if (result.progress !== undefined && onProgress) {
onProgress(result.progress);
}
} catch (err) {
// If the poll request itself fails, we log it but keep trying until maxAttempts
console.warn("Polling request failed, will retry next interval:", err);
}
attempts++;
await delay(intervalMs);
}
throw new Error("Polling timeout exceeded.");
}
+56
View File
@@ -0,0 +1,56 @@
import { fetchWithRetry, pollStatus } from './apiUtils';
const MESHY_BASE_URL = 'https://api.meshy.ai/openapi/v1';
export interface MeshyTaskResponse {
result: string;
progress: number;
status: 'PENDING' | 'IN_PROGRESS' | 'SUCCEEDED' | 'FAILED' | 'EXPIRED';
task_error?: { message: string };
model_urls?: { glb: string; usdz?: string };
}
export async function createMeshyTask(apiKey: string, imageBase64DataUrl: string): Promise<string> {
const response = await fetchWithRetry(`${MESHY_BASE_URL}/image-to-3d`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
image_url: imageBase64DataUrl,
enable_pbr: true,
should_remesh: true
})
});
const data = await response.json();
return data.result; // Task ID
}
export async function waitForMeshyTask(apiKey: string, taskId: string, onProgress?: (p: number) => void): Promise<string> {
return pollStatus(async () => {
const response = await fetchWithRetry(`${MESHY_BASE_URL}/image-to-3d/${taskId}`, {
headers: { 'Authorization': `Bearer ${apiKey}` },
maxRetries: 2,
baseDelayMs: 500
});
const data: MeshyTaskResponse = await response.json();
if (data.status === 'SUCCEEDED' && data.model_urls?.glb) {
return { status: 'success', data: data.model_urls.glb };
}
if (data.status === 'FAILED' || data.status === 'EXPIRED') {
console.error("Meshy task failed:", data.task_error);
return { status: 'failed' };
}
return { status: 'processing', progress: data.progress || 0 };
}, {
intervalMs: 3000,
maxAttempts: 100, // 5 minutes max wait
onProgress
});
}
+83
View File
@@ -0,0 +1,83 @@
import { fetchWithRetry, pollStatus } from './apiUtils';
const TRIPO_BASE_URL = 'https://api.tripo3d.ai/v2/openapi';
export interface TripoTaskResponse {
code: number;
data: {
task_id: string;
status: 'queued' | 'running' | 'success' | 'failed' | 'cancelled';
progress: number;
output?: {
model?: string; // URL to the model
base_model?: string;
};
};
}
// 1. Upload Image
export async function uploadImageToTripo(apiKey: string, imageFile: File): Promise<string> {
const formData = new FormData();
formData.append('file', imageFile);
const response = await fetchWithRetry(`${TRIPO_BASE_URL}/upload`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${apiKey}` },
body: formData,
});
const data = await response.json();
if (data.code !== 0 || !data.data.image_token) {
throw new Error("Failed to upload image to Tripo");
}
return data.data.image_token;
}
// 2. Create Task
export async function createTripoTask(apiKey: string, imageToken: string): Promise<string> {
const response = await fetchWithRetry(`${TRIPO_BASE_URL}/task`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
type: 'image_to_model',
file: { type: 'jpg', file_token: imageToken }
})
});
const data = await response.json();
if (data.code !== 0 || !data.data.task_id) {
throw new Error("Failed to create Tripo task");
}
return data.data.task_id;
}
// 3. Poll Task
export async function waitForTripoTask(apiKey: string, taskId: string, onProgress?: (p: number) => void): Promise<string> {
return pollStatus(async () => {
const response = await fetchWithRetry(`${TRIPO_BASE_URL}/task/${taskId}`, {
headers: { 'Authorization': `Bearer ${apiKey}` },
maxRetries: 2,
baseDelayMs: 500
});
const json: TripoTaskResponse = await response.json();
const data = json.data;
if (data.status === 'success' && data.output?.model) {
return { status: 'success', data: data.output.model };
}
if (data.status === 'failed' || data.status === 'cancelled') {
return { status: 'failed' };
}
return { status: 'processing', progress: data.progress || 0 };
}, {
intervalMs: 3000,
maxAttempts: 100,
onProgress
});
}
+19
View File
@@ -0,0 +1,19 @@
import { api } from './api';
export const uploadAssetToStorage = async (file: Blob | File, bucket: string, path: string): Promise<string> => {
try {
const url = await api.uploadAsset(file);
return url;
} catch (err) {
console.warn("Storage upload exception, falling back to data URL.", err);
return await fileToDataUrl(file);
}
};
export const fileToDataUrl = (file: Blob | File): Promise<string> => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.readAsDataURL(file);
});
};
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+10
View File
@@ -0,0 +1,10 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
+52
View File
@@ -0,0 +1,52 @@
import * as THREE from 'three';
import { StandardUVMaterial } from './StandardUVMaterial';
import { TriplanarMaterial } from './TriplanarMaterial';
type MaterialCacheKey = string;
export class MaterialFactory {
private static instance: MaterialFactory;
private cache: Map<MaterialCacheKey, THREE.Material> = new Map();
private constructor() {}
public static getInstance(): MaterialFactory {
if (!MaterialFactory.instance) {
MaterialFactory.instance = new MaterialFactory();
}
return MaterialFactory.instance;
}
public getStandardUVMaterial(
parameters: THREE.MeshPhysicalMaterialParameters,
key: string
): StandardUVMaterial {
const cacheKey = `standard_uv_${key}`;
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey) as StandardUVMaterial;
}
const material = new StandardUVMaterial(parameters);
this.cache.set(cacheKey, material);
return material;
}
public getTriplanarMaterial(
parameters: THREE.MeshPhysicalMaterialParameters,
key: string
): TriplanarMaterial {
const cacheKey = `triplanar_${key}`;
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey) as TriplanarMaterial;
}
const material = new TriplanarMaterial(parameters);
this.cache.set(cacheKey, material);
return material;
}
public clearCache() {
this.cache.forEach((material) => material.dispose());
this.cache.clear();
}
}
+95
View File
@@ -0,0 +1,95 @@
import * as THREE from 'three';
export class StandardUVMaterial extends THREE.MeshPhysicalMaterial {
public uvUniforms: {
uMappingMode: { value: number }; // 0: imported, 1: planar, 2: box, 3: cylindrical
uUVTransform: { value: THREE.Matrix3 };
};
constructor(parameters?: THREE.MeshPhysicalMaterialParameters) {
super(parameters);
this.uvUniforms = {
uMappingMode: { value: 0 },
uUVTransform: { value: new THREE.Matrix3() },
};
this.customProgramCacheKey = () => `standard_uv_v2`;
this.onBeforeCompile = (shader) => {
shader.uniforms.uMappingMode = this.uvUniforms.uMappingMode;
shader.uniforms.uUVTransform = this.uvUniforms.uUVTransform;
shader.vertexShader = `
attribute vec2 uvPlanar;
attribute vec2 uvBox;
attribute vec2 uvCylinder;
uniform int uMappingMode;
uniform mat3 uUVTransform;
varying vec2 vCustomUV;
` + shader.vertexShader;
shader.vertexShader = shader.vertexShader.replace(
`#include <uv_vertex>`,
`
#include <uv_vertex>
vec2 selectedUV = vec2(0.0);
#ifdef USE_UV
selectedUV = uv;
#endif
if (uMappingMode == 1) {
selectedUV = uvPlanar;
} else if (uMappingMode == 2) {
selectedUV = uvBox;
} else if (uMappingMode == 3) {
selectedUV = uvCylinder;
}
vCustomUV = (uUVTransform * vec3(selectedUV, 1.0)).xy;
`
);
shader.fragmentShader = `
varying vec2 vCustomUV;
` + shader.fragmentShader;
const mainStartIndex = shader.fragmentShader.indexOf('void main() {');
if (mainStartIndex !== -1) {
const prefix = shader.fragmentShader.substring(0, mainStartIndex);
let mainBody = shader.fragmentShader.substring(mainStartIndex);
mainBody = mainBody.replace(/\bvUv\b/g, 'vCustomUV');
mainBody = mainBody.replace(/\bvMapUv\b/g, 'vCustomUV');
mainBody = mainBody.replace(/\bvNormalMapUv\b/g, 'vCustomUV');
mainBody = mainBody.replace(/\bvRoughnessMapUv\b/g, 'vCustomUV');
mainBody = mainBody.replace(/\bvMetalnessMapUv\b/g, 'vCustomUV');
mainBody = mainBody.replace(/\bvAlphaMapUv\b/g, 'vCustomUV');
mainBody = mainBody.replace(/\bvEmissiveMapUv\b/g, 'vCustomUV');
mainBody = mainBody.replace(/\bvAoMapUv\b/g, 'vCustomUV');
mainBody = mainBody.replace(/\bvClearcoatMapUv\b/g, 'vCustomUV');
mainBody = mainBody.replace(/\bvClearcoatNormalMapUv\b/g, 'vCustomUV');
mainBody = mainBody.replace(/\bvClearcoatRoughnessMapUv\b/g, 'vCustomUV');
mainBody = mainBody.replace(/\bvTransmissionMapUv\b/g, 'vCustomUV');
mainBody = mainBody.replace(/\bvThicknessMapUv\b/g, 'vCustomUV');
mainBody = mainBody.replace(/\bvSpecularMapUv\b/g, 'vCustomUV');
mainBody = mainBody.replace(/\bvSpecularIntensityMapUv\b/g, 'vCustomUV');
mainBody = mainBody.replace(/\bvSpecularColorMapUv\b/g, 'vCustomUV');
shader.fragmentShader = prefix + mainBody;
}
};
}
public updateUVTransform(offset: [number, number], repeat: [number, number], rotation: number) {
this.uvUniforms.uUVTransform.value.setUvTransform(
offset[0], offset[1],
repeat[0], repeat[1],
rotation,
0.5, 0.5
);
}
}
+134
View File
@@ -0,0 +1,134 @@
import * as THREE from 'three';
export class TriplanarMaterial extends THREE.MeshPhysicalMaterial {
public triplanarUniforms: {
tAlbedo: { value: THREE.Texture | null };
tNormal: { value: THREE.Texture | null };
tRoughness: { value: THREE.Texture | null };
tAo: { value: THREE.Texture | null };
uTriplanarScale: { value: number };
uBlendSharpness: { value: number };
};
constructor(parameters?: THREE.MeshPhysicalMaterialParameters) {
super(parameters);
this.triplanarUniforms = {
tAlbedo: { value: null },
tNormal: { value: null },
tRoughness: { value: null },
tAo: { value: null },
uTriplanarScale: { value: 1.0 },
uBlendSharpness: { value: 5.0 },
};
this.customProgramCacheKey = () => `triplanar_mat_v1`;
this.onBeforeCompile = (shader) => {
// Inject Uniforms
shader.uniforms.tAlbedo = this.triplanarUniforms.tAlbedo;
shader.uniforms.tNormal = this.triplanarUniforms.tNormal;
shader.uniforms.tRoughness = this.triplanarUniforms.tRoughness;
shader.uniforms.tAo = this.triplanarUniforms.tAo;
shader.uniforms.uTriplanarScale = this.triplanarUniforms.uTriplanarScale;
shader.uniforms.uBlendSharpness = this.triplanarUniforms.uBlendSharpness;
// Vertex Shader Injections
shader.vertexShader = `
varying vec3 vTriplanarPosition;
varying vec3 vTriplanarNormal;
` + shader.vertexShader;
shader.vertexShader = shader.vertexShader.replace(
`#include <worldpos_vertex>`,
`
#include <worldpos_vertex>
vTriplanarPosition = position; // Object space
vTriplanarNormal = normal; // Object space normal
`
);
// Fragment Shader Injections
shader.fragmentShader = `
uniform sampler2D tAlbedo;
uniform sampler2D tNormal;
uniform sampler2D tRoughness;
uniform sampler2D tAo;
uniform float uTriplanarScale;
uniform float uBlendSharpness;
varying vec3 vTriplanarPosition;
varying vec3 vTriplanarNormal;
// Triplanar blending logic
vec4 getTriplanarBlend(sampler2D tex, vec3 pos, vec3 norm) {
vec3 blendWeights = abs(norm);
blendWeights = pow(blendWeights, vec3(uBlendSharpness));
blendWeights /= dot(blendWeights, vec3(1.0)); // Normalize
vec2 uvX = pos.yz * uTriplanarScale;
vec2 uvY = pos.zx * uTriplanarScale;
vec2 uvZ = pos.xy * uTriplanarScale;
vec4 colX = texture2D(tex, uvX);
vec4 colY = texture2D(tex, uvY);
vec4 colZ = texture2D(tex, uvZ);
return colX * blendWeights.x + colY * blendWeights.y + colZ * blendWeights.z;
}
` + shader.fragmentShader;
// Replace map chunks
shader.fragmentShader = shader.fragmentShader.replace(
`#include <map_fragment>`,
`
#ifdef USE_MAP
vec4 texelColor = getTriplanarBlend(tAlbedo, vTriplanarPosition, vTriplanarNormal);
texelColor = mapTexelToLinear(texelColor);
diffuseColor *= texelColor;
#endif
`
);
shader.fragmentShader = shader.fragmentShader.replace(
`#include <roughnessmap_fragment>`,
`
float roughnessFactor = roughness;
#ifdef USE_ROUGHNESSMAP
vec4 texelRoughness = getTriplanarBlend(tRoughness, vTriplanarPosition, vTriplanarNormal);
roughnessFactor *= texelRoughness.g;
#endif
`
);
shader.fragmentShader = shader.fragmentShader.replace(
`#include <normal_fragment_maps>`,
`
#ifdef USE_NORMALMAP
vec4 texelNormal = getTriplanarBlend(tNormal, vTriplanarPosition, vTriplanarNormal);
// Simplified triplanar normal calculation
vec3 n = texelNormal.rgb * 2.0 - 1.0;
normal = normalize(normalMatrix * n);
#elif defined( USE_BUMPMAP )
// Bump map omitted for brevity
#endif
`
);
shader.fragmentShader = shader.fragmentShader.replace(
`#include <aomap_fragment>`,
`
#ifdef USE_AOMAP
vec4 texelAo = getTriplanarBlend(tAo, vTriplanarPosition, vTriplanarNormal);
float ambientOcclusion = ( texelAo.r - 1.0 ) * aoMapIntensity + 1.0;
reflectedLight.indirectDiffuse *= ambientOcclusion;
#if defined( USE_ENVMAP ) && defined( STANDARD )
float dotNV = saturate( dot( geometry.normal, geometry.viewDir ) );
reflectedLight.indirectSpecular *= computeSpecularOcclusion( dotNV, ambientOcclusion, material.roughness );
#endif
#endif
`
);
};
}
}
+177
View File
@@ -0,0 +1,177 @@
import { useEffect, useState } from 'react';
import { useAuthStore } from '../store/authStore';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft, ShieldAlert, Loader2, Save } from 'lucide-react';
import { api } from '../lib/api';
interface UserProfile {
id: string;
email: string;
role: string;
api_credits: number;
storage_limit_mb: number;
is_active: number;
}
export default function Admin() {
const navigate = useNavigate();
const { profile } = useAuthStore();
const [users, setUsers] = useState<UserProfile[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchUsers = async () => {
if (profile?.role !== 'admin') return;
try {
const data = await api.getUsers();
setUsers(data.users || []);
} catch (error) {
console.error(error);
}
setLoading(false);
};
fetchUsers();
}, [profile]);
const updateLimits = async (userObj: UserProfile) => {
try {
await api.updateUser(userObj.id, {
role: userObj.role,
api_credits: userObj.api_credits,
storage_limit_mb: userObj.storage_limit_mb,
is_active: userObj.is_active
});
alert("Updated successfully!");
} catch (error: any) {
alert("Failed to update user: " + error.message);
}
};
if (profile?.role !== 'admin') {
return (
<div className="min-h-screen bg-zinc-950 flex flex-col items-center justify-center text-white">
<ShieldAlert size={48} className="text-red-500 mb-4" />
<h1 className="text-2xl font-bold mb-2">Access Denied</h1>
<p className="text-zinc-400 mb-6">You do not have administrative privileges.</p>
<button onClick={() => navigate('/dashboard')} className="px-6 py-2 bg-zinc-800 rounded-lg hover:bg-zinc-700">
Return to Dashboard
</button>
</div>
);
}
return (
<div className="min-h-screen bg-zinc-950 text-white p-8">
<div className="max-w-6xl mx-auto">
<button
onClick={() => navigate('/dashboard')}
className="flex items-center gap-2 text-zinc-400 hover:text-white mb-8 transition-colors"
>
<ArrowLeft size={16} /> Back to Dashboard
</button>
<h1 className="text-3xl font-bold mb-8">Admin Control Panel</h1>
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl overflow-hidden">
<div className="p-6 border-b border-zinc-800 flex justify-between items-center">
<h2 className="text-xl font-semibold">User Limit Management</h2>
</div>
{loading ? (
<div className="p-12 flex justify-center"><Loader2 className="animate-spin" /></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="bg-zinc-950 text-zinc-400">
<tr>
<th className="p-4 font-medium">User ID</th>
<th className="p-4 font-medium">Email</th>
<th className="p-4 font-medium">Role</th>
<th className="p-4 font-medium">Status</th>
<th className="p-4 font-medium">API Credits</th>
<th className="p-4 font-medium">Storage Limit (MB)</th>
<th className="p-4 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{users.map(u => (
<UserRow key={u.id} user={u} onUpdate={updateLimits} />
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
);
}
const UserRow = ({ user, onUpdate }: { user: UserProfile, onUpdate: (u: UserProfile) => void }) => {
const [role, setRole] = useState(user.role);
const [isActive, setIsActive] = useState(user.is_active);
const [credits, setCredits] = useState(user.api_credits);
const [storage, setStorage] = useState(user.storage_limit_mb);
const handleUpdate = () => {
onUpdate({
...user,
role,
is_active: isActive,
api_credits: credits,
storage_limit_mb: storage
});
};
return (
<tr className="hover:bg-zinc-800/50">
<td className="p-4 font-mono text-xs opacity-50" title={user.id}>{user.id.substring(0, 8)}...</td>
<td className="p-4 font-mono text-xs">{user.email}</td>
<td className="p-4">
<select
value={role}
onChange={(e) => setRole(e.target.value)}
className={`px-2 py-1 rounded text-xs focus:outline-none focus:border-primary border border-transparent ${role === 'admin' ? 'bg-red-500/20 text-red-400' : 'bg-blue-500/20 text-blue-400'}`}
>
<option value="user" className="bg-zinc-900 text-white">User</option>
<option value="admin" className="bg-zinc-900 text-white">Admin</option>
</select>
</td>
<td className="p-4">
<select
value={isActive}
onChange={(e) => setIsActive(Number(e.target.value))}
className={`px-2 py-1 rounded text-xs focus:outline-none focus:border-primary border border-transparent ${isActive === 1 ? 'bg-green-500/20 text-green-400' : 'bg-yellow-500/20 text-yellow-400'}`}
>
<option value={1} className="bg-zinc-900 text-white">Active</option>
<option value={0} className="bg-zinc-900 text-white">Pending</option>
</select>
</td>
<td className="p-4">
<input
type="number"
value={credits}
onChange={e => setCredits(Number(e.target.value))}
className="w-24 bg-zinc-950 border border-zinc-700 rounded px-2 py-1 text-white focus:outline-none focus:border-primary"
/>
</td>
<td className="p-4">
<input
type="number"
value={storage}
onChange={e => setStorage(Number(e.target.value))}
className="w-24 bg-zinc-950 border border-zinc-700 rounded px-2 py-1 text-white focus:outline-none focus:border-primary"
/>
</td>
<td className="p-4 text-right">
<button
onClick={handleUpdate}
className="bg-zinc-800 hover:bg-zinc-700 text-white px-3 py-1.5 rounded flex items-center justify-center gap-2 ml-auto transition-colors"
>
<Save size={14} /> Save
</button>
</td>
</tr>
);
}
+155
View File
@@ -0,0 +1,155 @@
import { useEffect, useState } from 'react';
import { useAuthStore } from '../store/authStore';
import { useNavigate, Link } from 'react-router-dom';
import { LogOut, Plus, Folder, Loader2, Trash2 } from 'lucide-react';
import { api } from '../lib/api';
interface Project {
id: string;
name: string;
created_at: string;
}
export default function Dashboard() {
const { user, profile, signOut } = useAuthStore();
const navigate = useNavigate();
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchProjects = async () => {
if (!user) return;
try {
const data = await api.getProjects();
setProjects(data.projects || []);
} catch (error) {
console.error(error);
}
setLoading(false);
};
fetchProjects();
}, [user]);
const handleDeleteProject = async (e: React.MouseEvent, id: string) => {
e.stopPropagation(); // prevent navigating to editor
if (!confirm('Are you sure you want to delete this project?')) return;
try {
await api.deleteProject(id);
setProjects(projects.filter(p => p.id !== id));
} catch (err) {
console.error('Error deleting project:', err);
alert('Failed to delete project');
}
};
const handleNewProject = async () => {
const name = prompt('Enter a name for the new project:');
if (!name || name.trim() === '') return;
try {
const res = await api.saveProject({
name: name.trim(),
scene_data: {}
});
if (res.id) {
navigate(`/editor/${res.id}`);
}
} catch (err) {
console.error('Failed to create project', err);
alert('Failed to create project');
}
};
const handleSignOut = async () => {
await signOut();
navigate('/');
};
const createNewProject = () => {
navigate('/editor/new');
};
return (
<div className="min-h-screen bg-zinc-950 text-white flex flex-col">
<header className="h-16 border-b border-zinc-800 bg-zinc-900 flex items-center justify-between px-8">
<div className="font-bold text-lg tracking-tight">Titan<span className="text-primary">3d</span> / Dashboard</div>
<div className="flex items-center gap-4">
{profile?.role === 'admin' && (
<Link to="/admin" className="text-sm text-primary hover:underline font-medium">Admin Panel</Link>
)}
<div className="text-sm text-zinc-400">{user?.email}</div>
<button
onClick={handleSignOut}
className="p-2 text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors"
title="Sign Out"
>
<LogOut size={18} />
</button>
</div>
</header>
<main className="flex-1 p-8 max-w-6xl mx-auto w-full">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6">
<h3 className="text-zinc-400 text-sm font-medium mb-2">AI Credits</h3>
<div className="text-3xl font-bold text-white">{profile?.api_credits ?? 0}</div>
</div>
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6">
<h3 className="text-zinc-400 text-sm font-medium mb-2">Storage Usage</h3>
<div className="text-3xl font-bold text-white">0 / {profile?.storage_limit_mb ?? 100} MB</div>
</div>
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6">
<h3 className="text-zinc-400 text-sm font-medium mb-2">Total Projects</h3>
<div className="text-3xl font-bold text-white">{projects.length}</div>
</div>
</div>
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold">Your Projects</h2>
<button
onClick={handleNewProject}
className="flex items-center gap-2 bg-primary hover:bg-primary/90 text-white px-4 py-2 rounded-xl transition-colors font-medium text-sm"
>
<Plus size={16} /> New Project
</button>
</div>
{loading ? (
<div className="flex justify-center p-12">
<Loader2 size={32} className="animate-spin text-zinc-500" />
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
<div className="aspect-video bg-zinc-900 border border-zinc-800 border-dashed rounded-2xl flex flex-col items-center justify-center text-zinc-500 hover:text-white hover:border-zinc-700 transition-colors cursor-pointer group" onClick={createNewProject}>
<div className="w-10 h-10 rounded-full bg-zinc-800 group-hover:bg-zinc-700 flex items-center justify-center mb-2 transition-colors">
<Plus size={20} />
</div>
<span className="text-sm font-medium">Create Blank Scene</span>
</div>
{projects.map(project => (
<div key={project.id} onClick={() => navigate(`/editor/${project.id}`)} className="aspect-video bg-zinc-900 border border-zinc-800 rounded-2xl p-4 flex flex-col cursor-pointer hover:border-zinc-700 transition-colors group relative overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-primary/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex-1 flex items-center justify-center">
<Folder size={48} className="text-zinc-800 group-hover:text-zinc-700 transition-colors" />
</div>
<div>
<h3 className="font-semibold text-white truncate">{project.name}</h3>
<p className="text-xs text-zinc-500 mt-1">{new Date(project.created_at).toLocaleDateString()}</p>
</div>
<button
onClick={(e) => handleDeleteProject(e, project.id)}
className="absolute top-4 right-4 p-2 text-zinc-500 hover:text-red-500 bg-zinc-900/80 rounded-lg opacity-0 group-hover:opacity-100 transition-all hover:bg-zinc-800"
title="Delete Project"
>
<Trash2 size={16} />
</button>
</div>
))}
</div>
)}
</main>
</div>
);
}
+5229
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
import { Canvas } from '@react-three/fiber';
import { ScrollControls } from '@react-three/drei';
import { Scene } from '../components/home/Scene';
import { Overlay } from '../components/home/Overlay';
import { Suspense } from 'react';
export default function Home() {
return (
<div className="w-screen h-screen bg-zinc-950 overflow-hidden">
<Canvas shadows camera={{ position: [0, 0, 10], fov: 45 }}>
<Suspense fallback={null}>
<ScrollControls pages={3} damping={0.2}>
<Scene />
<Overlay />
</ScrollControls>
</Suspense>
</Canvas>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { create } from 'zustand';
import { api } from '../lib/api';
interface UserProfile {
id: string;
email: string;
role: string;
api_credits: number;
storage_limit_mb: number;
}
interface AuthState {
user: { id: string, email: string } | null;
profile: UserProfile | null;
isLoading: boolean;
initialize: () => Promise<void>;
setSession: (token: string, profile: UserProfile) => void;
signOut: () => void;
setProfile: (profile: UserProfile) => void;
isAuthModalOpen: boolean;
openAuthModal: (mode?: 'login' | 'register') => void;
closeAuthModal: () => void;
authMode: 'login' | 'register';
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
profile: null,
isLoading: true,
isAuthModalOpen: false,
authMode: 'login',
openAuthModal: (mode = 'login') => set({ isAuthModalOpen: true, authMode: mode }),
closeAuthModal: () => set({ isAuthModalOpen: false }),
initialize: async () => {
try {
const token = localStorage.getItem('auth_token');
if (!token) {
set({ user: null, profile: null, isLoading: false });
return;
}
const { profile } = await api.getMe();
if (profile) {
set({
user: { id: profile.id, email: profile.email },
profile,
isLoading: false
});
} else {
localStorage.removeItem('auth_token');
set({ user: null, profile: null, isLoading: false });
}
} catch (error) {
console.error('Failed to initialize session:', error);
localStorage.removeItem('auth_token');
set({ user: null, profile: null, isLoading: false });
}
},
setSession: (token, profile) => {
localStorage.setItem('auth_token', token);
set({ user: { id: profile.id, email: profile.email }, profile });
},
signOut: () => {
localStorage.removeItem('auth_token');
set({ user: null, profile: null });
},
setProfile: (profile) => set({ profile }),
}));
+54
View File
@@ -0,0 +1,54 @@
import { create } from 'zustand';
export type MappingMode = 'imported' | 'planar' | 'box' | 'cylindrical' | 'triplanar';
export interface UVTransforms {
scale: [number, number];
repeat: [number, number];
offset: [number, number];
rotation: number;
}
export interface TriplanarTransforms {
scale: number;
blendSharpness: number;
rotation: [number, number, number];
worldSpace: boolean;
}
interface ConfiguratorState {
mappingMode: MappingMode;
uvTransforms: UVTransforms;
triplanarTransforms: TriplanarTransforms;
projectionAxis: 'x' | 'y' | 'z';
// Actions
setMappingMode: (mode: MappingMode) => void;
setUVTransforms: (transforms: Partial<UVTransforms>) => void;
setTriplanarTransforms: (transforms: Partial<TriplanarTransforms>) => void;
setProjectionAxis: (axis: 'x' | 'y' | 'z') => void;
}
export const useConfiguratorStore = create<ConfiguratorState>((set) => ({
mappingMode: 'imported',
projectionAxis: 'z',
uvTransforms: {
scale: [1, 1],
repeat: [1, 1],
offset: [0, 0],
rotation: 0,
},
triplanarTransforms: {
scale: 1,
blendSharpness: 5.0,
rotation: [0, 0, 0],
worldSpace: false,
},
setMappingMode: (mode) => set({ mappingMode: mode }),
setUVTransforms: (transforms) =>
set((state) => ({ uvTransforms: { ...state.uvTransforms, ...transforms } })),
setTriplanarTransforms: (transforms) =>
set((state) => ({ triplanarTransforms: { ...state.triplanarTransforms, ...transforms } })),
setProjectionAxis: (axis) => set({ projectionAxis: axis }),
}));
+71
View File
@@ -0,0 +1,71 @@
export interface UVQualityReport {
missingUVs: boolean;
extremeStretching: boolean;
overlappingUVs: boolean;
highComplexity: boolean;
recommendTriplanar: boolean;
details: {
vertexCount: number;
uvRange: [number, number];
};
}
export function analyzeUVQuality(
positions: Float32Array,
uvs?: Float32Array,
indices?: Uint32Array | Uint16Array
): UVQualityReport {
const vertexCount = positions.length / 3;
const highComplexity = vertexCount > 50000; // Arbitrary threshold for complex geometry
if (!uvs || uvs.length === 0) {
return {
missingUVs: true,
extremeStretching: false,
overlappingUVs: false,
highComplexity,
recommendTriplanar: true,
details: {
vertexCount,
uvRange: [0, 0]
}
};
}
let minU = Infinity, maxU = -Infinity;
let minV = Infinity, maxV = -Infinity;
for (let i = 0; i < uvs.length; i += 2) {
const u = uvs[i];
const v = uvs[i + 1];
if (u < minU) minU = u;
if (u > maxU) maxU = u;
if (v < minV) minV = v;
if (v > maxV) maxV = v;
}
const uRange = maxU - minU;
const vRange = maxV - minV;
// Basic heuristic: if UVs are heavily clamped or barely cover any area
const extremeStretching = (uRange < 0.01 && uRange > 0) || (vRange < 0.01 && vRange > 0);
// Overlapping detection is computationally expensive, skipping deep check for now
// A robust check would require comparing triangle areas in UV space vs World space
const overlappingUVs = false;
const recommendTriplanar = extremeStretching || highComplexity;
return {
missingUVs: false,
extremeStretching,
overlappingUVs,
highComplexity,
recommendTriplanar,
details: {
vertexCount,
uvRange: [uRange, vRange]
}
};
}
+49
View File
@@ -0,0 +1,49 @@
import { generatePlanarUVs } from '../core/uv/planarProjection';
import { generateBoxUVs } from '../core/uv/boxProjection';
import { generateCylindricalUVs } from '../core/uv/cylindricalProjection';
export type ProjectionType = 'planar' | 'box' | 'cylindrical';
export interface UVWorkerMessage {
id: string;
positions: Float32Array;
normals?: Float32Array;
projection: ProjectionType;
axis?: 'x' | 'y' | 'z';
}
export interface UVWorkerResponse {
id: string;
uvs: Float32Array;
error?: string;
}
self.onmessage = (event: MessageEvent<UVWorkerMessage>) => {
const { id, positions, normals, projection, axis } = event.data;
try {
const context = { positions, normals };
let uvs: Float32Array;
switch (projection) {
case 'planar':
uvs = generatePlanarUVs(context, axis || 'z');
break;
case 'box':
uvs = generateBoxUVs(context);
break;
case 'cylindrical':
uvs = generateCylindricalUVs(context, axis || 'y');
break;
default:
throw new Error(`Unsupported projection: ${projection}`);
}
// Transfer the ArrayBuffer back to main thread for performance
self.postMessage({ id, uvs } as UVWorkerResponse, {
transfer: [uvs.buffer],
});
} catch (error) {
self.postMessage({ id, error: String(error) } as UVWorkerResponse);
}
};

Some files were not shown because too many files have changed in this diff Show More