Compare commits

..
29 Commits
Author SHA1 Message Date
AI Bot 8eb2f844e5 chore: remove flip X and flip Y from baking options UI
Build and Deploy / build-and-push (push) Successful in 37s
2026-10-05 13:18:36 +05:30
AI Bot 66206c7ce5 fix: automatically re-apply baked UV projections on model reload
Build and Deploy / build-and-push (push) Successful in 31s
2026-10-05 13:00:01 +05:30
AI Bot e8b88fcead feat: improve AI layer editing UI, enforce aspect ratio matching through system prompt constraints
Build and Deploy / build-and-push (push) Successful in 57s
2026-10-05 12:48:43 +05:30
AI Bot cf3c114172 fix: correct UV mapping horizontal flip issue on cylindrical and spherical projections
Build and Deploy / build-and-push (push) Successful in 1m13s
2026-09-30 18:26:43 +05:30
AI Bot f898dfadb4 fix: proxy Meshy GLB download through backend to resolve CORS blocking, remove polling console logs
Build and Deploy / build-and-push (push) Successful in 31s
2026-09-30 17:40:49 +05:30
AI Bot 49a3882441 fix: resolve 401 Unauthorized in Gemini editUVLayer by properly passing auth token, update loading UI with grayscale/blur, fix WebGLShadowMap deprecation warnings
Build and Deploy / build-and-push (push) Successful in 28s
2026-09-30 17:15:53 +05:30
AI Bot 86ba3a961c refactor: simplify ImageTo3DModal, only use Meshy, and auto-clean uploaded images
Build and Deploy / build-and-push (push) Successful in 29s
2026-09-30 16:45:20 +05:30
AI Bot 0623f30837 feat: Add Clean Image (AI) button to pre-process images before Meshy 3D generation
Build and Deploy / build-and-push (push) Successful in 1m11s
2026-09-30 16:29:16 +05:30
AI Bot aa5de9f197 fix: Align planar UV projection with GLTF spec to fix upside-down textures
Build and Deploy / build-and-push (push) Successful in 1m14s
2026-09-22 10:11:54 +05:30
AI Bot 585e6a2032 fix: Remove backslash escape causing syntax error in backend/server.js
Build and Deploy / build-and-push (push) Successful in 1m2s
2026-09-17 11:12:05 +05:30
AI Bot df40822129 fix: Restore UVEditor and implement AI texture editing
Build and Deploy / build-and-push (push) Successful in 1m21s
2026-09-17 11:03:58 +05:30
AI Bot 76d4d39687 fix: Fetch keys when ImageTo3DModal opens
Build and Deploy / build-and-push (push) Failing after 24s
2026-09-17 10:43:50 +05:30
AI Bot 2d51352854 fix: Load API keys from database in ImageTo3DModal
Build and Deploy / build-and-push (push) Failing after 1m17s
2026-09-17 10:34:23 +05:30
AI Bot c4253022db feat: Add layer-wise texture editing using Gemini AI
Build and Deploy / build-and-push (push) Failing after 54s
2026-09-17 09:56:37 +05:30
AI Bot 6e182cf4ac feat: Add Flip X and Flip Y options for UV baking
Build and Deploy / build-and-push (push) Successful in 1m56s
2026-09-17 09:44:35 +05:30
AI Bot 12e406664b fix: Invert X and Y for baked UV texture
Build and Deploy / build-and-push (push) Successful in 1m19s
2026-09-17 09:41:34 +05:30
AI Bot e2ae339abf feat: Add UV reprojection and texture baking via WebGL
Build and Deploy / build-and-push (push) Successful in 1m27s
2026-09-17 09:34:14 +05:30
AI Bot c7ebd76e1c fix: Hardcode SMTP credentials for deployment
Build and Deploy / build-and-push (push) Successful in 42s
2026-09-17 09:05:04 +05:30
AI Bot b7a08b687d fix: Add missing auth API methods
Build and Deploy / build-and-push (push) Successful in 49s
2026-09-17 08:55:31 +05:30
AI Bot cbf44587aa feat: User management, SMTP email activation and password resets
Build and Deploy / build-and-push (push) Successful in 1m0s
2026-09-17 08:49:11 +05:30
AI Bot 603aee646c fix: Pass proxy URL to onPushToApp to prevent CORS errors when importing Meshy models into main scene
Build and Deploy / build-and-push (push) Successful in 25s
2026-09-14 23:04:03 +05:30
AI Bot 3351ab43e1 fix: Restore meshy-6 as the required Image-To-3D model since meshy-4 is deprecated
Build and Deploy / build-and-push (push) Successful in 23s
2026-09-14 22:51:59 +05:30
AI Bot 23941c23ed fix: Add detailed error logging for Meshy API task creation failure
Build and Deploy / build-and-push (push) Successful in 37s
2026-09-14 22:35:24 +05:30
AI Bot 1e79ca762f fix: Revert to Meshy Image-To-3D V1 and update models to meshy-4/meshy-3
Build and Deploy / build-and-push (push) Successful in 37s
2026-09-14 22:30:03 +05:30
AI Bot f893ed697b fix: Upgrade Meshy API to V2 endpoints to support meshy-6 and fix infinite stuck states
Build and Deploy / build-and-push (push) Successful in 28s
2026-09-14 22:23:49 +05:30
AI Bot df24db62e5 fix: Resolve Meshy stuck generation by fixing status string case matching and ai_model parameter
Build and Deploy / build-and-push (push) Successful in 25s
2026-09-14 22:16:13 +05:30
AI Bot 90127e98c0 fix: Add backend proxy for GLB files to resolve CORS in ImageTo3DModal
Build and Deploy / build-and-push (push) Successful in 27s
2026-09-14 21:31:40 +05:30
AI Bot 79d8d9b4fa fix: restore image upload UI in ImageTo3DModal and enforce high quality Meshy generation
Build and Deploy / build-and-push (push) Successful in 46s
2026-09-14 21:14:00 +05:30
AI Bot a4853e73e0 feat: Add HQ image preview modal in SparkPlug and improve Meshy 3D model quality via topology parameters
Build and Deploy / build-and-push (push) Successful in 57s
2026-09-03 15:38:26 +05:30
18 changed files with 1519 additions and 511 deletions
+12
View File
@@ -44,6 +44,18 @@ const initDB = async () => {
// Ignore if it already exists
}
try {
await client.query(`ALTER TABLE profiles ADD COLUMN activation_token TEXT`);
} catch (err) {
// Ignore if it already exists
}
try {
await client.query(`ALTER TABLE profiles ADD COLUMN reset_token TEXT`);
} catch (err) {
// Ignore if it already exists
}
await client.query(`
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
View File
+32 -4
View File
@@ -4,10 +4,10 @@ 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)
secure: process.env.SMTP_SECURE === 'true' || false, // true for 465, false for other ports (587 uses STARTTLS)
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
user: process.env.SMTP_USER || 'noreply@digifox.studio',
pass: process.env.SMTP_PASS || 'pynpzvcdrghqyxlj',
},
tls: {
ciphers: 'SSLv3', // sometimes required for Office365
@@ -26,7 +26,7 @@ const transporter = nodemailer.createTransport({
const sendMail = async (to, subject, text, html = '') => {
try {
const info = await transporter.sendMail({
from: process.env.SMTP_FROM,
from: process.env.SMTP_FROM || 'noreply@digifox.studio',
to,
subject,
text,
@@ -40,7 +40,35 @@ const sendMail = async (to, subject, text, html = '') => {
}
};
const sendActivationEmail = async (email, token, baseUrl) => {
const activationLink = `${baseUrl}/activate?token=${token}`;
const subject = "Activate your Titan 3D Account";
const html = `
<h2>Welcome to Titan 3D!</h2>
<p>Please click the link below to activate your account:</p>
<a href="${activationLink}">${activationLink}</a>
<p>If you did not request this, please ignore this email.</p>
`;
const text = `Welcome to Titan 3D! Please navigate to the following link to activate your account: ${activationLink}`;
return sendMail(email, subject, text, html);
};
const sendPasswordResetEmail = async (email, token, baseUrl) => {
const resetLink = `${baseUrl}/reset-password?token=${token}`;
const subject = "Reset your Titan 3D Password";
const html = `
<h2>Password Reset Request</h2>
<p>Please click the link below to securely reset your password:</p>
<a href="${resetLink}">${resetLink}</a>
<p>If you did not request this, please ignore this email.</p>
`;
const text = `Please navigate to the following link to reset your password: ${resetLink}`;
return sendMail(email, subject, text, html);
};
module.exports = {
sendMail,
sendActivationEmail,
sendPasswordResetEmail,
transporter
};
+236 -5
View File
@@ -11,8 +11,10 @@ const pdfParse = require('pdf-parse');
const { GoogleGenerativeAI } = require('@google/generative-ai');
const axios = require('axios');
const crypto = require('crypto');
const { db, run, get, all } = require('./database');
const { sendMail } = require('./mail');
const { sendMail, sendActivationEmail, sendPasswordResetEmail } = require('./mail');
const app = express();
app.use(cors());
@@ -70,13 +72,26 @@ app.post('/api/auth/register', async (req, res) => {
const role = parseInt(countRow.count) === 0 ? 'admin' : 'user';
const is_active = parseInt(countRow.count) === 0 ? true : false;
let activation_token = null;
if (!is_active) {
activation_token = crypto.randomBytes(32).toString('hex');
}
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]
'INSERT INTO profiles (id, email, password_hash, role, api_credits, storage_limit_mb, is_active, activation_token) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[id, email, hash, role, 10, 500, is_active, activation_token]
);
if (is_active === false) {
return res.json({ status: 'pending', message: 'Account created. Please contact an administrator to activate your account.' });
// Send activation email
const baseUrl = req.headers.origin || 'http://localhost:5173';
try {
await sendActivationEmail(email, activation_token, baseUrl);
} catch (err) {
console.error('Failed to send activation email', err);
// We still return pending, maybe they can resend later
}
return res.json({ status: 'pending', message: 'Account created. Please check your email for the activation link.' });
}
const token = jwt.sign({ id, email, role }, JWT_SECRET, { expiresIn: '7d' });
@@ -87,6 +102,21 @@ app.post('/api/auth/register', async (req, res) => {
}
});
// 1.5. Auth Activate
app.post('/api/auth/activate', async (req, res) => {
const { token } = req.body;
if (!token) return res.status(400).json({ error: 'Activation token required' });
try {
const user = await get('SELECT * FROM profiles WHERE activation_token = ?', [token]);
if (!user) return res.status(400).json({ error: 'Invalid or expired activation token' });
await run('UPDATE profiles SET is_active = true, activation_token = NULL WHERE id = ?', [user.id]);
res.json({ success: true, message: 'Account activated successfully.' });
} 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;
@@ -221,6 +251,53 @@ app.get('/api/users', authenticate, async (req, res) => {
}
});
// 7.1. Add user (Admin only)
app.post('/api/users', authenticate, async (req, res) => {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Forbidden' });
const { email, role, api_credits, storage_limit_mb } = req.body;
if (!email) return res.status(400).json({ error: 'Email required' });
try {
const existing = await get('SELECT * FROM profiles WHERE email = ?', [email]);
if (existing) return res.status(400).json({ error: 'User already exists' });
// Generate random temp password (they will reset it anyway)
const tempPassword = crypto.randomBytes(16).toString('hex');
const hash = await bcrypt.hash(tempPassword, 10);
const id = uuidv4();
const activation_token = crypto.randomBytes(32).toString('hex');
await run(
'INSERT INTO profiles (id, email, password_hash, role, api_credits, storage_limit_mb, is_active, activation_token) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[id, email, hash, role || 'user', api_credits || 10, storage_limit_mb || 500, false, activation_token]
);
const baseUrl = req.headers.origin || 'http://localhost:5173';
try {
await sendActivationEmail(email, activation_token, baseUrl);
} catch (err) {
console.error('Failed to send activation email', err);
}
res.json({ success: true, message: 'User created and activation email sent.' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 7.2. Delete user (Admin only)
app.delete('/api/users/:id', authenticate, async (req, res) => {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Forbidden' });
try {
// Cascade delete projects for the user
await run('DELETE FROM projects WHERE user_id = ?', [req.params.id]);
await run('DELETE FROM profiles WHERE id = ?', [req.params.id]);
res.json({ success: true });
} 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' });
@@ -233,6 +310,68 @@ app.put('/api/users/:id', authenticate, async (req, res) => {
}
});
// 8.1. Admin trigger reset password for user
app.post('/api/users/:id/reset-password', authenticate, async (req, res) => {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Forbidden' });
try {
const user = await get('SELECT * FROM profiles WHERE id = ?', [req.params.id]);
if (!user) return res.status(404).json({ error: 'User not found' });
const reset_token = crypto.randomBytes(32).toString('hex');
await run('UPDATE profiles SET reset_token = ? WHERE id = ?', [reset_token, user.id]);
const baseUrl = req.headers.origin || 'http://localhost:5173';
try {
await sendPasswordResetEmail(user.email, reset_token, baseUrl);
} catch (err) {
console.error('Failed to send reset email', err);
}
res.json({ success: true, message: 'Password reset email sent to user.' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 8.2. Forgot Password (User request)
app.post('/api/auth/forgot-password', async (req, res) => {
const { email } = req.body;
if (!email) return res.status(400).json({ error: 'Email required' });
try {
const user = await get('SELECT * FROM profiles WHERE email = ?', [email]);
if (user) {
const reset_token = crypto.randomBytes(32).toString('hex');
await run('UPDATE profiles SET reset_token = ? WHERE id = ?', [reset_token, user.id]);
const baseUrl = req.headers.origin || 'http://localhost:5173';
try {
await sendPasswordResetEmail(user.email, reset_token, baseUrl);
} catch (err) {
console.error('Failed to send reset email', err);
}
}
// Always return success to prevent email enumeration
res.json({ success: true, message: 'If an account exists, a reset link has been sent.' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 8.3. Reset Password (User submission)
app.post('/api/auth/reset-password', async (req, res) => {
const { token, newPassword } = req.body;
if (!token || !newPassword) return res.status(400).json({ error: 'Token and new password required' });
try {
const user = await get('SELECT * FROM profiles WHERE reset_token = ?', [token]);
if (!user) return res.status(400).json({ error: 'Invalid or expired reset token' });
const hash = await bcrypt.hash(newPassword, 10);
await run('UPDATE profiles SET password_hash = ?, reset_token = NULL WHERE id = ?', [hash, user.id]);
res.json({ success: true, message: 'Password has been reset successfully.' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 9. Deduct API credit
app.post('/api/users/deduct-credit', authenticate, async (req, res) => {
try {
@@ -246,6 +385,22 @@ app.post('/api/users/deduct-credit', authenticate, async (req, res) => {
}
});
// 9.5 Proxy for downloading GLB files to fix CORS issues in frontend viewers
app.get('/api/proxy/glb', async (req, res) => {
const { url } = req.query;
if (!url) return res.status(400).json({ error: 'URL required' });
try {
const response = await axios.get(url, { responseType: 'stream' });
res.setHeader('Content-Type', 'model/gltf-binary');
// Add CORS headers for the frontend to easily fetch it
res.setHeader('Access-Control-Allow-Origin', '*');
response.data.pipe(res);
} catch (err) {
console.error('Proxy error:', err.message);
res.status(500).json({ error: 'Failed to proxy file' });
}
});
// 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' });
@@ -492,6 +647,77 @@ CRITICAL INSTRUCTIONS:
}
});
// Edit Layer (Gemini Flash Image)
app.post('/api/sparkplug/edit-layer', authenticate, async (req, res) => {
const { prompt, imageBase64, mimeType } = req.body;
if (!prompt || !imageBase64) return res.status(400).json({ error: 'Missing prompt or image' });
try {
const keys = await getKeysFromProfile(req.user.id);
const apiKey = keys.GEMINI_API_KEY || keys.NANO_BANANA_API_KEY;
if (!apiKey) return res.status(400).json({ error: 'Gemini API key missing' });
const baseUrl = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-image:generateContent';
const base64Data = imageBase64.replace(/^data:image\/\w+;base64,/, "");
const payload = {
contents: [
{
parts: [
{ text: `You are an expert texture editor. Edit the following image based on this prompt: "${prompt}". \nCRITICAL INSTRUCTIONS:\n1. Output MUST be an image with the exact same aspect ratio as the input.\n2. Do NOT change the layout or mapping of the texture.\n3. Increase the resolution and quality of the visual elements as requested.` },
{
inlineData: {
mimeType: mimeType || "image/png",
data: base64Data
}
}
]
}
],
generationConfig: {
responseModalities: ["IMAGE"]
}
};
const response = await axios.post(`${baseUrl}?key=${apiKey}`, payload, {
headers: { 'Content-Type': 'application/json', 'Accept-Encoding': 'gzip,deflate,compress' }
});
let resultBase64 = null;
let resultMime = 'image/jpeg';
const candidate = response.data?.candidates?.[0];
if (candidate?.content?.parts) {
for (const part of candidate.content.parts) {
if (part.inlineData && part.inlineData.data) {
resultBase64 = part.inlineData.data;
resultMime = part.inlineData.mimeType || 'image/jpeg';
break;
}
}
}
if (!resultBase64) {
throw new Error(`Could not extract base64 image data from Gemini response`);
}
const buffer = Buffer.from(resultBase64, 'base64');
const ext = resultMime === 'image/png' ? 'png' : 'jpg';
const filename = `layer-edit-${req.user.id}-${Date.now()}.${ext}`;
const filepath = path.join(uploadsDir, filename);
fs.writeFileSync(filepath, buffer);
res.json({ imageUrl: `/uploads/${filename}` });
} catch (err) {
let errorMsg = err.message;
if (err.response && err.response.data) {
errorMsg = typeof err.response.data === 'object' ? JSON.stringify(err.response.data) : err.response.data;
}
console.error("Gemini Edit Layer Error:", errorMsg);
res.status(500).json({ error: errorMsg });
}
});
// 3. Generate 3D (Meshy)
app.post('/api/sparkplug/generate-3d', authenticate, async (req, res) => {
const { imageUrls } = req.body;
@@ -516,7 +742,12 @@ app.post('/api/sparkplug/generate-3d', authenticate, async (req, res) => {
const response = await axios.post('https://api.meshy.ai/openapi/v1/multi-image-to-3d', {
image_urls: base64Images,
enable_pbr: true
enable_pbr: true,
topology: 'quad',
target_polycount: 50000,
ai_model: 'meshy-6',
hd_texture: true,
model_type: 'standard'
}, {
headers: {
'Authorization': `Bearer ${meshyKey}`,
+4
View File
@@ -9,6 +9,8 @@ import Home from './pages/Home';
import Dashboard from './pages/Dashboard';
import Admin from './pages/Admin';
import SparkPlug from './pages/SparkPlug';
import Activate from './pages/Activate';
import ResetPassword from './pages/ResetPassword';
import { AuthModal } from './components/AuthModal';
export default function App() {
@@ -32,6 +34,8 @@ export default function App() {
<Routes>
{/* Public Routes */}
<Route path="/" element={<Home />} />
<Route path="/activate" element={<Activate />} />
<Route path="/reset-password" element={<ResetPassword />} />
{/* Protected Routes (User) */}
<Route element={<ProtectedRoute allowedRoles={['user', 'admin']} />}>
+33 -18
View File
@@ -26,10 +26,13 @@ export function AuthModal() {
setSuccess('');
try {
if (isRegister) {
if (authMode === 'forgot-password') {
await api.forgotPassword(email);
setSuccess('If an account exists, a reset link has been sent to your email.');
} else 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.');
setSuccess(res.message || 'Account created successfully! Please check your email for the activation link.');
setEmail('');
setPassword('');
} else {
@@ -84,10 +87,10 @@ export function AuthModal() {
</div>
<h2 className="text-2xl font-bold text-white text-center mb-2 relative z-10">
{isRegister ? 'Create an Account' : 'Welcome Back'}
{authMode === 'forgot-password' ? 'Reset Password' : 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.'}
{authMode === 'forgot-password' ? 'Enter your email to receive a reset link.' : isRegister ? 'Sign up to start building 3D scenes.' : 'Sign in to access your projects.'}
</p>
{error && (
@@ -116,18 +119,28 @@ export function AuthModal() {
/>
</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>
{authMode !== 'forgot-password' && (
<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>
)}
{authMode === 'login' && (
<div className="flex justify-end">
<button type="button" onClick={() => openAuthModal('forgot-password')} className="text-xs text-primary hover:underline">
Forgot Password?
</button>
</div>
)}
<button
type="submit"
@@ -135,12 +148,14 @@ export function AuthModal() {
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'}
{authMode === 'forgot-password' ? 'Send Reset Link' : isRegister ? 'Sign Up' : 'Sign In'}
</button>
</form>
<div className="mt-8 text-center text-sm text-zinc-400 relative z-10">
{isRegister ? (
{authMode === 'forgot-password' ? (
<p>Remember your password? <button onClick={() => openAuthModal('login')} className="text-primary hover:underline font-medium">Sign in</button></p>
) : 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>
+300 -443
View File
@@ -1,10 +1,10 @@
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 { X, Upload, Settings as SettingsIcon, Box, AlertCircle, Loader2, RefreshCw, Check } from 'lucide-react';
import { cn } from '../lib/utils';
import { useAuthStore } from '../store/authStore';
import { api } from '../lib/api';
import { editUVLayer } from '../lib/api/apiUtils';
import { Canvas } from '@react-three/fiber';
import { OrbitControls, Stage } from '@react-three/drei';
import { GLTFLoader } from 'three-stdlib';
@@ -16,13 +16,6 @@ interface ImageTo3DModalProps {
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);
@@ -36,7 +29,7 @@ const ModelPreview = ({ url }: { url: string }) => {
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 }}>
<Canvas shadows={{ type: THREE.PCFShadowMap }} camera={{ position: [0, 1.5, 3], fov: 45 }}>
<Stage environment="studio" intensity={0.5}>
<primitive object={model} />
</Stage>
@@ -47,13 +40,15 @@ const ModelPreview = ({ url }: { url: string }) => {
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);
// Image states
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [originalImagePreview, setOriginalImagePreview] = useState<string | null>(null);
const [cleanedImageFile, setCleanedImageFile] = useState<File | null>(null);
const [cleanedImagePreview, setCleanedImagePreview] = useState<string | null>(null);
const [isCleaningImage, setIsCleaningImage] = useState(false);
// Meshy specific state
const [meshyModelType, setMeshyModelType] = useState('standard');
@@ -73,68 +68,54 @@ export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ isOpen, onClose,
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);
if (isOpen) {
api.getUserKeys().then(keys => {
if (keys.MESHY_API_KEY) setMeshyApiKey(keys.MESHY_API_KEY);
}).catch(err => console.error("Failed to load user keys", err));
}
};
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);
}, []);
}, [isOpen]);
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setImageFile(file);
const url = URL.createObjectURL(file);
setImagePreview(url);
setOriginalImagePreview(url);
setGeneratedModelUrl(null);
setError(null);
// Auto clean on upload
handleCleanImage(file);
}
};
const handleCleanImage = async (fileToClean: File) => {
setIsCleaningImage(true);
setCleanedImageFile(null);
setCleanedImagePreview(null);
try {
const base64 = await fileToDataUrl(fileToClean);
const token = localStorage.getItem('auth_token') || '';
const newUrl = await editUVLayer("Remove the background and make it solid transparent or white. Also carefully remove all text, labels, logos, and stickers, keeping the product as a perfectly clean, plain, unbranded object.", base64, token);
const res = await fetch(newUrl);
const blob = await res.blob();
const newFile = new File([blob], "cleaned-image.png", { type: "image/png" });
setCleanedImageFile(newFile);
setCleanedImagePreview(newUrl);
} catch (err: any) {
setError("Failed to clean image: " + err.message);
// Fallback to original
setCleanedImageFile(fileToClean);
setCleanedImagePreview(URL.createObjectURL(fileToClean));
} finally {
setIsCleaningImage(false);
}
};
// 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();
@@ -145,7 +126,8 @@ export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ isOpen, onClose,
};
const generate3DModel = async () => {
if (!imageFile) {
const targetFile = cleanedImageFile || imageFile;
if (!targetFile) {
setError("Please upload an image first.");
return;
}
@@ -153,15 +135,7 @@ export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ isOpen, onClose,
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) {
if (!meshyApiKey) {
setError("Meshy API Key is required. Please set it in Tools > API Settings.");
return;
}
@@ -180,190 +154,80 @@ export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ isOpen, onClose,
try {
setIsGenerating(true);
setError(null);
setProgressMessage("Uploading image...");
const dataUrl = await fileToDataUrl(targetFile);
setProgressMessage("Generating 3D Model with Meshy... This might take up to a minute.");
if (apiProvider === 'fal') {
fal.config({ credentials: apiKey });
const dataUrl = await fileToDataUrl(imageFile);
setProgressMessage("Generating 3D Model... This might take up to a minute.");
let payload: any = {
image_url: dataUrl,
model_type: meshyModelType,
should_texture: meshyShouldTexture,
should_enhance_image: meshyEnhanceImage,
target_formats: ['glb']
};
let inputPayload: any = { image_url: dataUrl };
if (selectedModel.includes('tripo')) {
inputPayload = { image_url: dataUrl };
if (meshyModelType !== 'lowpoly') {
payload.ai_model = meshyAiModel === 'latest' ? 'meshy-6' : meshyAiModel;
payload.should_remesh = meshyShouldRemesh;
if (meshyShouldRemesh) {
payload.topology = meshyTopology;
payload.target_polycount = meshyTargetPolycount;
}
}
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 (meshyShouldTexture) {
payload.enable_pbr = meshyEnablePbr;
if (meshyAiModel !== 'meshy-5') {
payload.hd_texture = meshyHdTexture;
}
if (modelUrl) {
setGeneratedModelUrl(modelUrl);
setProgressMessage("Done!");
deductCredit();
} else {
setError("Failed to extract 3D model from response.");
if (meshyTextureImageUrl) {
payload.texture_image_url = meshyTextureImageUrl;
} else if (meshyTexturePrompt) {
payload.texture_prompt = meshyTexturePrompt;
}
} 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',
}
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) {
const errData = await taskRes.text();
console.error("Meshy task creation error payload:", errData);
throw new Error(`Meshy API: Task creation failed. ${errData}`);
}
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 ${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,
target_formats: ['glb']
};
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}`);
}
});
if (!pollRes.ok) throw new Error("Meshy API: Polling failed.");
const pollData = await pollRes.json();
const status = (pollData.status || '').toUpperCase();
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(`/api/proxy/glb?url=${encodeURIComponent(modelUrl)}`);
setProgressMessage("Done!");
isDone = true;
deductCredit();
} else if (status === 'FAILED' || status === 'EXPIRED') {
throw new Error(`Meshy API Task ${status}: ${pollData.task_error?.message || 'Unknown error'}`);
}
}
} catch (err: any) {
@@ -383,233 +247,226 @@ export const ImageTo3DModal: React.FC<ImageTo3DModalProps> = ({ isOpen, onClose,
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]"
className="w-full max-w-5xl bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden flex flex-col h-[700px]"
>
{/* Header */}
<div className="h-12 bg-zinc-800 flex items-center justify-between px-6 border-b border-zinc-700">
<div className="h-14 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>
<Box size={20} />
<span className="text-base font-bold text-zinc-200">Meshy 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 onClick={onClose} className="p-2 hover:bg-zinc-700 rounded transition-colors">
<X size={18} 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="grid grid-cols-2 gap-8 h-full">
{/* Left Column: Upload & Clean */}
<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>
<h3 className="text-sm font-bold text-zinc-200">Input Image (Auto Cleaned)</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>
</>
) : (
{!originalImagePreview ? (
<label className="flex-1 min-h-[220px] 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} />
<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 className="w-14 h-14 rounded-full bg-zinc-800 flex items-center justify-center">
<Upload size={24} 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>
<p className="text-base font-medium text-zinc-300 mb-1">Click to upload image</p>
<p className="text-sm">PNG, JPG up to 10MB</p>
<p className="text-xs mt-3 text-blue-400 font-medium">Image will be automatically cleaned of text/logos.</p>
</div>
</div>
</label>
) : (
<div className="flex gap-4 h-[220px]">
{/* Original Image */}
<label className="flex-1 relative border-2 border-dashed border-zinc-700 rounded-xl overflow-hidden group cursor-pointer">
<input type="file" ref={fileInputRef} accept="image/png, image/jpeg" className="hidden" onChange={handleImageUpload} />
<img src={originalImagePreview} alt="Original" className="w-full h-full object-contain p-2" />
<div className="absolute inset-x-0 bottom-0 bg-black/80 text-center text-xs text-zinc-400 py-1.5 border-t border-zinc-800">Original</div>
<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-sm font-medium flex items-center gap-2"><Upload size={16}/> Change Image</span>
</div>
</label>
{/* Cleaned Image */}
<div className="flex-1 relative border-2 border-solid border-zinc-700 rounded-xl overflow-hidden bg-zinc-900/50 flex items-center justify-center group">
{isCleaningImage ? (
<>
<img src={originalImagePreview} alt="Original Grayscale" className="absolute inset-0 w-full h-full object-contain p-2 opacity-30 grayscale blur-sm" />
<div className="absolute inset-0 flex flex-col items-center justify-center text-zinc-300 gap-3 bg-black/40 z-10">
<Loader2 size={28} className="animate-spin text-blue-500" />
<span className="text-sm font-medium">AI Cleaning Image...</span>
</div>
</>
) : cleanedImagePreview ? (
<>
<img src={cleanedImagePreview} alt="Cleaned" className="w-full h-full object-contain p-2" />
<div className="absolute inset-x-0 bottom-0 bg-blue-900/80 text-center text-xs text-blue-200 py-1.5 font-bold border-t border-blue-800">Cleaned Result</div>
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 flex flex-col items-center justify-center gap-3 transition-opacity">
<button onClick={(e) => { e.preventDefault(); handleCleanImage(imageFile!); }} className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm font-bold shadow-lg flex items-center gap-2 transition-colors">
<RefreshCw size={14} /> Try Again
</button>
</div>
</>
) : (
<span className="text-zinc-600 text-sm">Failed to clean.</span>
)}
</div>
</div>
)}
<div className="flex flex-col gap-3 mt-4 bg-zinc-900 p-4 rounded-xl border border-zinc-800">
<h4 className="text-xs font-bold text-zinc-400 uppercase tracking-wider flex items-center gap-1.5"><SettingsIcon size={14}/> Meshy Config</h4>
<div className="grid grid-cols-2 gap-4 mt-1">
<div className="flex flex-col gap-1.5">
<label className="text-xs 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-lg px-3 py-2 text-sm text-zinc-300">
<option value="standard">Standard</option>
<option value="lowpoly">Low Poly</option>
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs 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-lg px-3 py-2 text-sm 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-5 mt-2">
<label className="flex items-center gap-2 text-sm text-zinc-300 cursor-pointer">
<input type="checkbox" checked={meshyShouldTexture} onChange={e => setMeshyShouldTexture(e.target.checked)} className="rounded border-zinc-700 bg-zinc-900 w-4 h-4 text-blue-500 cursor-pointer" />
Texture
</label>
<label className={cn("flex items-center gap-2 text-sm 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 w-4 h-4 text-blue-500 cursor-pointer" />
PBR
</label>
<label className={cn("flex items-center gap-2 text-sm 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 w-4 h-4 text-blue-500 cursor-pointer" />
4K HD
</label>
</div>
{meshyShouldTexture && (
<div className="flex flex-col gap-2 mt-1">
<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-lg px-3 py-2 text-sm text-zinc-300" />
</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>
<div className="flex items-center gap-5 mt-2">
<label className={cn("flex items-center gap-2 text-sm 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 w-4 h-4 text-blue-500 cursor-pointer" />
Remesh
</label>
<label className="flex items-center gap-2 text-sm 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 w-4 h-4 text-blue-500 cursor-pointer" />
Enhance Image
</label>
</div>
{meshyShouldRemesh && meshyModelType !== 'lowpoly' && (
<div className="grid grid-cols-2 gap-4 mt-1">
<div className="flex flex-col gap-1.5">
<label className="text-xs 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-lg px-3 py-2 text-sm 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">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 className="flex flex-col gap-1.5">
<label className="text-xs 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-lg px-3 py-2 text-sm text-zinc-300" />
</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>
{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-sm mt-auto">
<AlertCircle size={16} className="shrink-0 mt-0.5" />
<p>{error}</p>
</div>
)}
</div>
{/* Right Column: 3D Preview */}
<div className="flex flex-col h-full bg-zinc-950 rounded-xl overflow-hidden border border-zinc-800 relative">
<div className="absolute top-4 left-4 z-10">
<h3 className="text-sm font-bold text-zinc-200">3D Preview</h3>
</div>
<div className="flex-1 relative flex items-center justify-center overflow-hidden">
{isGenerating ? (
<>
{cleanedImagePreview || originalImagePreview ? (
<img src={(cleanedImagePreview || originalImagePreview) as string} alt="Generating Base" className="absolute inset-0 w-full h-full object-contain p-8 opacity-20 grayscale blur-md scale-110" />
) : null}
<div className="absolute inset-0 flex flex-col items-center justify-center text-zinc-300 bg-black/60 z-20">
<Loader2 size={36} className="animate-spin text-blue-500 mb-4" />
<p className="text-sm font-medium text-zinc-200">{progressMessage}</p>
</div>
</>
) : generatedModelUrl ? (
<ModelPreview url={generatedModelUrl} />
) : (
<div className="flex flex-col items-center justify-center text-zinc-600 p-6 text-center">
<Box size={32} className="mb-3 opacity-50" />
<p className="text-sm">Your 3D model will appear here</p>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
{/* Footer actions */}
<div className="h-16 bg-zinc-900 border-t border-zinc-800 px-6 flex items-center justify-between shrink-0">
<div className="text-xs font-medium px-3 py-1.5 bg-blue-500/10 text-blue-400 rounded-full border border-blue-500/20 flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-pulse" />
Meshy Generation: 200 AI Credits
</div>
<div className="flex gap-3">
<button
onClick={onClose}
className="px-6 py-2 bg-zinc-800 hover:bg-zinc-700 text-white rounded-lg text-sm font-bold transition-colors"
>
Cancel
</button>
{generatedModelUrl ? (
<button
onClick={() => onPushToApp(generatedModelUrl)}
className="px-6 py-2 bg-green-600 hover:bg-green-500 text-white rounded-lg text-sm font-bold shadow-lg shadow-green-900/20 transition-all flex items-center gap-2"
>
<Check size={16} />
Use Model
</button>
) : (
<button
onClick={generate3DModel}
disabled={!imageFile || isGenerating || isCleaningImage}
className="px-8 py-2 bg-blue-600 hover:bg-blue-500 disabled:bg-zinc-800 disabled:text-zinc-500 text-white rounded-lg text-sm font-bold shadow-lg shadow-blue-900/20 transition-all flex items-center gap-2"
>
{isGenerating ? <Loader2 size={16} className="animate-spin" /> : <Box size={16} />}
{isGenerating ? 'Generating...' : 'Generate 3D Model'}
</button>
)}
</div>
</div>
</motion.div>
</div>
+49 -1
View File
@@ -3,10 +3,13 @@ import * as THREE from "three";
import { useFrame, useThree, createPortal } from "@react-three/fiber";
import { MaterialFactory } from "../materials/MaterialFactory";
import { useTexturePipeline } from "../hooks/useTexturePipeline";
import { reprojectUVs, bakeTextureToNewUVs } from '../lib/bakingUtils';
interface ModelViewerProps {
object: THREE.Object3D | null;
sceneObjects: any[];
bakeRequest?: { id: string, type: 'planar' | 'cylinder' | 'sphere' | 'box', flipX?: boolean, flipY?: boolean, timestamp: number } | null;
onBakeComplete?: (dataUrl: string) => void;
}
interface MeshMaterialManagerProps {
@@ -25,6 +28,12 @@ const MeshMaterialManager = ({ mesh, sceneObj, textureCache }: MeshMaterialManag
const uvTiling = props.uvTiling || [1, 1];
const uvOffset = props.uvOffset || [0, 0];
const uvRotation = props.uvRotation || 0;
const bakedMappingType = props.bakedMappingType;
// Automatically re-apply baked UV projections on load
if (bakedMappingType && ['planar', 'cylinder', 'sphere', 'box'].includes(bakedMappingType)) {
reprojectUVs(mesh.geometry, bakedMappingType as any);
}
const origMat = (Array.isArray(mesh.material) ? mesh.material[0] : mesh.material) as THREE.MeshPhysicalMaterial;
@@ -211,7 +220,9 @@ const FillMesh = ({ fillProps, originalMesh }: { fillProps: any, originalMesh: T
return createPortal(fillNode, originalMesh);
};
export const ModelViewer = forwardRef<THREE.Group, ModelViewerProps>(({ object, sceneObjects }, ref) => {
export const ModelViewer = forwardRef<THREE.Group, ModelViewerProps>(({ object, sceneObjects, bakeRequest, onBakeComplete }, ref) => {
const internalRef = useRef<THREE.Group>(null);
const groupRef = (ref as any) || internalRef;
const textureCache = useRef<Map<string, THREE.Texture>>(new Map());
@@ -241,6 +252,43 @@ export const ModelViewer = forwardRef<THREE.Group, ModelViewerProps>(({ object,
};
}, []);
const { gl } = useThree();
// Handle Baking Request
useEffect(() => {
if (bakeRequest && onBakeComplete && meshCache.size > 0) {
const sceneObj = sceneObjects.find(obj => obj.id === bakeRequest.id);
if (!sceneObj) return;
// Find the primary mesh for this object (we assume the first mesh for now)
let targetMesh: THREE.Mesh | null = null;
for (const mesh of meshCache.values()) {
targetMesh = mesh;
break;
}
if (targetMesh && sceneObj.materialProps?.map) {
const tex = textureCache.current.get(sceneObj.materialProps.map);
if (tex) {
// Keep old UVs
const oldUVArray = targetMesh.geometry.attributes.uv.array.slice() as Float32Array;
// Apply new UV mapping (mutates geometry.attributes.uv)
reprojectUVs(targetMesh.geometry, bakeRequest.type);
// Bake
try {
const dataUrl = bakeTextureToNewUVs(targetMesh, oldUVArray, tex, gl, 1024, bakeRequest.flipX, bakeRequest.flipY);
onBakeComplete(dataUrl);
} catch (e) {
console.error("Baking failed", e);
onBakeComplete(sceneObj.materialProps.map); // Fallback to original
}
}
}
}
}, [bakeRequest, meshCache, sceneObjects, gl, onBakeComplete]);
// Main transform update for the core model
useEffect(() => {
if (object && sceneObjects) {
+116 -1
View File
@@ -4,6 +4,9 @@ import { Canvas } from '@react-three/fiber';
import { OrbitControls, Environment } from '@react-three/drei';
import * as THREE from 'three';
import { ModelViewer } from './ModelViewer';
import { editUVLayer } from '../lib/api/apiUtils';
import { useAuthStore } from '../store/authStore';
import { Loader2 } from 'lucide-react';
interface Layer {
id: string;
@@ -45,6 +48,76 @@ export function UVEditor({ onClose, onSave, targetObject, loadedModel, sceneObje
const fileInputRef = useRef<HTMLInputElement>(null);
const dragRef = useRef<{ handle: Handle; startX: number; startY: number; layer: Layer } | null>(null);
const token = localStorage.getItem('auth_token') || '';
const [aiPrompt, setAiPrompt] = useState("");
const [isEditingLayer, setIsEditingLayer] = useState(false);
const [aiPreviewUrl, setAiPreviewUrl] = useState<string | null>(null);
const [originalLayerUrl, setOriginalLayerUrl] = useState<string | null>(null);
const handleAiEdit = async (regenerate = false) => {
if (!selectedId || !aiPrompt.trim()) return;
const layer = layers.find(l => l.id === selectedId);
if (!layer || !layer.img) return;
setIsEditingLayer(true);
try {
const tempCanvas = document.createElement('canvas');
tempCanvas.width = layer.img.width;
tempCanvas.height = layer.img.height;
const ctx = tempCanvas.getContext('2d');
if (ctx) {
ctx.drawImage(layer.img, 0, 0);
const base64 = tempCanvas.toDataURL('image/png');
const fullPrompt = `The user has provided an image layer. Please apply the following edit exactly as requested, while strictly maintaining the EXACT same aspect ratio, resolution, and general structure of the original image.
User Prompt: "${aiPrompt}"
Negative constraints: Do NOT change the aspect ratio. Do NOT add completely unrelated elements. Do NOT crop the image boundaries.`;
const newUrl = await editUVLayer(fullPrompt, base64, token || '');
if (!originalLayerUrl && !regenerate) {
setOriginalLayerUrl(layer.url);
}
setAiPreviewUrl(newUrl);
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
setLayers(ls => ls.map(l => l.id === selectedId ? { ...l, url: newUrl, img } : l));
setIsEditingLayer(false);
};
img.src = newUrl;
return;
}
} catch (e: any) {
alert("AI Edit failed: " + e.message);
}
setIsEditingLayer(false);
};
const handleApproveEdit = () => {
setAiPreviewUrl(null);
setOriginalLayerUrl(null);
setAiPrompt("");
};
const handleRejectEdit = () => {
if (selectedId && originalLayerUrl) {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
setLayers(ls => ls.map(l => l.id === selectedId ? { ...l, url: originalLayerUrl, img } : l));
setAiPreviewUrl(null);
setOriginalLayerUrl(null);
};
img.src = originalLayerUrl;
} else {
setAiPreviewUrl(null);
setOriginalLayerUrl(null);
}
};
useEffect(() => {
const saved = targetObject?.materialProps?.uvLayers;
if (saved?.length) {
@@ -412,6 +485,48 @@ export function UVEditor({ onClose, onSave, targetObject, loadedModel, sceneObje
<span className="text-[9px] font-mono text-orange-400 w-6 text-right">{Math.round(selectedLayer[s] * 100)}%</span>
</div>
))}
{/* AI Layer Edit */}
<div className="h-px bg-zinc-700 my-1" />
<p className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest flex items-center justify-between">
<span className="flex items-center gap-1.5"><span className="text-blue-400">✨</span> Edit with AI</span>
</p>
<div className="flex flex-col gap-2 relative">
{isEditingLayer && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-zinc-900/80 backdrop-blur-sm rounded border border-zinc-700 p-2 text-center text-xs text-blue-400">
<Loader2 size={18} className="animate-spin mb-1" />
Generating...
</div>
)}
<textarea
placeholder="e.g. Change the color to red, add a vintage filter..."
value={aiPrompt}
onChange={e => setAiPrompt(e.target.value)}
className="w-full h-16 bg-zinc-900 border border-zinc-700 rounded p-2 text-xs text-zinc-300 placeholder:text-zinc-600 resize-none outline-none focus:border-blue-500"
/>
{aiPreviewUrl ? (
<div className="flex gap-2">
<button onClick={handleApproveEdit} className="flex-1 py-1.5 bg-green-500/20 text-green-400 hover:bg-green-500/30 rounded text-[11px] font-semibold border border-green-500/30 transition-colors">
Approve
</button>
<button onClick={() => handleAiEdit(true)} className="flex-1 py-1.5 bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 rounded text-[11px] font-semibold border border-blue-500/30 transition-colors">
Regenerate
</button>
<button onClick={handleRejectEdit} className="flex-1 py-1.5 bg-red-500/20 text-red-400 hover:bg-red-500/30 rounded text-[11px] font-semibold border border-red-500/30 transition-colors">
Revert
</button>
</div>
) : (
<button
onClick={() => handleAiEdit(false)}
disabled={!aiPrompt.trim()}
className="w-full py-1.5 bg-blue-500/10 hover:bg-blue-500/20 disabled:opacity-50 disabled:cursor-not-allowed rounded text-[11px] font-semibold text-blue-400 border border-blue-500/30 transition-colors flex items-center justify-center gap-1.5"
>
<span className="text-[14px] leading-none">✨</span> Generate Edit
</button>
)}
</div>
</div>
)}
</div>
@@ -487,7 +602,7 @@ export function UVEditor({ onClose, onSave, targetObject, loadedModel, sceneObje
<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 }}>
<Canvas shadows={{ type: THREE.PCFShadowMap }} camera={{ position: [2, 2, 2], fov: 45 }}>
<ambientLight intensity={0.5} />
<directionalLight position={[5, 5, 5]} intensity={1} castShadow />
<Environment preset="studio" />
+65
View File
@@ -117,6 +117,71 @@ export const api = {
if (!res.ok) throw new Error(data.error);
return data;
},
addUser: async (userData) => {
const res = await fetch(`${API_URL}/users`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(userData)
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
return data;
},
deleteUser: async (id) => {
const res = await fetch(`${API_URL}/users/${id}`, {
method: 'DELETE',
headers: getHeaders()
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
return data;
},
triggerPasswordReset: async (id) => {
const res = await fetch(`${API_URL}/users/${id}/reset-password`, {
method: 'POST',
headers: getHeaders()
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
return data;
},
// Auth Extensions
activateUser: async (token) => {
const res = await fetch(`${API_URL}/auth/activate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
return data;
},
forgotPassword: async (email) => {
const res = await fetch(`${API_URL}/auth/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
return data;
},
resetPassword: async (token, newPassword) => {
const res = await fetch(`${API_URL}/auth/reset-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, newPassword })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
return data;
},
// Credits
deductCredit: async () => {
+30
View File
@@ -79,3 +79,33 @@ export async function pollStatus<T>(
throw new Error("Polling timeout exceeded.");
}
export async function editUVLayer(prompt: string, imageBase64: string, token: string): Promise<string> {
// Extract mime type if present in the data URI
let mimeType = 'image/png';
if (imageBase64.startsWith('data:')) {
const match = imageBase64.match(/^data:([^;]+);/);
if (match) {
mimeType = match[1];
}
}
const response = await fetch('/api/sparkplug/edit-layer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ prompt, imageBase64, mimeType })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.error) throw new Error(data.error);
return data.imageUrl;
}
+210
View File
@@ -0,0 +1,210 @@
import * as THREE from 'three';
/**
* Calculates new UVs for a geometry based on the chosen projection mapping.
* Supported types: 'planar', 'cylinder', 'sphere', 'box'
*/
export function reprojectUVs(geometry: THREE.BufferGeometry, mappingType: 'planar' | 'cylinder' | 'sphere' | 'box') {
if (!geometry.attributes.position) return;
geometry.computeBoundingBox();
const bbox = geometry.boundingBox!;
const size = new THREE.Vector3();
bbox.getSize(size);
const center = new THREE.Vector3();
bbox.getCenter(center);
const posAttr = geometry.attributes.position;
const uvArray = new Float32Array(posAttr.count * 2);
for (let i = 0; i < posAttr.count; i++) {
const x = posAttr.getX(i);
const y = posAttr.getY(i);
const z = posAttr.getZ(i);
// Normalize coordinates around center, scaled by size
const nx = size.x === 0 ? 0.5 : (x - bbox.min.x) / size.x;
const ny = size.y === 0 ? 0.5 : (y - bbox.min.y) / size.y;
const nz = size.z === 0 ? 0.5 : (z - bbox.min.z) / size.z;
let u = 0;
let v = 0;
if (mappingType === 'planar') {
// Front planar projection
u = nx;
v = 1.0 - ny;
} else if (mappingType === 'cylinder') {
// Cylindrical projection
const centeredX = x - center.x;
const centeredZ = z - center.z;
// atan2 returns -PI to PI. We want 0 to 1 around the cylinder.
let angle = Math.atan2(centeredZ, centeredX);
u = 1.0 - ((angle + Math.PI) / (2 * Math.PI));
v = 1.0 - ny;
} else if (mappingType === 'sphere') {
// Spherical projection
const centeredX = x - center.x;
const centeredY = y - center.y;
const centeredZ = z - center.z;
const radius = Math.sqrt(centeredX * centeredX + centeredY * centeredY + centeredZ * centeredZ);
const phi = Math.acos(centeredY / radius); // 0 to PI
let theta = Math.atan2(centeredZ, centeredX); // -PI to PI
u = 1.0 - ((theta + Math.PI) / (2 * Math.PI));
v = phi / Math.PI; // 0 at top, 1 at bottom
} else if (mappingType === 'box') {
// Simple box projection (pick dominant axis based on normal)
// This requires normals
let normX = 0, normY = 0, normZ = 1;
if (geometry.attributes.normal) {
normX = geometry.attributes.normal.getX(i);
normY = geometry.attributes.normal.getY(i);
normZ = geometry.attributes.normal.getZ(i);
}
const ax = Math.abs(normX), ay = Math.abs(normY), az = Math.abs(normZ);
if (ax > ay && ax > az) {
u = nz; v = 1.0 - ny;
} else if (ay > ax && ay > az) {
u = nx; v = 1.0 - nz;
} else {
u = nx; v = 1.0 - ny;
}
}
// Clamp UVs to avoid edge artifacts
u = Math.max(0.001, Math.min(0.999, u));
v = Math.max(0.001, Math.min(0.999, v));
uvArray[i * 2] = u;
uvArray[i * 2 + 1] = v;
}
geometry.setAttribute('uv', new THREE.BufferAttribute(uvArray, 2));
geometry.attributes.uv.needsUpdate = true;
}
/**
* Bakes the old texture applied to the old UVs onto a new texture using the new UVs.
* We do this by rendering the mesh to a WebGLRenderTarget using a custom shader.
* @param mesh The mesh with the new UVs applied to geometry.attributes.uv
* @param oldUVArray The Float32Array of the original UVs
* @param oldTexture The original texture map
* @param renderer The active WebGLRenderer
* @param resolution The resolution of the baked texture
* @returns A data URL of the baked texture
*/
export function bakeTextureToNewUVs(
mesh: THREE.Mesh,
oldUVArray: Float32Array,
oldTexture: THREE.Texture,
renderer: THREE.WebGLRenderer,
resolution = 1024,
flipX = false,
flipY = false
): string {
// Store original geometry attributes
const geometry = mesh.geometry.clone();
// Create a secondary UV set for the old UVs
geometry.setAttribute('uv2', new THREE.BufferAttribute(oldUVArray, 2));
// Custom Shader:
// Position is derived from the new UVs (to flatten the mesh into the 2D canvas)
// Color is sampled from the old texture using the old UVs (uv2)
const bakeMaterial = new THREE.ShaderMaterial({
uniforms: {
tDiffuse: { value: oldTexture },
uFlipX: { value: flipX ? 1.0 : 0.0 },
uFlipY: { value: flipY ? 1.0 : 0.0 }
},
vertexShader: `
attribute vec2 uv2;
varying vec2 vOldUV;
uniform float uFlipX;
uniform float uFlipY;
void main() {
vOldUV = uv2;
float finalX = mix(uv.x * 2.0 - 1.0, 1.0 - uv.x * 2.0, uFlipX);
float finalY = mix(1.0 - uv.y * 2.0, uv.y * 2.0 - 1.0, uFlipY);
gl_Position = vec4(finalX, finalY, 0.0, 1.0);
}
`,
fragmentShader: `
uniform sampler2D tDiffuse;
varying vec2 vOldUV;
void main() {
gl_FragColor = texture2D(tDiffuse, vOldUV);
}
`,
side: THREE.DoubleSide
});
const bakeMesh = new THREE.Mesh(geometry, bakeMaterial);
const scene = new THREE.Scene();
scene.add(bakeMesh);
// Orthographic camera covering exactly -1 to 1 clip space
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, -1, 1);
// Render target for the bake
const renderTarget = new THREE.WebGLRenderTarget(resolution, resolution, {
format: THREE.RGBAFormat,
type: THREE.UnsignedByteType,
colorSpace: THREE.SRGBColorSpace,
magFilter: THREE.LinearFilter,
minFilter: THREE.LinearFilter
});
const originalRenderTarget = renderer.getRenderTarget();
const originalClearColor = renderer.getClearColor(new THREE.Color());
const originalClearAlpha = renderer.getClearAlpha();
// Render
renderer.setRenderTarget(renderTarget);
renderer.setClearColor(0x000000, 0); // Transparent background
renderer.clear();
renderer.render(scene, camera);
// Read pixels from render target
const pixels = new Uint8Array(resolution * resolution * 4);
renderer.readRenderTargetPixels(renderTarget, 0, 0, resolution, resolution, pixels);
// Restore renderer state
renderer.setRenderTarget(originalRenderTarget);
renderer.setClearColor(originalClearColor, originalClearAlpha);
// Flip Y when copying to canvas because readRenderTargetPixels reads bottom-up
const canvas = document.createElement('canvas');
canvas.width = resolution;
canvas.height = resolution;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error("Could not get 2d context");
const imgData = ctx.createImageData(resolution, resolution);
// Manual flip Y
for (let y = 0; y < resolution; y++) {
for (let x = 0; x < resolution; x++) {
const srcIndex = ((resolution - 1 - y) * resolution + x) * 4;
const destIndex = (y * resolution + x) * 4;
imgData.data[destIndex] = pixels[srcIndex];
imgData.data[destIndex + 1] = pixels[srcIndex + 1];
imgData.data[destIndex + 2] = pixels[srcIndex + 2];
imgData.data[destIndex + 3] = pixels[srcIndex + 3];
}
}
ctx.putImageData(imgData, 0, 0);
// Clean up
renderTarget.dispose();
geometry.dispose();
bakeMaterial.dispose();
// Return base64 JPEG to match sizes
return canvas.toDataURL('image/jpeg', 0.85);
}
+76
View File
@@ -0,0 +1,76 @@
import { useEffect, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { Loader2, CheckCircle, XCircle } from 'lucide-react';
import { api } from '../lib/api';
export default function Activate() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const token = searchParams.get('token');
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
const [message, setMessage] = useState('');
useEffect(() => {
if (!token) {
setStatus('error');
setMessage('Invalid or missing activation token.');
return;
}
const activateAccount = async () => {
try {
await api.activateUser(token);
setStatus('success');
setMessage('Your account has been successfully activated!');
} catch (error: any) {
setStatus('error');
setMessage(error.message || 'Failed to activate account.');
}
};
activateAccount();
}, [token]);
return (
<div className="min-h-screen bg-zinc-950 flex flex-col items-center justify-center p-4">
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-8 max-w-md w-full text-center shadow-2xl">
{status === 'loading' && (
<div className="flex flex-col items-center">
<Loader2 className="w-12 h-12 text-primary animate-spin mb-4" />
<h2 className="text-xl font-bold text-white mb-2">Activating Account...</h2>
<p className="text-zinc-400">Please wait while we verify your token.</p>
</div>
)}
{status === 'success' && (
<div className="flex flex-col items-center">
<CheckCircle className="w-12 h-12 text-green-500 mb-4" />
<h2 className="text-xl font-bold text-white mb-2">Success!</h2>
<p className="text-zinc-400 mb-6">{message}</p>
<button
onClick={() => navigate('/')}
className="bg-primary hover:bg-primary/90 text-white font-medium py-2 px-6 rounded-xl transition-colors"
>
Go to Login
</button>
</div>
)}
{status === 'error' && (
<div className="flex flex-col items-center">
<XCircle className="w-12 h-12 text-red-500 mb-4" />
<h2 className="text-xl font-bold text-white mb-2">Activation Failed</h2>
<p className="text-zinc-400 mb-6">{message}</p>
<button
onClick={() => navigate('/')}
className="bg-zinc-800 hover:bg-zinc-700 text-white font-medium py-2 px-6 rounded-xl transition-colors"
>
Return Home
</button>
</div>
)}
</div>
</div>
);
}
+149 -27
View File
@@ -1,7 +1,7 @@
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 { ArrowLeft, ShieldAlert, Loader2, Save, Trash2, KeyRound, Plus, X } from 'lucide-react';
import { api } from '../lib/api';
interface UserProfile {
@@ -18,19 +18,22 @@ export default function Admin() {
const { profile } = useAuthStore();
const [users, setUsers] = useState<UserProfile[]>([]);
const [loading, setLoading] = useState(true);
const [showAddUser, setShowAddUser] = useState(false);
const [newEmail, setNewEmail] = useState('');
const [newRole, setNewRole] = useState('user');
const fetchUsers = async () => {
if (profile?.role !== 'admin') return;
try {
const data = await api.getUsers();
setUsers(data.users || []);
} catch (error) {
console.error(error);
}
setLoading(false);
};
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]);
@@ -48,6 +51,41 @@ export default function Admin() {
}
};
const handleDelete = async (id: string) => {
if (!confirm("Are you sure you want to delete this user? All their projects will be permanently deleted as well.")) return;
try {
await api.deleteUser(id);
setUsers(users.filter(u => u.id !== id));
alert("User deleted successfully.");
} catch (error: any) {
alert("Failed to delete user: " + error.message);
}
};
const handleResetPassword = async (id: string) => {
if (!confirm("Send a password reset email to this user?")) return;
try {
await api.triggerPasswordReset(id);
alert("Password reset email sent.");
} catch (error: any) {
alert("Failed to send reset email: " + error.message);
}
};
const handleAddUser = async (e: React.FormEvent) => {
e.preventDefault();
if (!newEmail) return;
try {
await api.addUser({ email: newEmail, role: newRole });
alert("User created and activation email sent.");
setShowAddUser(false);
setNewEmail('');
fetchUsers();
} catch (error: any) {
alert("Failed to create 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">
@@ -62,8 +100,8 @@ export default function Admin() {
}
return (
<div className="min-h-screen bg-zinc-950 text-white p-8">
<div className="max-w-6xl mx-auto">
<div className="min-h-screen bg-zinc-950 text-white p-8 relative">
<div className="max-w-7xl mx-auto">
<button
onClick={() => navigate('/dashboard')}
className="flex items-center gap-2 text-zinc-400 hover:text-white mb-8 transition-colors"
@@ -71,11 +109,19 @@ export default function Admin() {
<ArrowLeft size={16} /> Back to Dashboard
</button>
<h1 className="text-3xl font-bold mb-8">Admin Control Panel</h1>
<div className="flex items-center justify-between mb-8">
<h1 className="text-3xl font-bold">Admin Control Panel</h1>
<button
onClick={() => setShowAddUser(true)}
className="flex items-center gap-2 bg-primary hover:bg-primary/90 text-white px-4 py-2 rounded-xl transition-colors font-medium"
>
<Plus size={16} /> Add User
</button>
</div>
<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>
<h2 className="text-xl font-semibold">User Management</h2>
</div>
{loading ? (
@@ -96,7 +142,13 @@ export default function Admin() {
</thead>
<tbody className="divide-y divide-zinc-800">
{users.map(u => (
<UserRow key={u.id} user={u} onUpdate={updateLimits} />
<UserRow
key={u.id}
user={u}
onUpdate={updateLimits}
onDelete={handleDelete}
onResetPassword={handleResetPassword}
/>
))}
</tbody>
</table>
@@ -104,11 +156,64 @@ export default function Admin() {
)}
</div>
</div>
{showAddUser && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6 w-full max-w-md shadow-2xl">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-bold">Add New User</h3>
<button onClick={() => setShowAddUser(false)} className="p-2 text-zinc-500 hover:text-white rounded-lg transition-colors">
<X size={20} />
</button>
</div>
<form onSubmit={handleAddUser} className="space-y-4">
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Email Address</label>
<input
type="email"
value={newEmail}
onChange={e => setNewEmail(e.target.value)}
className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-primary transition-colors"
placeholder="user@example.com"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Role</label>
<select
value={newRole}
onChange={e => setNewRole(e.target.value)}
className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-primary transition-colors"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<button
type="submit"
className="w-full bg-primary hover:bg-primary/90 text-white font-medium py-3 rounded-xl transition-colors mt-2"
>
Send Activation Email
</button>
</form>
</div>
</div>
)}
</div>
);
}
const UserRow = ({ user, onUpdate }: { user: UserProfile, onUpdate: (u: UserProfile) => void }) => {
const UserRow = ({
user,
onUpdate,
onDelete,
onResetPassword
}: {
user: UserProfile,
onUpdate: (u: UserProfile) => void,
onDelete: (id: string) => void,
onResetPassword: (id: string) => void
}) => {
const [role, setRole] = useState(user.role);
const [isActive, setIsActive] = useState(user.is_active);
const [credits, setCredits] = useState(user.api_credits);
@@ -153,7 +258,7 @@ const UserRow = ({ user, onUpdate }: { user: UserProfile, onUpdate: (u: UserProf
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"
className="w-20 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">
@@ -161,16 +266,33 @@ const UserRow = ({ user, onUpdate }: { user: UserProfile, onUpdate: (u: UserProf
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"
className="w-20 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 className="p-4">
<div className="flex items-center justify-end gap-2">
<button
onClick={handleUpdate}
title="Save Changes"
className="p-1.5 text-zinc-400 hover:text-white bg-zinc-800 hover:bg-zinc-700 rounded transition-colors"
>
<Save size={16} />
</button>
<button
onClick={() => onResetPassword(user.id)}
title="Send Password Reset"
className="p-1.5 text-amber-500/70 hover:text-amber-400 bg-amber-500/10 hover:bg-amber-500/20 rounded transition-colors"
>
<KeyRound size={16} />
</button>
<button
onClick={() => onDelete(user.id)}
title="Delete User"
className="p-1.5 text-red-500/70 hover:text-red-400 bg-red-500/10 hover:bg-red-500/20 rounded transition-colors"
>
<Trash2 size={16} />
</button>
</div>
</td>
</tr>
);
+43 -2
View File
@@ -638,6 +638,12 @@ export default function Editor() {
} | null>(null);
const [exportDialogName, setExportDialogName] = useState('');
const [isRendering, setIsRendering] = useState(false);
const [rendering, setRendering] = useState(false);
const [renderProgress, setRenderProgress] = useState<{ step: string; progress: number } | null>(null);
const [isRenderComplete, setIsRenderComplete] = useState(false);
const [bakedDataUrl, setBakedDataUrl] = useState<string | null>(null);
const [bakeOptions, setBakeOptions] = useState({ flipX: false, flipY: false });
const [bakeRequest, setBakeRequest] = useState<{ id: string, type: 'planar' | 'cylinder' | 'sphere' | 'box', flipX: boolean, flipY: boolean, timestamp: number } | null>(null);
const [importStatus, setImportStatus] = useState<string | null>(null);
const [importError, setImportError] = useState<string | null>(null);
const [modelData, setModelData] = useState<{ name: string, data: string, extension: string } | null>(null);
@@ -2445,7 +2451,7 @@ export default function Editor() {
<div className="flex-1 relative bg-zinc-950">
<ErrorBoundary>
<Canvas
shadows
shadows={{ type: THREE.PCFShadowMap }}
dpr={[1, 2]}
gl={{ preserveDrawingBuffer: true, alpha: true }}
performance={{ min: 0.5 }}
@@ -2528,7 +2534,22 @@ export default function Editor() {
return null;
})}
<ModelViewer ref={modelRef} object={loadedModel} sceneObjects={sceneObjects} />
<ModelViewer
ref={modelRef}
object={loadedModel}
sceneObjects={sceneObjects}
bakeRequest={bakeRequest}
onBakeComplete={(dataUrl) => {
if (bakeRequest) {
updateObjectMaterial(bakeRequest.id, {
map: dataUrl,
mappingType: 'uv',
bakedMappingType: bakeRequest.type
});
}
setBakeRequest(null);
}}
/>
{(!isRendering || !renderRequest?.includeAlpha) ? (
customHdri ? (
@@ -4667,8 +4688,28 @@ export default function Editor() {
<option value="planar">Planar</option>
<option value="box">Box</option>
<option value="cylinder">Cylinder</option>
<option value="sphere">Sphere</option>
<option value="triplanar">Triplanar</option>
</select>
{selectedObject.materialProps?.mappingType && selectedObject.materialProps.mappingType !== 'uv' && selectedObject.materialProps.mappingType !== 'triplanar' && (
<div className="flex flex-col gap-2 mt-2 bg-zinc-800/50 p-2 rounded border border-zinc-700/50">
<button
onClick={() => {
if (!selectedObject.materialProps?.map) return;
setBakeRequest({
id: selectedObject.id,
type: selectedObject.materialProps.mappingType as any,
flipX: false,
flipY: false,
timestamp: Date.now()
});
}}
className="w-full py-2 bg-orange-500 hover:bg-orange-600 text-white font-bold rounded shadow transition-colors text-[11px] mt-1"
>
{bakeRequest?.id === selectedObject.id ? 'Baking...' : 'Bake to UVs (For 2D AI)'}
</button>
</div>
)}
</div>
{/* UV Offset */}
+124
View File
@@ -0,0 +1,124 @@
import { useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { Loader2, KeyRound } from 'lucide-react';
import { api } from '../lib/api';
export default function ResetPassword() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const token = searchParams.get('token');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
if (!token) {
return (
<div className="min-h-screen bg-zinc-950 flex flex-col items-center justify-center p-4">
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-8 max-w-md w-full text-center shadow-2xl text-white">
<h2 className="text-xl font-bold text-red-500 mb-2">Invalid Link</h2>
<p className="text-zinc-400 mb-6">The password reset token is missing or invalid.</p>
<button onClick={() => navigate('/')} className="bg-zinc-800 hover:bg-zinc-700 text-white font-medium py-2 px-6 rounded-xl transition-colors">
Return Home
</button>
</div>
</div>
);
}
const handleReset = async (e: React.FormEvent) => {
e.preventDefault();
if (password !== confirmPassword) {
setError('Passwords do not match.');
return;
}
if (password.length < 6) {
setError('Password must be at least 6 characters.');
return;
}
setLoading(true);
setError('');
try {
await api.resetPassword(token, password);
setSuccess(true);
setMessage('Your password has been reset successfully!');
} catch (err: any) {
setError(err.message || 'Failed to reset password.');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-zinc-950 flex flex-col items-center justify-center p-4">
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-8 max-w-md w-full shadow-2xl">
<div className="flex justify-center mb-6">
<div className="w-12 h-12 bg-primary/20 rounded-full flex items-center justify-center text-primary">
<KeyRound size={24} />
</div>
</div>
<h2 className="text-2xl font-bold text-white text-center mb-2">Reset Password</h2>
{success ? (
<div className="text-center">
<p className="text-green-400 mb-6">{message}</p>
<button
onClick={() => navigate('/')}
className="bg-primary hover:bg-primary/90 text-white font-medium py-2 px-6 rounded-xl transition-colors w-full"
>
Go to Login
</button>
</div>
) : (
<form onSubmit={handleReset} className="space-y-4">
<p className="text-zinc-400 text-sm text-center mb-6">Enter your new password below.</p>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 text-sm p-3 rounded-lg">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">New Password</label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-primary transition-colors"
placeholder="••••••••"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Confirm New Password</label>
<input
type="password"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-primary transition-colors"
placeholder="••••••••"
required
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-primary hover:bg-primary/90 text-white font-medium py-3 rounded-xl transition-colors flex items-center justify-center disabled:opacity-50 mt-2"
>
{loading ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Reset Password'}
</button>
</form>
)}
</div>
</div>
);
}
+38 -8
View File
@@ -35,6 +35,9 @@ export default function SparkPlug() {
// Step 4: 3D Model
const [modelProgress, setModelProgress] = useState(0);
const [generatedGlbUrl, setGeneratedGlbUrl] = useState<string | null>(null);
// HQ Image Preview
const [previewImage, setPreviewImage] = useState<string | null>(null);
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
@@ -338,14 +341,17 @@ export default function SparkPlug() {
<div className="flex-1 bg-zinc-950 border border-zinc-800 rounded-xl p-4">
{generatedImages.length > 0 ? (
<div className="grid grid-cols-2 gap-4 h-full">
{generatedImages.map((img, idx) => (
<div key={idx} className="relative bg-zinc-900 rounded-lg overflow-hidden border border-zinc-800 flex items-center justify-center">
<img src={img} alt={`View ${idx + 1}`} className="max-w-full max-h-[150px] object-contain" />
<div className="absolute top-2 left-2 bg-black/50 backdrop-blur-sm px-2 py-1 rounded text-[10px] font-bold text-zinc-300 uppercase">
{idx === 0 ? 'FRONT' : idx === 1 ? 'BACK' : idx === 2 ? 'LEFT' : 'RIGHT'}
{generatedImages.map((img, idx) => (
<div key={idx} className="relative bg-zinc-900 rounded-lg overflow-hidden border border-zinc-800 flex items-center justify-center group cursor-pointer" onClick={() => setPreviewImage(img)}>
<img src={img} alt={`View ${idx + 1}`} className="max-w-full max-h-[150px] object-contain transition-transform duration-300 group-hover:scale-110" />
<div className="absolute top-2 left-2 bg-black/50 backdrop-blur-sm px-2 py-1 rounded text-[10px] font-bold text-zinc-300 uppercase">
{idx === 0 ? 'FRONT' : idx === 1 ? 'BACK' : idx === 2 ? 'LEFT' : 'RIGHT'}
</div>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
<span className="opacity-0 group-hover:opacity-100 text-white font-bold bg-black/50 px-3 py-1 rounded backdrop-blur-sm transition-opacity">Preview HQ</span>
</div>
</div>
</div>
))}
))}
</div>
) : (
<div className="flex h-full items-center justify-center">
@@ -400,7 +406,7 @@ export default function SparkPlug() {
) : (
<div className="w-full h-[400px] bg-zinc-900 rounded-xl overflow-hidden relative border border-zinc-800 flex flex-col items-center justify-center">
{generatedGlbUrl ? (
<Canvas shadows dpr={[1, 2]} camera={{ position: [0, 0, 4], fov: 45 }}>
<Canvas shadows={{ type: THREE.PCFShadowMap }} dpr={[1, 2]} camera={{ position: [0, 0, 4], fov: 45 }}>
<Suspense fallback={null}>
<Stage environment="studio" intensity={0.5}>
<ModelPreview url={generatedGlbUrl} />
@@ -428,6 +434,30 @@ export default function SparkPlug() {
</div>
</main>
{/* HQ Image Preview Modal */}
{previewImage && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-8 cursor-zoom-out"
onClick={() => setPreviewImage(null)}
>
<button
className="absolute top-6 right-6 text-white hover:text-red-500 transition-colors z-50"
onClick={(e) => {
e.stopPropagation();
setPreviewImage(null);
}}
>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
<img
src={previewImage}
alt="HQ Preview"
className="max-w-full max-h-full object-contain rounded-lg shadow-2xl cursor-default"
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
</div>
);
}
+2 -2
View File
@@ -18,9 +18,9 @@ interface AuthState {
signOut: () => void;
setProfile: (profile: UserProfile) => void;
isAuthModalOpen: boolean;
openAuthModal: (mode?: 'login' | 'register') => void;
openAuthModal: (mode?: 'login' | 'register' | 'forgot-password') => void;
closeAuthModal: () => void;
authMode: 'login' | 'register';
authMode: 'login' | 'register' | 'forgot-password';
}
export const useAuthStore = create<AuthState>((set) => ({