chore: flatten repository to completely erase 255MB history
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# GEMINI_API_KEY: Required for Gemini AI API calls.
|
||||||
|
# AI Studio automatically injects this at runtime from user secrets.
|
||||||
|
# Users configure this via the Secrets panel in the AI Studio UI.
|
||||||
|
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
||||||
|
|
||||||
|
# APP_URL: The URL where this applet is hosted.
|
||||||
|
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
||||||
|
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
||||||
|
APP_URL="MY_APP_URL"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
backend/node_modules/
|
||||||
|
backend/data/
|
||||||
|
backend/uploads/
|
||||||
|
After Width: | Height: | Size: 1017 KiB |
|
After Width: | Height: | Size: 732 KiB |
|
After Width: | Height: | Size: 741 KiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 903 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 840 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 69 MiB |
|
After Width: | Height: | Size: 3.8 MiB |
|
After Width: | Height: | Size: 7.5 MiB |
|
After Width: | Height: | Size: 8.8 MiB |
|
After Width: | Height: | Size: 8.7 MiB |
|
After Width: | Height: | Size: 7.3 MiB |
|
After Width: | Height: | Size: 3.5 MiB |
|
After Width: | Height: | Size: 3.6 MiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 3.5 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 3.7 MiB |
|
After Width: | Height: | Size: 3.5 MiB |
@@ -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;"]
|
||||||
@@ -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`.
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
CMD ["npm", "start"]
|
||||||
@@ -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 };
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
});
|
||||||
@@ -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:
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "VIZ 3D",
|
||||||
|
"description": "A professional 3D product visualization studio.",
|
||||||
|
"requestFramePermissions": [],
|
||||||
|
"majorCapabilities": []
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 642 KiB |
|
After Width: | Height: | Size: 773 KiB |
|
After Width: | Height: | Size: 935 KiB |
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
public state: State = {
|
||||||
|
hasError: false,
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
|
||||||
|
public static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error('Uncaught error:', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return this.props.fallback || (
|
||||||
|
<div className="flex flex-col items-center justify-center p-8 bg-red-50 dark:bg-red-900/20 rounded-2xl border border-red-200 dark:border-red-800 text-center">
|
||||||
|
<h2 className="text-xl font-bold text-red-600 dark:text-red-400 mb-2">Something went wrong</h2>
|
||||||
|
<p className="text-sm text-red-500 dark:text-red-300 mb-4">{this.state.error?.message}</p>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||||
|
onClick={() => this.setState({ hasError: false, error: null })}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useRef, useMemo, forwardRef } from "react";
|
||||||
|
import { useFrame } from "@react-three/fiber";
|
||||||
|
import { RoundedBox, Text } from "@react-three/drei";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { Liquid } from "./LiquidShader";
|
||||||
|
|
||||||
|
export const JuiceBox = forwardRef<THREE.Group, any>(({
|
||||||
|
color = "#4ade80",
|
||||||
|
roughness = 0.1,
|
||||||
|
metalness = 0.05,
|
||||||
|
clearcoat = 1.0,
|
||||||
|
transmission = 0,
|
||||||
|
thickness = 0,
|
||||||
|
ior = 1.5,
|
||||||
|
sheen = 0,
|
||||||
|
map = undefined,
|
||||||
|
uvScale = 1,
|
||||||
|
showLiquid = true,
|
||||||
|
liquidColor = "#ff9900",
|
||||||
|
...props
|
||||||
|
}, ref) => {
|
||||||
|
const meshRef = useRef<THREE.Mesh>(null);
|
||||||
|
|
||||||
|
// Create a custom texture for the juice box
|
||||||
|
const customTexture = useMemo(() => {
|
||||||
|
if (map) return null;
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = 512;
|
||||||
|
canvas.height = 1024;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (ctx) {
|
||||||
|
// Background
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fillRect(0, 0, 512, 1024);
|
||||||
|
|
||||||
|
// Header
|
||||||
|
ctx.fillStyle = "#166534";
|
||||||
|
ctx.fillRect(0, 0, 512, 100);
|
||||||
|
|
||||||
|
// Text
|
||||||
|
ctx.fillStyle = "white";
|
||||||
|
ctx.font = "bold 80px sans-serif";
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.fillText("MAA", 256, 300);
|
||||||
|
|
||||||
|
ctx.font = "bold 60px sans-serif";
|
||||||
|
ctx.fillStyle = "#be123c";
|
||||||
|
ctx.fillText("GUAVA", 256, 400);
|
||||||
|
|
||||||
|
// Guava illustration (simple circles)
|
||||||
|
ctx.fillStyle = "#f472b6";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(256, 650, 150, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.strokeStyle = "#166534";
|
||||||
|
ctx.lineWidth = 10;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.fillStyle = "white";
|
||||||
|
ctx.font = "24px sans-serif";
|
||||||
|
ctx.fillText("REFRESHING FRUIT DRINK", 256, 150);
|
||||||
|
}
|
||||||
|
const tex = new THREE.CanvasTexture(canvas);
|
||||||
|
tex.needsUpdate = true;
|
||||||
|
return tex;
|
||||||
|
}, [color, map]);
|
||||||
|
|
||||||
|
const uploadedTexture = useMemo(() => {
|
||||||
|
if (!map) return null;
|
||||||
|
const loader = new THREE.TextureLoader();
|
||||||
|
const tex = loader.load(map);
|
||||||
|
tex.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
||||||
|
tex.repeat.set(uvScale, uvScale);
|
||||||
|
return tex;
|
||||||
|
}, [map, uvScale]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={ref} {...props}>
|
||||||
|
<RoundedBox
|
||||||
|
ref={meshRef}
|
||||||
|
args={[1, 2, 0.6]} // Width, Height, Depth
|
||||||
|
radius={0.05}
|
||||||
|
smoothness={4}
|
||||||
|
castShadow
|
||||||
|
receiveShadow
|
||||||
|
>
|
||||||
|
<meshPhysicalMaterial
|
||||||
|
map={uploadedTexture || customTexture}
|
||||||
|
roughness={roughness}
|
||||||
|
metalness={metalness}
|
||||||
|
clearcoat={clearcoat}
|
||||||
|
transmission={transmission}
|
||||||
|
thickness={thickness}
|
||||||
|
ior={ior}
|
||||||
|
sheen={sheen}
|
||||||
|
attenuationDistance={props.attenuationDistance ?? Infinity}
|
||||||
|
attenuationColor={props.attenuationColor ?? color}
|
||||||
|
transparent={transmission > 0}
|
||||||
|
envMapIntensity={1.5}
|
||||||
|
/>
|
||||||
|
</RoundedBox>
|
||||||
|
|
||||||
|
{showLiquid && (
|
||||||
|
<group position={[0, 0, 0]}>
|
||||||
|
<Liquid color={liquidColor} size={[0.9, 1.9, 0.5]} />
|
||||||
|
</group>
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { useRef, useMemo } from "react";
|
||||||
|
import { useFrame, useThree, extend, ThreeElement } from "@react-three/fiber";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { shaderMaterial } from "@react-three/drei";
|
||||||
|
|
||||||
|
// Type declaration for JSX
|
||||||
|
declare global {
|
||||||
|
namespace JSX {
|
||||||
|
interface IntrinsicElements {
|
||||||
|
liquidMaterialImpl: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const LiquidMaterialImpl = shaderMaterial(
|
||||||
|
{
|
||||||
|
uTime: 0,
|
||||||
|
uColor: new THREE.Color("#ff9900"),
|
||||||
|
uResolution: new THREE.Vector2(),
|
||||||
|
uFresnelBias: 0.1,
|
||||||
|
uFresnelScale: 1.0,
|
||||||
|
uFresnelPower: 2.0,
|
||||||
|
uRefractionRatio: 0.98,
|
||||||
|
uIor: 1.33,
|
||||||
|
},
|
||||||
|
// Vertex Shader
|
||||||
|
`
|
||||||
|
#include <clipping_planes_pars_vertex>
|
||||||
|
varying vec3 vNormal;
|
||||||
|
varying vec3 vViewPosition;
|
||||||
|
varying vec2 vUv;
|
||||||
|
varying vec3 vWorldPosition;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vUv = uv;
|
||||||
|
vNormal = normalize(normalMatrix * normal);
|
||||||
|
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
|
||||||
|
vWorldPosition = worldPosition.xyz;
|
||||||
|
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
|
||||||
|
vViewPosition = -mvPosition.xyz;
|
||||||
|
#include <clipping_planes_vertex>
|
||||||
|
gl_Position = projectionMatrix * mvPosition;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
// Fragment Shader
|
||||||
|
`
|
||||||
|
#include <clipping_planes_pars_fragment>
|
||||||
|
|
||||||
|
uniform float uTime;
|
||||||
|
uniform vec3 uColor;
|
||||||
|
uniform vec2 uResolution;
|
||||||
|
uniform float uFresnelBias;
|
||||||
|
uniform float uFresnelScale;
|
||||||
|
uniform float uFresnelPower;
|
||||||
|
uniform float uIor;
|
||||||
|
|
||||||
|
varying vec3 vNormal;
|
||||||
|
varying vec3 vViewPosition;
|
||||||
|
varying vec2 vUv;
|
||||||
|
varying vec3 vWorldPosition;
|
||||||
|
|
||||||
|
// Simple noise function for caustics and imperfections
|
||||||
|
float hash(vec2 p) {
|
||||||
|
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
|
||||||
|
}
|
||||||
|
|
||||||
|
float noise(vec2 p) {
|
||||||
|
vec2 i = floor(p);
|
||||||
|
vec2 f = fract(p);
|
||||||
|
f = f * f * (3.0 - 2.0 * f);
|
||||||
|
return mix(mix(hash(i + vec2(0.0, 0.0)), hash(i + vec2(1.0, 0.0)), f.x),
|
||||||
|
mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x), f.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
#include <clipping_planes_fragment>
|
||||||
|
vec3 normal = normalize(vNormal);
|
||||||
|
vec3 viewDir = normalize(vViewPosition);
|
||||||
|
|
||||||
|
// Fresnel effect
|
||||||
|
float fresnel = uFresnelBias + uFresnelScale * pow(1.0 + dot(viewDir, normal), uFresnelPower);
|
||||||
|
|
||||||
|
// Caustics simulation
|
||||||
|
vec2 causticUv = vWorldPosition.xz * 2.0 + vWorldPosition.y * 0.5;
|
||||||
|
float caustic = noise(causticUv * 5.0 + uTime * 0.5) * 0.5 + 0.5;
|
||||||
|
caustic *= noise(causticUv * 10.0 - uTime * 0.3) * 0.5 + 0.5;
|
||||||
|
caustic = pow(caustic, 3.0) * 1.5;
|
||||||
|
|
||||||
|
// Surface imperfections
|
||||||
|
float imperfection = noise(vUv * 20.0 + uTime * 0.1) * 0.05;
|
||||||
|
|
||||||
|
vec3 finalColor = uColor;
|
||||||
|
finalColor += caustic * 0.2 * uColor;
|
||||||
|
finalColor -= imperfection;
|
||||||
|
|
||||||
|
// Simple refraction-like look by blending with fresnel
|
||||||
|
vec3 skyColor = vec3(0.8, 0.9, 1.0);
|
||||||
|
finalColor = mix(finalColor, skyColor, fresnel * 0.3);
|
||||||
|
|
||||||
|
gl_FragColor = vec4(finalColor, 0.9);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
extend({ LiquidMaterialImpl });
|
||||||
|
|
||||||
|
export function Liquid({ color = "#ff9900", size = [0.9, 1.8, 0.5] }) {
|
||||||
|
const materialRef = useRef<any>(null);
|
||||||
|
const { size: winSize } = useThree();
|
||||||
|
|
||||||
|
useFrame((state) => {
|
||||||
|
if (materialRef.current) {
|
||||||
|
const mat = materialRef.current;
|
||||||
|
if (mat.uTime !== undefined) mat.uTime = state.clock.elapsedTime;
|
||||||
|
if (mat.uResolution && mat.uResolution.set) {
|
||||||
|
mat.uResolution.set(winSize.width || 800, winSize.height || 600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={size as any} />
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
<liquidMaterialImpl
|
||||||
|
ref={materialRef}
|
||||||
|
uColor={new THREE.Color(color)}
|
||||||
|
transparent
|
||||||
|
side={THREE.DoubleSide}
|
||||||
|
clipping={true}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import { useRef, useEffect, forwardRef, useMemo, useState } from "react";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { useFrame, useThree, createPortal } from "@react-three/fiber";
|
||||||
|
import { MaterialFactory } from "../materials/MaterialFactory";
|
||||||
|
import { useTexturePipeline } from "../hooks/useTexturePipeline";
|
||||||
|
|
||||||
|
interface ModelViewerProps {
|
||||||
|
object: THREE.Object3D | null;
|
||||||
|
sceneObjects: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MeshMaterialManagerProps {
|
||||||
|
mesh: THREE.Mesh;
|
||||||
|
sceneObj: any;
|
||||||
|
textureCache: React.MutableRefObject<Map<string, THREE.Texture>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MeshMaterialManager = ({ mesh, sceneObj, textureCache }: MeshMaterialManagerProps) => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mesh || !sceneObj.materialProps) return;
|
||||||
|
|
||||||
|
const props = sceneObj.materialProps;
|
||||||
|
const mappingType = props.mappingType || 'uv';
|
||||||
|
const uvScale = props.uvScale || 1;
|
||||||
|
const uvTiling = props.uvTiling || [1, 1];
|
||||||
|
const uvOffset = props.uvOffset || [0, 0];
|
||||||
|
const uvRotation = props.uvRotation || 0;
|
||||||
|
|
||||||
|
const origMat = (Array.isArray(mesh.material) ? mesh.material[0] : mesh.material) as THREE.MeshPhysicalMaterial;
|
||||||
|
|
||||||
|
const baseParams: any = {
|
||||||
|
color: origMat.color,
|
||||||
|
roughness: origMat.roughness !== undefined ? origMat.roughness : 0.5,
|
||||||
|
metalness: origMat.metalness !== undefined ? origMat.metalness : 0,
|
||||||
|
map: origMat.map,
|
||||||
|
normalMap: origMat.normalMap,
|
||||||
|
roughnessMap: origMat.roughnessMap,
|
||||||
|
metalnessMap: origMat.metalnessMap,
|
||||||
|
aoMap: origMat.aoMap,
|
||||||
|
emissiveMap: origMat.emissiveMap,
|
||||||
|
emissive: origMat.emissive,
|
||||||
|
emissiveIntensity: origMat.emissiveIntensity,
|
||||||
|
envMapIntensity: origMat.envMapIntensity,
|
||||||
|
alphaMap: origMat.alphaMap,
|
||||||
|
transparent: origMat.transparent,
|
||||||
|
opacity: origMat.opacity,
|
||||||
|
side: origMat.side,
|
||||||
|
alphaTest: origMat.alphaTest,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use MaterialFactory to get reusable materials
|
||||||
|
let activeMat: any;
|
||||||
|
if (mappingType === 'triplanar') {
|
||||||
|
activeMat = MaterialFactory.getInstance().getTriplanarMaterial(
|
||||||
|
baseParams,
|
||||||
|
mesh.uuid
|
||||||
|
);
|
||||||
|
|
||||||
|
activeMat.triplanarUniforms.uTriplanarScale.value = uvScale;
|
||||||
|
activeMat.triplanarUniforms.uBlendSharpness.value = 5.0; // Default or from props
|
||||||
|
} else {
|
||||||
|
activeMat = MaterialFactory.getInstance().getStandardUVMaterial(
|
||||||
|
baseParams,
|
||||||
|
mesh.uuid
|
||||||
|
);
|
||||||
|
|
||||||
|
let modeIndex = 0;
|
||||||
|
if (mappingType === 'planar') modeIndex = 1;
|
||||||
|
else if (mappingType === 'box') modeIndex = 2;
|
||||||
|
else if (mappingType === 'cylinder') modeIndex = 3;
|
||||||
|
|
||||||
|
activeMat.uvUniforms.uMappingMode.value = modeIndex;
|
||||||
|
activeMat.updateUVTransform(uvOffset, [uvTiling[0] * uvScale, uvTiling[1] * uvScale], uvRotation * (Math.PI / 180));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply basic props
|
||||||
|
if (props.color) activeMat.color.set(props.color);
|
||||||
|
if (props.roughness !== undefined) activeMat.roughness = props.roughness;
|
||||||
|
if (props.metalness !== undefined) activeMat.metalness = props.metalness;
|
||||||
|
if (props.opacity !== undefined) activeMat.opacity = props.opacity;
|
||||||
|
if (props.transmission !== undefined) activeMat.transmission = props.transmission;
|
||||||
|
activeMat.transparent = (props.transmission > 0) || (props.opacity < 1) || !!props.alphaMap;
|
||||||
|
|
||||||
|
if (Array.isArray(mesh.material)) {
|
||||||
|
mesh.material = mesh.material.map(() => activeMat);
|
||||||
|
} else {
|
||||||
|
mesh.material = activeMat;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Textures
|
||||||
|
const textureTypes: ('map' | 'normalMap' | 'specularMap' | 'alphaMap')[] = ['map', 'normalMap', 'specularMap', 'alphaMap'];
|
||||||
|
textureTypes.forEach(type => {
|
||||||
|
const url = props[type];
|
||||||
|
if (url) {
|
||||||
|
if (!activeMat[type] || activeMat[type]!.name !== url) {
|
||||||
|
if (textureCache.current.has(url)) {
|
||||||
|
activeMat[type] = textureCache.current.get(url)!;
|
||||||
|
if (mappingType === 'triplanar') activeMat.triplanarUniforms['t' + type.charAt(0).toUpperCase() + type.slice(1).replace('Map', '')].value = activeMat[type];
|
||||||
|
activeMat.needsUpdate = true;
|
||||||
|
} else {
|
||||||
|
const loader = new THREE.TextureLoader();
|
||||||
|
loader.load(url, (tex) => {
|
||||||
|
tex.name = url;
|
||||||
|
if (type === 'map') tex.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
||||||
|
textureCache.current.set(url, tex);
|
||||||
|
activeMat[type] = tex;
|
||||||
|
if (mappingType === 'triplanar') activeMat.triplanarUniforms['t' + type.charAt(0).toUpperCase() + type.slice(1).replace('Map', '')].value = tex;
|
||||||
|
activeMat.needsUpdate = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (url === null && activeMat[type]) {
|
||||||
|
activeMat[type] = null;
|
||||||
|
if (mappingType === 'triplanar') activeMat.triplanarUniforms['t' + type.charAt(0).toUpperCase() + type.slice(1).replace('Map', '')].value = null;
|
||||||
|
activeMat.needsUpdate = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
mesh.visible = sceneObj.visible;
|
||||||
|
mesh.castShadow = true;
|
||||||
|
mesh.receiveShadow = true;
|
||||||
|
mesh.frustumCulled = false;
|
||||||
|
|
||||||
|
// Sync individual mesh transform
|
||||||
|
if (sceneObj.transformProps) {
|
||||||
|
const { position, rotation, scale } = sceneObj.transformProps;
|
||||||
|
mesh.position.set(position[0], position[1], position[2]);
|
||||||
|
mesh.rotation.set(rotation[0], rotation[1], rotation[2]);
|
||||||
|
mesh.scale.set(scale[0], scale[1], scale[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
}, [sceneObj, mesh]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FillMesh = ({ fillProps, originalMesh }: { fillProps: any, originalMesh: THREE.Mesh }) => {
|
||||||
|
const materialRef = useRef<any>(null);
|
||||||
|
const [clippingPlane] = useState(() => new THREE.Plane(new THREE.Vector3(0, -1, 0), 0));
|
||||||
|
const { size: winSize } = useThree();
|
||||||
|
|
||||||
|
// Clone geometry and fix interpolation
|
||||||
|
const geometry = useMemo(() => {
|
||||||
|
const geo = originalMesh.geometry.clone();
|
||||||
|
geo.computeVertexNormals();
|
||||||
|
return geo;
|
||||||
|
}, [originalMesh.geometry]);
|
||||||
|
|
||||||
|
// Update clipping plane based on height
|
||||||
|
useEffect(() => {
|
||||||
|
geometry.computeBoundingBox();
|
||||||
|
const box = geometry.boundingBox || new THREE.Box3().setFromObject(originalMesh);
|
||||||
|
const minY = box.min.y;
|
||||||
|
const maxY = box.max.y;
|
||||||
|
// height is 0 to 1
|
||||||
|
const clipY = minY + (maxY - minY) * (fillProps.height ?? 0.5);
|
||||||
|
// Since this plane is applied in world space by Three.js by default,
|
||||||
|
// wait, localClippingEnabled = true means the plane is in LOCAL space!
|
||||||
|
// If it's local space, clipY is exact.
|
||||||
|
clippingPlane.constant = clipY;
|
||||||
|
}, [geometry, fillProps.height, originalMesh]);
|
||||||
|
|
||||||
|
useFrame((state) => {
|
||||||
|
if (materialRef.current && fillProps.type === 'liquid') {
|
||||||
|
const mat = materialRef.current;
|
||||||
|
if (mat.uTime !== undefined) mat.uTime = state.clock.elapsedTime;
|
||||||
|
if (mat.uResolution && mat.uResolution.set) {
|
||||||
|
mat.uResolution.set(winSize.width || 800, winSize.height || 600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const fillNode = (
|
||||||
|
<mesh geometry={geometry} scale={[0.995, 0.995, 0.995]}>
|
||||||
|
{fillProps.type === 'liquid' ? (
|
||||||
|
// @ts-ignore
|
||||||
|
<liquidMaterialImpl
|
||||||
|
ref={materialRef}
|
||||||
|
uColor={new THREE.Color(fillProps.color)}
|
||||||
|
transparent
|
||||||
|
side={THREE.DoubleSide}
|
||||||
|
clippingPlanes={[clippingPlane]}
|
||||||
|
clipping={true}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<meshPhysicalMaterial
|
||||||
|
color={fillProps.color}
|
||||||
|
side={THREE.DoubleSide}
|
||||||
|
clippingPlanes={[clippingPlane]}
|
||||||
|
roughness={0.2}
|
||||||
|
clearcoat={0.5}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
|
||||||
|
return createPortal(fillNode, originalMesh);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ModelViewer = forwardRef<THREE.Group, ModelViewerProps>(({ object, sceneObjects }, ref) => {
|
||||||
|
const internalRef = useRef<THREE.Group>(null);
|
||||||
|
const groupRef = (ref as any) || internalRef;
|
||||||
|
const textureCache = useRef<Map<string, THREE.Texture>>(new Map());
|
||||||
|
const meshCache = useRef<Map<string, THREE.Mesh>>(new Map());
|
||||||
|
|
||||||
|
useTexturePipeline(object);
|
||||||
|
|
||||||
|
// Cleanup textures on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
textureCache.current.forEach(tex => tex.dispose());
|
||||||
|
textureCache.current.clear();
|
||||||
|
meshCache.current.clear();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Build mesh cache when object changes
|
||||||
|
useEffect(() => {
|
||||||
|
meshCache.current.clear();
|
||||||
|
if (object) {
|
||||||
|
let mIdx = 0;
|
||||||
|
object.traverse((child) => {
|
||||||
|
if (child instanceof THREE.Mesh) {
|
||||||
|
const stableId = `mesh-${mIdx}`;
|
||||||
|
meshCache.current.set(stableId, child);
|
||||||
|
mIdx++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [object]);
|
||||||
|
|
||||||
|
// Main transform update for the core model
|
||||||
|
useEffect(() => {
|
||||||
|
if (object && sceneObjects) {
|
||||||
|
const mainModel = sceneObjects.find(obj => obj.type === 'model');
|
||||||
|
if (mainModel && mainModel.transformProps && groupRef.current) {
|
||||||
|
const { position: p, rotation: r, scale: s } = mainModel.transformProps;
|
||||||
|
groupRef.current.position.set(p[0], p[1], p[2]);
|
||||||
|
groupRef.current.rotation.set(r[0], r[1], r[2]);
|
||||||
|
groupRef.current.scale.set(s[0], s[1], s[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [object, sceneObjects]);
|
||||||
|
|
||||||
|
if (!object) return null;
|
||||||
|
|
||||||
|
const meshObjects = sceneObjects.filter(obj => obj.type === 'mesh');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={groupRef}>
|
||||||
|
<primitive object={object} />
|
||||||
|
{meshObjects.map(obj => {
|
||||||
|
const mesh = meshCache.current.get(obj.id);
|
||||||
|
if (!mesh) return null;
|
||||||
|
return (
|
||||||
|
<group key={obj.id}>
|
||||||
|
<MeshMaterialManager
|
||||||
|
mesh={mesh}
|
||||||
|
sceneObj={obj}
|
||||||
|
textureCache={textureCache}
|
||||||
|
/>
|
||||||
|
{obj.fillProps?.enabled && (
|
||||||
|
<FillMesh fillProps={obj.fillProps} originalMesh={mesh} />
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,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 />;
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import React, { useMemo, useRef, useEffect } from 'react';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { useTexture } from '@react-three/drei';
|
||||||
|
|
||||||
|
// Mapping Types Enum
|
||||||
|
export const MAPPING_TYPES = {
|
||||||
|
UV: 0,
|
||||||
|
PLANAR: 1,
|
||||||
|
CYLINDER: 2,
|
||||||
|
SPHERE: 3,
|
||||||
|
BOX: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const vertexShader = `
|
||||||
|
varying vec2 vUv;
|
||||||
|
varying vec3 vLocalPos;
|
||||||
|
varying vec3 vNormal;
|
||||||
|
varying vec3 vWorldPos;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vUv = uv;
|
||||||
|
vLocalPos = position;
|
||||||
|
vNormal = normalize(normal);
|
||||||
|
vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz;
|
||||||
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const fragmentShader = `
|
||||||
|
uniform sampler2D uTexture;
|
||||||
|
uniform int uMappingType;
|
||||||
|
uniform float uScale;
|
||||||
|
uniform vec2 uTiling;
|
||||||
|
uniform vec2 uOffset;
|
||||||
|
uniform float uRotation;
|
||||||
|
uniform vec3 uBBoxMin;
|
||||||
|
uniform vec3 uBBoxMax;
|
||||||
|
|
||||||
|
varying vec2 vUv;
|
||||||
|
varying vec3 vLocalPos;
|
||||||
|
varying vec3 vNormal;
|
||||||
|
|
||||||
|
#ifndef PI
|
||||||
|
#define PI 3.14159265359
|
||||||
|
#endif
|
||||||
|
|
||||||
|
vec2 rotateUV(vec2 uv, float rotation) {
|
||||||
|
float mid = 0.5;
|
||||||
|
float cosAngle = cos(rotation);
|
||||||
|
float sinAngle = sin(rotation);
|
||||||
|
float dx = uv.x - mid;
|
||||||
|
float dy = uv.y - mid;
|
||||||
|
return vec2(
|
||||||
|
cosAngle * dx - sinAngle * dy + mid,
|
||||||
|
sinAngle * dx + cosAngle * dy + mid
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec2 finalUv = vec2(0.0);
|
||||||
|
|
||||||
|
// Bounding box calculations for dynamic projections
|
||||||
|
vec3 boundsSize = uBBoxMax - uBBoxMin;
|
||||||
|
vec3 boundsPos = (vLocalPos - uBBoxMin) / (boundsSize + 0.00001);
|
||||||
|
vec3 center = (uBBoxMax + uBBoxMin) * 0.5;
|
||||||
|
vec3 dir = vLocalPos - center;
|
||||||
|
vec3 normDir = normalize(dir + 0.00001);
|
||||||
|
|
||||||
|
if (uMappingType == 0) {
|
||||||
|
// UV: Applies native geometry vUv
|
||||||
|
finalUv = vUv;
|
||||||
|
} else if (uMappingType == 1) {
|
||||||
|
// PLANAR (XZ / Top-Down)
|
||||||
|
finalUv = boundsPos.xz;
|
||||||
|
} else if (uMappingType == 2) {
|
||||||
|
// CYLINDER: u = angle around Y, v = normalized height
|
||||||
|
vec3 localRel = vLocalPos - center;
|
||||||
|
float u = (atan(localRel.z, localRel.x) / (2.0 * PI)) + 0.5;
|
||||||
|
float v = boundsPos.y;
|
||||||
|
finalUv = vec2(u, v);
|
||||||
|
} else if (uMappingType == 3) {
|
||||||
|
// SPHERE
|
||||||
|
float u = (atan(normDir.z, normDir.x) / (2.0 * PI)) + 0.5;
|
||||||
|
float v = (asin(clamp(normDir.y, -1.0, 1.0)) / PI) + 0.5;
|
||||||
|
finalUv = vec2(u, v);
|
||||||
|
} else if (uMappingType == 4) {
|
||||||
|
// BOX (Triplanar)
|
||||||
|
vec3 absNormal = abs(vNormal);
|
||||||
|
if (absNormal.x > absNormal.y && absNormal.x > absNormal.z) {
|
||||||
|
finalUv = boundsPos.zy;
|
||||||
|
} else if (absNormal.y > absNormal.x && absNormal.y > absNormal.z) {
|
||||||
|
finalUv = boundsPos.xz;
|
||||||
|
} else {
|
||||||
|
finalUv = boundsPos.xy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply Tiling, Scale, and Offset
|
||||||
|
finalUv = finalUv * uTiling * uScale + uOffset;
|
||||||
|
|
||||||
|
// Apply Rotation
|
||||||
|
if (uRotation != 0.0) {
|
||||||
|
finalUv = rotateUV(finalUv, uRotation);
|
||||||
|
}
|
||||||
|
|
||||||
|
vec4 texColor = texture2D(uTexture, finalUv);
|
||||||
|
gl_FragColor = texColor;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UnifiedMappingMaterial = ({
|
||||||
|
textureUrl,
|
||||||
|
mappingType = 'UV',
|
||||||
|
uvScale = 1.0,
|
||||||
|
uvTiling = [1, 1],
|
||||||
|
uvOffset = [0, 0],
|
||||||
|
uvRotation = 0,
|
||||||
|
geometry
|
||||||
|
}) => {
|
||||||
|
const materialRef = useRef<THREE.ShaderMaterial>(null);
|
||||||
|
|
||||||
|
// Load texture
|
||||||
|
const texture = useTexture(textureUrl) as THREE.Texture;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (texture) {
|
||||||
|
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
|
||||||
|
texture.needsUpdate = true;
|
||||||
|
}
|
||||||
|
}, [texture]);
|
||||||
|
|
||||||
|
// Initialize Uniforms
|
||||||
|
const uniforms = useMemo(() => ({
|
||||||
|
uTexture: { value: texture },
|
||||||
|
uMappingType: { value: MAPPING_TYPES[mappingType as keyof typeof MAPPING_TYPES] || 0 },
|
||||||
|
uScale: { value: uvScale },
|
||||||
|
uTiling: { value: new THREE.Vector2(uvTiling[0], uvTiling[1]) },
|
||||||
|
uOffset: { value: new THREE.Vector2(uvOffset[0], uvOffset[1]) },
|
||||||
|
uRotation: { value: uvRotation * (Math.PI / 180) },
|
||||||
|
uBBoxMin: { value: new THREE.Vector3(-1, -1, -1) },
|
||||||
|
uBBoxMax: { value: new THREE.Vector3(1, 1, 1) }
|
||||||
|
}), [texture]);
|
||||||
|
|
||||||
|
// Real-time Uniform Updates
|
||||||
|
useEffect(() => {
|
||||||
|
if (materialRef.current) {
|
||||||
|
const u = materialRef.current.uniforms;
|
||||||
|
u.uMappingType.value = MAPPING_TYPES[mappingType as keyof typeof MAPPING_TYPES] || 0;
|
||||||
|
u.uScale.value = uvScale;
|
||||||
|
u.uTiling.value.set(uvTiling[0], uvTiling[1]);
|
||||||
|
u.uOffset.value.set(uvOffset[0], uvOffset[1]);
|
||||||
|
u.uRotation.value = uvRotation * (Math.PI / 180);
|
||||||
|
}
|
||||||
|
}, [mappingType, uvScale, uvTiling, uvOffset, uvRotation]);
|
||||||
|
|
||||||
|
// Bounding Box Update
|
||||||
|
useEffect(() => {
|
||||||
|
if (geometry && materialRef.current) {
|
||||||
|
geometry.computeBoundingBox();
|
||||||
|
const box = geometry.boundingBox || new THREE.Box3().setFromObject(new THREE.Mesh(geometry));
|
||||||
|
materialRef.current.uniforms.uBBoxMin.value.copy(box.min);
|
||||||
|
materialRef.current.uniforms.uBBoxMax.value.copy(box.max);
|
||||||
|
}
|
||||||
|
}, [geometry]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<shaderMaterial
|
||||||
|
ref={materialRef}
|
||||||
|
uniforms={uniforms}
|
||||||
|
vertexShader={vertexShader}
|
||||||
|
fragmentShader={fragmentShader}
|
||||||
|
side={THREE.DoubleSide}
|
||||||
|
transparent={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { UVGenerationContext, computeBoundingBox, normalizeCoordinate } from './uvUtils';
|
||||||
|
|
||||||
|
export function generateBoxUVs(context: UVGenerationContext): Float32Array {
|
||||||
|
const { positions, normals } = context;
|
||||||
|
const numVertices = positions.length / 3;
|
||||||
|
const uvs = new Float32Array(numVertices * 2);
|
||||||
|
const bbox = computeBoundingBox(positions);
|
||||||
|
|
||||||
|
if (!normals) {
|
||||||
|
// Fallback if no normals provided: use position to determine primary axis from center
|
||||||
|
for (let i = 0; i < numVertices; i++) {
|
||||||
|
const x = positions[i * 3];
|
||||||
|
const y = positions[i * 3 + 1];
|
||||||
|
const z = positions[i * 3 + 2];
|
||||||
|
|
||||||
|
const dx = Math.abs(x - bbox.center[0]);
|
||||||
|
const dy = Math.abs(y - bbox.center[1]);
|
||||||
|
const dz = Math.abs(z - bbox.center[2]);
|
||||||
|
|
||||||
|
let u = 0;
|
||||||
|
let v = 0;
|
||||||
|
|
||||||
|
if (dx >= dy && dx >= dz) {
|
||||||
|
u = normalizeCoordinate(z, bbox.min[2], bbox.size[2]);
|
||||||
|
v = normalizeCoordinate(y, bbox.min[1], bbox.size[1]);
|
||||||
|
} else if (dy >= dx && dy >= dz) {
|
||||||
|
u = normalizeCoordinate(x, bbox.min[0], bbox.size[0]);
|
||||||
|
v = normalizeCoordinate(z, bbox.min[2], bbox.size[2]);
|
||||||
|
} else {
|
||||||
|
u = normalizeCoordinate(x, bbox.min[0], bbox.size[0]);
|
||||||
|
v = normalizeCoordinate(y, bbox.min[1], bbox.size[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
uvs[i * 2] = u;
|
||||||
|
uvs[i * 2 + 1] = v;
|
||||||
|
}
|
||||||
|
return uvs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use normals to determine mapping face
|
||||||
|
for (let i = 0; i < numVertices; i++) {
|
||||||
|
const x = positions[i * 3];
|
||||||
|
const y = positions[i * 3 + 1];
|
||||||
|
const z = positions[i * 3 + 2];
|
||||||
|
|
||||||
|
const nx = Math.abs(normals[i * 3]);
|
||||||
|
const ny = Math.abs(normals[i * 3 + 1]);
|
||||||
|
const nz = Math.abs(normals[i * 3 + 2]);
|
||||||
|
|
||||||
|
let u = 0;
|
||||||
|
let v = 0;
|
||||||
|
|
||||||
|
if (nx >= ny && nx >= nz) {
|
||||||
|
u = normalizeCoordinate(z, bbox.min[2], bbox.size[2]);
|
||||||
|
v = normalizeCoordinate(y, bbox.min[1], bbox.size[1]);
|
||||||
|
if (normals[i * 3] < 0) u = 1 - u; // Flip for negative X
|
||||||
|
} else if (ny >= nx && ny >= nz) {
|
||||||
|
u = normalizeCoordinate(x, bbox.min[0], bbox.size[0]);
|
||||||
|
v = normalizeCoordinate(z, bbox.min[2], bbox.size[2]);
|
||||||
|
if (normals[i * 3 + 1] < 0) u = 1 - u; // Flip for negative Y
|
||||||
|
} else {
|
||||||
|
u = normalizeCoordinate(x, bbox.min[0], bbox.size[0]);
|
||||||
|
v = normalizeCoordinate(y, bbox.min[1], bbox.size[1]);
|
||||||
|
if (normals[i * 3 + 2] < 0) u = 1 - u; // Flip for negative Z
|
||||||
|
}
|
||||||
|
|
||||||
|
uvs[i * 2] = u;
|
||||||
|
uvs[i * 2 + 1] = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
return uvs;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { UVGenerationContext, computeBoundingBox, normalizeCoordinate } from './uvUtils';
|
||||||
|
|
||||||
|
export function generateCylindricalUVs(context: UVGenerationContext, axis: 'x' | 'y' | 'z' = 'y'): Float32Array {
|
||||||
|
const { positions } = context;
|
||||||
|
const numVertices = positions.length / 3;
|
||||||
|
const uvs = new Float32Array(numVertices * 2);
|
||||||
|
const bbox = computeBoundingBox(positions);
|
||||||
|
|
||||||
|
for (let i = 0; i < numVertices; i++) {
|
||||||
|
const x = positions[i * 3];
|
||||||
|
const y = positions[i * 3 + 1];
|
||||||
|
const z = positions[i * 3 + 2];
|
||||||
|
|
||||||
|
const cx = x - bbox.center[0];
|
||||||
|
const cy = y - bbox.center[1];
|
||||||
|
const cz = z - bbox.center[2];
|
||||||
|
|
||||||
|
let u = 0;
|
||||||
|
let v = 0;
|
||||||
|
|
||||||
|
switch (axis) {
|
||||||
|
case 'x':
|
||||||
|
u = (Math.atan2(cy, cz) + Math.PI) / (2 * Math.PI);
|
||||||
|
v = normalizeCoordinate(x, bbox.min[0], bbox.size[0]);
|
||||||
|
break;
|
||||||
|
case 'z':
|
||||||
|
u = (Math.atan2(cy, cx) + Math.PI) / (2 * Math.PI);
|
||||||
|
v = normalizeCoordinate(z, bbox.min[2], bbox.size[2]);
|
||||||
|
break;
|
||||||
|
case 'y':
|
||||||
|
default:
|
||||||
|
u = (Math.atan2(cz, cx) + Math.PI) / (2 * Math.PI);
|
||||||
|
v = normalizeCoordinate(y, bbox.min[1], bbox.size[1]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
uvs[i * 2] = u;
|
||||||
|
uvs[i * 2 + 1] = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
return uvs;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { UVGenerationContext, computeBoundingBox, normalizeCoordinate } from './uvUtils';
|
||||||
|
|
||||||
|
export function generatePlanarUVs(context: UVGenerationContext, axis: 'x' | 'y' | 'z' = 'z'): Float32Array {
|
||||||
|
const { positions } = context;
|
||||||
|
const numVertices = positions.length / 3;
|
||||||
|
const uvs = new Float32Array(numVertices * 2);
|
||||||
|
const bbox = computeBoundingBox(positions);
|
||||||
|
|
||||||
|
for (let i = 0; i < numVertices; i++) {
|
||||||
|
const x = positions[i * 3];
|
||||||
|
const y = positions[i * 3 + 1];
|
||||||
|
const z = positions[i * 3 + 2];
|
||||||
|
|
||||||
|
let u = 0;
|
||||||
|
let v = 0;
|
||||||
|
|
||||||
|
switch (axis) {
|
||||||
|
case 'x':
|
||||||
|
u = normalizeCoordinate(z, bbox.min[2], bbox.size[2]);
|
||||||
|
v = normalizeCoordinate(y, bbox.min[1], bbox.size[1]);
|
||||||
|
break;
|
||||||
|
case 'y':
|
||||||
|
u = normalizeCoordinate(x, bbox.min[0], bbox.size[0]);
|
||||||
|
v = normalizeCoordinate(z, bbox.min[2], bbox.size[2]);
|
||||||
|
break;
|
||||||
|
case 'z':
|
||||||
|
default:
|
||||||
|
u = normalizeCoordinate(x, bbox.min[0], bbox.size[0]);
|
||||||
|
v = normalizeCoordinate(y, bbox.min[1], bbox.size[1]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
uvs[i * 2] = u;
|
||||||
|
uvs[i * 2 + 1] = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
return uvs;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
export interface UVGenerationContext {
|
||||||
|
positions: Float32Array;
|
||||||
|
normals?: Float32Array;
|
||||||
|
indices?: Uint32Array | Uint16Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoundingBox {
|
||||||
|
min: [number, number, number];
|
||||||
|
max: [number, number, number];
|
||||||
|
size: [number, number, number];
|
||||||
|
center: [number, number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeBoundingBox(positions: Float32Array): BoundingBox {
|
||||||
|
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
||||||
|
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
||||||
|
|
||||||
|
for (let i = 0; i < positions.length; i += 3) {
|
||||||
|
const x = positions[i];
|
||||||
|
const y = positions[i + 1];
|
||||||
|
const z = positions[i + 2];
|
||||||
|
|
||||||
|
if (x < minX) minX = x;
|
||||||
|
if (y < minY) minY = y;
|
||||||
|
if (z < minZ) minZ = z;
|
||||||
|
|
||||||
|
if (x > maxX) maxX = x;
|
||||||
|
if (y > maxY) maxY = y;
|
||||||
|
if (z > maxZ) maxZ = z;
|
||||||
|
}
|
||||||
|
|
||||||
|
const size: [number, number, number] = [maxX - minX, maxY - minY, maxZ - minZ];
|
||||||
|
const center: [number, number, number] = [
|
||||||
|
minX + size[0] / 2,
|
||||||
|
minY + size[1] / 2,
|
||||||
|
minZ + size[2] / 2,
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
min: [minX, minY, minZ],
|
||||||
|
max: [maxX, maxY, maxZ],
|
||||||
|
size,
|
||||||
|
center,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCoordinate(value: number, min: number, size: number): number {
|
||||||
|
if (size === 0) return 0;
|
||||||
|
return (value - min) / size;
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { ProjectionType, UVWorkerMessage, UVWorkerResponse } from '../workers/uvWorker';
|
||||||
|
|
||||||
|
export function useTexturePipeline(scene: THREE.Object3D | null) {
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const workerRef = useRef<Worker | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
workerRef.current = new Worker(new URL('../workers/uvWorker.ts', import.meta.url), {
|
||||||
|
type: 'module',
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
workerRef.current?.terminate();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scene || !workerRef.current) return;
|
||||||
|
|
||||||
|
const processMesh = async (mesh: THREE.Mesh) => {
|
||||||
|
const geometry = mesh.geometry;
|
||||||
|
if (!geometry.attributes.position) return;
|
||||||
|
|
||||||
|
const positions = geometry.attributes.position.array as Float32Array;
|
||||||
|
const normals = geometry.attributes.normal?.array as Float32Array | undefined;
|
||||||
|
|
||||||
|
const generateUVs = (projection: ProjectionType, axis?: 'x'|'y'|'z'): Promise<Float32Array> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!workerRef.current) return reject('No worker');
|
||||||
|
const id = Math.random().toString(36).substring(7);
|
||||||
|
|
||||||
|
const handleMessage = (e: MessageEvent<UVWorkerResponse>) => {
|
||||||
|
if (e.data.id === id) {
|
||||||
|
workerRef.current?.removeEventListener('message', handleMessage);
|
||||||
|
if (e.data.error) reject(new Error(e.data.error));
|
||||||
|
else resolve(e.data.uvs);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
workerRef.current.addEventListener('message', handleMessage);
|
||||||
|
|
||||||
|
workerRef.current.postMessage({
|
||||||
|
id,
|
||||||
|
positions,
|
||||||
|
normals,
|
||||||
|
projection,
|
||||||
|
axis
|
||||||
|
} as UVWorkerMessage);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const numVertices = positions.length / 3;
|
||||||
|
// Add dummy attributes synchronously to prevent shader compile errors
|
||||||
|
if (!geometry.attributes.uvPlanar) geometry.setAttribute('uvPlanar', new THREE.BufferAttribute(new Float32Array(numVertices * 2), 2));
|
||||||
|
if (!geometry.attributes.uvBox) geometry.setAttribute('uvBox', new THREE.BufferAttribute(new Float32Array(numVertices * 2), 2));
|
||||||
|
if (!geometry.attributes.uvCylinder) geometry.setAttribute('uvCylinder', new THREE.BufferAttribute(new Float32Array(numVertices * 2), 2));
|
||||||
|
|
||||||
|
const uvPlanar = await generateUVs('planar', 'z');
|
||||||
|
(geometry.attributes.uvPlanar as THREE.BufferAttribute).array.set(uvPlanar);
|
||||||
|
geometry.attributes.uvPlanar.needsUpdate = true;
|
||||||
|
|
||||||
|
const uvBox = await generateUVs('box');
|
||||||
|
(geometry.attributes.uvBox as THREE.BufferAttribute).array.set(uvBox);
|
||||||
|
geometry.attributes.uvBox.needsUpdate = true;
|
||||||
|
|
||||||
|
const uvCylinder = await generateUVs('cylindrical', 'y');
|
||||||
|
(geometry.attributes.uvCylinder as THREE.BufferAttribute).array.set(uvCylinder);
|
||||||
|
geometry.attributes.uvCylinder.needsUpdate = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("UV Generation error", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const processAllMeshes = async () => {
|
||||||
|
setIsProcessing(true);
|
||||||
|
const meshes: THREE.Mesh[] = [];
|
||||||
|
scene.traverse((child) => {
|
||||||
|
if ((child as THREE.Mesh).isMesh) {
|
||||||
|
meshes.push(child as THREE.Mesh);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process in parallel
|
||||||
|
await Promise.all(meshes.map(processMesh));
|
||||||
|
setIsProcessing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
processAllMeshes();
|
||||||
|
}, [scene]);
|
||||||
|
|
||||||
|
return { isProcessing };
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&family=Outfit:wght@400;500;600;700&display=swap');
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: "Outfit", "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
|
||||||
|
|
||||||
|
/* Theme Colors */
|
||||||
|
--color-primary: #4DA3FF;
|
||||||
|
--color-accent: #FF2E88;
|
||||||
|
--color-glass-bg: rgba(246, 249, 255, 0.8);
|
||||||
|
--color-glass-card: rgba(255, 255, 255, 0.7);
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--shadow-glass: 0 8px 32px 0 rgba(31, 38, 135, 0.15);
|
||||||
|
--shadow-emboss: inset 2px 2px 4px #dbe6ff, inset -2px -2px 4px #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
@apply bg-[#F6F9FF] text-gray-900 antialiased overflow-hidden transition-colors duration-500;
|
||||||
|
font-feature-settings: "ss01", "ss02", "cv01", "cv11";
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark {
|
||||||
|
@apply bg-[#0a0a0a] text-gray-100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.glass-panel {
|
||||||
|
@apply bg-white/70 backdrop-blur-xl border border-white/40 shadow-glass transition-all duration-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .glass-panel {
|
||||||
|
@apply bg-white/5 border-white/10 shadow-2xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card {
|
||||||
|
@apply bg-white/50 backdrop-blur-md border border-white/40 hover:bg-white/80 transition-all duration-300 shadow-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .glass-card {
|
||||||
|
@apply bg-white/5 border-white/10 hover:bg-white/10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emboss-btn {
|
||||||
|
@apply shadow-glass border border-white/40 transition-all duration-200 active:shadow-emboss;
|
||||||
|
box-shadow: 4px 4px 8px rgba(0,0,0,0.05), -4px -4px 8px rgba(255,255,255,0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .emboss-btn {
|
||||||
|
@apply border-white/10 shadow-none active:bg-white/10;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item {
|
||||||
|
@apply flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 cursor-pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item-active {
|
||||||
|
@apply bg-primary/20 text-primary border border-primary/30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item:hover:not(.sidebar-item-active) {
|
||||||
|
@apply bg-white/20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .sidebar-item:hover:not(.sidebar-item-active) {
|
||||||
|
@apply bg-white/5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neon-glow:hover {
|
||||||
|
box-shadow: 0 0 15px rgba(255, 46, 136, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
|
@apply bg-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-black/10 rounded-full hover:bg-black/20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-white/10 hover:bg-white/20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation for the background glow */
|
||||||
|
@keyframes float {
|
||||||
|
0% { transform: translate(0, 0) scale(1); }
|
||||||
|
33% { transform: translate(2%, 2%) scale(1.1); }
|
||||||
|
66% { transform: translate(-1%, 3%) scale(0.9); }
|
||||||
|
100% { transform: translate(0, 0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-float {
|
||||||
|
animation: float 20s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-float-delayed {
|
||||||
|
animation: float 25s ease-in-out infinite reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide TransformControls UI when rendering */
|
||||||
|
.rendering-hide {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import {StrictMode} from 'react';
|
||||||
|
import {createRoot} from 'react-dom/client';
|
||||||
|
import App from './App.tsx';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import { StandardUVMaterial } from './StandardUVMaterial';
|
||||||
|
import { TriplanarMaterial } from './TriplanarMaterial';
|
||||||
|
|
||||||
|
type MaterialCacheKey = string;
|
||||||
|
|
||||||
|
export class MaterialFactory {
|
||||||
|
private static instance: MaterialFactory;
|
||||||
|
private cache: Map<MaterialCacheKey, THREE.Material> = new Map();
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
public static getInstance(): MaterialFactory {
|
||||||
|
if (!MaterialFactory.instance) {
|
||||||
|
MaterialFactory.instance = new MaterialFactory();
|
||||||
|
}
|
||||||
|
return MaterialFactory.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getStandardUVMaterial(
|
||||||
|
parameters: THREE.MeshPhysicalMaterialParameters,
|
||||||
|
key: string
|
||||||
|
): StandardUVMaterial {
|
||||||
|
const cacheKey = `standard_uv_${key}`;
|
||||||
|
if (this.cache.has(cacheKey)) {
|
||||||
|
return this.cache.get(cacheKey) as StandardUVMaterial;
|
||||||
|
}
|
||||||
|
|
||||||
|
const material = new StandardUVMaterial(parameters);
|
||||||
|
this.cache.set(cacheKey, material);
|
||||||
|
return material;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getTriplanarMaterial(
|
||||||
|
parameters: THREE.MeshPhysicalMaterialParameters,
|
||||||
|
key: string
|
||||||
|
): TriplanarMaterial {
|
||||||
|
const cacheKey = `triplanar_${key}`;
|
||||||
|
if (this.cache.has(cacheKey)) {
|
||||||
|
return this.cache.get(cacheKey) as TriplanarMaterial;
|
||||||
|
}
|
||||||
|
|
||||||
|
const material = new TriplanarMaterial(parameters);
|
||||||
|
this.cache.set(cacheKey, material);
|
||||||
|
return material;
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearCache() {
|
||||||
|
this.cache.forEach((material) => material.dispose());
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
export class StandardUVMaterial extends THREE.MeshPhysicalMaterial {
|
||||||
|
public uvUniforms: {
|
||||||
|
uMappingMode: { value: number }; // 0: imported, 1: planar, 2: box, 3: cylindrical
|
||||||
|
uUVTransform: { value: THREE.Matrix3 };
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(parameters?: THREE.MeshPhysicalMaterialParameters) {
|
||||||
|
super(parameters);
|
||||||
|
|
||||||
|
this.uvUniforms = {
|
||||||
|
uMappingMode: { value: 0 },
|
||||||
|
uUVTransform: { value: new THREE.Matrix3() },
|
||||||
|
};
|
||||||
|
|
||||||
|
this.customProgramCacheKey = () => `standard_uv_v2`;
|
||||||
|
|
||||||
|
this.onBeforeCompile = (shader) => {
|
||||||
|
shader.uniforms.uMappingMode = this.uvUniforms.uMappingMode;
|
||||||
|
shader.uniforms.uUVTransform = this.uvUniforms.uUVTransform;
|
||||||
|
|
||||||
|
shader.vertexShader = `
|
||||||
|
attribute vec2 uvPlanar;
|
||||||
|
attribute vec2 uvBox;
|
||||||
|
attribute vec2 uvCylinder;
|
||||||
|
|
||||||
|
uniform int uMappingMode;
|
||||||
|
uniform mat3 uUVTransform;
|
||||||
|
|
||||||
|
varying vec2 vCustomUV;
|
||||||
|
` + shader.vertexShader;
|
||||||
|
|
||||||
|
shader.vertexShader = shader.vertexShader.replace(
|
||||||
|
`#include <uv_vertex>`,
|
||||||
|
`
|
||||||
|
#include <uv_vertex>
|
||||||
|
|
||||||
|
vec2 selectedUV = vec2(0.0);
|
||||||
|
#ifdef USE_UV
|
||||||
|
selectedUV = uv;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
if (uMappingMode == 1) {
|
||||||
|
selectedUV = uvPlanar;
|
||||||
|
} else if (uMappingMode == 2) {
|
||||||
|
selectedUV = uvBox;
|
||||||
|
} else if (uMappingMode == 3) {
|
||||||
|
selectedUV = uvCylinder;
|
||||||
|
}
|
||||||
|
|
||||||
|
vCustomUV = (uUVTransform * vec3(selectedUV, 1.0)).xy;
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
shader.fragmentShader = `
|
||||||
|
varying vec2 vCustomUV;
|
||||||
|
` + shader.fragmentShader;
|
||||||
|
|
||||||
|
const mainStartIndex = shader.fragmentShader.indexOf('void main() {');
|
||||||
|
if (mainStartIndex !== -1) {
|
||||||
|
const prefix = shader.fragmentShader.substring(0, mainStartIndex);
|
||||||
|
let mainBody = shader.fragmentShader.substring(mainStartIndex);
|
||||||
|
|
||||||
|
mainBody = mainBody.replace(/\bvUv\b/g, 'vCustomUV');
|
||||||
|
mainBody = mainBody.replace(/\bvMapUv\b/g, 'vCustomUV');
|
||||||
|
mainBody = mainBody.replace(/\bvNormalMapUv\b/g, 'vCustomUV');
|
||||||
|
mainBody = mainBody.replace(/\bvRoughnessMapUv\b/g, 'vCustomUV');
|
||||||
|
mainBody = mainBody.replace(/\bvMetalnessMapUv\b/g, 'vCustomUV');
|
||||||
|
mainBody = mainBody.replace(/\bvAlphaMapUv\b/g, 'vCustomUV');
|
||||||
|
mainBody = mainBody.replace(/\bvEmissiveMapUv\b/g, 'vCustomUV');
|
||||||
|
mainBody = mainBody.replace(/\bvAoMapUv\b/g, 'vCustomUV');
|
||||||
|
mainBody = mainBody.replace(/\bvClearcoatMapUv\b/g, 'vCustomUV');
|
||||||
|
mainBody = mainBody.replace(/\bvClearcoatNormalMapUv\b/g, 'vCustomUV');
|
||||||
|
mainBody = mainBody.replace(/\bvClearcoatRoughnessMapUv\b/g, 'vCustomUV');
|
||||||
|
mainBody = mainBody.replace(/\bvTransmissionMapUv\b/g, 'vCustomUV');
|
||||||
|
mainBody = mainBody.replace(/\bvThicknessMapUv\b/g, 'vCustomUV');
|
||||||
|
mainBody = mainBody.replace(/\bvSpecularMapUv\b/g, 'vCustomUV');
|
||||||
|
mainBody = mainBody.replace(/\bvSpecularIntensityMapUv\b/g, 'vCustomUV');
|
||||||
|
mainBody = mainBody.replace(/\bvSpecularColorMapUv\b/g, 'vCustomUV');
|
||||||
|
|
||||||
|
shader.fragmentShader = prefix + mainBody;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public updateUVTransform(offset: [number, number], repeat: [number, number], rotation: number) {
|
||||||
|
this.uvUniforms.uUVTransform.value.setUvTransform(
|
||||||
|
offset[0], offset[1],
|
||||||
|
repeat[0], repeat[1],
|
||||||
|
rotation,
|
||||||
|
0.5, 0.5
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
export class TriplanarMaterial extends THREE.MeshPhysicalMaterial {
|
||||||
|
public triplanarUniforms: {
|
||||||
|
tAlbedo: { value: THREE.Texture | null };
|
||||||
|
tNormal: { value: THREE.Texture | null };
|
||||||
|
tRoughness: { value: THREE.Texture | null };
|
||||||
|
tAo: { value: THREE.Texture | null };
|
||||||
|
uTriplanarScale: { value: number };
|
||||||
|
uBlendSharpness: { value: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(parameters?: THREE.MeshPhysicalMaterialParameters) {
|
||||||
|
super(parameters);
|
||||||
|
|
||||||
|
this.triplanarUniforms = {
|
||||||
|
tAlbedo: { value: null },
|
||||||
|
tNormal: { value: null },
|
||||||
|
tRoughness: { value: null },
|
||||||
|
tAo: { value: null },
|
||||||
|
uTriplanarScale: { value: 1.0 },
|
||||||
|
uBlendSharpness: { value: 5.0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
this.customProgramCacheKey = () => `triplanar_mat_v1`;
|
||||||
|
|
||||||
|
this.onBeforeCompile = (shader) => {
|
||||||
|
// Inject Uniforms
|
||||||
|
shader.uniforms.tAlbedo = this.triplanarUniforms.tAlbedo;
|
||||||
|
shader.uniforms.tNormal = this.triplanarUniforms.tNormal;
|
||||||
|
shader.uniforms.tRoughness = this.triplanarUniforms.tRoughness;
|
||||||
|
shader.uniforms.tAo = this.triplanarUniforms.tAo;
|
||||||
|
shader.uniforms.uTriplanarScale = this.triplanarUniforms.uTriplanarScale;
|
||||||
|
shader.uniforms.uBlendSharpness = this.triplanarUniforms.uBlendSharpness;
|
||||||
|
|
||||||
|
// Vertex Shader Injections
|
||||||
|
shader.vertexShader = `
|
||||||
|
varying vec3 vTriplanarPosition;
|
||||||
|
varying vec3 vTriplanarNormal;
|
||||||
|
` + shader.vertexShader;
|
||||||
|
|
||||||
|
shader.vertexShader = shader.vertexShader.replace(
|
||||||
|
`#include <worldpos_vertex>`,
|
||||||
|
`
|
||||||
|
#include <worldpos_vertex>
|
||||||
|
vTriplanarPosition = position; // Object space
|
||||||
|
vTriplanarNormal = normal; // Object space normal
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fragment Shader Injections
|
||||||
|
shader.fragmentShader = `
|
||||||
|
uniform sampler2D tAlbedo;
|
||||||
|
uniform sampler2D tNormal;
|
||||||
|
uniform sampler2D tRoughness;
|
||||||
|
uniform sampler2D tAo;
|
||||||
|
uniform float uTriplanarScale;
|
||||||
|
uniform float uBlendSharpness;
|
||||||
|
|
||||||
|
varying vec3 vTriplanarPosition;
|
||||||
|
varying vec3 vTriplanarNormal;
|
||||||
|
|
||||||
|
// Triplanar blending logic
|
||||||
|
vec4 getTriplanarBlend(sampler2D tex, vec3 pos, vec3 norm) {
|
||||||
|
vec3 blendWeights = abs(norm);
|
||||||
|
blendWeights = pow(blendWeights, vec3(uBlendSharpness));
|
||||||
|
blendWeights /= dot(blendWeights, vec3(1.0)); // Normalize
|
||||||
|
|
||||||
|
vec2 uvX = pos.yz * uTriplanarScale;
|
||||||
|
vec2 uvY = pos.zx * uTriplanarScale;
|
||||||
|
vec2 uvZ = pos.xy * uTriplanarScale;
|
||||||
|
|
||||||
|
vec4 colX = texture2D(tex, uvX);
|
||||||
|
vec4 colY = texture2D(tex, uvY);
|
||||||
|
vec4 colZ = texture2D(tex, uvZ);
|
||||||
|
|
||||||
|
return colX * blendWeights.x + colY * blendWeights.y + colZ * blendWeights.z;
|
||||||
|
}
|
||||||
|
` + shader.fragmentShader;
|
||||||
|
|
||||||
|
// Replace map chunks
|
||||||
|
shader.fragmentShader = shader.fragmentShader.replace(
|
||||||
|
`#include <map_fragment>`,
|
||||||
|
`
|
||||||
|
#ifdef USE_MAP
|
||||||
|
vec4 texelColor = getTriplanarBlend(tAlbedo, vTriplanarPosition, vTriplanarNormal);
|
||||||
|
texelColor = mapTexelToLinear(texelColor);
|
||||||
|
diffuseColor *= texelColor;
|
||||||
|
#endif
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
shader.fragmentShader = shader.fragmentShader.replace(
|
||||||
|
`#include <roughnessmap_fragment>`,
|
||||||
|
`
|
||||||
|
float roughnessFactor = roughness;
|
||||||
|
#ifdef USE_ROUGHNESSMAP
|
||||||
|
vec4 texelRoughness = getTriplanarBlend(tRoughness, vTriplanarPosition, vTriplanarNormal);
|
||||||
|
roughnessFactor *= texelRoughness.g;
|
||||||
|
#endif
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
shader.fragmentShader = shader.fragmentShader.replace(
|
||||||
|
`#include <normal_fragment_maps>`,
|
||||||
|
`
|
||||||
|
#ifdef USE_NORMALMAP
|
||||||
|
vec4 texelNormal = getTriplanarBlend(tNormal, vTriplanarPosition, vTriplanarNormal);
|
||||||
|
// Simplified triplanar normal calculation
|
||||||
|
vec3 n = texelNormal.rgb * 2.0 - 1.0;
|
||||||
|
normal = normalize(normalMatrix * n);
|
||||||
|
#elif defined( USE_BUMPMAP )
|
||||||
|
// Bump map omitted for brevity
|
||||||
|
#endif
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
shader.fragmentShader = shader.fragmentShader.replace(
|
||||||
|
`#include <aomap_fragment>`,
|
||||||
|
`
|
||||||
|
#ifdef USE_AOMAP
|
||||||
|
vec4 texelAo = getTriplanarBlend(tAo, vTriplanarPosition, vTriplanarNormal);
|
||||||
|
float ambientOcclusion = ( texelAo.r - 1.0 ) * aoMapIntensity + 1.0;
|
||||||
|
reflectedLight.indirectDiffuse *= ambientOcclusion;
|
||||||
|
#if defined( USE_ENVMAP ) && defined( STANDARD )
|
||||||
|
float dotNV = saturate( dot( geometry.normal, geometry.viewDir ) );
|
||||||
|
reflectedLight.indirectSpecular *= computeSpecularOcclusion( dotNV, ambientOcclusion, material.roughness );
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
export type MappingMode = 'imported' | 'planar' | 'box' | 'cylindrical' | 'triplanar';
|
||||||
|
|
||||||
|
export interface UVTransforms {
|
||||||
|
scale: [number, number];
|
||||||
|
repeat: [number, number];
|
||||||
|
offset: [number, number];
|
||||||
|
rotation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TriplanarTransforms {
|
||||||
|
scale: number;
|
||||||
|
blendSharpness: number;
|
||||||
|
rotation: [number, number, number];
|
||||||
|
worldSpace: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfiguratorState {
|
||||||
|
mappingMode: MappingMode;
|
||||||
|
uvTransforms: UVTransforms;
|
||||||
|
triplanarTransforms: TriplanarTransforms;
|
||||||
|
projectionAxis: 'x' | 'y' | 'z';
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setMappingMode: (mode: MappingMode) => void;
|
||||||
|
setUVTransforms: (transforms: Partial<UVTransforms>) => void;
|
||||||
|
setTriplanarTransforms: (transforms: Partial<TriplanarTransforms>) => void;
|
||||||
|
setProjectionAxis: (axis: 'x' | 'y' | 'z') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useConfiguratorStore = create<ConfiguratorState>((set) => ({
|
||||||
|
mappingMode: 'imported',
|
||||||
|
projectionAxis: 'z',
|
||||||
|
uvTransforms: {
|
||||||
|
scale: [1, 1],
|
||||||
|
repeat: [1, 1],
|
||||||
|
offset: [0, 0],
|
||||||
|
rotation: 0,
|
||||||
|
},
|
||||||
|
triplanarTransforms: {
|
||||||
|
scale: 1,
|
||||||
|
blendSharpness: 5.0,
|
||||||
|
rotation: [0, 0, 0],
|
||||||
|
worldSpace: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
setMappingMode: (mode) => set({ mappingMode: mode }),
|
||||||
|
setUVTransforms: (transforms) =>
|
||||||
|
set((state) => ({ uvTransforms: { ...state.uvTransforms, ...transforms } })),
|
||||||
|
setTriplanarTransforms: (transforms) =>
|
||||||
|
set((state) => ({ triplanarTransforms: { ...state.triplanarTransforms, ...transforms } })),
|
||||||
|
setProjectionAxis: (axis) => set({ projectionAxis: axis }),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
export interface UVQualityReport {
|
||||||
|
missingUVs: boolean;
|
||||||
|
extremeStretching: boolean;
|
||||||
|
overlappingUVs: boolean;
|
||||||
|
highComplexity: boolean;
|
||||||
|
recommendTriplanar: boolean;
|
||||||
|
details: {
|
||||||
|
vertexCount: number;
|
||||||
|
uvRange: [number, number];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function analyzeUVQuality(
|
||||||
|
positions: Float32Array,
|
||||||
|
uvs?: Float32Array,
|
||||||
|
indices?: Uint32Array | Uint16Array
|
||||||
|
): UVQualityReport {
|
||||||
|
const vertexCount = positions.length / 3;
|
||||||
|
const highComplexity = vertexCount > 50000; // Arbitrary threshold for complex geometry
|
||||||
|
|
||||||
|
if (!uvs || uvs.length === 0) {
|
||||||
|
return {
|
||||||
|
missingUVs: true,
|
||||||
|
extremeStretching: false,
|
||||||
|
overlappingUVs: false,
|
||||||
|
highComplexity,
|
||||||
|
recommendTriplanar: true,
|
||||||
|
details: {
|
||||||
|
vertexCount,
|
||||||
|
uvRange: [0, 0]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let minU = Infinity, maxU = -Infinity;
|
||||||
|
let minV = Infinity, maxV = -Infinity;
|
||||||
|
|
||||||
|
for (let i = 0; i < uvs.length; i += 2) {
|
||||||
|
const u = uvs[i];
|
||||||
|
const v = uvs[i + 1];
|
||||||
|
|
||||||
|
if (u < minU) minU = u;
|
||||||
|
if (u > maxU) maxU = u;
|
||||||
|
if (v < minV) minV = v;
|
||||||
|
if (v > maxV) maxV = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uRange = maxU - minU;
|
||||||
|
const vRange = maxV - minV;
|
||||||
|
|
||||||
|
// Basic heuristic: if UVs are heavily clamped or barely cover any area
|
||||||
|
const extremeStretching = (uRange < 0.01 && uRange > 0) || (vRange < 0.01 && vRange > 0);
|
||||||
|
|
||||||
|
// Overlapping detection is computationally expensive, skipping deep check for now
|
||||||
|
// A robust check would require comparing triangle areas in UV space vs World space
|
||||||
|
const overlappingUVs = false;
|
||||||
|
|
||||||
|
const recommendTriplanar = extremeStretching || highComplexity;
|
||||||
|
|
||||||
|
return {
|
||||||
|
missingUVs: false,
|
||||||
|
extremeStretching,
|
||||||
|
overlappingUVs,
|
||||||
|
highComplexity,
|
||||||
|
recommendTriplanar,
|
||||||
|
details: {
|
||||||
|
vertexCount,
|
||||||
|
uvRange: [uRange, vRange]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { generatePlanarUVs } from '../core/uv/planarProjection';
|
||||||
|
import { generateBoxUVs } from '../core/uv/boxProjection';
|
||||||
|
import { generateCylindricalUVs } from '../core/uv/cylindricalProjection';
|
||||||
|
|
||||||
|
export type ProjectionType = 'planar' | 'box' | 'cylindrical';
|
||||||
|
|
||||||
|
export interface UVWorkerMessage {
|
||||||
|
id: string;
|
||||||
|
positions: Float32Array;
|
||||||
|
normals?: Float32Array;
|
||||||
|
projection: ProjectionType;
|
||||||
|
axis?: 'x' | 'y' | 'z';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UVWorkerResponse {
|
||||||
|
id: string;
|
||||||
|
uvs: Float32Array;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.onmessage = (event: MessageEvent<UVWorkerMessage>) => {
|
||||||
|
const { id, positions, normals, projection, axis } = event.data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const context = { positions, normals };
|
||||||
|
let uvs: Float32Array;
|
||||||
|
|
||||||
|
switch (projection) {
|
||||||
|
case 'planar':
|
||||||
|
uvs = generatePlanarUVs(context, axis || 'z');
|
||||||
|
break;
|
||||||
|
case 'box':
|
||||||
|
uvs = generateBoxUVs(context);
|
||||||
|
break;
|
||||||
|
case 'cylindrical':
|
||||||
|
uvs = generateCylindricalUVs(context, axis || 'y');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported projection: ${projection}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transfer the ArrayBuffer back to main thread for performance
|
||||||
|
self.postMessage({ id, uvs } as UVWorkerResponse, {
|
||||||
|
transfer: [uvs.buffer],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
self.postMessage({ id, error: String(error) } as UVWorkerResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||