From 07a1e8f37fd07e3dca933bed25e1e0965bfee8a5 Mon Sep 17 00:00:00 2001 From: AI Bot Date: Thu, 30 Jul 2026 12:49:29 +0530 Subject: [PATCH] Deploy to production --- .gitignore | 3 + Dockerfile | 2 + api.json | 12 + backend/Dockerfile | 7 + backend/database.js | 70 + backend/package.json | 18 + backend/server.js | 204 ++ docker-compose.yml | 32 +- nginx.conf | 29 + package-lock.json | 58 + package.json | 3 +- src/App.tsx | 4913 +------------------------- src/components/ApiSettingsModal.tsx | 2 +- src/components/ImageTo3DModal.tsx | 21 + src/components/ProtectedRoute.tsx | 23 + src/components/RenderModal.tsx | 2 +- src/components/VizAiModal.tsx | 18 +- src/lib/api.ts | 115 + src/lib/api/apiUtils.ts | 81 + src/lib/api/meshyApi.ts | 56 + src/lib/api/tripoApi.ts | 83 + src/lib/storageUtils.ts | 19 + src/pages/Admin.tsx | 174 + src/pages/Auth.tsx | 124 + src/pages/Dashboard.tsx | 118 + src/pages/Editor.tsx | 4951 +++++++++++++++++++++++++++ src/pages/Home.tsx | 45 + src/store/authStore.ts | 64 + tsconfig.json | 1 + vite.config.ts | 2 + 30 files changed, 6363 insertions(+), 4887 deletions(-) create mode 100644 api.json create mode 100644 backend/Dockerfile create mode 100644 backend/database.js create mode 100644 backend/package.json create mode 100644 backend/server.js create mode 100644 nginx.conf create mode 100644 src/components/ProtectedRoute.tsx create mode 100644 src/lib/api.ts create mode 100644 src/lib/api/apiUtils.ts create mode 100644 src/lib/api/meshyApi.ts create mode 100644 src/lib/api/tripoApi.ts create mode 100644 src/lib/storageUtils.ts create mode 100644 src/pages/Admin.tsx create mode 100644 src/pages/Auth.tsx create mode 100644 src/pages/Dashboard.tsx create mode 100644 src/pages/Editor.tsx create mode 100644 src/pages/Home.tsx create mode 100644 src/store/authStore.ts diff --git a/.gitignore b/.gitignore index 5a86d2a..d6da975 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ coverage/ *.log .env* !.env.example +backend/node_modules/ +backend/data/ +backend/uploads/ diff --git a/Dockerfile b/Dockerfile index f02387f..d9df68e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,8 @@ RUN npm run build FROM nginx:alpine AS production # Copy built assets from the build stage to Nginx COPY --from=build /app/dist /usr/share/nginx/html +# Copy custom nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf # Expose port 80 for Nginx EXPOSE 80 # Start Nginx diff --git a/api.json b/api.json new file mode 100644 index 0000000..fd22ca2 --- /dev/null +++ b/api.json @@ -0,0 +1,12 @@ + + + + + + + Redirecting to https://deploy.digifox.live/login + + + Redirecting to https://deploy.digifox.live/login. + + \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..2daec85 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,7 @@ +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +EXPOSE 3005 +CMD ["npm", "start"] diff --git a/backend/database.js b/backend/database.js new file mode 100644 index 0000000..ac7d6f5 --- /dev/null +++ b/backend/database.js @@ -0,0 +1,70 @@ +const sqlite3 = require('sqlite3').verbose(); +const path = require('path'); +const fs = require('fs'); + +const dbPath = path.join(__dirname, 'data', 'database.sqlite'); +const dataDir = path.dirname(dbPath); + +if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); +} + +const db = new sqlite3.Database(dbPath, (err) => { + if (err) { + console.error('Error opening database', err.message); + } else { + console.log('Connected to the SQLite database.'); + db.run(`CREATE TABLE IF NOT EXISTS profiles ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE, + password_hash TEXT, + role TEXT DEFAULT 'user', + api_credits INTEGER DEFAULT 10, + storage_limit_mb INTEGER DEFAULT 500, + is_active INTEGER DEFAULT 0 + )`, (err) => { + // Attempt to add column for existing databases (fails silently if exists) + if (!err) { + db.run('ALTER TABLE profiles ADD COLUMN is_active INTEGER DEFAULT 0', () => {}); + } + }); + + db.run(`CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + user_id TEXT, + name TEXT, + scene_data TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES profiles (id) + )`); + } +}); + +const run = (sql, params = []) => { + return new Promise((resolve, reject) => { + db.run(sql, params, function (err) { + if (err) reject(err); + else resolve({ id: this.lastID, changes: this.changes }); + }); + }); +}; + +const get = (sql, params = []) => { + return new Promise((resolve, reject) => { + db.get(sql, params, (err, result) => { + if (err) reject(err); + else resolve(result); + }); + }); +}; + +const all = (sql, params = []) => { + return new Promise((resolve, reject) => { + db.all(sql, params, (err, rows) => { + if (err) reject(err); + else resolve(rows); + }); + }); +}; + +module.exports = { db, run, get, all }; diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..531384a --- /dev/null +++ b/backend/package.json @@ -0,0 +1,18 @@ +{ + "name": "titan3d-backend", + "version": "1.0.0", + "description": "Local API for Titan3d", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "bcrypt": "^5.1.1", + "cors": "^2.8.5", + "express": "^4.18.2", + "jsonwebtoken": "^9.0.2", + "multer": "^1.4.5-lts.1", + "sqlite3": "^5.1.7", + "uuid": "^9.0.1" + } +} diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..d309ab8 --- /dev/null +++ b/backend/server.js @@ -0,0 +1,204 @@ +const express = require('express'); +const cors = require('cors'); +const bcrypt = require('bcrypt'); +const jwt = require('jsonwebtoken'); +const { v4: uuidv4 } = require('uuid'); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); + +const { db, run, get, all } = require('./database'); + +const app = express(); +app.use(cors()); +app.use(express.json({ limit: '100mb' })); + +const JWT_SECRET = 'viz-3d-local-secret-key-1234'; + +// Ensure uploads dir exists +const uploadsDir = path.join(__dirname, 'uploads'); +if (!fs.existsSync(uploadsDir)) { + fs.mkdirSync(uploadsDir, { recursive: true }); +} + +app.use('/uploads', express.static(uploadsDir)); + +// Multer storage +const storage = multer.diskStorage({ + destination: function (req, file, cb) { + cb(null, uploadsDir); + }, + filename: function (req, file, cb) { + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); + cb(null, uniqueSuffix + '-' + file.originalname); + } +}); +const upload = multer({ storage: storage }); + +// --- AUTH MIDDLEWARE --- +const authenticate = (req, res, next) => { + const authHeader = req.headers.authorization; + if (!authHeader) return res.status(401).json({ error: 'No token provided' }); + const token = authHeader.split(' ')[1]; + jwt.verify(token, JWT_SECRET, (err, decoded) => { + if (err) return res.status(401).json({ error: 'Invalid token' }); + req.user = decoded; + next(); + }); +}; + +// --- ROUTES --- + +// 1. Auth Register +app.post('/api/auth/register', async (req, res) => { + const { email, password } = req.body; + if (!email || !password) return res.status(400).json({ error: 'Email and password required' }); + try { + const existing = await get('SELECT * FROM profiles WHERE email = ?', [email]); + if (existing) return res.status(400).json({ error: 'User already exists' }); + + const hash = await bcrypt.hash(password, 10); + const id = uuidv4(); + // First user becomes admin and is automatically active + const countRow = await get('SELECT COUNT(*) as count FROM profiles'); + const role = countRow.count === 0 ? 'admin' : 'user'; + const is_active = countRow.count === 0 ? 1 : 0; + + await run( + 'INSERT INTO profiles (id, email, password_hash, role, api_credits, storage_limit_mb, is_active) VALUES (?, ?, ?, ?, ?, ?, ?)', + [id, email, hash, role, 10, 500, is_active] + ); + + if (is_active === 0) { + return res.json({ status: 'pending', message: 'Account created. Please contact an administrator to activate your account.' }); + } + + const token = jwt.sign({ id, email, role }, JWT_SECRET, { expiresIn: '7d' }); + const profile = await get('SELECT id, email, role, api_credits, storage_limit_mb, is_active FROM profiles WHERE id = ?', [id]); + res.json({ status: 'active', token, profile }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// 2. Auth Login +app.post('/api/auth/login', async (req, res) => { + const { email, password } = req.body; + try { + const user = await get('SELECT * FROM profiles WHERE email = ?', [email]); + if (!user) return res.status(400).json({ error: 'Invalid credentials' }); + + const valid = await bcrypt.compare(password, user.password_hash); + if (!valid) return res.status(400).json({ error: 'Invalid credentials' }); + + if (user.is_active === 0) { + return res.status(403).json({ error: 'Account pending admin approval. Please contact an administrator.' }); + } + + const token = jwt.sign({ id: user.id, email: user.email, role: user.role }, JWT_SECRET, { expiresIn: '7d' }); + const profile = { id: user.id, email: user.email, role: user.role, api_credits: user.api_credits, storage_limit_mb: user.storage_limit_mb, is_active: user.is_active }; + res.json({ token, profile }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// 3. Auth Me +app.get('/api/auth/me', authenticate, async (req, res) => { + try { + const profile = await get('SELECT id, email, role, api_credits, storage_limit_mb, is_active FROM profiles WHERE id = ?', [req.user.id]); + res.json({ profile }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// 4. Get all projects for user +app.get('/api/projects', authenticate, async (req, res) => { + try { + const projects = await all('SELECT id, name FROM projects WHERE user_id = ?', [req.user.id]); + res.json({ projects }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// 5. Get project by ID +app.get('/api/projects/:id', authenticate, async (req, res) => { + try { + const project = await get('SELECT * FROM projects WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]); + if (!project) return res.status(404).json({ error: 'Project not found' }); + if (project.scene_data) { + project.scene_data = JSON.parse(project.scene_data); + } + res.json({ project }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// 6. Save (create or update) project +app.post('/api/projects', authenticate, async (req, res) => { + const { id, name, scene_data } = req.body; + const projectId = id || uuidv4(); + try { + const existing = await get('SELECT * FROM projects WHERE id = ?', [projectId]); + if (existing) { + if (existing.user_id !== req.user.id) return res.status(403).json({ error: 'Forbidden' }); + await run('UPDATE projects SET name = ?, scene_data = ? WHERE id = ?', [name, JSON.stringify(scene_data), projectId]); + } else { + await run('INSERT INTO projects (id, user_id, name, scene_data) VALUES (?, ?, ?, ?)', [projectId, req.user.id, name, JSON.stringify(scene_data)]); + } + res.json({ success: true, id: projectId }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// 7. Get users (Admin only) +app.get('/api/users', authenticate, async (req, res) => { + if (req.user.role !== 'admin') return res.status(403).json({ error: 'Forbidden' }); + try { + const users = await all('SELECT id, email, role, api_credits, storage_limit_mb, is_active FROM profiles'); + res.json({ users }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// 8. Update user (Admin only) +app.put('/api/users/:id', authenticate, async (req, res) => { + if (req.user.role !== 'admin') return res.status(403).json({ error: 'Forbidden' }); + const { role, api_credits, storage_limit_mb, is_active } = req.body; + try { + await run('UPDATE profiles SET role = ?, api_credits = ?, storage_limit_mb = ?, is_active = ? WHERE id = ?', [role, api_credits, storage_limit_mb, is_active !== undefined ? is_active : 1, req.params.id]); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// 9. Deduct API credit +app.post('/api/users/deduct-credit', authenticate, async (req, res) => { + try { + const user = await get('SELECT api_credits FROM profiles WHERE id = ?', [req.user.id]); + if (user.api_credits <= 0) return res.status(400).json({ error: 'Not enough credits' }); + await run('UPDATE profiles SET api_credits = api_credits - 1 WHERE id = ?', [req.user.id]); + const updated = await get('SELECT api_credits FROM profiles WHERE id = ?', [req.user.id]); + res.json({ api_credits: updated.api_credits }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// 10. Upload file +app.post('/api/upload', authenticate, upload.single('file'), (req, res) => { + if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); + const url = `${req.protocol}://${req.get('host')}/uploads/${req.file.filename}`; + res.json({ url }); +}); + +const PORT = process.env.PORT || 3005; +app.listen(PORT, () => { + console.log(`Backend server running on port ${PORT}`); +}); diff --git a/docker-compose.yml b/docker-compose.yml index e5f9a97..046aa07 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,16 +3,26 @@ services: restart: always build: context: . - target: development + target: production ports: - - "3000:3000" - volumes: - # Map the host source code to the container for hot-reloading - - .:/app - # Prevents the container's node_modules from being overwritten by the host - - /app/node_modules + - "3001:80" environment: - - NODE_ENV=development - # Optional: ensure container runs smoothly on Windows host - stdin_open: true - tty: true + - NODE_ENV=production + labels: + - "coolify.endpoint=true" + + api: + restart: always + build: + context: ./backend + ports: + - "3005:3005" + volumes: + - data-volume:/app/data + - uploads-volume:/app/uploads + environment: + - NODE_ENV=production + +volumes: + data-volume: + uploads-volume: diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..0e6efb3 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,29 @@ +server { + listen 80; + server_name localhost; + + # Serve static assets + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } + + # Proxy API requests to backend + location /api/ { + proxy_pass http://api:3005; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_cache_bypass $http_upgrade; + } + + # Proxy /uploads to backend + location /uploads/ { + proxy_pass http://api:3005; + proxy_set_header Host $host; + } +} diff --git a/package-lock.json b/package-lock.json index 204b720..8a3446e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "postprocessing": "^6.39.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-router-dom": "^7.18.2", "tailwind-merge": "^3.5.0", "three": "^0.183.2", "three-stdlib": "^2.36.1", @@ -3827,6 +3828,57 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-router/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/react-use-measure": { "version": "2.1.7", "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", @@ -4015,6 +4067,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", diff --git a/package.json b/package.json index 758aba5..81d2548 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "viz-3D_hun", + "name": "titan3d", "private": true, "version": "0.0.0", "type": "module", @@ -28,6 +28,7 @@ "postprocessing": "^6.39.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-router-dom": "^7.18.2", "tailwind-merge": "^3.5.0", "three": "^0.183.2", "three-stdlib": "^2.36.1", diff --git a/src/App.tsx b/src/App.tsx index e4221bb..b8cad4b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4882 +1,51 @@ -import { useState, Suspense, useRef, useEffect, useMemo } from "react"; -import { Canvas } from "@react-three/fiber"; -import { OrbitControls, Environment, ContactShadows, PerspectiveCamera, OrthographicCamera, Float, MeshReflectorMaterial, TransformControls, Grid, useTexture } from "@react-three/drei"; -import { GLTFExporter } from "three-stdlib"; -import { - Plus, - Layers, - Palette, - Grid3X3 as TextureIcon, - Camera, - Sun, - Image as ImageIcon, - Settings, - Search, - ChevronDown, - Play, - Pause, - Maximize2, - Box, - Cpu, - Eye, - EyeOff, - Wrench, - Import, - Library, - Layout, - Video, - Share2, - BoxSelect, - Smartphone, - Monitor, - Menu, - Loader2, - FolderPlus, - Edit2, - Trash2, - Undo2, - Redo2, - Copy, - Clipboard, - RotateCw, - Save, - FolderOpen, - Sparkles -} from "lucide-react"; -import { motion, AnimatePresence } from "framer-motion"; -import { ErrorBoundary } from "./components/ErrorBoundary"; -import { ModelViewer } from "./components/ModelViewer"; -import { JuiceBox } from "./components/JuiceBox"; -import { RenderModal, RenderSettings } from "./components/RenderModal"; -import { UnifiedMappingMaterial } from './components/UnifiedMappingMaterial'; -import { ImageTo3DModal } from "./components/ImageTo3DModal"; -import { UVEditor } from "./components/UVEditor"; -import { VizAiModal } from "./components/VizAiModal"; -import { ApiSettingsModal } from "./components/ApiSettingsModal"; -import { EffectComposer, DepthOfField, Bloom, Vignette, BrightnessContrast, HueSaturation } from "@react-three/postprocessing"; -import { cn } from "./lib/utils"; -import * as THREE from "three"; -import { useThree } from "@react-three/fiber"; -import * as fflate from "fflate"; -import { GLTFLoader, FBXLoader, OBJLoader, MTLLoader, DRACOLoader } from "three-stdlib"; -import { USDLoader } from "three/examples/jsm/loaders/USDLoader.js"; -import { USDZExporter } from "three/examples/jsm/exporters/USDZExporter.js"; +import { useEffect } from 'react'; +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { useAuthStore } from './store/authStore'; +import { ProtectedRoute } from './components/ProtectedRoute'; -const SidebarItem = ({ icon: Icon, label, active, onClick }: { icon: any, label: string, active?: boolean, onClick?: () => void }) => ( - -); - -const ToolbarButton = ({ icon: Icon, label, active }: { icon: any, label?: string, active?: boolean }) => ( - -); - -const MaterialCard = ({ color, name, active, onClick, roughness = 0.5, metalness = 0, transmission = 0 }: { - color: string, - name: string, - active?: boolean, - onClick?: () => void, - roughness?: number, - metalness?: number, - transmission?: number -}) => ( - -); - -interface MaterialProps { - color: string; - roughness: number; - metalness: number; - clearcoat: number; - transmission: number; - thickness: number; - ior: number; - sheen: number; - opacity: number; - specularIntensity: number; - emissive?: string; - emissiveIntensity?: number; - attenuationDistance?: number; - attenuationColor?: string; - map?: string | null; - normalMap?: string | null; - specularMap?: string | null; - alphaMap?: string | null; - uvScale?: number; - uvTiling?: [number, number]; - uvOffset?: [number, number]; - uvRotation?: number; - mappingType?: 'uv' | 'planar' | 'box' | 'cylinder' | 'sphere'; - uvLayers?: any[]; - uvBackground?: string; - uvTransparent?: boolean; -} - -interface AdditionalLight { - id: string; - type: 'point' | 'spot'; - position: [number, number, number]; - intensity: number; - color: string; - distance: number; - decay: number; - angle?: number; - penumbra?: number; - castShadow: boolean; -} - -interface TransformProps { - position: [number, number, number]; - rotation: [number, number, number]; - scale: [number, number, number]; -} - -interface SceneObject { - id: string; - name: string; - type: 'cube' | 'model' | 'group' | 'mesh'; - parentId: string | null; - visible: boolean; - materialProps?: MaterialProps; - transformProps?: TransformProps; - fillProps?: { - enabled: boolean; - type: 'solid' | 'liquid'; - height: number; - color: string; - }; -} - -const SceneSetup = () => { - const { gl } = useThree(); - useEffect(() => { - gl.shadowMap.type = THREE.PCFSoftShadowMap; - gl.localClippingEnabled = true; - }, [gl]); - return null; -}; - -const FixedBackplate = ({ url }: { url: string }) => { - const texture = useTexture(url); - const { scene } = useThree(); - - useEffect(() => { - if (texture) { - texture.colorSpace = THREE.SRGBColorSpace; - const originalBackground = scene.background; - scene.background = texture; - return () => { - scene.background = originalBackground; - }; - } - }, [scene, texture]); - - return null; -}; - -const ShadowFloor = ({ bounds, opacity, rotation, softness }: { bounds: any, opacity: number, rotation: number, softness: number }) => { - const alphaTexture = useMemo(() => { - const canvas = document.createElement('canvas'); - canvas.width = 256; - canvas.height = 256; - const context = canvas.getContext('2d'); - if (!context) return null; - - // Softness affects the inner radius and the falloff - // Higher softness = more gradual fade - const innerRadius = 0; - const outerRadius = 128; - const gradient = context.createRadialGradient(128, 128, innerRadius, 128, 128, outerRadius); - - // At softness 0, it's a sharper circle (but still a gradient) - // At softness 25, it's an extremely soft fade - const midPoint = Math.max(0.001, 0.5 * Math.exp(-(softness || 0.5) * 0.2)); - gradient.addColorStop(0, 'white'); - gradient.addColorStop(midPoint, 'white'); - gradient.addColorStop(1, 'black'); - - context.fillStyle = gradient; - context.fillRect(0, 0, 256, 256); - return new THREE.CanvasTexture(canvas); - }, [softness]); - - if (!bounds || !alphaTexture) return null; - - const planeSize = (bounds.maxDim || 5) * 5; - const posY = bounds.minY !== undefined ? bounds.minY : -0.5; - - return ( - - - - - ); -}; +// Pages +import Editor from './pages/Editor'; +import Home from './pages/Home'; +import Auth from './pages/Auth'; +import Dashboard from './pages/Dashboard'; +import Admin from './pages/Admin'; export default function App() { - const [activeSidebar, setActiveSidebar] = useState("Materials"); - const [theme, setTheme] = useState<"light" | "dark">("dark"); - const [loadedModel, setLoadedModel] = useState(null); - const [blobURLs, setBlobURLs] = useState([]); - - // Cleanup blob URLs on unmount - useEffect(() => { - return () => { - blobURLs.forEach(url => URL.revokeObjectURL(url)); - }; - }, [blobURLs]); - - // Dispose old model when new one is loaded - useEffect(() => { - return () => { - if (loadedModel) { - loadedModel.traverse((child) => { - if (child instanceof THREE.Mesh) { - child.geometry.dispose(); - if (Array.isArray(child.material)) { - child.material.forEach(m => m.dispose()); - } else { - child.material.dispose(); - } - } - }); - } - }; - }, [loadedModel]); - const [isLoading, setIsLoading] = useState(false); - const [importProgress, setImportProgress] = useState(0); - const [sceneObjects, setSceneObjects] = useState([]); - const [selectedIds, setSelectedIds] = useState([]); - const [editingId, setEditingId] = useState(null); - const [activeRightTab, setActiveRightTab] = useState("Scene"); - const [showUVEditor, setShowUVEditor] = useState(false); - const [showImageTo3D, setShowImageTo3D] = useState(false); - - // History State - const [history, setHistory] = useState([]); - const [historyIndex, setHistoryIndex] = useState(-1); - const historyRef = useRef([]); - const historyIndexRef = useRef(-1); - const [isUndoingRedoing, setIsUndoingRedoing] = useState(false); - const skipHistoryRef = useRef(false); - - // Sync refs with state - useEffect(() => { - historyRef.current = history; - historyIndexRef.current = historyIndex; - }, [history, historyIndex]); - - const pushToHistory = (newObjects: SceneObject[]) => { - if (isUndoingRedoing) return; - const currentHistory = historyRef.current; - const currentIndex = historyIndexRef.current; - - const newHistory = currentHistory.slice(0, currentIndex + 1); - newHistory.push(JSON.parse(JSON.stringify(newObjects))); - if (newHistory.length > 50) newHistory.shift(); // Limit history - - setHistory(newHistory); - setHistoryIndex(newHistory.length - 1); - }; - - const undo = () => { - const currentIndex = historyIndexRef.current; - if (currentIndex > 0) { - skipHistoryRef.current = true; - setIsUndoingRedoing(true); - const prevIndex = currentIndex - 1; - const prevObjects = JSON.parse(JSON.stringify(historyRef.current[prevIndex])); - setSceneObjects(prevObjects); - setHistoryIndex(prevIndex); - // Wait a bit longer to ensure the effect doesn't trigger - setTimeout(() => { setIsUndoingRedoing(false); skipHistoryRef.current = false; }, 100); - } - }; - - const redo = () => { - const currentIndex = historyIndexRef.current; - if (currentIndex < historyRef.current.length - 1) { - skipHistoryRef.current = true; - setIsUndoingRedoing(true); - const nextIndex = currentIndex + 1; - const nextObjects = JSON.parse(JSON.stringify(historyRef.current[nextIndex])); - setSceneObjects(nextObjects); - setHistoryIndex(nextIndex); - // Wait a bit longer to ensure the effect doesn't trigger - setTimeout(() => { setIsUndoingRedoing(false); skipHistoryRef.current = false; }, 100); - } - }; - - // Clipboard State - const [clipboard, setClipboard] = useState<{ - materialProps?: MaterialProps; - transformProps?: TransformProps; - } | null>(null); - - const [contextMenu, setContextMenu] = useState<{ - x: number; - y: number; - objectId: string; - } | null>(null); - - // Default material props for new objects or global edits - const [globalMaterialProps, setGlobalMaterialProps] = useState({ - color: "#ffffff", - roughness: 0.2, - metalness: 0.1, - clearcoat: 0, - transmission: 0, - thickness: 0, - ior: 1.5, - sheen: 0, - opacity: 1, - specularIntensity: 1, - emissive: "#000000", - emissiveIntensity: 0, - map: undefined, - normalMap: undefined, - specularMap: undefined, - alphaMap: undefined, - uvScale: 1, - uvTiling: [1, 1], - uvOffset: [0, 0], - uvRotation: 0, - mappingType: 'uv' - }); - - const [lightProps, setLightProps] = useState({ - intensity: 1, - color: "#ffffff", - shadowBias: -0.0005, - shadowRadius: 4 - }); - - const [additionalLights, setAdditionalLights] = useState([]); - - const [groundProps, setGroundProps] = useState({ - showShadow: true, - shadowIntensity: 0.4, - shadowRotation: 0, - shadowSoftness: 0.5, - shadowLength: 10 - }); - - const [selectedCategory, setSelectedCategory] = useState("plastics"); - const [envPreset, setEnvPreset] = useState("studio"); - const [envRotation, setEnvRotation] = useState(0); - const [customHdri, setCustomHdri] = useState(null); - const [backplateImage, setBackplateImage] = useState(null); - const [backgroundColor, setBackgroundColor] = useState("#444444"); // Default grey - const [backgroundType, setBackgroundType] = useState<"color" | "hdri" | "image">("color"); - const [showGrid, setShowGrid] = useState(true); - const [showRenderModal, setShowRenderModal] = useState(false); - const [showVizAi, setShowVizAi] = useState(false); - const [showApiSettings, setShowApiSettings] = useState(false); - const [vizAiBaseImage, setVizAiBaseImage] = useState(null); - const [renderRequest, setRenderRequest] = useState(null); - const [exportRequest, setExportRequest] = useState(false); - const [exportFormat, setExportFormat] = useState<'glb' | 'usdz'>('glb'); - // Export / Save dialog - const [exportDialog, setExportDialog] = useState<{ - mode: 'scene' | 'glb' | 'usdz'; - defaultName: string; - } | null>(null); - const [exportDialogName, setExportDialogName] = useState(''); - const [isRendering, setIsRendering] = useState(false); - const [importStatus, setImportStatus] = useState(null); - const [importError, setImportError] = useState(null); - const [modelData, setModelData] = useState<{ name: string, data: string, extension: string } | null>(null); - const [cameraProps, setCameraProps] = useState({ - fov: 35, - zoom: 1, - autoRotate: false, - orthographic: false, - target: [0, 0.5, 0] as [number, number, number], - position: [5, 3, 5] as [number, number, number], - pivot: [0, 0, 0] as [number, number, number], - cameraMode: "absolute" as "spherical" | "absolute", - spherical: { - distance: 7.68, - azimuth: 45, - inclination: 20, - twist: 0 - }, - walkthroughMode: false, - groundGrid: false, - depthOfField: { - enabled: false, - focusDistance: 0.05, - focalLength: 0.05, - bokehScale: 3 - }, - bloom: { - enabled: false, - intensity: 1.0, - luminanceThreshold: 0.9, - luminanceSmoothing: 0.025, - mipmapBlur: true - }, - vignette: { - enabled: false, - offset: 0.5, - darkness: 0.5 - }, - colorGrading: { - enabled: false, - brightness: 0, - contrast: 0, - hue: 0, - saturation: 0 - } - }); - - const fovToFocalLength = (fov: number) => { - return 18 / Math.tan((fov * Math.PI) / 360); - }; - - const focalLengthToFov = (focalLength: number) => { - return (2 * Math.atan(18 / focalLength) * 180) / Math.PI; - }; - - const setStandardView = (view: string) => { - if (!orbitControlsRef.current) return; - const controls = orbitControlsRef.current; - if (!controls.object || !controls.target) return; - const dist = controls.object.position.distanceTo(controls.target); - - const t = controls.target; - const target = [t.x || 0, t.y || 0, t.z || 0]; - let position: [number, number, number] = [0, 0, 0]; - - switch (view) { - case "front": position = [target[0], target[1], target[2] + dist]; break; - case "back": position = [target[0], target[1], target[2] - dist]; break; - case "top": position = [target[0], target[1] + dist, target[2]]; break; - case "bottom": position = [target[0], target[1] - dist, target[2]]; break; - case "left": position = [target[0] - dist, target[1], target[2]]; break; - case "right": position = [target[0] + dist, target[1], target[2]]; break; - case "isometric": position = [target[0] + dist, target[1] + dist, target[2] + dist]; break; - } - - if (position.some(v => isNaN(v))) return; - - controls.object.position.set(...position); - controls.update(); - - // Get new spherical - const azimuth = (controls.getAzimuthalAngle() || 0) * (180 / Math.PI); - const inclination = (Math.PI / 2 - (controls.getPolarAngle() || 0)) * (180 / Math.PI); - - setCameraProps(prev => ({ - ...prev, - position, - spherical: { - ...prev.spherical, - distance: dist, - azimuth, - inclination - } - })); - }; - const [cameraPresets, setCameraPresets] = useState([ - { id: 'default', name: 'Default View', fov: 35, zoom: 1, position: [3, 2, 5], target: [0, 0, 0] } - ]); - const orbitControlsRef = useRef(null); - const [modelBounds, setModelBounds] = useState<{ - center: THREE.Vector3; - size: THREE.Vector3; - maxDim: number; - minY: number; - radius: number; - } | null>(null); - - const controlsRef = useRef(null); - const directionalLightRef = useRef(null); - const cameraRef = useRef(null); - const juiceBoxRef = useRef(null); - const cubeRef = useRef(null); - const modelRef = useRef(null); - const transformRef = useRef(null); - const fileInputRef = useRef(null); - const hdriInputRef = useRef(null); - const backplateInputRef = useRef(null); - const textureInputRef = useRef(null); - const normalInputRef = useRef(null); - const specularInputRef = useRef(null); - const alphaInputRef = useRef(null); - - const [openMenu, setOpenMenu] = useState(null); - const sceneInputRef = useRef(null); - - const newScene = () => { - // Reset all scene state to defaults - setSceneObjects([]); - setSelectedIds([]); - setLoadedModel(null); - setModelData(null); - setModelBounds(null); - setHistory([]); - setHistoryIndex(-1); - setAdditionalLights([]); - setBackplateImage(null); - setCustomHdri(null); - setBackgroundColor('#444444'); - setBackgroundType('color'); - setEnvPreset('studio'); - setEnvRotation(0); - setGlobalMaterialProps({ - color: '#ffffff', roughness: 0.2, metalness: 0.1, clearcoat: 0, - transmission: 0, thickness: 0, ior: 1.5, sheen: 0, opacity: 1, - specularIntensity: 1, emissive: '#000000', emissiveIntensity: 0, - map: undefined, normalMap: undefined, specularMap: undefined, alphaMap: undefined, - uvScale: 1, uvTiling: [1, 1], uvOffset: [0, 0], uvRotation: 0, mappingType: 'uv' - }); - setLightProps({ intensity: 1, color: '#ffffff', shadowBias: -0.0005, shadowRadius: 4 }); - setGroundProps({ showShadow: true, shadowIntensity: 0.4, shadowRotation: 0, shadowSoftness: 0.5, shadowLength: 10 }); - setCameraProps(prev => ({ - ...prev, fov: 35, zoom: 1, autoRotate: false, orthographic: false, - position: [5, 3, 5], target: [0, 0.5, 0], pivot: [0, 0, 0], - spherical: { distance: 7.68, azimuth: 45, inclination: 20, twist: 0 }, - })); - setCameraPresets([{ id: 'default', name: 'Default View', fov: 35, zoom: 1, position: [3, 2, 5], target: [0, 0, 0] }]); - blobURLs.forEach(url => URL.revokeObjectURL(url)); - setBlobURLs([]); - setOpenMenu(null); - setShowUVEditor(false); - setIsRendering(false); - setImportStatus(null); - setImportError(null); - if (fileInputRef.current) fileInputRef.current.value = ''; - if (sceneInputRef.current) sceneInputRef.current.value = ''; - }; - - const saveScene = (name?: string) => { - const sceneData = { - sceneObjects, lightProps, groundProps, cameraProps, - envPreset, envRotation, backgroundColor, backgroundType, - customHdri, backplateImage, additionalLights, modelBounds, modelData, cameraPresets - }; - const filename = (name || 'scene').replace(/\.json$/i, ''); - const blob = new Blob([JSON.stringify(sceneData, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = `${filename}.json`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - setOpenMenu(null); - }; - - const openSaveDialog = () => { - const defaultName = modelData?.name ? modelData.name.replace(/\.[^.]+$/, '') : 'scene'; - setExportDialogName(defaultName); - setExportDialog({ mode: 'scene', defaultName }); - setOpenMenu(null); - }; - - const openExportDialog = (fmt: 'glb' | 'usdz') => { - const defaultName = modelData?.name ? modelData.name.replace(/\.[^.]+$/, '') : 'model'; - setExportDialogName(defaultName); - setExportDialog({ mode: fmt, defaultName }); - setOpenMenu(null); - }; - - const confirmExportDialog = () => { - if (!exportDialog) return; - const name = exportDialogName.trim() || exportDialog.defaultName; - if (exportDialog.mode === 'scene') { - saveScene(name); - } else { - setExportFormat(exportDialog.mode); - // Store name so ExportManager can use it - setExportFileName(name); - setExportRequest(true); - } - setExportDialog(null); - }; - - const [exportFileName, setExportFileName] = useState('model'); - - const loadScene = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (!file) return; - const reader = new FileReader(); - reader.onload = (e) => { - try { - const data = JSON.parse(e.target?.result as string); - if (data.sceneObjects) setSceneObjects(data.sceneObjects); - if (data.lightProps) setLightProps(data.lightProps); - if (data.groundProps) setGroundProps(data.groundProps); - if (data.cameraProps) { - const cProps = data.cameraProps; - setCameraProps(cProps); - - // Use a longer timeout and multiple attempts to ensure OrbitControls are updated - const updateControls = (attempts = 0) => { - if (orbitControlsRef.current) { - const controls = orbitControlsRef.current; - if (cProps.target) controls.target.set(cProps.target[0], cProps.target[1], cProps.target[2]); - if (cProps.position) { - controls.object.position.set(cProps.position[0], cProps.position[1], cProps.position[2]); - } - controls.update(); - } else if (attempts < 10) { - setTimeout(() => updateControls(attempts + 1), 100); - } - }; - updateControls(); - } - if (data.envPreset !== undefined) setEnvPreset(data.envPreset); - if (data.envRotation !== undefined) setEnvRotation(data.envRotation); - if (data.backgroundColor) setBackgroundColor(data.backgroundColor); - if (data.backgroundType) setBackgroundType(data.backgroundType); - if (data.customHdri) setCustomHdri(data.customHdri); - if (data.backplateImage) setBackplateImage(data.backplateImage); - if (data.additionalLights) setAdditionalLights(data.additionalLights); - if (data.modelBounds) setModelBounds(data.modelBounds); - if (data.cameraPresets) setCameraPresets(data.cameraPresets); - - // Re-load model if data exists - if (data.modelData && data.modelData.data) { - reImportModel(data.modelData); - } - } catch (err) { - console.error("Failed to load scene", err); - } - }; - reader.readAsText(file); - setOpenMenu(null); - // Clear input so same file can be loaded again - event.target.value = ''; - }; - - const reImportModel = async (modelInfo: { name: string, data: string, extension: string }) => { - setIsLoading(true); - setImportStatus("Restoring model..."); - try { - const response = await fetch(modelInfo.data); - const blob = await response.blob(); - const url = URL.createObjectURL(blob); - - const manager = new THREE.LoadingManager(); - let object: THREE.Object3D | null = null; - const extension = modelInfo.extension; - - if (extension === 'glb' || extension === 'gltf') { - const loader = new GLTFLoader(manager); - const dracoLoader = new DRACOLoader(); - dracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.6/'); - loader.setDRACOLoader(dracoLoader); - const gltf = await loader.loadAsync(url); - object = gltf.scene; - dracoLoader.dispose(); - } else if (extension === 'fbx') { - const loader = new FBXLoader(manager); - object = await loader.loadAsync(url); - } else if (extension === 'obj') { - const loader = new OBJLoader(manager); - object = await loader.loadAsync(url); - } else if (['usdz', 'usd', 'usda', 'usdc'].includes(extension)) { - const loader = new USDLoader(manager); - object = await loader.loadAsync(url); - } - - if (object) { - object.visible = true; - // Setup shadows - object.traverse((child) => { - if (child instanceof THREE.Mesh) { - child.castShadow = true; - child.receiveShadow = true; - child.frustumCulled = false; // Prevent flickering on large models - } - }); - setLoadedModel(object); - setBlobURLs(prev => [...prev, url]); - } - setIsLoading(false); - } catch (error) { - console.error('Error re-importing model:', error); - setIsLoading(false); - } - }; - - const handleTextureUpload = (event: React.ChangeEvent, type: 'map' | 'normalMap' | 'specularMap' | 'alphaMap' = 'map') => { - const file = event.target.files?.[0]; - if (!file || !selectedObject) return; - - const reader = new FileReader(); - reader.onload = (e) => { - const textureUrl = e.target?.result as string; - updateObjectMaterial(selectedObject.id, { [type]: textureUrl }); - }; - reader.readAsDataURL(file); - }; - - const handleHdriUpload = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (!file) return; - - const reader = new FileReader(); - reader.onload = (e) => { - const hdriUrl = e.target?.result as string; - setCustomHdri(hdriUrl); - setEnvPreset(null); // Clear preset when custom HDRI is used - }; - reader.readAsDataURL(file); - }; - - const removeTexture = () => { - if (selectedObject) { - updateObjectMaterial(selectedObject.id, { map: null }); - if (textureInputRef.current) textureInputRef.current.value = ""; - } - }; - - const removeNormalMap = () => { - if (selectedObject) { - updateObjectMaterial(selectedObject.id, { normalMap: null }); - if (normalInputRef.current) normalInputRef.current.value = ""; - } - }; - - const removeSpecularMap = () => { - if (selectedObject) { - updateObjectMaterial(selectedObject.id, { specularMap: null }); - if (specularInputRef.current) specularInputRef.current.value = ""; - } - }; - - const removeAlphaMap = () => { - if (selectedObject) { - updateObjectMaterial(selectedObject.id, { alphaMap: null }); - if (alphaInputRef.current) alphaInputRef.current.value = ""; - } - }; - - const saveCameraPreset = (name: string) => { - if (!orbitControlsRef.current) return; - const controls = orbitControlsRef.current; - if (!controls.object || !controls.target) return; - - // Get current camera position and target - const p = controls.object.position; - const t = controls.target; - const position = [p.x || 0, p.y || 0, p.z || 0]; - const target = [t.x || 0, t.y || 0, t.z || 0]; - - const newPreset = { - id: Math.random().toString(36).substr(2, 9), - name, - fov: cameraProps.fov, - zoom: cameraProps.zoom, - autoRotate: cameraProps.autoRotate, - orthographic: cameraProps.orthographic, - cameraMode: cameraProps.cameraMode, - spherical: { ...cameraProps.spherical }, - position, - target - }; - - setCameraPresets(prev => [...prev, newPreset]); - }; - - const loadCameraPreset = (preset: any) => { - if (!orbitControlsRef.current) return; - const controls = orbitControlsRef.current; - - setCameraProps(prev => ({ - ...prev, - fov: preset.fov, - zoom: preset.zoom, - autoRotate: preset.autoRotate || false, - orthographic: preset.orthographic || false, - cameraMode: preset.cameraMode || "absolute", - spherical: preset.spherical || { distance: 6.326, azimuth: -91.475, inclination: -0.393, twist: 0 }, - target: preset.target || [0, 0, 0], - position: preset.position || [3, 2, 5], - pivot: preset.target || [0, 0, 0], - walkthroughMode: false, - groundGrid: false, - depthOfField: { enabled: false, focusDistance: 0.01, focalLength: 0.02, bokehScale: 2 } - })); - - // We need to set the camera position and the controls target - const p = preset.position; - const t = preset.target; - controls.object.position.set(p[0], p[1], p[2]); - controls.target.set(t[0], t[1], t[2]); - controls.update(); - }; - - // Keyboard Shortcuts - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // Don't intercept if user is typing in an input - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { - return; - } - - // Prevent default for common shortcuts - if (e.ctrlKey || e.metaKey) { - switch (e.key.toLowerCase()) { - case 'n': - e.preventDefault(); - newScene(); - break; - case 's': - e.preventDefault(); - openSaveDialog(); - break; - case 'o': - e.preventDefault(); - sceneInputRef.current?.click(); - break; - case 'i': - e.preventDefault(); - fileInputRef.current?.click(); - break; - case 'e': - e.preventDefault(); - setExportRequest(true); - break; - case 'z': - e.preventDefault(); - if (e.shiftKey) { - redo(); - } else { - undo(); - } - break; - case 'y': - e.preventDefault(); - redo(); - break; - } - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, []); - - const RenderManager = () => { - const { gl, scene, camera } = useThree(); - - useEffect(() => { - if (!renderRequest) return; - const { width, height, includeAlpha, format, name } = renderRequest; - - setIsRendering(true); - - // Use an offscreen render target so we get exactly the requested resolution - // without resizing the visible canvas (which can produce multiple download events) - const renderFrame = () => { - // Save original state - const originalBackground = scene.background; - const originalToneMapping = gl.toneMapping; - const originalToneMappingExposure = gl.toneMappingExposure; - - // Create offscreen render target at exact requested size - const rt = new THREE.WebGLRenderTarget(width, height, { - minFilter: THREE.LinearFilter, - magFilter: THREE.LinearFilter, - format: includeAlpha ? THREE.RGBAFormat : THREE.RGBFormat, - colorSpace: THREE.SRGBColorSpace, - }); - - if (includeAlpha) { - scene.background = null; - } - - // Render scene into the offscreen target - gl.setRenderTarget(rt); - gl.render(scene, camera); - gl.setRenderTarget(null); - - // Read pixels from the render target - const pixels = new Uint8Array(width * height * 4); - gl.readRenderTargetPixels(rt, 0, 0, width, height, pixels); - - // Flip vertically (WebGL reads bottom-to-top) - const flipped = new Uint8Array(width * height * 4); - for (let row = 0; row < height; row++) { - const src = (height - 1 - row) * width * 4; - const dst = row * width * 4; - flipped.set(pixels.subarray(src, src + width * 4), dst); - } - - // Draw into a 2D canvas and export - const offscreen = document.createElement('canvas'); - offscreen.width = width; - offscreen.height = height; - const ctx = offscreen.getContext('2d')!; - const imageData = ctx.createImageData(width, height); - imageData.data.set(flipped); - ctx.putImageData(imageData, 0, 0); - - const mimeType = format === 'jpg' ? 'image/jpeg' : 'image/png'; - const dataUrl = offscreen.toDataURL(mimeType, 0.95); - const link = document.createElement('a'); - link.download = `${name}.${format}`; - link.href = dataUrl; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - // Restore - rt.dispose(); - scene.background = originalBackground; - gl.toneMapping = originalToneMapping; - gl.toneMappingExposure = originalToneMappingExposure; - - setRenderRequest(null); - setShowRenderModal(false); - setIsRendering(false); - }; - - // Wait for React to hide UI elements before capturing - setTimeout(renderFrame, 150); - }, [renderRequest, gl, scene, camera]); - - return null; - }; - - const ExportManager = () => { - const { scene } = useThree(); - - useEffect(() => { - if (exportRequest) { - if (exportFormat === 'glb') { - const exporter = new GLTFExporter(); - const exportScene = scene.clone(); - - // Collect things to remove from exportScene - const toRemove: THREE.Object3D[] = []; - exportScene.traverse((child: any) => { - if ( - child instanceof THREE.Camera || - child instanceof THREE.Light || - child instanceof THREE.GridHelper || - child instanceof THREE.PlaneHelper || - child.type === 'GridHelper' || - child.type === 'DirectionalLightHelper' || - child.name === '__background__' || - child.type === 'TransformControls' || - (child.name && child.name.includes('TransformControls')) || - (child.name && child.name.includes('Grid')) || - // Exclude Floor/Shadow plane - (child instanceof THREE.Mesh && child.geometry && child.geometry.type === 'PlaneGeometry') || - // Exclude TransformControls gizmo meshes - (child.parent && child.parent.type === 'TransformControls') - ) { - toRemove.push(child); - } - }); - - toRemove.forEach(c => c.removeFromParent()); - - exporter.parse( - exportScene, - (result) => { - const blob = new Blob([result as ArrayBuffer], { type: 'application/octet-stream' }); - const link = document.createElement('a'); - link.href = URL.createObjectURL(blob); - const fname = (exportFileName || 'model').replace(/\.glb$/i, ''); - link.download = `${fname}.glb`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - setExportRequest(false); - }, - (error) => { - console.error('GLB export error:', error); - setExportRequest(false); - }, - { binary: true, trs: true, onlyVisible: true, truncateDrawRange: true } - ); - } else if (exportFormat === 'usdz') { - const exporter = new USDZExporter(); - const exportScene = scene.clone(); - const toRemove: THREE.Object3D[] = []; - exportScene.traverse((child: any) => { - if ( - child instanceof THREE.Camera || - child instanceof THREE.Light || - child instanceof THREE.GridHelper || - child instanceof THREE.PlaneHelper || - child.type === 'GridHelper' || - child.type === 'DirectionalLightHelper' || - child.name === '__background__' || - child.type === 'TransformControls' || - (child.name && child.name.includes('TransformControls')) || - (child.name && child.name.includes('Grid')) || - (child instanceof THREE.Mesh && child.geometry && child.geometry.type === 'PlaneGeometry') || - (child.parent && child.parent.type === 'TransformControls') - ) { - toRemove.push(child); - } - }); - toRemove.forEach(c => c.removeFromParent()); - - exporter.parse( - exportScene, - (result) => { - const blob = new Blob([result], { type: 'model/vnd.usdz+zip' }); - const link = document.createElement('a'); - link.href = URL.createObjectURL(blob); - const fname = (exportFileName || 'model').replace(/\.usdz$/i, ''); - link.download = `${fname}.usdz`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - setExportRequest(false); - }, - (error) => { - console.error('USDZ export error:', error); - setExportRequest(false); - } - ); - } - } - }, [exportRequest, exportFormat, scene]); - - return null; - }; - - const loadBackplate = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (!file) return; - const reader = new FileReader(); - reader.onload = (e) => { - const result = e.target?.result; - if (typeof result === 'string') { - setBackplateImage(result); - setBackgroundType("image"); - } - }; - reader.readAsDataURL(file); - }; - - const handleImport = async (event: React.ChangeEvent) => { - const files = event.target.files; - if (!files || files.length === 0) return; - - setIsLoading(true); - setImportProgress(0); - setImportStatus("Preparing files..."); - setImportError(null); - - const fileMap: { [key: string]: string } = {}; - const currentBlobURLs: string[] = []; - let rootFile: { name: string, url: string } | null = null; - - try { - const processFile = (name: string, data: Uint8Array | Blob) => { - const blob = data instanceof Blob ? data : new Blob([data]); - const url = URL.createObjectURL(blob); - // Store both full name and just the filename for better matching - fileMap[name] = url; - const fileName = name.split('/').pop() || name; - fileMap[fileName] = url; - currentBlobURLs.push(url); - - const ext = name.split('.').pop()?.toLowerCase(); - if (!rootFile && ['glb', 'gltf', 'fbx', 'obj', 'usdz', 'usd', 'usda', 'usdc'].includes(ext || '')) { - rootFile = { name, url }; - } - }; - - // Handle Zip or Multiple Files - if (files.length === 1 && files[0].name.endsWith('.zip')) { - setImportStatus("Unzipping archive..."); - const buffer = await files[0].arrayBuffer(); - const unzipped = fflate.unzipSync(new Uint8Array(buffer)); - for (const name in unzipped) { - processFile(name, unzipped[name]); - } - } else { - for (let i = 0; i < files.length; i++) { - const file = files[i]; - processFile(file.name, file); - } - } - - if (!rootFile) { - throw new Error("No supported 3D model file found. Please select a .glb, .gltf, .fbx, .obj, or .usdz file."); - } - - setImportStatus("Parsing 3D data..."); - const extension = rootFile.name.split('.').pop()?.toLowerCase(); - - const manager = new THREE.LoadingManager(); - manager.setURLModifier((url) => { - const fileName = url.split('/').pop() || url; - // Try to find the file in our map - if (fileMap[fileName]) return fileMap[fileName]; - if (fileMap[url]) return fileMap[url]; - - // Handle cases where the path might be slightly different - const decodedUrl = decodeURIComponent(url); - const decodedFileName = decodedUrl.split('/').pop() || decodedUrl; - if (fileMap[decodedFileName]) return fileMap[decodedFileName]; - - return url; - }); - - let object: THREE.Object3D | null = null; - - if (extension === 'glb' || extension === 'gltf') { - const loader = new GLTFLoader(manager); - const dracoLoader = new DRACOLoader(); - dracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.6/'); - loader.setDRACOLoader(dracoLoader); - const gltf = await loader.loadAsync(rootFile.url); - object = gltf.scene; - // Dispose draco loader after use to avoid memory issues - dracoLoader.dispose(); - } else if (extension === 'fbx') { - const loader = new FBXLoader(manager); - object = await loader.loadAsync(rootFile.url); - } else if (extension === 'obj') { - const loader = new OBJLoader(manager); - // Look for companion .mtl file - const mtlFile = Object.keys(fileMap).find(name => name.toLowerCase().endsWith('.mtl')); - if (mtlFile) { - const mtlLoader = new MTLLoader(manager); - try { - const materials = await mtlLoader.loadAsync(fileMap[mtlFile]); - materials.preload(); - loader.setMaterials(materials); - } catch (err) { - console.warn("Failed to load MTL file:", err); - } - } - object = await loader.loadAsync(rootFile.url); - } else if (['usdz', 'usd', 'usda', 'usdc'].includes(extension || '')) { - const loader = new USDLoader(manager); - object = await loader.loadAsync(rootFile.url); - } - - if (object) { - // Save model data for scene persistence - const modelBlob = await fetch(rootFile.url).then(r => r.blob()); - const modelBase64 = await new Promise((resolve) => { - const reader = new FileReader(); - reader.onloadend = () => resolve(reader.result as string); - reader.readAsDataURL(modelBlob); - }); - setModelData({ name: rootFile.name, data: modelBase64, extension: extension || '' }); - - setImportStatus("Optimizing geometry..."); - // 1. Calculate model bounds - const boundingBox = new THREE.Box3().setFromObject(object); - const center = new THREE.Vector3(); - boundingBox.getCenter(center); - const size = new THREE.Vector3(); - boundingBox.getSize(size); - const maxDim = Math.max(size.x || 0, size.y || 0, size.z || 0) || 1; - const radius = maxDim / 2; - const minY = boundingBox.min.y; - - setModelBounds({ center, size, maxDim, minY, radius }); - - // 2. Adjust Directional Light Shadow Camera - if (directionalLightRef.current) { - const light = directionalLightRef.current; - light.shadow.camera.left = -maxDim; - light.shadow.camera.right = maxDim; - light.shadow.camera.top = maxDim; - light.shadow.camera.bottom = -maxDim; - light.shadow.camera.near = 0.1; - light.shadow.camera.far = maxDim * 5; - light.shadow.camera.updateProjectionMatrix(); - light.shadow.mapSize.width = 2048; - light.shadow.mapSize.height = 2048; - } - - // 3. Auto-framing Camera - let cameraZDistance = 0; - if (cameraRef.current) { - const camera = cameraRef.current; - const fovRadians = camera.fov * (Math.PI / 180); - cameraZDistance = radius / Math.sin(fovRadians / 2); - const padding = 1.5; - cameraZDistance *= padding; - - camera.position.set( - center.x, - center.y + (maxDim / 4), - center.z + cameraZDistance - ); - camera.near = radius / 100; - camera.far = radius * 100; - camera.updateProjectionMatrix(); - camera.lookAt(center); - } - - // 4. Update OrbitControls - if (orbitControlsRef.current) { - const controls = orbitControlsRef.current; - if (center) controls.target.copy(center); - controls.minDistance = radius / 2; - controls.maxDistance = (cameraZDistance || radius * 10) * 5; - controls.update(); - } - - const mainModelId = `model-${Date.now()}`; - const newSceneObjects: SceneObject[] = [ - { - id: mainModelId, - name: rootFile.name, - type: 'model', - parentId: null, - visible: true, - transformProps: { - position: [0, 0, 0], - rotation: [0, 0, 0], - scale: [1, 1, 1] - } - } - ]; - - object.visible = true; - let mIdx = 0; - object.traverse((child) => { - if (child instanceof THREE.Mesh) { - child.castShadow = true; - child.receiveShadow = true; - child.frustumCulled = false; - const meshId = `mesh-${mIdx}`; - mIdx++; - - // Extract mesh material properties if available - const material = Array.isArray(child.material) ? child.material[0] : child.material; - const mProps = { ...globalMaterialProps }; - - if (material) { - if (material.color) mProps.color = `#${material.color.getHexString()}`; - if (material.roughness !== undefined) mProps.roughness = material.roughness; - if (material.metalness !== undefined) mProps.metalness = material.metalness; - if (material.opacity !== undefined) mProps.opacity = material.opacity; - - // Standard material props - const m = material as any; - if (m.transmission !== undefined) mProps.transmission = m.transmission; - if (m.ior !== undefined) mProps.ior = m.ior; - if (m.thickness !== undefined) mProps.thickness = m.thickness; - if (m.clearcoat !== undefined) mProps.clearcoat = m.clearcoat; - if (m.sheen !== undefined) mProps.sheen = m.sheen; - if (m.specularIntensity !== undefined) mProps.specularIntensity = m.specularIntensity; - } - - newSceneObjects.push({ - id: meshId, - name: child.name || `Mesh ${meshId.slice(0, 4)}`, - type: 'mesh', - parentId: mainModelId, - visible: true, - transformProps: { - position: [child.position?.x || 0, child.position?.y || 0, child.position?.z || 0], - rotation: [child.rotation?.x || 0, child.rotation?.y || 0, child.rotation?.z || 0], - scale: [child.scale?.x || 1, child.scale?.y || 1, child.scale?.z || 1] - }, - materialProps: mProps - }); - } - }); - - setLoadedModel(object); - setBlobURLs(prev => { - prev.forEach(url => URL.revokeObjectURL(url)); - return currentBlobURLs; - }); - setSceneObjects(prev => [ - ...prev.filter(obj => obj.type !== 'cube'), - ...newSceneObjects - ]); - setSelectedIds([mainModelId]); - - setTimeout(() => { - setIsLoading(false); - setImportProgress(0); - }, 500); - } - } catch (error) { - console.error('Error loading model:', error); - setImportError(error instanceof Error ? error.message : "Failed to load model."); - setIsLoading(false); - setImportProgress(0); - } - }; - - const toggleSelect = (id: string, multi: boolean) => { - if (multi) { - setSelectedIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]); - } else { - setSelectedIds([id]); - } - }; - - const renameObject = (id: string, newName: string) => { - updateSceneObjects(prev => prev.map(obj => obj.id === id ? { ...obj, name: newName } : obj)); - setEditingId(null); - }; - - const groupObjects = () => { - if (selectedIds.length < 2) return; - const groupId = `group-${Date.now()}`; - const groupName = "New Group"; - - updateSceneObjects(prev => [ - ...prev.map(obj => selectedIds.includes(obj.id) ? { ...obj, parentId: groupId } : obj), - { id: groupId, name: groupName, type: 'group', parentId: null, visible: true } - ]); - setSelectedIds([groupId]); - }; - - const deleteObjects = () => { - updateSceneObjects(prev => prev.filter(obj => !selectedIds.includes(obj.id))); - setSelectedIds([]); - }; - - const materialCategories = [ - { - id: "plastics", - name: "Plastics & Polymers", - items: [ - { name: "Matte ABS", color: "#64748b", roughness: 0.8, metalness: 0, specularIntensity: 0.2 }, - { name: "Glossy PVC", color: "#e2e8f0", roughness: 0.05, metalness: 0, specularIntensity: 1 }, - { name: "Polycarbonate", color: "#f8fafc", roughness: 0.05, metalness: 0, transmission: 0.95, ior: 1.58, thickness: 1 }, - { name: "Textured Polypropylene", color: "#334155", roughness: 0.6, metalness: 0, clearcoat: 0.1 }, - { name: "Frosted Acrylic", color: "#ffffff", roughness: 0.5, metalness: 0, transmission: 0.9, ior: 1.49, thickness: 0.5 }, - { name: "Translucent Silicone", color: "#cbd5e1", roughness: 0.7, metalness: 0, transmission: 0.4, ior: 1.4, thickness: 2, sheen: 0.3 }, - { name: "Soft-Touch Elastomer", color: "#1e293b", roughness: 0.9, metalness: 0, specularIntensity: 0.1, sheen: 0.5 }, - { name: "Carbon Fiber (CFRP)", color: "#111111", roughness: 0.3, metalness: 0.4, clearcoat: 1 }, - { name: "PETG", color: "#ffffff", roughness: 0.05, metalness: 0, transmission: 0.98, ior: 1.53, thickness: 0.1 }, - { name: "Melamine Resin", color: "#fef3c7", roughness: 0.1, metalness: 0, transmission: 0.1, ior: 1.5 }, - { name: "Nylon 6/6", color: "#f1f5f9", roughness: 0.5, metalness: 0, sheen: 0.2 }, - { name: "Bakelite", color: "#451a03", roughness: 0.1, metalness: 0, specularIntensity: 0.8 }, - { name: "Iridescent Acrylic", color: "#ffffff", roughness: 0.1, metalness: 0, transmission: 0.8, ior: 1.49, thickness: 0.5, clearcoat: 1, sheen: 1 }, - { name: "Recycled HDPE", color: "#86efac", roughness: 0.7, metalness: 0 }, - { name: "Pearlescent Polystyrene", color: "#fdf4ff", roughness: 0.1, metalness: 0, clearcoat: 1, sheen: 1 }, - { name: "Foamed Polystyrene", color: "#ffffff", roughness: 0.9, metalness: 0 }, - { name: "Clear Epoxy Resin", color: "#ffffff", roughness: 0.02, metalness: 0, transmission: 0.95, ior: 1.55, thickness: 2, clearcoat: 1 }, - { name: "Teflon (PTFE)", color: "#f8fafc", roughness: 0.9, metalness: 0, specularIntensity: 0 }, - { name: "Vinyl", color: "#1a1a1a", roughness: 0.4, metalness: 0, sheen: 0.3 }, - { name: "Polyurethane Foam", color: "#fef08a", roughness: 1.0, metalness: 0, specularIntensity: 0 } - ] - }, - { - id: "metals", - name: "Metals", - items: [ - { name: "Polished Chrome", color: "#ffffff", roughness: 0, metalness: 1 }, - { name: "Brushed Aluminum", color: "#a0a0a0", roughness: 0.4, metalness: 1 }, - { name: "Scratched Stainless Steel", color: "#888888", roughness: 0.3, metalness: 1 }, - { name: "Raw Cast Iron", color: "#222222", roughness: 0.8, metalness: 0.8 }, - { name: "Anodized Aluminum", color: "#991b1b", roughness: 0.3, metalness: 1 }, - { name: "Galvanized Steel", color: "#94a3b8", roughness: 0.5, metalness: 1 }, - { name: "Hammered Copper", color: "#b87333", roughness: 0.3, metalness: 1 }, - { name: "Tarnished Brass", color: "#8a7b31", roughness: 0.4, metalness: 0.9 }, - { name: "Polished Gold", color: "#ffd700", roughness: 0.05, metalness: 1 }, - { name: "Matte Rose Gold", color: "#b76e79", roughness: 0.3, metalness: 1 }, - { name: "Gunmetal", color: "#2a2a2a", roughness: 0.2, metalness: 1 }, - { name: "Titanium", color: "#71717a", roughness: 0.25, metalness: 1 }, - { name: "Rusted Corten Steel", color: "#7c2d12", roughness: 0.9, metalness: 0.2 }, - { name: "Diamond Plate Steel", color: "#cbd5e1", roughness: 0.3, metalness: 1 }, - { name: "Sintered Bronze", color: "#806020", roughness: 0.7, metalness: 0.8 }, - { name: "Wrought Iron", color: "#0a0a0a", roughness: 0.8, metalness: 0.6, specularIntensity: 0.1 }, - { name: "Lead", color: "#3f3f46", roughness: 0.6, metalness: 1 }, - { name: "Polished Silver", color: "#f8fafc", roughness: 0.02, metalness: 1 }, - { name: "Magnesium Alloy", color: "#52525b", roughness: 0.4, metalness: 1 }, - { name: "Bismuth", color: "#a78bfa", roughness: 0.2, metalness: 1, clearcoat: 0.5, sheen: 0.5 } - ] - }, - { - id: "glass", - name: "Glass", - items: [ - { name: "Clear Float Glass", color: "#ffffff", roughness: 0, metalness: 0, transmission: 1, ior: 1.52, thickness: 0.5 }, - { name: "Frosted Glass", color: "#ffffff", roughness: 0.4, metalness: 0, transmission: 0.9, ior: 1.52, thickness: 0.5 }, - { name: "Tinted Bronze Glass", color: "#785b46", roughness: 0, metalness: 0, transmission: 0.8, ior: 1.52, thickness: 1 }, - { name: "Fluted Glass", color: "#ffffff", roughness: 0.1, metalness: 0, transmission: 0.9, ior: 1.52, thickness: 1 }, - { name: "Tempered Glass", color: "#e0f2fe", roughness: 0, metalness: 0, transmission: 0.95, ior: 1.52, thickness: 0.8 }, - { name: "Leaded Crystal", color: "#ffffff", roughness: 0, metalness: 0, transmission: 1, ior: 1.7, thickness: 2 }, - { name: "Dichroic Glass", color: "#fbcfe8", roughness: 0.05, metalness: 0, transmission: 0.9, ior: 1.52, thickness: 0.5, sheen: 1 }, - { name: "Smoked Glass", color: "#1e293b", roughness: 0.02, metalness: 0, transmission: 0.7, ior: 1.52, thickness: 1 }, - { name: "One-Way Mirror", color: "#e2e8f0", roughness: 0, metalness: 0.8, transmission: 0.2, ior: 1.52, thickness: 0.1 }, - { name: "Wired Safety Glass", color: "#e2e8f0", roughness: 0.1, metalness: 0, transmission: 0.8, ior: 1.52, thickness: 1 }, - { name: "Sea Glass", color: "#99f6e4", roughness: 0.6, metalness: 0, transmission: 0.8, ior: 1.52, thickness: 1.5 }, - { name: "Amber Apothecary", color: "#d97706", roughness: 0.05, metalness: 0, transmission: 0.9, ior: 1.52, thickness: 1.2 }, - { name: "Bulletproof Glass", color: "#bbf7d0", roughness: 0, metalness: 0, transmission: 0.85, ior: 1.55, thickness: 3 }, - { name: "Anti-Reflective Coated", color: "#ffffff", roughness: 0, metalness: 0, transmission: 0.99, ior: 1.52, thickness: 0.2, specularIntensity: 0.1 }, - { name: "Obscured Glass", color: "#ffffff", roughness: 0.5, metalness: 0, transmission: 0.7, ior: 1.52, thickness: 1 }, - { name: "Stained Glass", color: "#3b82f6", roughness: 0.1, metalness: 0, transmission: 0.9, ior: 1.52, thickness: 0.3 }, - { name: "Opal Glass", color: "#f8fafc", roughness: 0.1, metalness: 0, transmission: 0.3, ior: 1.52, thickness: 2, sheen: 0.5 }, - { name: "Uranium Glass", color: "#86efac", roughness: 0.05, metalness: 0, transmission: 0.9, ior: 1.52, thickness: 1, emissive: "#22c55e", emissiveIntensity: 0.2 }, - { name: "Shattered Glass", color: "#ffffff", roughness: 0.1, metalness: 0, transmission: 0.9, ior: 1.52, thickness: 0.5 }, - { name: "Smart Glass (Opaque)", color: "#f1f5f9", roughness: 0.8, metalness: 0, transmission: 0.1, ior: 1.52, thickness: 0.2 } - ] - }, - { - id: "liquids", - name: "Liquids", - items: [ - { name: "Clear Water", color: "#ffffff", roughness: 0, metalness: 0, transmission: 1, ior: 1.33, thickness: 2 }, - { name: "Ocean Water", color: "#0284c7", roughness: 0.1, metalness: 0, transmission: 0.9, ior: 1.33, thickness: 5 }, - { name: "Engine Oil", color: "#451a03", roughness: 0.02, metalness: 0, transmission: 0.3, ior: 1.45, thickness: 3 }, - { name: "Milk", color: "#ffffff", roughness: 0.1, metalness: 0, transmission: 0.05, ior: 1.35, thickness: 3, sheen: 0.5 }, - { name: "Orange Juice", color: "#f97316", roughness: 0.05, metalness: 0, transmission: 0.4, ior: 1.35, thickness: 2 }, - { name: "Red Wine", color: "#7f1d1d", roughness: 0, metalness: 0, transmission: 0.8, ior: 1.34, thickness: 1.5 }, - { name: "Honey", color: "#d97706", roughness: 0, metalness: 0, transmission: 0.7, ior: 1.49, thickness: 2 }, - { name: "Liquid Mercury", color: "#e2e8f0", roughness: 0, metalness: 1, transmission: 0, ior: 1 }, - { name: "Coffee", color: "#291304", roughness: 0, metalness: 0, transmission: 0.2, ior: 1.33, thickness: 3 }, - { name: "Carbonated Soda", color: "#ffffff", roughness: 0, metalness: 0, transmission: 0.95, ior: 1.33, thickness: 1.5 }, - { name: "Liquid Nitrogen", color: "#ffffff", roughness: 0, metalness: 0, transmission: 1, ior: 1.2, thickness: 1 }, - { name: "Blood", color: "#7f1d1d", roughness: 0, metalness: 0, transmission: 0.1, ior: 1.35, thickness: 2 }, - { name: "Shampoo", color: "#c084fc", roughness: 0.05, metalness: 0, transmission: 0.8, ior: 1.38, thickness: 1.5, sheen: 0.8 }, - { name: "Beer", color: "#d97706", roughness: 0, metalness: 0, transmission: 0.8, ior: 1.34, thickness: 2 }, - { name: "Glycerin", color: "#ffffff", roughness: 0, metalness: 0, transmission: 1, ior: 1.47, thickness: 2 }, - { name: "Melted Chocolate", color: "#3b1e08", roughness: 0.2, metalness: 0, transmission: 0, ior: 1.5, thickness: 1 }, - { name: "Automotive Coolant", color: "#22c55e", roughness: 0, metalness: 0, transmission: 0.9, ior: 1.33, thickness: 1.5 }, - { name: "Olive Oil", color: "#84cc16", roughness: 0, metalness: 0, transmission: 0.85, ior: 1.47, thickness: 2 }, - { name: "Ink", color: "#000000", roughness: 0, metalness: 0, transmission: 0, ior: 1.33, thickness: 1 }, - { name: "Perfume", color: "#fbcfe8", roughness: 0, metalness: 0, transmission: 0.98, ior: 1.4, thickness: 1.2 } - ] - } - ]; - - const updateSceneObjects = (newObjects: SceneObject[] | ((prev: SceneObject[]) => SceneObject[])) => { - setSceneObjects(prev => { - const next = typeof newObjects === 'function' ? newObjects(prev) : newObjects; - // We'll use a separate effect or a callback to push to history to avoid state update during render - return next; - }); - }; + const { initialize, isLoading } = useAuthStore(); useEffect(() => { - if (skipHistoryRef.current) return; - if (!isUndoingRedoing && sceneObjects.length > 0) { - const timer = setTimeout(() => { - pushToHistory(sceneObjects); - }, 500); // Debounce history pushes for performance - return () => clearTimeout(timer); - } - }, [sceneObjects]); + initialize(); + }, [initialize]); - useEffect(() => { - // Initial history - if (history.length === 0 && sceneObjects.length > 0) { - setHistory([JSON.parse(JSON.stringify(sceneObjects))]); - setHistoryIndex(0); - } - }, []); - - useEffect(() => { - if (theme === 'dark') { - document.body.classList.add('dark'); - } else { - document.body.classList.remove('dark'); - } - }, [theme]); - - const updateObjectMaterial = (id: string, props: Partial) => { - updateSceneObjects(prev => { - const targetObj = prev.find(obj => obj.id === id); - if (!targetObj) return prev; - - // If it's a model, apply material properties to all its child meshes - if (targetObj.type === 'model') { - return prev.map(obj => { - if (obj.id === id || obj.parentId === id) { - return { - ...obj, - materialProps: { ...(obj.materialProps || globalMaterialProps), ...props } - }; - } - return obj; - }); - } - - return prev.map(obj => - obj.id === id ? { ...obj, materialProps: { ...(obj.materialProps || globalMaterialProps), ...props } } : obj - ); - }); - }; - - const updateObjectTransform = (id: string, props: Partial) => { - updateSceneObjects(prev => prev.map(obj => - obj.id === id ? { ...obj, transformProps: { ...obj.transformProps!, ...props } } : obj - )); - }; - - const updateObjectVisibility = (id: string, visible: boolean) => { - updateSceneObjects(prev => prev.map(obj => - obj.id === id ? { ...obj, visible } : obj - )); - }; - - const selectedObject = sceneObjects.find(obj => selectedIds.includes(obj.id)); - const currentCategory = materialCategories.find(c => c.id === selectedCategory) || materialCategories[0]; + if (isLoading) { + return ( +
+
+
+ ); + } return ( -
- {/* Background Decorative Blobs */} -
-
-
-
- - {/* Hidden File Input */} - - - - - - - {/* Top Menu Bar */} -
-
-
-
- setOpenMenu(openMenu === 'file' ? null : 'file')} - > - File - - {openMenu === 'file' && ( -
- -
- -
- - - - -
- )} -
- -
- setOpenMenu(openMenu === 'edit' ? null : 'edit')} - > - Edit - - {openMenu === 'edit' && ( -
- - -
- )} -
- - Environment - Lighting - Camera - Image - Render -
- setOpenMenu(openMenu === 'tools' ? null : 'tools')} - > - Tools - - {openMenu === 'tools' && ( -
- -
- )} -
-
- setOpenMenu(openMenu === 'view' ? null : 'view')} - > - View - - {openMenu === 'view' && ( -
- - -
- )} -
- Window - Help -
-
-
-
- Startup - -
-
- 100 % - -
-
-
- - {/* Main Toolbar */} -
-
- -
- - -
- - -
- - -
- - -
-
-
- - -
- 50.0 FPS -
-
-
- - {/* Main Studio Area */} -
- {/* Left Sidebar */} -
-
- setActiveSidebar("Materials")} /> - setActiveSidebar("Colors")} /> - setActiveSidebar("Textures")} /> - setActiveSidebar("Environ...")} /> - setActiveSidebar("Favorites")} /> - setActiveSidebar("Models")} /> - setShowImageTo3D(true)} /> -
-
-
- {activeSidebar} -
- - -
-
-
-
- - -
-
-
- {activeSidebar === "Materials" && materialCategories.map(cat => ( - - ))} - {activeSidebar === "Textures" && ["Patterns", "Materials", "Nature", "Abstract"].map(cat => ( - - ))} -
-
- {activeSidebar === "Materials" && ( -
- {currentCategory.items.map((mat) => ( - { - if (selectedIds.length > 0) { - selectedIds.forEach(id => { - updateObjectMaterial(id, { - color: mat.color, - roughness: mat.roughness, - metalness: mat.metalness, - clearcoat: mat.clearcoat || 0, - transmission: mat.transmission || 0, - thickness: mat.thickness || 0, - ior: mat.ior || 1.5, - sheen: mat.sheen || 0, - opacity: mat.opacity || 1, - specularIntensity: mat.specularIntensity || 1, - uvScale: mat.uvScale || 1 - }); - }); - } - }} - /> - ))} -
- )} - - {activeSidebar === "Textures" && ( -
- {[ - { name: "Carbon Fiber", url: "https://picsum.photos/seed/carbon/200/200" }, - { name: "Wood Grain", url: "https://picsum.photos/seed/wood/200/200" }, - { name: "Brushed Metal", url: "https://picsum.photos/seed/metal/200/200" }, - { name: "Denim Texture", url: "https://picsum.photos/seed/denim/200/200" }, - { name: "Leather", url: "https://picsum.photos/seed/leather/200/200" }, - { name: "Marble", url: "https://picsum.photos/seed/marble/200/200" }, - ].map((tex) => ( - - ))} - -
- )} - - {activeSidebar === "Colors" && ( -
- {["#ef4444", "#f97316", "#f59e0b", "#eab308", "#84cc16", "#22c55e", "#10b981", "#06b6d4", "#3b82f6", "#6366f1", "#8b5cf6", "#a855f7", "#d946ef", "#ec4899", "#f43f5e", "#ffffff", "#a1a1aa", "#3f3f46", "#18181b", "#000000"].map((color) => ( -
- )} - - {activeSidebar === "Environ..." && ( -
- Environment presets are also available in the right sidebar. -
- {['studio', 'apartment', 'city', 'dawn', 'forest'].map(preset => ( - - ))} -
-
- )} -
-
-
- - {/* Viewport */} -
- - - - {cameraProps.orthographic ? ( - - ) : ( - - )} - {backgroundType === "color" && } - - - - {sceneObjects.map(obj => { - if (obj.id === 'juicebox-1' && obj.visible) { - return ( - { - e.stopPropagation(); - setSelectedIds([obj.id]); - }} - /> - ); - } - if (obj.type === 'cube' && obj.visible) { - return ( - { - e.stopPropagation(); - setSelectedIds([obj.id]); - }} - > - - 0 || (obj.materialProps?.opacity ?? 1) < 1} - /> - - ); - } - return null; - })} - - - - {(!isRendering || !renderRequest?.includeAlpha) ? ( - customHdri ? ( - - ) : envPreset ? ( - - ) : null - ) : ( - /* During alpha render, provide environment but no background */ - customHdri ? ( - - ) : envPreset ? ( - - ) : null - )} - - {cameraProps.groundGrid && !isRendering && ( - - )} - - {showGrid && !cameraProps.groundGrid && !isRendering && ( - - )} - - {groundProps.showShadow && ( - - )} - - {selectedIds.length === 1 && !isRendering && ( - { - const id = selectedIds[0]; - const obj = sceneObjects.find(o => o.id === id); - if (!obj) return undefined; - - if (id === 'juicebox-1') return juiceBoxRef.current || undefined; - if (obj.type === 'cube') return cubeRef.current || undefined; - if (obj.type === 'model') return modelRef.current || undefined; - if (obj.type === 'mesh' && loadedModel) { - let targetMesh: THREE.Object3D | undefined; - let mIdx = 0; - loadedModel.traverse(child => { - if (child instanceof THREE.Mesh) { - const stableId = `mesh-${mIdx}`; - if (stableId === id) targetMesh = child; - mIdx++; - } - }); - return targetMesh; - } - return undefined; - })()} - onMouseDown={() => { - if (orbitControlsRef.current) orbitControlsRef.current.enabled = false; - }} - onMouseUp={() => { - if (orbitControlsRef.current) orbitControlsRef.current.enabled = true; - const target = transformRef.current?.object; - if (target && target.position && target.rotation && target.scale) { - updateObjectTransform(selectedIds[0], { - position: [target.position.x || 0, target.position.y || 0, target.position.z || 0], - rotation: [target.rotation.x || 0, target.rotation.y || 0, target.rotation.z || 0], - scale: [target.scale.x || 1, target.scale.y || 1, target.scale.z || 1] - }); - } - }} - /> - )} - {/* Background */} - {backgroundType === "image" && backplateImage && (!isRendering || !renderRequest?.includeAlpha) && ( - - )} - - - {/* Primary Light Source */} - - - - {/* Additional Lights */} - {additionalLights.map(light => ( - light.type === 'point' ? ( - - ) : ( - - ) - ))} - - - { - if (orbitControlsRef.current) { - const controls = orbitControlsRef.current; - const target = controls.target; - const position = controls.object?.position; - - if (!target || !position) return; - - // Spherical - const distance = controls.getDistance(); - const azimuth = controls.getAzimuthalAngle() * (180 / Math.PI); - const inclination = (Math.PI / 2 - controls.getPolarAngle()) * (180 / Math.PI); - - setCameraProps(prev => ({ - ...prev, - target: [target.x, target.y, target.z], - position: [position.x, position.y, position.z], - spherical: { - ...prev.spherical, - distance, - azimuth, - inclination - } - })); - } - }} - /> - - {(cameraProps.depthOfField.enabled || cameraProps.bloom.enabled || cameraProps.vignette.enabled || cameraProps.colorGrading.enabled) && ( - - {cameraProps.depthOfField.enabled && ( - - )} - {cameraProps.bloom.enabled && ( - - )} - {cameraProps.vignette.enabled && ( - - )} - {cameraProps.colorGrading.enabled && ( - <> - - - - )} - - )} - - - - {/* Viewport Overlays */} -
-
-
-
- Real-time Render -
-
-
- - {/* Loading Overlay */} - - {(isLoading || importError) && ( - -
- {importError ? ( -
-
- -
-
- Import Failed - {importError} -
- -
- ) : ( - <> -
- -
-
-
- - - {importStatus || "Importing Model"} - -
- {importProgress}% Complete -
- - )} -
-
- )} -
- - setShowRenderModal(false)} - onRender={(settings) => setRenderRequest(settings)} - /> - - setShowVizAi(false)} - baseImage={vizAiBaseImage} - /> - - setShowApiSettings(false)} - /> - - {/* Export / Save Dialog */} - {exportDialog && ( -
setExportDialog(null)}> - e.stopPropagation()} - className="w-full max-w-sm bg-zinc-900 border border-zinc-700 rounded-2xl shadow-2xl overflow-hidden" - > - {/* Header */} -
-
- - - {exportDialog.mode === 'scene' ? 'Save Scene' : - exportDialog.mode === 'glb' ? 'Export GLB' : 'Export USDZ'} - -
- -
- - {/* Body */} -
-
- - setExportDialogName(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') confirmExportDialog(); if (e.key === 'Escape') setExportDialog(null); }} - placeholder={exportDialog.defaultName} - className="bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-zinc-100 outline-none focus:border-blue-500 transition-colors" - /> -
-
- -

- Downloads folder — browser default -

-

File will be saved as: {(exportDialogName || exportDialog.defaultName).trim()}.{exportDialog.mode === 'scene' ? 'json' : exportDialog.mode}

-
-
- - {/* Footer */} -
- - -
-
-
- )} -
- - {/* Right Sidebar */} -
-
- - - - - -
- -
- {activeRightTab === "Scene" ? ( - <> -
-
- - Show -
-
- - -
-
- -
-
-
- Hierarchy -
- - -
-
- -
- {sceneObjects.filter(obj => obj.parentId === null).map(obj => ( -
-
toggleSelect(obj.id, e.ctrlKey || e.metaKey)} - onContextMenu={(e) => { - e.preventDefault(); - setContextMenu({ x: e.clientX, y: e.clientY, objectId: obj.id }); - }} - className={cn( - "flex items-center gap-2 p-1 rounded text-[10px] cursor-pointer group transition-colors", - selectedIds.includes(obj.id) ? "bg-blue-500/20 text-blue-400" : "hover:bg-zinc-800 text-zinc-300" - )} - > - - {obj.type === 'group' ? : } - - {editingId === obj.id ? ( - renameObject(obj.id, e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') renameObject(obj.id, e.currentTarget.value); - if (e.key === 'Escape') setEditingId(null); - }} - /> - ) : ( - {obj.name} - )} - - {!editingId && selectedIds.includes(obj.id) && ( - - )} -
- - {/* Render Children */} -
- {sceneObjects.filter(child => child.parentId === obj.id).map(child => ( -
toggleSelect(child.id, e.ctrlKey || e.metaKey)} - onContextMenu={(e) => { - e.preventDefault(); - setContextMenu({ x: e.clientX, y: e.clientY, objectId: child.id }); - }} - className={cn( - "flex items-center gap-2 p-1 rounded text-[10px] cursor-pointer group transition-colors", - selectedIds.includes(child.id) ? "bg-blue-500/20 text-blue-400" : "hover:bg-zinc-800 text-zinc-300" - )} - > - - - - {editingId === child.id ? ( - renameObject(child.id, e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') renameObject(child.id, e.currentTarget.value); - if (e.key === 'Escape') setEditingId(null); - }} - /> - ) : ( - {child.name} - )} - - {!editingId && selectedIds.includes(child.id) && ( - - )} -
- ))} -
-
- ))} -
-
-
- - ) : activeRightTab === "Transform" ? ( -
-
- Transform -
-
- - {!selectedObject || !selectedObject.transformProps ? ( -
- - Select an object to edit its transform -
- ) : ( - <> - {/* Position */} -
- Position - {['x', 'y', 'z'].map((axis, i) => ( -
- {axis} - { - const newPos = [...selectedObject.transformProps!.position] as [number, number, number]; - newPos[i] = parseFloat(e.target.value); - updateObjectTransform(selectedObject.id, { position: newPos }); - }} - className="flex-1 bg-zinc-800 border-none rounded py-1 px-2 text-[10px] text-zinc-300" - /> -
- ))} -
- - {/* Rotation */} -
- Rotation - {['x', 'y', 'z'].map((axis, i) => ( -
- {axis} - { - const newRot = [...selectedObject.transformProps!.rotation] as [number, number, number]; - newRot[i] = parseFloat(e.target.value); - updateObjectTransform(selectedObject.id, { rotation: newRot }); - }} - className="flex-1 bg-zinc-800 border-none rounded py-1 px-2 text-[10px] text-zinc-300" - /> -
- ))} -
- - {/* Scale */} -
- Scale - {['x', 'y', 'z'].map((axis, i) => ( -
- {axis} - { - const newScale = [...selectedObject.transformProps!.scale] as [number, number, number]; - newScale[i] = parseFloat(e.target.value); - updateObjectTransform(selectedObject.id, { scale: newScale }); - }} - className="flex-1 bg-zinc-800 border-none rounded py-1 px-2 text-[10px] text-zinc-300" - /> -
- ))} -
- - )} -
- ) : activeRightTab === "Camera" ? ( -
-
- Position and Orientation -
-
- - {/* Mode Toggle */} -
- - -
- - {/* Spherical Controls */} - {cameraProps.cameraMode === "spherical" && ( -
-
-
- Distance - {cameraProps.spherical.distance.toFixed(3)} m -
- { - const dist = parseFloat(e.target.value); - setCameraProps(prev => ({ ...prev, spherical: { ...prev.spherical, distance: dist } })); - // Update camera position based on spherical - if (orbitControlsRef.current) { - const controls = orbitControlsRef.current; - const phi = (90 - cameraProps.spherical.inclination) * (Math.PI / 180); - const theta = cameraProps.spherical.azimuth * (Math.PI / 180); - const x = dist * Math.sin(phi) * Math.cos(theta) + cameraProps.target[0]; - const y = dist * Math.cos(phi) + cameraProps.target[1]; - const z = dist * Math.sin(phi) * Math.sin(theta) + cameraProps.target[2]; - controls.object.position.set(x, y, z); - controls.update(); - } - }} - className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
- -
-
- Azimuth - {cameraProps.spherical.azimuth.toFixed(3)} ° -
- { - const az = parseFloat(e.target.value); - setCameraProps(prev => ({ ...prev, spherical: { ...prev.spherical, azimuth: az } })); - if (orbitControlsRef.current) { - const controls = orbitControlsRef.current; - const phi = (90 - cameraProps.spherical.inclination) * (Math.PI / 180); - const theta = az * (Math.PI / 180); - const x = cameraProps.spherical.distance * Math.sin(phi) * Math.cos(theta) + cameraProps.target[0]; - const y = cameraProps.spherical.distance * Math.cos(phi) + cameraProps.target[1]; - const z = cameraProps.spherical.distance * Math.sin(phi) * Math.sin(theta) + cameraProps.target[2]; - controls.object.position.set(x, y, z); - controls.update(); - } - }} - className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
- -
-
- Inclination - {cameraProps.spherical.inclination.toFixed(3)} ° -
- { - const inc = parseFloat(e.target.value); - setCameraProps(prev => ({ ...prev, spherical: { ...prev.spherical, inclination: inc } })); - if (orbitControlsRef.current) { - const controls = orbitControlsRef.current; - const phi = (90 - inc) * (Math.PI / 180); - const theta = cameraProps.spherical.azimuth * (Math.PI / 180); - const x = cameraProps.spherical.distance * Math.sin(phi) * Math.cos(theta) + cameraProps.target[0]; - const y = cameraProps.spherical.distance * Math.cos(phi) + cameraProps.target[1]; - const z = cameraProps.spherical.distance * Math.sin(phi) * Math.sin(theta) + cameraProps.target[2]; - controls.object.position.set(x, y, z); - controls.update(); - } - }} - className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
-
- )} - - {/* Absolute Position Controls */} - {cameraProps.cameraMode === "absolute" && ( -
- Position (Absolute) -
- {["x", "y", "z"].map((axis, i) => ( -
- {axis} - { - const newPos = [...cameraProps.position] as [number, number, number]; - newPos[i] = parseFloat(e.target.value); - setCameraProps(prev => ({ ...prev, position: newPos })); - if (orbitControlsRef.current) { - orbitControlsRef.current.object.position.set(...newPos); - orbitControlsRef.current.update(); - } - }} - className="w-full bg-zinc-900 border border-zinc-800 rounded px-1 py-0.5 text-[10px] text-zinc-300" - /> -
- ))} -
-
- )} - - {/* Target Controls */} -
- Target -
- {["x", "y", "z"].map((axis, i) => ( -
- {axis} - { - const newTarget = [...cameraProps.target] as [number, number, number]; - newTarget[i] = parseFloat(e.target.value); - setCameraProps(prev => ({ ...prev, target: newTarget })); - if (orbitControlsRef.current) { - orbitControlsRef.current.target.set(...newTarget); - orbitControlsRef.current.update(); - } - }} - className="w-full bg-zinc-900 border border-zinc-800 rounded px-1 py-0.5 text-[10px] text-zinc-300" - /> -
- ))} -
-
- - {/* Pivot Controls */} -
- Pivot -
- {["x", "y", "z"].map((axis, i) => ( -
- {axis} - { - const newPivot = [...cameraProps.pivot] as [number, number, number]; - newPivot[i] = parseFloat(e.target.value); - setCameraProps(prev => ({ ...prev, pivot: newPivot })); - }} - className="w-full bg-zinc-900 border border-zinc-800 rounded px-1 py-0.5 text-[10px] text-zinc-300" - /> -
- ))} -
-
- - {/* Standard Views */} -
- Standard Views -
- {["front", "back", "top", "bottom", "left", "right", "isometric"].map((view) => ( - - ))} -
-
- -
- Lens Settings -
-
- - {/* Camera Type */} -
- - -
- - {/* Focal Length / FOV */} - {!cameraProps.orthographic && ( - <> -
-
- Focal Length - {fovToFocalLength(cameraProps.fov).toFixed(1)} mm -
- setCameraProps(prev => ({ ...prev, fov: focalLengthToFov(parseInt(e.target.value)) }))} - className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
- -
-
- Field of View - {cameraProps.fov.toFixed(1)}° -
- setCameraProps(prev => ({ ...prev, fov: parseInt(e.target.value) }))} - className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
- - )} - - {/* Zoom */} -
-
- Zoom Level - {cameraProps.zoom.toFixed(2)}x -
- setCameraProps(prev => ({ ...prev, zoom: parseFloat(e.target.value) }))} - className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
- - {/* Depth of Field Toggle */} -
-
- Depth of Field - Enable bokeh effect -
- -
- - {/* DOF Settings */} - {cameraProps.depthOfField.enabled && ( -
-
-
- Focus Distance - {cameraProps.depthOfField.focusDistance.toFixed(3)} -
- setCameraProps(prev => ({ ...prev, depthOfField: { ...prev.depthOfField, focusDistance: parseFloat(e.target.value) } }))} - className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
-
-
- Bokeh Scale - {cameraProps.depthOfField.bokehScale.toFixed(1)} -
- setCameraProps(prev => ({ ...prev, depthOfField: { ...prev.depthOfField, bokehScale: parseFloat(e.target.value) } }))} - className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
-
- )} - - {/* Post Processing Section */} -
- Post Processing -
-
- - {/* Bloom Toggle */} -
-
- Bloom - Add glow to highlights -
- -
- - {/* Bloom Settings */} - {cameraProps.bloom.enabled && ( -
-
-
- Intensity - {cameraProps.bloom.intensity.toFixed(2)} -
- setCameraProps(prev => ({ ...prev, bloom: { ...prev.bloom, intensity: parseFloat(e.target.value) } }))} - className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
-
-
- Threshold - {cameraProps.bloom.luminanceThreshold.toFixed(2)} -
- setCameraProps(prev => ({ ...prev, bloom: { ...prev.bloom, luminanceThreshold: parseFloat(e.target.value) } }))} - className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
-
- )} - - {/* Vignette Toggle */} -
-
- Vignette - Darken image edges -
- -
- - {/* Vignette Settings */} - {cameraProps.vignette.enabled && ( -
-
-
- Darkness - {cameraProps.vignette.darkness.toFixed(2)} -
- setCameraProps(prev => ({ ...prev, vignette: { ...prev.vignette, darkness: parseFloat(e.target.value) } }))} - className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
-
- )} - - {/* Color Grading Toggle */} -
-
- Color Grading - Adjust brightness & color -
- -
- - {/* Color Grading Settings */} - {cameraProps.colorGrading.enabled && ( -
-
-
- Brightness - {cameraProps.colorGrading.brightness.toFixed(2)} -
- setCameraProps(prev => ({ ...prev, colorGrading: { ...prev.colorGrading, brightness: parseFloat(e.target.value) } }))} - className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
-
-
- Contrast - {cameraProps.colorGrading.contrast.toFixed(2)} -
- setCameraProps(prev => ({ ...prev, colorGrading: { ...prev.colorGrading, contrast: parseFloat(e.target.value) } }))} - className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
-
-
- Saturation - {cameraProps.colorGrading.saturation.toFixed(2)} -
- setCameraProps(prev => ({ ...prev, colorGrading: { ...prev.colorGrading, saturation: parseFloat(e.target.value) } }))} - className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
-
- )} - - {/* Grid and Ground Options */} -
-
-
- Ground Grid - Show grid on floor -
- -
- -
-
- Stay Above Ground - Limit camera height -
- -
-
- - {/* Auto Rotate */} -
-
- Auto-Rotate - Rotate around model -
- -
- - {/* Camera Presets */} -
-
- Camera Presets - -
-
- -
- {cameraPresets.map((preset) => ( -
- -
- {preset.id !== 'default' && ( - - )} -
-
- ))} -
-
- -
- -
-
- ) : activeRightTab === "Environ..." ? ( -
-
- Viewport Background -
-
- - {/* Background Type Toggle */} -
- - -
- - {/* Background Color & Image Selection */} - {(backgroundType === "color" || backgroundType === "image") && ( -
-
-
- Background Color -
-
-
-
- -
-
-
- Backplate Image - {backgroundType === "image" && ( - - )} -
-
- - {[ - { name: "Studio", url: "https://picsum.photos/seed/studio/1920/1080?blur=4" }, - { name: "Outdoor", url: "https://picsum.photos/seed/outdoor/1920/1080?blur=4" }, - { name: "Interior", url: "https://picsum.photos/seed/interior/1920/1080?blur=4" }, - { name: "Abstract", url: "https://picsum.photos/seed/abstract/1920/1080?blur=4" } - ].map((plate) => ( - - ))} -
-
-
- Custom Backplate URL -
- { - if (e.target.value) { - setBackplateImage(e.target.value); - setBackgroundType("image"); - } - }} - /> - -
-
-
-
- )} - - {/* Environment Rotation Wheel */} -
-
- Environment Rotation - {envRotation}° -
-
-
{ - const rect = e.currentTarget.getBoundingClientRect(); - const centerX = rect.left + rect.width / 2; - const centerY = rect.top + rect.height / 2; - - const handleMouseMove = (moveEvent: MouseEvent) => { - const angle = Math.atan2(moveEvent.clientY - centerY, moveEvent.clientX - centerX); - let deg = angle * (180 / Math.PI) + 90; - if (deg < 0) deg += 360; - setEnvRotation(Math.round(deg)); - }; - - const handleMouseUp = () => { - window.removeEventListener('mousemove', handleMouseMove); - window.removeEventListener('mouseup', handleMouseUp); - }; - - window.addEventListener('mousemove', handleMouseMove); - window.addEventListener('mouseup', handleMouseUp); - }} - > - {/* Degree markers */} - {[0, 45, 90, 135, 180, 225, 270, 315].map(deg => ( -
- ))} - {/* Pointer */} -
-
-
-
- setEnvRotation(parseInt(e.target.value))} - className="w-full accent-blue-500 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer" - /> -
- - 180° - 360° -
-
-
-
- - {/* Grid Toggle */} -
-
- Viewport Grid - Show spatial reference -
- -
- -
- Primary Light -
-
- - {/* Light Intensity */} -
-
- Intensity - {lightProps.intensity.toFixed(2)} -
- setLightProps(prev => ({ ...prev, intensity: parseFloat(e.target.value) }))} - className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
- - {/* Light Color */} -
-
- Light Color - setLightProps(prev => ({ ...prev, color: e.target.value }))} - className="w-6 h-6 rounded border border-zinc-700 bg-transparent cursor-pointer" - /> -
-
- - {/* Advanced Lighting Controls */} -
-
-
- Shadow Advanced -
-
-
- Shadow Bias - {lightProps.shadowBias.toFixed(4)} -
- setLightProps(prev => ({ ...prev, shadowBias: parseFloat(e.target.value) }))} - className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
-
-
- Shadow Radius - {lightProps.shadowRadius.toFixed(1)} -
- setLightProps(prev => ({ ...prev, shadowRadius: parseFloat(e.target.value) }))} - className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
-
-
- - {/* Additional Lights Section */} -
-
- Additional Lights -
- -
-
-
-
- - {/* Additional Lights List */} -
- {additionalLights.map((light, index) => ( -
-
-
- - {light.type} Light {index + 1} -
- -
- -
-
-
- Intensity - {light.intensity.toFixed(1)} -
- setAdditionalLights(prev => prev.map(l => l.id === light.id ? { ...l, intensity: parseFloat(e.target.value) } : l))} - className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
-
-
- Color - setAdditionalLights(prev => prev.map(l => l.id === light.id ? { ...l, color: e.target.value } : l))} - className="w-4 h-4 rounded border border-zinc-700 bg-transparent cursor-pointer" - /> -
-
-
- -
- Position (X, Y, Z) -
- {[0, 1, 2].map(i => ( - { - const newPos = [...light.position] as [number, number, number]; - newPos[i] = parseFloat(e.target.value); - setAdditionalLights(prev => prev.map(l => l.id === light.id ? { ...l, position: newPos } : l)); - }} - className="bg-zinc-900 border border-zinc-800 rounded px-1.5 py-1 text-[10px] text-zinc-300" - /> - ))} -
-
-
- ))} - {additionalLights.length === 0 && ( -
- No additional lights added -
- )} -
- -
- Ground & Shadows -
-
- - {/* Ground Shadow Toggle */} -
-
- Ground Shadow - Enable soft shadows -
- -
- - {/* Shadow Controls */} - {groundProps.showShadow && ( - <> -
-
- Shadow Intensity - {groundProps.shadowIntensity.toFixed(2)} -
- setGroundProps(prev => ({ ...prev, shadowIntensity: parseFloat(e.target.value) }))} - className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
- -
-
- Shadow Softness - {groundProps.shadowSoftness.toFixed(2)} -
- setGroundProps(prev => ({ ...prev, shadowSoftness: parseFloat(e.target.value) }))} - className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
- -
-
- Shadow Length - {groundProps.shadowLength.toFixed(1)} -
- setGroundProps(prev => ({ ...prev, shadowLength: parseFloat(e.target.value) }))} - className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
- -
-
- Shadow Rotation - {groundProps.shadowRotation}° -
-
-
-
-
-
-
- setGroundProps(prev => ({ ...prev, shadowRotation: parseInt(e.target.value) }))} - className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10" - /> - -
-
- setGroundProps(prev => ({ ...prev, shadowRotation: parseInt(e.target.value) }))} - className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
- - 180° - 360° -
-
-
-
- - )} - -
- Environment Presets -
-
- -
- - - {customHdri && ( - - )} - - {[ - { id: 'studio', name: 'Studio', desc: 'Clean, neutral lighting' }, - { id: 'apartment', name: 'Apartment', desc: 'Indoor home lighting' }, - { id: 'city', name: 'City', desc: 'Urban outdoor lighting' }, - { id: 'dawn', name: 'Dawn', desc: 'Soft morning light' }, - { id: 'forest', name: 'Forest', desc: 'Natural outdoor light' }, - { id: 'lobby', name: 'Lobby', desc: 'Commercial indoor light' }, - { id: 'night', name: 'Night', desc: 'Low light, dark environment' }, - { id: 'park', name: 'Park', desc: 'Bright outdoor light' }, - { id: 'sunset', name: 'Sunset', desc: 'Warm evening light' }, - { id: 'warehouse', name: 'Warehouse', desc: 'Industrial lighting' }, - ].map((preset) => ( - - ))} -
-
- ) : ( -
-
- Material Properties -
-
- - {!selectedObject || (selectedObject.type !== 'mesh' && selectedObject.type !== 'model') ? ( -
- - Select a model or mesh to edit its material properties -
- ) : ( - <> -
- {selectedObject.name} - {selectedObject.type === 'model' ? "Bulk Editing Model" : "Mesh Editor"} -
- - {/* Fill Settings */} -
-
- Mesh Fill - -
- - {selectedObject.fillProps?.enabled && ( - <> -
- - -
- -
-
- Height - {Math.round(selectedObject.fillProps.height * 100)}% -
- { - const newObjects = sceneObjects.map(obj => - obj.id === selectedObject.id ? { ...obj, fillProps: { ...obj.fillProps!, height: parseFloat(e.target.value) } } : obj - ); - setSceneObjects(newObjects); - }} - onMouseUp={() => pushToHistory(sceneObjects)} - className="w-full accent-primary" - /> -
- -
- Fill Color -
-
- { - const newObjects = sceneObjects.map(obj => - obj.id === selectedObject.id ? { ...obj, fillProps: { ...obj.fillProps!, color: e.target.value } } : obj - ); - setSceneObjects(newObjects); - }} - onBlur={() => pushToHistory(sceneObjects)} - className="opacity-0 w-5 h-5 cursor-pointer absolute right-0" - /> -
-
- - )} -
- - {/* Color Picker */} -
-
- Diffuse Color -
-
- updateObjectMaterial(selectedObject.id, { color: e.target.value })} - className="w-full h-8 bg-zinc-800 border-none rounded cursor-pointer" - /> -
- - {/* Texture Mapping */} -
-
- Texture Map - {selectedObject.materialProps?.map ? ( - - ) : ( - None - )} -
- -
textureInputRef.current?.click()} - className={cn( - "w-full h-24 rounded border-2 border-dashed flex flex-col items-center justify-center gap-2 cursor-pointer transition-all", - selectedObject.materialProps?.map - ? "border-blue-500/50 bg-blue-500/5 overflow-hidden" - : "border-zinc-800 hover:border-zinc-700 bg-zinc-800/30" - )} - > - {selectedObject.materialProps?.map ? ( - Texture Preview - ) : ( - <> - - Upload Texture - - )} -
- -
- - {/* UV Scale & Mapping */} -
-
- Mapping -
-
- -
- -
- - {/* UV Projection Dropdown */} -
- UV Projection - -
- - {/* UV Offset */} -
- Offset (X, Y) -
- updateObjectMaterial(selectedObject.id, { uvOffset: [parseFloat(e.target.value), selectedObject.materialProps?.uvOffset?.[1] ?? 0] })} - className="flex-1 bg-zinc-800 border-none rounded py-1.5 px-2 text-[10px] text-zinc-300" /> - updateObjectMaterial(selectedObject.id, { uvOffset: [selectedObject.materialProps?.uvOffset?.[0] ?? 0, parseFloat(e.target.value)] })} - className="flex-1 bg-zinc-800 border-none rounded py-1.5 px-2 text-[10px] text-zinc-300" /> -
-
- - {/* UV Scale / Tiling */} -
- Scale / Tiling (X, Y) -
- updateObjectMaterial(selectedObject.id, { uvTiling: [parseFloat(e.target.value), selectedObject.materialProps?.uvTiling?.[1] ?? 1] })} - className="flex-1 bg-zinc-800 border-none rounded py-1.5 px-2 text-[10px] text-zinc-300" /> - updateObjectMaterial(selectedObject.id, { uvTiling: [selectedObject.materialProps?.uvTiling?.[0] ?? 1, parseFloat(e.target.value)] })} - className="flex-1 bg-zinc-800 border-none rounded py-1.5 px-2 text-[10px] text-zinc-300" /> -
-
- - {/* UV Rotation */} -
-
- Rotation - {(selectedObject.materialProps?.uvRotation ?? 0).toFixed(0)}° -
- updateObjectMaterial(selectedObject.id, { uvRotation: parseFloat(e.target.value) })} - className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" /> -
-
- - {/* Roughness */} -
-
- Roughness - {(selectedObject.materialProps?.roughness ?? 0.5).toFixed(2)} -
- updateObjectMaterial(selectedObject.id, { roughness: parseFloat(e.target.value) })} - className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
- - {/* Metalness */} -
-
- Metalness - {(selectedObject.materialProps?.metalness ?? 0.5).toFixed(2)} -
- updateObjectMaterial(selectedObject.id, { metalness: parseFloat(e.target.value) })} - className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
- - {/* Clearcoat */} -
-
- Clearcoat - {(selectedObject.materialProps?.clearcoat ?? 0).toFixed(2)} -
- updateObjectMaterial(selectedObject.id, { clearcoat: parseFloat(e.target.value) })} - className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
- - {/* Transmission */} -
-
- Transmission - {(selectedObject.materialProps?.transmission ?? 0).toFixed(2)} -
- updateObjectMaterial(selectedObject.id, { transmission: parseFloat(e.target.value) })} - className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
- - {/* IOR */} -
-
- Index of Refraction - {(selectedObject.materialProps?.ior ?? 1.5).toFixed(2)} -
- updateObjectMaterial(selectedObject.id, { ior: parseFloat(e.target.value) })} - className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
- - {/* Thickness */} -
-
- Thickness - {(selectedObject.materialProps?.thickness ?? 0).toFixed(2)} -
- updateObjectMaterial(selectedObject.id, { thickness: parseFloat(e.target.value) })} - className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
- - {/* Sheen */} -
-
- Sheen - {(selectedObject.materialProps?.sheen ?? 0).toFixed(2)} -
- updateObjectMaterial(selectedObject.id, { sheen: parseFloat(e.target.value) })} - className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
- - {/* Opacity */} -
-
- Opacity - {(selectedObject.materialProps?.opacity ?? 1).toFixed(2)} -
- updateObjectMaterial(selectedObject.id, { opacity: parseFloat(e.target.value) })} - className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
- - {/* Specular Intensity */} -
-
- Specular Intensity - {(selectedObject.materialProps?.specularIntensity ?? 1).toFixed(2)} -
- updateObjectMaterial(selectedObject.id, { specularIntensity: parseFloat(e.target.value) })} - className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
- - {/* Emissive */} -
-
- Emissive Color -
-
- updateObjectMaterial(selectedObject.id, { emissive: e.target.value })} - className="w-full h-8 bg-zinc-800 border-none rounded cursor-pointer" - /> -
- -
-
- Emissive Intensity - {(selectedObject.materialProps?.emissiveIntensity ?? 0).toFixed(2)} -
- updateObjectMaterial(selectedObject.id, { emissiveIntensity: parseFloat(e.target.value) })} - className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
- - {/* Advanced Maps */} -
- Advanced Maps - {/* Normal Map */} -
-
- Normal Map - {selectedObject.materialProps?.normalMap && ( - - )} -
-
normalInputRef.current?.click()} - className={cn( - "w-full h-12 rounded border border-dashed flex items-center justify-center gap-2 cursor-pointer transition-all", - selectedObject.materialProps?.normalMap ? "border-blue-500/50 bg-blue-500/5" : "border-zinc-800 hover:border-zinc-700 bg-zinc-800/30" - )} - > - {selectedObject.materialProps?.normalMap ? "Normal Map Loaded" : "Upload Normal Map"} -
- handleTextureUpload(e, 'normalMap')} accept="image/*" className="hidden" /> -
- - {/* Specular Map */} -
-
- Specular Map - {selectedObject.materialProps?.specularMap && ( - - )} -
-
specularInputRef.current?.click()} - className={cn( - "w-full h-12 rounded border border-dashed flex items-center justify-center gap-2 cursor-pointer transition-all", - selectedObject.materialProps?.specularMap ? "border-blue-500/50 bg-blue-500/5" : "border-zinc-800 hover:border-zinc-700 bg-zinc-800/30" - )} - > - {selectedObject.materialProps?.specularMap ? "Specular Map Loaded" : "Upload Specular Map"} -
- handleTextureUpload(e, 'specularMap')} accept="image/*" className="hidden" /> -
- - {/* Alpha Map */} -
-
- Alpha Map - {selectedObject.materialProps?.alphaMap && ( - - )} -
-
alphaInputRef.current?.click()} - className={cn( - "w-full h-12 rounded border border-dashed flex items-center justify-center gap-2 cursor-pointer transition-all", - selectedObject.materialProps?.alphaMap ? "border-blue-500/50 bg-blue-500/5" : "border-zinc-800 hover:border-zinc-700 bg-zinc-800/30" - )} - > - {selectedObject.materialProps?.alphaMap ? "Alpha Map Loaded" : "Upload Alpha Map"} -
- handleTextureUpload(e, 'alphaMap')} accept="image/*" className="hidden" /> -
-
- -
- -
- - )} -
- )} - -
-
- Scene Information - -
-
-
- Triangles: - {loadedModel ? "Dynamic" : "12,452"} -
-
- Vertices: - {loadedModel ? "Dynamic" : "8,210"} -
-
- Objects: - 1 -
-
-
-
-
-
- - {/* Bottom Toolbar */} -
-
- - - - - - - - - -
-
-
- Cloud Library | VIZ3D Hub -
- -
-
- - {/* Context Menu */} - - {contextMenu && ( - <> -
setContextMenu(null)} - onContextMenu={(e) => { e.preventDefault(); setContextMenu(null); }} - /> - - - - -
- - - - - - )} - - - {/* UV Editor Overlay */} - {showUVEditor && selectedObject && ( - setShowUVEditor(false)} - onSave={(dataUrl, mappingType, layers, bgColor, bgTransparent) => { - updateObjectMaterial(selectedObject.id, { map: dataUrl, mappingType: (mappingType as 'uv'|'box'|'planar'|'cylinder'|'sphere') || 'uv', uvLayers: layers, uvBackground: bgColor, uvTransparent: bgTransparent }); - }} - targetObject={selectedObject} - loadedModel={loadedModel as THREE.Group} - sceneObjects={sceneObjects} - /> - )} - - setShowImageTo3D(false)} - onPushToApp={async (modelUrl) => { - setIsLoading(true); - setImportStatus("Importing generated model..."); - try { - const manager = new THREE.LoadingManager(); - const loader = new GLTFLoader(manager); - const dracoLoader = new DRACOLoader(); - dracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.6/'); - loader.setDRACOLoader(dracoLoader); - - const gltf = await loader.loadAsync(modelUrl); - const object = gltf.scene; - dracoLoader.dispose(); - - const mainModelId = `model-${Date.now()}`; - const newSceneObjects: SceneObject[] = [ - { - id: mainModelId, - name: 'Generated 3D Model', - type: 'model', - parentId: null, - visible: true, - transformProps: { position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] } - } - ]; - - object.visible = true; - let mIdx = 0; - object.traverse((child) => { - if (child instanceof THREE.Mesh) { - child.castShadow = true; - child.receiveShadow = true; - child.frustumCulled = false; - const meshId = `mesh-${mIdx}`; - mIdx++; - - const material = Array.isArray(child.material) ? child.material[0] : child.material; - const mProps = { ...globalMaterialProps }; - - if (material) { - if (material.color) mProps.color = `#${material.color.getHexString()}`; - if (material.roughness !== undefined) mProps.roughness = material.roughness; - if (material.metalness !== undefined) mProps.metalness = material.metalness; - if (material.opacity !== undefined) mProps.opacity = material.opacity; - } - - newSceneObjects.push({ - id: meshId, - name: child.name || `Mesh ${meshId.slice(0, 4)}`, - type: 'mesh', - parentId: mainModelId, - visible: true, - transformProps: { - position: [child.position?.x || 0, child.position?.y || 0, child.position?.z || 0], - rotation: [child.rotation?.x || 0, child.rotation?.y || 0, child.rotation?.z || 0], - scale: [child.scale?.x || 1, child.scale?.y || 1, child.scale?.z || 1] - }, - materialProps: mProps - }); - } - }); - - // Save model data for scene persistence - const modelBlob = await fetch(modelUrl).then(r => r.blob()); - const modelBase64 = await new Promise((resolve) => { - const reader = new FileReader(); - reader.onloadend = () => resolve(reader.result as string); - reader.readAsDataURL(modelBlob); - }); - setModelData({ name: "Generated_Model.glb", data: modelBase64, extension: 'glb' }); - - const boundingBox = new THREE.Box3().setFromObject(object); - const center = new THREE.Vector3(); - boundingBox.getCenter(center); - const size = new THREE.Vector3(); - boundingBox.getSize(size); - const maxDim = Math.max(size.x || 0, size.y || 0, size.z || 0) || 1; - const radius = maxDim / 2; - const minY = boundingBox.min.y; - - setModelBounds({ center, size, maxDim, minY, radius }); - - if (directionalLightRef.current) { - const light = directionalLightRef.current; - light.shadow.camera.left = -maxDim; - light.shadow.camera.right = maxDim; - light.shadow.camera.top = maxDim; - light.shadow.camera.bottom = -maxDim; - light.shadow.camera.near = 0.1; - light.shadow.camera.far = maxDim * 5; - light.shadow.camera.updateProjectionMatrix(); - light.shadow.mapSize.width = 2048; - light.shadow.mapSize.height = 2048; - } - - let cameraZDistance = 0; - if (cameraRef.current) { - const camera = cameraRef.current; - const fovRadians = camera.fov * (Math.PI / 180); - cameraZDistance = radius / Math.sin(fovRadians / 2); - cameraZDistance *= 1.5; - - camera.position.set(center.x, center.y + (maxDim / 4), center.z + cameraZDistance); - camera.near = radius / 100; - camera.far = radius * 100; - camera.updateProjectionMatrix(); - camera.lookAt(center); - } - - if (orbitControlsRef.current) { - const controls = orbitControlsRef.current; - if (center) controls.target.copy(center); - controls.minDistance = radius / 2; - controls.maxDistance = (cameraZDistance || radius * 10) * 5; - controls.update(); - } - - setLoadedModel(object); - setBlobURLs(prev => [...prev, modelUrl]); - setSceneObjects(prev => [ - ...prev.filter(obj => obj.type !== 'cube'), - ...newSceneObjects - ]); - setSelectedIds([mainModelId]); - setShowImageTo3D(false); - - setTimeout(() => { - setIsLoading(false); - setImportStatus(""); - }, 500); - - } catch (error) { - console.error('Error loading generated model:', error); - setIsLoading(false); - setImportStatus(""); - } - }} - /> -
+ + + {/* Public Routes */} + } /> + } /> + + {/* Protected Routes (User) */} + }> + } /> + } /> + + + {/* Protected Routes (Admin) */} + }> + } /> + + + {/* Fallback */} + } /> + + ); } diff --git a/src/components/ApiSettingsModal.tsx b/src/components/ApiSettingsModal.tsx index a417b4d..6c12bfd 100644 --- a/src/components/ApiSettingsModal.tsx +++ b/src/components/ApiSettingsModal.tsx @@ -121,7 +121,7 @@ export const ApiSettingsModal: React.FC = ({ isOpen, onCl {/* Viz AI (Image-to-Image & LLM) */}

- Viz AI & Mockup Generation + Titan3d AI & Mockup Generation

diff --git a/src/components/ImageTo3DModal.tsx b/src/components/ImageTo3DModal.tsx index bfb407e..a72ad07 100644 --- a/src/components/ImageTo3DModal.tsx +++ b/src/components/ImageTo3DModal.tsx @@ -3,6 +3,8 @@ import { motion, AnimatePresence } from 'framer-motion'; import { X, Upload, Settings as SettingsIcon, Image as ImageIcon, Box, Download, AlertCircle, Loader2, Check } from 'lucide-react'; import { fal } from '@fal-ai/client'; import { cn } from '../lib/utils'; +import { useAuthStore } from '../store/authStore'; +import { api } from '../lib/api'; import { Canvas } from '@react-three/fiber'; import { OrbitControls, Stage } from '@react-three/drei'; import { GLTFLoader } from 'three-stdlib'; @@ -44,6 +46,7 @@ const ModelPreview = ({ url }: { url: string }) => { }; export const ImageTo3DModal: React.FC = ({ isOpen, onClose, onPushToApp }) => { + const { profile, setProfile } = useAuthStore(); const [apiProvider, setApiProvider] = useState<'fal' | 'tripo' | 'meshy'>('fal'); const [apiKey, setApiKey] = useState(''); const [tripoApiKey, setTripoApiKey] = useState(''); @@ -146,6 +149,10 @@ export const ImageTo3DModal: React.FC = ({ isOpen, onClose, setError("Please upload an image first."); return; } + if (!profile || profile.api_credits <= 0) { + setError("Not enough AI Credits. Please contact an administrator."); + return; + } if (apiProvider === 'fal' && !apiKey) { setError("Fal API Key is required. Please set it in Tools > API Settings."); return; @@ -158,6 +165,17 @@ export const ImageTo3DModal: React.FC = ({ isOpen, onClose, setError("Meshy API Key is required. Please set it in Tools > API Settings."); return; } + + const deductCredit = async () => { + if (profile) { + try { + const { api_credits } = await api.deductCredit(); + setProfile({ ...profile, api_credits }); + } catch (e) { + console.error("Credit deduction failed", e); + } + } + }; try { setIsGenerating(true); @@ -202,6 +220,7 @@ export const ImageTo3DModal: React.FC = ({ isOpen, onClose, if (modelUrl) { setGeneratedModelUrl(modelUrl); setProgressMessage("Done!"); + deductCredit(); } else { setError("Failed to extract 3D model from response."); } @@ -268,6 +287,7 @@ export const ImageTo3DModal: React.FC = ({ isOpen, onClose, setGeneratedModelUrl(modelUrl); setProgressMessage("Done!"); isDone = true; + deductCredit(); } else if (status === 'failed' || status === 'cancelled') { throw new Error(`Tripo API Task ${status}`); } @@ -339,6 +359,7 @@ export const ImageTo3DModal: React.FC = ({ isOpen, onClose, setGeneratedModelUrl(modelUrl); setProgressMessage("Done!"); isDone = true; + deductCredit(); } else if (status === 'FAILED' || status === 'EXPIRED') { throw new Error(`Meshy API Task ${status}`); } diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..a60314a --- /dev/null +++ b/src/components/ProtectedRoute.tsx @@ -0,0 +1,23 @@ +import { Navigate, Outlet, useLocation } from 'react-router-dom'; +import { useAuthStore } from '../store/authStore'; + +interface ProtectedRouteProps { + allowedRoles?: ('user' | 'admin')[]; +} + +export const ProtectedRoute = ({ allowedRoles = ['user', 'admin'] }: ProtectedRouteProps) => { + const { user, profile } = useAuthStore(); + const location = useLocation(); + + if (!user) { + // Redirect to login but save the attempted url + return ; + } + + if (profile && !allowedRoles.includes(profile.role)) { + // Role not authorized, go to dashboard + return ; + } + + return ; +}; diff --git a/src/components/RenderModal.tsx b/src/components/RenderModal.tsx index 5ba94b4..0a77237 100644 --- a/src/components/RenderModal.tsx +++ b/src/components/RenderModal.tsx @@ -83,7 +83,7 @@ export const RenderModal: React.FC = ({ isOpen, onClose, onRen
{/* Mode Tabs */}
- {['Still Image', 'Animation', 'VIZ3D XR', 'Configurator', 'CMF'].map((mode) => ( + {['Still Image', 'Animation', 'Titan3d XR', 'Configurator', 'CMF'].map((mode) => (
-

VIZ AI Generator

+

Titan3d AI Generator

Generate stunning product mockups directly from your 3D scene

diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..919289d --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,115 @@ +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3005/api'; + +const getHeaders = () => { + const token = localStorage.getItem('auth_token'); + return { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}) + }; +}; + +export const api = { + // Auth + register: async (email, password) => { + const res = await fetch(`${API_URL}/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }) + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + return data; // returns { status: 'pending' | 'active', token, profile } + }, + + login: async (email, password) => { + const res = await fetch(`${API_URL}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }) + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + return data; + }, + + getMe: async () => { + const res = await fetch(`${API_URL}/auth/me`, { headers: getHeaders() }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + return data; + }, + + // Projects + getProjects: async () => { + const res = await fetch(`${API_URL}/projects`, { headers: getHeaders() }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + return data; + }, + + getProject: async (id) => { + const res = await fetch(`${API_URL}/projects/${id}`, { headers: getHeaders() }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + return data; + }, + + saveProject: async (projectData) => { + const res = await fetch(`${API_URL}/projects`, { + method: 'POST', + headers: getHeaders(), + body: JSON.stringify(projectData) + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + return data; + }, + + // Admin + getUsers: async () => { + const res = await fetch(`${API_URL}/users`, { headers: getHeaders() }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + return data; + }, + + updateUser: async (id, updateData) => { + const res = await fetch(`${API_URL}/users/${id}`, { + method: 'PUT', + headers: getHeaders(), + body: JSON.stringify(updateData) + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + return data; + }, + + // Credits + deductCredit: async () => { + const res = await fetch(`${API_URL}/users/deduct-credit`, { + method: 'POST', + headers: getHeaders() + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + return data; + }, + + // Storage + uploadAsset: async (fileBlob) => { + const formData = new FormData(); + formData.append('file', fileBlob); + + const token = localStorage.getItem('auth_token'); + const headers = token ? { Authorization: `Bearer ${token}` } : {}; + + const res = await fetch(`${API_URL}/upload`, { + method: 'POST', + headers, + body: formData + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + return data.url; + } +}; diff --git a/src/lib/api/apiUtils.ts b/src/lib/api/apiUtils.ts new file mode 100644 index 0000000..744f15b --- /dev/null +++ b/src/lib/api/apiUtils.ts @@ -0,0 +1,81 @@ +/** + * Utility functions for API requests with exponential backoff and polling. + */ + +export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +export interface FetchOptions extends RequestInit { + maxRetries?: number; + baseDelayMs?: number; +} + +export async function fetchWithRetry(url: string, options: FetchOptions = {}): Promise { + const { maxRetries = 3, baseDelayMs = 1000, ...fetchOptions } = options; + let retries = 0; + + while (true) { + try { + const response = await fetch(url, fetchOptions); + if (response.ok) { + return response; + } + + // Don't retry on 4xx errors (client errors) + if (response.status >= 400 && response.status < 500 && response.status !== 429) { + throw new Error(`HTTP Error: ${response.status} ${response.statusText}`); + } + + // If it's a 429 (Rate Limit) or 5xx, we throw to trigger a retry + throw new Error(`HTTP Error: ${response.status} ${response.statusText}`); + } catch (error) { + if (retries >= maxRetries) { + throw error; + } + retries++; + // Exponential backoff with some jitter + const waitTime = (baseDelayMs * Math.pow(2, retries - 1)) + (Math.random() * 500); + console.warn(`Request failed, retrying in ${Math.round(waitTime)}ms... (Attempt ${retries}/${maxRetries})`); + await delay(waitTime); + } + } +} + +export interface PollOptions { + intervalMs?: number; + maxAttempts?: number; + onProgress?: (progress: number) => void; +} + +export async function pollStatus( + pollFn: () => Promise<{ status: 'processing' | 'success' | 'failed', data?: T, progress?: number }>, + options: PollOptions = {} +): Promise { + const { intervalMs = 2000, maxAttempts = 150, onProgress } = options; // Default to 5 minutes total polling + let attempts = 0; + + while (attempts < maxAttempts) { + try { + const result = await pollFn(); + + if (result.status === 'success' && result.data) { + return result.data; + } + + if (result.status === 'failed') { + throw new Error("Task failed during processing on the remote server."); + } + + if (result.progress !== undefined && onProgress) { + onProgress(result.progress); + } + } catch (err) { + // If the poll request itself fails, we log it but keep trying until maxAttempts + console.warn("Polling request failed, will retry next interval:", err); + } + + attempts++; + await delay(intervalMs); + } + + throw new Error("Polling timeout exceeded."); +} diff --git a/src/lib/api/meshyApi.ts b/src/lib/api/meshyApi.ts new file mode 100644 index 0000000..e63a90e --- /dev/null +++ b/src/lib/api/meshyApi.ts @@ -0,0 +1,56 @@ +import { fetchWithRetry, pollStatus } from './apiUtils'; + +const MESHY_BASE_URL = 'https://api.meshy.ai/openapi/v1'; + +export interface MeshyTaskResponse { + result: string; + progress: number; + status: 'PENDING' | 'IN_PROGRESS' | 'SUCCEEDED' | 'FAILED' | 'EXPIRED'; + task_error?: { message: string }; + model_urls?: { glb: string; usdz?: string }; +} + +export async function createMeshyTask(apiKey: string, imageBase64DataUrl: string): Promise { + const response = await fetchWithRetry(`${MESHY_BASE_URL}/image-to-3d`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + image_url: imageBase64DataUrl, + enable_pbr: true, + should_remesh: true + }) + }); + + const data = await response.json(); + return data.result; // Task ID +} + +export async function waitForMeshyTask(apiKey: string, taskId: string, onProgress?: (p: number) => void): Promise { + return pollStatus(async () => { + const response = await fetchWithRetry(`${MESHY_BASE_URL}/image-to-3d/${taskId}`, { + headers: { 'Authorization': `Bearer ${apiKey}` }, + maxRetries: 2, + baseDelayMs: 500 + }); + + const data: MeshyTaskResponse = await response.json(); + + if (data.status === 'SUCCEEDED' && data.model_urls?.glb) { + return { status: 'success', data: data.model_urls.glb }; + } + + if (data.status === 'FAILED' || data.status === 'EXPIRED') { + console.error("Meshy task failed:", data.task_error); + return { status: 'failed' }; + } + + return { status: 'processing', progress: data.progress || 0 }; + }, { + intervalMs: 3000, + maxAttempts: 100, // 5 minutes max wait + onProgress + }); +} diff --git a/src/lib/api/tripoApi.ts b/src/lib/api/tripoApi.ts new file mode 100644 index 0000000..b08c221 --- /dev/null +++ b/src/lib/api/tripoApi.ts @@ -0,0 +1,83 @@ +import { fetchWithRetry, pollStatus } from './apiUtils'; + +const TRIPO_BASE_URL = 'https://api.tripo3d.ai/v2/openapi'; + +export interface TripoTaskResponse { + code: number; + data: { + task_id: string; + status: 'queued' | 'running' | 'success' | 'failed' | 'cancelled'; + progress: number; + output?: { + model?: string; // URL to the model + base_model?: string; + }; + }; +} + +// 1. Upload Image +export async function uploadImageToTripo(apiKey: string, imageFile: File): Promise { + const formData = new FormData(); + formData.append('file', imageFile); + + const response = await fetchWithRetry(`${TRIPO_BASE_URL}/upload`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${apiKey}` }, + body: formData, + }); + + const data = await response.json(); + if (data.code !== 0 || !data.data.image_token) { + throw new Error("Failed to upload image to Tripo"); + } + return data.data.image_token; +} + +// 2. Create Task +export async function createTripoTask(apiKey: string, imageToken: string): Promise { + const response = await fetchWithRetry(`${TRIPO_BASE_URL}/task`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + type: 'image_to_model', + file: { type: 'jpg', file_token: imageToken } + }) + }); + + const data = await response.json(); + if (data.code !== 0 || !data.data.task_id) { + throw new Error("Failed to create Tripo task"); + } + return data.data.task_id; +} + +// 3. Poll Task +export async function waitForTripoTask(apiKey: string, taskId: string, onProgress?: (p: number) => void): Promise { + return pollStatus(async () => { + const response = await fetchWithRetry(`${TRIPO_BASE_URL}/task/${taskId}`, { + headers: { 'Authorization': `Bearer ${apiKey}` }, + maxRetries: 2, + baseDelayMs: 500 + }); + + const json: TripoTaskResponse = await response.json(); + const data = json.data; + + if (data.status === 'success' && data.output?.model) { + return { status: 'success', data: data.output.model }; + } + + if (data.status === 'failed' || data.status === 'cancelled') { + return { status: 'failed' }; + } + + return { status: 'processing', progress: data.progress || 0 }; + }, { + intervalMs: 3000, + maxAttempts: 100, + onProgress + }); +} diff --git a/src/lib/storageUtils.ts b/src/lib/storageUtils.ts new file mode 100644 index 0000000..1be4b0b --- /dev/null +++ b/src/lib/storageUtils.ts @@ -0,0 +1,19 @@ +import { api } from './api'; + +export const uploadAssetToStorage = async (file: Blob | File, bucket: string, path: string): Promise => { + try { + const url = await api.uploadAsset(file); + return url; + } catch (err) { + console.warn("Storage upload exception, falling back to data URL.", err); + return await fileToDataUrl(file); + } +}; + +export const fileToDataUrl = (file: Blob | File): Promise => { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.readAsDataURL(file); + }); +}; diff --git a/src/pages/Admin.tsx b/src/pages/Admin.tsx new file mode 100644 index 0000000..ba56132 --- /dev/null +++ b/src/pages/Admin.tsx @@ -0,0 +1,174 @@ +import { useEffect, useState } from 'react'; +import { useAuthStore } from '../store/authStore'; +import { useNavigate } from 'react-router-dom'; +import { ArrowLeft, ShieldAlert, Loader2, Save } from 'lucide-react'; +import { api } from '../lib/api'; + +interface UserProfile { + id: string; + role: string; + api_credits: number; + storage_limit_mb: number; + is_active: number; +} + +export default function Admin() { + const navigate = useNavigate(); + const { profile } = useAuthStore(); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchUsers = async () => { + if (profile?.role !== 'admin') return; + try { + const data = await api.getUsers(); + setUsers(data.users || []); + } catch (error) { + console.error(error); + } + setLoading(false); + }; + + fetchUsers(); + }, [profile]); + + const updateLimits = async (userObj: UserProfile) => { + try { + await api.updateUser(userObj.id, { + role: userObj.role, + api_credits: userObj.api_credits, + storage_limit_mb: userObj.storage_limit_mb, + is_active: userObj.is_active + }); + alert("Updated successfully!"); + } catch (error: any) { + alert("Failed to update user: " + error.message); + } + }; + + if (profile?.role !== 'admin') { + return ( +
+ +

Access Denied

+

You do not have administrative privileges.

+ +
+ ); + } + + return ( +
+
+ + +

Admin Control Panel

+ +
+
+

User Limit Management

+
+ + {loading ? ( +
+ ) : ( +
+ + + + + + + + + + + + + {users.map(u => ( + + ))} + +
User IDRoleStatusAPI CreditsStorage Limit (MB)Actions
+
+ )} +
+
+
+ ); +} + +const UserRow = ({ user, onUpdate }: { user: UserProfile, onUpdate: (u: UserProfile) => void }) => { + const [role, setRole] = useState(user.role); + const [isActive, setIsActive] = useState(user.is_active); + const [credits, setCredits] = useState(user.api_credits); + const [storage, setStorage] = useState(user.storage_limit_mb); + + const handleUpdate = () => { + onUpdate({ + ...user, + role, + is_active: isActive, + api_credits: credits, + storage_limit_mb: storage + }); + }; + + return ( + + {user.id} + + + + + + + + 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" + /> + + + 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" + /> + + + + + + ); +} diff --git a/src/pages/Auth.tsx b/src/pages/Auth.tsx new file mode 100644 index 0000000..2f747f9 --- /dev/null +++ b/src/pages/Auth.tsx @@ -0,0 +1,124 @@ +import React, { useState } from 'react'; +import { useSearchParams, useNavigate, useLocation, Link } from 'react-router-dom'; +import { api } from '../lib/api'; +import { useAuthStore } from '../store/authStore'; +import { Box, Mail, Lock, Loader2 } from 'lucide-react'; + +export default function Auth() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const location = useLocation(); + const { setSession } = useAuthStore(); + + const isRegister = searchParams.get('mode') === 'register'; + const from = location.state?.from?.pathname || '/dashboard'; + + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + setSuccess(''); + + try { + if (isRegister) { + const res = await api.register(email, password); + if (res.status === 'pending') { + setSuccess(res.message || 'Account created successfully! Please contact an admin to activate your account.'); + setEmail(''); + setPassword(''); + } else { + setSession(res.token, res.profile); + navigate(from, { replace: true }); + } + } else { + const { token, profile } = await api.login(email, password); + setSession(token, profile); + navigate(from, { replace: true }); + } + } catch (err: any) { + setError(err.message || 'An error occurred during authentication.'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
+ +
+
+ +

+ {isRegister ? 'Create an Account' : 'Welcome Back'} +

+

+ {isRegister ? 'Sign up to start building 3D scenes.' : 'Sign in to access your projects.'} +

+ + {error && ( +
+ {error} +
+ )} + + {success && ( +
+ {success} +
+ )} + +
+
+ + setEmail(e.target.value)} + className="w-full bg-zinc-950 border border-zinc-800 rounded-xl py-3 pl-10 pr-4 text-white focus:outline-none focus:border-primary transition-colors" + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full bg-zinc-950 border border-zinc-800 rounded-xl py-3 pl-10 pr-4 text-white focus:outline-none focus:border-primary transition-colors" + /> +
+ + +
+ +
+ {isRegister ? ( +

Already have an account? Sign in

+ ) : ( +

Don't have an account? Sign up

+ )} +
+
+
+ ); +} diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx new file mode 100644 index 0000000..468cf09 --- /dev/null +++ b/src/pages/Dashboard.tsx @@ -0,0 +1,118 @@ +import { useEffect, useState } from 'react'; +import { useAuthStore } from '../store/authStore'; +import { useNavigate, Link } from 'react-router-dom'; +import { LogOut, Plus, Folder, Loader2 } from 'lucide-react'; +import { api } from '../lib/api'; + +interface Project { + id: string; + name: string; + created_at: string; +} + +export default function Dashboard() { + const { user, profile, signOut } = useAuthStore(); + const navigate = useNavigate(); + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchProjects = async () => { + if (!user) return; + try { + const data = await api.getProjects(); + setProjects(data.projects || []); + } catch (error) { + console.error(error); + } + setLoading(false); + }; + + fetchProjects(); + }, [user]); + + const handleSignOut = async () => { + await signOut(); + navigate('/'); + }; + + const createNewProject = () => { + navigate('/editor/new'); + }; + + return ( +
+
+
Titan3d / Dashboard
+
+ {profile?.role === 'admin' && ( + Admin Panel + )} +
{user?.email}
+ +
+
+ +
+
+
+

AI Credits

+
{profile?.api_credits ?? 0}
+
+
+

Storage Usage

+
0 / {profile?.storage_limit_mb ?? 100} MB
+
+
+

Total Projects

+
{projects.length}
+
+
+ +
+

Your Projects

+ +
+ + {loading ? ( +
+ +
+ ) : ( +
+
+
+ +
+ Create Blank Scene +
+ + {projects.map(project => ( +
navigate(`/editor/${project.id}`)} className="aspect-video bg-zinc-900 border border-zinc-800 rounded-2xl p-4 flex flex-col cursor-pointer hover:border-zinc-700 transition-colors group relative overflow-hidden"> +
+
+ +
+
+

{project.name}

+

{new Date(project.created_at).toLocaleDateString()}

+
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx new file mode 100644 index 0000000..cb08499 --- /dev/null +++ b/src/pages/Editor.tsx @@ -0,0 +1,4951 @@ +import { useState, Suspense, useRef, useEffect, useMemo } from "react"; +import { Canvas } from "@react-three/fiber"; +import { OrbitControls, Environment, ContactShadows, PerspectiveCamera, OrthographicCamera, Float, MeshReflectorMaterial, TransformControls, Grid, useTexture } from "@react-three/drei"; +import { GLTFExporter } from "three-stdlib"; +import { + Plus, + Layers, + Palette, + Grid3X3 as TextureIcon, + Camera, + Sun, + Image as ImageIcon, + Settings, + Search, + ChevronDown, + Play, + Pause, + Maximize2, + Box, + Cpu, + Eye, + EyeOff, + Wrench, + Import, + Library, + Layout, + Video, + Share2, + BoxSelect, + Smartphone, + Monitor, + Menu, + Loader2, + FolderPlus, + Edit2, + Trash2, + Undo2, + Redo2, + Copy, + Clipboard, + RotateCw, + Save, + FolderOpen, + Sparkles +} from "lucide-react"; +import { motion, AnimatePresence } from "framer-motion"; +import { ErrorBoundary } from "../components/ErrorBoundary"; +import { ModelViewer } from "../components/ModelViewer"; +import { JuiceBox } from "../components/JuiceBox"; +import { RenderModal, RenderSettings } from "../components/RenderModal"; +import { UnifiedMappingMaterial } from '../components/UnifiedMappingMaterial'; +import { ImageTo3DModal } from "../components/ImageTo3DModal"; +import { UVEditor } from "../components/UVEditor"; +import { VizAiModal } from "../components/VizAiModal"; +import { ApiSettingsModal } from "../components/ApiSettingsModal"; +import { EffectComposer, DepthOfField, Bloom, Vignette, BrightnessContrast, HueSaturation } from "@react-three/postprocessing"; +import { cn } from "../lib/utils"; +import * as THREE from "three"; +import { useThree } from "@react-three/fiber"; +import * as fflate from "fflate"; +import { GLTFLoader, FBXLoader, OBJLoader, MTLLoader, DRACOLoader } from "three-stdlib"; +import { USDLoader } from "three/examples/jsm/loaders/USDLoader.js"; +import { USDZExporter } from "three/examples/jsm/exporters/USDZExporter.js"; +import { useParams, useNavigate } from 'react-router-dom'; +import { api } from '../lib/api'; +import { useAuthStore } from '../store/authStore'; +import { uploadAssetToStorage } from '../lib/storageUtils'; + +const SidebarItem = ({ icon: Icon, label, active, onClick }: { icon: any, label: string, active?: boolean, onClick?: () => void }) => ( + +); + +const ToolbarButton = ({ icon: Icon, label, active }: { icon: any, label?: string, active?: boolean }) => ( + +); + +const MaterialCard = ({ color, name, active, onClick, roughness = 0.5, metalness = 0, transmission = 0 }: { + color: string, + name: string, + active?: boolean, + onClick?: () => void, + roughness?: number, + metalness?: number, + transmission?: number +}) => ( + +); + +interface MaterialProps { + color: string; + roughness: number; + metalness: number; + clearcoat: number; + transmission: number; + thickness: number; + ior: number; + sheen: number; + opacity: number; + specularIntensity: number; + emissive?: string; + emissiveIntensity?: number; + attenuationDistance?: number; + attenuationColor?: string; + map?: string | null; + normalMap?: string | null; + specularMap?: string | null; + alphaMap?: string | null; + uvScale?: number; + uvTiling?: [number, number]; + uvOffset?: [number, number]; + uvRotation?: number; + mappingType?: 'uv' | 'planar' | 'box' | 'cylinder' | 'sphere'; + uvLayers?: any[]; + uvBackground?: string; + uvTransparent?: boolean; +} + +interface AdditionalLight { + id: string; + type: 'point' | 'spot'; + position: [number, number, number]; + intensity: number; + color: string; + distance: number; + decay: number; + angle?: number; + penumbra?: number; + castShadow: boolean; +} + +interface TransformProps { + position: [number, number, number]; + rotation: [number, number, number]; + scale: [number, number, number]; +} + +interface SceneObject { + id: string; + name: string; + type: 'cube' | 'model' | 'group' | 'mesh'; + parentId: string | null; + visible: boolean; + materialProps?: MaterialProps; + transformProps?: TransformProps; + fillProps?: { + enabled: boolean; + type: 'solid' | 'liquid'; + height: number; + color: string; + }; +} + +const SceneSetup = () => { + const { gl } = useThree(); + useEffect(() => { + gl.shadowMap.type = THREE.PCFSoftShadowMap; + gl.localClippingEnabled = true; + }, [gl]); + return null; +}; + +const FixedBackplate = ({ url }: { url: string }) => { + const texture = useTexture(url); + const { scene } = useThree(); + + useEffect(() => { + if (texture) { + texture.colorSpace = THREE.SRGBColorSpace; + const originalBackground = scene.background; + scene.background = texture; + return () => { + scene.background = originalBackground; + }; + } + }, [scene, texture]); + + return null; +}; + +const ShadowFloor = ({ bounds, opacity, rotation, softness }: { bounds: any, opacity: number, rotation: number, softness: number }) => { + const alphaTexture = useMemo(() => { + const canvas = document.createElement('canvas'); + canvas.width = 256; + canvas.height = 256; + const context = canvas.getContext('2d'); + if (!context) return null; + + // Softness affects the inner radius and the falloff + // Higher softness = more gradual fade + const innerRadius = 0; + const outerRadius = 128; + const gradient = context.createRadialGradient(128, 128, innerRadius, 128, 128, outerRadius); + + // At softness 0, it's a sharper circle (but still a gradient) + // At softness 25, it's an extremely soft fade + const midPoint = Math.max(0.001, 0.5 * Math.exp(-(softness || 0.5) * 0.2)); + gradient.addColorStop(0, 'white'); + gradient.addColorStop(midPoint, 'white'); + gradient.addColorStop(1, 'black'); + + context.fillStyle = gradient; + context.fillRect(0, 0, 256, 256); + return new THREE.CanvasTexture(canvas); + }, [softness]); + + if (!bounds || !alphaTexture) return null; + + const planeSize = (bounds.maxDim || 5) * 5; + const posY = bounds.minY !== undefined ? bounds.minY : -0.5; + + return ( + + + + + ); +}; + +export default function Editor() { + const { projectId } = useParams(); + const navigate = useNavigate(); + const { user } = useAuthStore(); + const [activeSidebar, setActiveSidebar] = useState("Materials"); + const [theme, setTheme] = useState<"light" | "dark">("dark"); + const [loadedModel, setLoadedModel] = useState(null); + const [blobURLs, setBlobURLs] = useState([]); + + // Cleanup blob URLs on unmount + useEffect(() => { + return () => { + blobURLs.forEach(url => URL.revokeObjectURL(url)); + }; + }, [blobURLs]); + + // Dispose old model when new one is loaded + useEffect(() => { + return () => { + if (loadedModel) { + loadedModel.traverse((child) => { + if (child instanceof THREE.Mesh) { + child.geometry.dispose(); + if (Array.isArray(child.material)) { + child.material.forEach(m => m.dispose()); + } else { + child.material.dispose(); + } + } + }); + } + }; + }, [loadedModel]); + const [isLoading, setIsLoading] = useState(false); + const [importProgress, setImportProgress] = useState(0); + const [sceneObjects, setSceneObjects] = useState([]); + const [selectedIds, setSelectedIds] = useState([]); + const [editingId, setEditingId] = useState(null); + const [activeRightTab, setActiveRightTab] = useState("Scene"); + const [showUVEditor, setShowUVEditor] = useState(false); + const [showImageTo3D, setShowImageTo3D] = useState(false); + + // Fetch cloud project + useEffect(() => { + const fetchProject = async () => { + if (!projectId || projectId === 'new' || !user) return; + + setIsLoading(true); + try { + const data = await api.getProject(projectId); + if (data.project && data.project.scene_data) { + const sd = data.project.scene_data; + if (sd.sceneObjects) setSceneObjects(sd.sceneObjects); + if (sd.lightProps) setLightProps(sd.lightProps); + if (sd.groundProps) setGroundProps(sd.groundProps); + if (sd.cameraProps) { + setCameraProps(sd.cameraProps); + const updateControls = (attempts = 0) => { + if (orbitControlsRef.current) { + const controls = orbitControlsRef.current; + if (sd.cameraProps.target) controls.target.set(sd.cameraProps.target[0], sd.cameraProps.target[1], sd.cameraProps.target[2]); + if (sd.cameraProps.position) { + controls.object.position.set(sd.cameraProps.position[0], sd.cameraProps.position[1], sd.cameraProps.position[2]); + } + controls.update(); + } else if (attempts < 10) { + setTimeout(() => updateControls(attempts + 1), 100); + } + }; + updateControls(); + } + if (sd.envPreset !== undefined) setEnvPreset(sd.envPreset); + if (sd.envRotation !== undefined) setEnvRotation(sd.envRotation); + if (sd.backgroundColor) setBackgroundColor(sd.backgroundColor); + if (sd.backgroundType) setBackgroundType(sd.backgroundType); + if (sd.customHdri) setCustomHdri(sd.customHdri); + if (sd.backplateImage) setBackplateImage(sd.backplateImage); + if (sd.additionalLights) setAdditionalLights(sd.additionalLights); + if (sd.modelBounds) setModelBounds(sd.modelBounds); + if (sd.cameraPresets) setCameraPresets(sd.cameraPresets); + + if (sd.modelData && sd.modelData.data) { + reImportModel(sd.modelData); + } + } + } catch (err) { + console.error("Failed to fetch project", err); + } finally { + setIsLoading(false); + } + }; + + fetchProject(); + }, [projectId, user]); + + // History State + const [history, setHistory] = useState([]); + const [historyIndex, setHistoryIndex] = useState(-1); + const historyRef = useRef([]); + const historyIndexRef = useRef(-1); + const [isUndoingRedoing, setIsUndoingRedoing] = useState(false); + const skipHistoryRef = useRef(false); + + // Sync refs with state + useEffect(() => { + historyRef.current = history; + historyIndexRef.current = historyIndex; + }, [history, historyIndex]); + + const pushToHistory = (newObjects: SceneObject[]) => { + if (isUndoingRedoing) return; + const currentHistory = historyRef.current; + const currentIndex = historyIndexRef.current; + + const newHistory = currentHistory.slice(0, currentIndex + 1); + newHistory.push(JSON.parse(JSON.stringify(newObjects))); + if (newHistory.length > 50) newHistory.shift(); // Limit history + + setHistory(newHistory); + setHistoryIndex(newHistory.length - 1); + }; + + const undo = () => { + const currentIndex = historyIndexRef.current; + if (currentIndex > 0) { + skipHistoryRef.current = true; + setIsUndoingRedoing(true); + const prevIndex = currentIndex - 1; + const prevObjects = JSON.parse(JSON.stringify(historyRef.current[prevIndex])); + setSceneObjects(prevObjects); + setHistoryIndex(prevIndex); + // Wait a bit longer to ensure the effect doesn't trigger + setTimeout(() => { setIsUndoingRedoing(false); skipHistoryRef.current = false; }, 100); + } + }; + + const redo = () => { + const currentIndex = historyIndexRef.current; + if (currentIndex < historyRef.current.length - 1) { + skipHistoryRef.current = true; + setIsUndoingRedoing(true); + const nextIndex = currentIndex + 1; + const nextObjects = JSON.parse(JSON.stringify(historyRef.current[nextIndex])); + setSceneObjects(nextObjects); + setHistoryIndex(nextIndex); + // Wait a bit longer to ensure the effect doesn't trigger + setTimeout(() => { setIsUndoingRedoing(false); skipHistoryRef.current = false; }, 100); + } + }; + + // Clipboard State + const [clipboard, setClipboard] = useState<{ + materialProps?: MaterialProps; + transformProps?: TransformProps; + } | null>(null); + + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + objectId: string; + } | null>(null); + + // Default material props for new objects or global edits + const [globalMaterialProps, setGlobalMaterialProps] = useState({ + color: "#ffffff", + roughness: 0.2, + metalness: 0.1, + clearcoat: 0, + transmission: 0, + thickness: 0, + ior: 1.5, + sheen: 0, + opacity: 1, + specularIntensity: 1, + emissive: "#000000", + emissiveIntensity: 0, + map: undefined, + normalMap: undefined, + specularMap: undefined, + alphaMap: undefined, + uvScale: 1, + uvTiling: [1, 1], + uvOffset: [0, 0], + uvRotation: 0, + mappingType: 'uv' + }); + + const [lightProps, setLightProps] = useState({ + intensity: 1, + color: "#ffffff", + shadowBias: -0.0005, + shadowRadius: 4 + }); + + const [additionalLights, setAdditionalLights] = useState([]); + + const [groundProps, setGroundProps] = useState({ + showShadow: true, + shadowIntensity: 0.4, + shadowRotation: 0, + shadowSoftness: 0.5, + shadowLength: 10 + }); + + const [selectedCategory, setSelectedCategory] = useState("plastics"); + const [envPreset, setEnvPreset] = useState("studio"); + const [envRotation, setEnvRotation] = useState(0); + const [customHdri, setCustomHdri] = useState(null); + const [backplateImage, setBackplateImage] = useState(null); + const [backgroundColor, setBackgroundColor] = useState("#444444"); // Default grey + const [backgroundType, setBackgroundType] = useState<"color" | "hdri" | "image">("color"); + const [showGrid, setShowGrid] = useState(true); + const [showRenderModal, setShowRenderModal] = useState(false); + const [showVizAi, setShowVizAi] = useState(false); + const [showApiSettings, setShowApiSettings] = useState(false); + const [vizAiBaseImage, setVizAiBaseImage] = useState(null); + const [renderRequest, setRenderRequest] = useState(null); + const [exportRequest, setExportRequest] = useState(false); + const [exportFormat, setExportFormat] = useState<'glb' | 'usdz'>('glb'); + // Export / Save dialog + const [exportDialog, setExportDialog] = useState<{ + mode: 'scene' | 'glb' | 'usdz'; + defaultName: string; + } | null>(null); + const [exportDialogName, setExportDialogName] = useState(''); + const [isRendering, setIsRendering] = useState(false); + const [importStatus, setImportStatus] = useState(null); + const [importError, setImportError] = useState(null); + const [modelData, setModelData] = useState<{ name: string, data: string, extension: string } | null>(null); + const [cameraProps, setCameraProps] = useState({ + fov: 35, + zoom: 1, + autoRotate: false, + orthographic: false, + target: [0, 0.5, 0] as [number, number, number], + position: [5, 3, 5] as [number, number, number], + pivot: [0, 0, 0] as [number, number, number], + cameraMode: "absolute" as "spherical" | "absolute", + spherical: { + distance: 7.68, + azimuth: 45, + inclination: 20, + twist: 0 + }, + walkthroughMode: false, + groundGrid: false, + depthOfField: { + enabled: false, + focusDistance: 0.05, + focalLength: 0.05, + bokehScale: 3 + }, + bloom: { + enabled: false, + intensity: 1.0, + luminanceThreshold: 0.9, + luminanceSmoothing: 0.025, + mipmapBlur: true + }, + vignette: { + enabled: false, + offset: 0.5, + darkness: 0.5 + }, + colorGrading: { + enabled: false, + brightness: 0, + contrast: 0, + hue: 0, + saturation: 0 + } + }); + + const fovToFocalLength = (fov: number) => { + return 18 / Math.tan((fov * Math.PI) / 360); + }; + + const focalLengthToFov = (focalLength: number) => { + return (2 * Math.atan(18 / focalLength) * 180) / Math.PI; + }; + + const setStandardView = (view: string) => { + if (!orbitControlsRef.current) return; + const controls = orbitControlsRef.current; + if (!controls.object || !controls.target) return; + const dist = controls.object.position.distanceTo(controls.target); + + const t = controls.target; + const target = [t.x || 0, t.y || 0, t.z || 0]; + let position: [number, number, number] = [0, 0, 0]; + + switch (view) { + case "front": position = [target[0], target[1], target[2] + dist]; break; + case "back": position = [target[0], target[1], target[2] - dist]; break; + case "top": position = [target[0], target[1] + dist, target[2]]; break; + case "bottom": position = [target[0], target[1] - dist, target[2]]; break; + case "left": position = [target[0] - dist, target[1], target[2]]; break; + case "right": position = [target[0] + dist, target[1], target[2]]; break; + case "isometric": position = [target[0] + dist, target[1] + dist, target[2] + dist]; break; + } + + if (position.some(v => isNaN(v))) return; + + controls.object.position.set(...position); + controls.update(); + + // Get new spherical + const azimuth = (controls.getAzimuthalAngle() || 0) * (180 / Math.PI); + const inclination = (Math.PI / 2 - (controls.getPolarAngle() || 0)) * (180 / Math.PI); + + setCameraProps(prev => ({ + ...prev, + position, + spherical: { + ...prev.spherical, + distance: dist, + azimuth, + inclination + } + })); + }; + const [cameraPresets, setCameraPresets] = useState([ + { id: 'default', name: 'Default View', fov: 35, zoom: 1, position: [3, 2, 5], target: [0, 0, 0] } + ]); + const orbitControlsRef = useRef(null); + const [modelBounds, setModelBounds] = useState<{ + center: THREE.Vector3; + size: THREE.Vector3; + maxDim: number; + minY: number; + radius: number; + } | null>(null); + + const controlsRef = useRef(null); + const directionalLightRef = useRef(null); + const cameraRef = useRef(null); + const juiceBoxRef = useRef(null); + const cubeRef = useRef(null); + const modelRef = useRef(null); + const transformRef = useRef(null); + const fileInputRef = useRef(null); + const hdriInputRef = useRef(null); + const backplateInputRef = useRef(null); + const textureInputRef = useRef(null); + const normalInputRef = useRef(null); + const specularInputRef = useRef(null); + const alphaInputRef = useRef(null); + + const [openMenu, setOpenMenu] = useState(null); + const sceneInputRef = useRef(null); + + const newScene = () => { + // Reset all scene state to defaults + setSceneObjects([]); + setSelectedIds([]); + setLoadedModel(null); + setModelData(null); + setModelBounds(null); + setHistory([]); + setHistoryIndex(-1); + setAdditionalLights([]); + setBackplateImage(null); + setCustomHdri(null); + setBackgroundColor('#444444'); + setBackgroundType('color'); + setEnvPreset('studio'); + setEnvRotation(0); + setGlobalMaterialProps({ + color: '#ffffff', roughness: 0.2, metalness: 0.1, clearcoat: 0, + transmission: 0, thickness: 0, ior: 1.5, sheen: 0, opacity: 1, + specularIntensity: 1, emissive: '#000000', emissiveIntensity: 0, + map: undefined, normalMap: undefined, specularMap: undefined, alphaMap: undefined, + uvScale: 1, uvTiling: [1, 1], uvOffset: [0, 0], uvRotation: 0, mappingType: 'uv' + }); + setLightProps({ intensity: 1, color: '#ffffff', shadowBias: -0.0005, shadowRadius: 4 }); + setGroundProps({ showShadow: true, shadowIntensity: 0.4, shadowRotation: 0, shadowSoftness: 0.5, shadowLength: 10 }); + setCameraProps(prev => ({ + ...prev, fov: 35, zoom: 1, autoRotate: false, orthographic: false, + position: [5, 3, 5], target: [0, 0.5, 0], pivot: [0, 0, 0], + spherical: { distance: 7.68, azimuth: 45, inclination: 20, twist: 0 }, + })); + setCameraPresets([{ id: 'default', name: 'Default View', fov: 35, zoom: 1, position: [3, 2, 5], target: [0, 0, 0] }]); + blobURLs.forEach(url => URL.revokeObjectURL(url)); + setBlobURLs([]); + setOpenMenu(null); + setShowUVEditor(false); + setIsRendering(false); + setImportStatus(null); + setImportError(null); + if (fileInputRef.current) fileInputRef.current.value = ''; + if (sceneInputRef.current) sceneInputRef.current.value = ''; + }; + + const saveScene = async (name?: string) => { + if (!user) return; + + const sceneData = { + sceneObjects, lightProps, groundProps, cameraProps, + envPreset, envRotation, backgroundColor, backgroundType, + customHdri, backplateImage, additionalLights, modelBounds, modelData, cameraPresets + }; + + const projectName = name || exportDialogName || 'Untitled Scene'; + + try { + setIsRendering(true); // Re-use spinner state for saving + const result = await api.saveProject({ + id: projectId && projectId !== 'new' ? projectId : undefined, + name: projectName, + scene_data: sceneData + }); + if (projectId === 'new' && result.id) { + navigate(`/editor/${result.id}`, { replace: true }); + } + } catch (err) { + console.error("Failed to save project to cloud", err); + alert("Failed to save project"); + } finally { + setIsRendering(false); + setOpenMenu(null); + setExportDialog(null); + } + }; + + const openSaveDialog = () => { + const defaultName = modelData?.name ? modelData.name.replace(/\.[^.]+$/, '') : 'scene'; + setExportDialogName(defaultName); + setExportDialog({ mode: 'scene', defaultName }); + setOpenMenu(null); + }; + + const openExportDialog = (fmt: 'glb' | 'usdz') => { + const defaultName = modelData?.name ? modelData.name.replace(/\.[^.]+$/, '') : 'model'; + setExportDialogName(defaultName); + setExportDialog({ mode: fmt, defaultName }); + setOpenMenu(null); + }; + + const confirmExportDialog = () => { + if (!exportDialog) return; + const name = exportDialogName.trim() || exportDialog.defaultName; + if (exportDialog.mode === 'scene') { + saveScene(name); + } else { + setExportFormat(exportDialog.mode); + // Store name so ExportManager can use it + setExportFileName(name); + setExportRequest(true); + } + setExportDialog(null); + }; + + const [exportFileName, setExportFileName] = useState('model'); + + const loadScene = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (e) => { + try { + const data = JSON.parse(e.target?.result as string); + if (data.sceneObjects) setSceneObjects(data.sceneObjects); + if (data.lightProps) setLightProps(data.lightProps); + if (data.groundProps) setGroundProps(data.groundProps); + if (data.cameraProps) { + const cProps = data.cameraProps; + setCameraProps(cProps); + + // Use a longer timeout and multiple attempts to ensure OrbitControls are updated + const updateControls = (attempts = 0) => { + if (orbitControlsRef.current) { + const controls = orbitControlsRef.current; + if (cProps.target) controls.target.set(cProps.target[0], cProps.target[1], cProps.target[2]); + if (cProps.position) { + controls.object.position.set(cProps.position[0], cProps.position[1], cProps.position[2]); + } + controls.update(); + } else if (attempts < 10) { + setTimeout(() => updateControls(attempts + 1), 100); + } + }; + updateControls(); + } + if (data.envPreset !== undefined) setEnvPreset(data.envPreset); + if (data.envRotation !== undefined) setEnvRotation(data.envRotation); + if (data.backgroundColor) setBackgroundColor(data.backgroundColor); + if (data.backgroundType) setBackgroundType(data.backgroundType); + if (data.customHdri) setCustomHdri(data.customHdri); + if (data.backplateImage) setBackplateImage(data.backplateImage); + if (data.additionalLights) setAdditionalLights(data.additionalLights); + if (data.modelBounds) setModelBounds(data.modelBounds); + if (data.cameraPresets) setCameraPresets(data.cameraPresets); + + // Re-load model if data exists + if (data.modelData && data.modelData.data) { + reImportModel(data.modelData); + } + } catch (err) { + console.error("Failed to load scene", err); + } + }; + reader.readAsText(file); + setOpenMenu(null); + // Clear input so same file can be loaded again + event.target.value = ''; + }; + + const reImportModel = async (modelInfo: { name: string, data: string, extension: string }) => { + setIsLoading(true); + setImportStatus("Restoring model..."); + try { + const response = await fetch(modelInfo.data); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + + const manager = new THREE.LoadingManager(); + let object: THREE.Object3D | null = null; + const extension = modelInfo.extension; + + if (extension === 'glb' || extension === 'gltf') { + const loader = new GLTFLoader(manager); + const dracoLoader = new DRACOLoader(); + dracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.6/'); + loader.setDRACOLoader(dracoLoader); + const gltf = await loader.loadAsync(url); + object = gltf.scene; + dracoLoader.dispose(); + } else if (extension === 'fbx') { + const loader = new FBXLoader(manager); + object = await loader.loadAsync(url); + } else if (extension === 'obj') { + const loader = new OBJLoader(manager); + object = await loader.loadAsync(url); + } else if (['usdz', 'usd', 'usda', 'usdc'].includes(extension)) { + const loader = new USDLoader(manager); + object = await loader.loadAsync(url); + } + + if (object) { + object.visible = true; + // Setup shadows + object.traverse((child) => { + if (child instanceof THREE.Mesh) { + child.castShadow = true; + child.receiveShadow = true; + child.frustumCulled = false; // Prevent flickering on large models + } + }); + setLoadedModel(object); + setBlobURLs(prev => [...prev, url]); + } + setIsLoading(false); + } catch (error) { + console.error('Error re-importing model:', error); + setIsLoading(false); + } + }; + + const handleTextureUpload = (event: React.ChangeEvent, type: 'map' | 'normalMap' | 'specularMap' | 'alphaMap' = 'map') => { + const file = event.target.files?.[0]; + if (!file || !selectedObject) return; + + const reader = new FileReader(); + reader.onload = (e) => { + const textureUrl = e.target?.result as string; + updateObjectMaterial(selectedObject.id, { [type]: textureUrl }); + }; + reader.readAsDataURL(file); + }; + + const handleHdriUpload = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (e) => { + const hdriUrl = e.target?.result as string; + setCustomHdri(hdriUrl); + setEnvPreset(null); // Clear preset when custom HDRI is used + }; + reader.readAsDataURL(file); + }; + + const removeTexture = () => { + if (selectedObject) { + updateObjectMaterial(selectedObject.id, { map: null }); + if (textureInputRef.current) textureInputRef.current.value = ""; + } + }; + + const removeNormalMap = () => { + if (selectedObject) { + updateObjectMaterial(selectedObject.id, { normalMap: null }); + if (normalInputRef.current) normalInputRef.current.value = ""; + } + }; + + const removeSpecularMap = () => { + if (selectedObject) { + updateObjectMaterial(selectedObject.id, { specularMap: null }); + if (specularInputRef.current) specularInputRef.current.value = ""; + } + }; + + const removeAlphaMap = () => { + if (selectedObject) { + updateObjectMaterial(selectedObject.id, { alphaMap: null }); + if (alphaInputRef.current) alphaInputRef.current.value = ""; + } + }; + + const saveCameraPreset = (name: string) => { + if (!orbitControlsRef.current) return; + const controls = orbitControlsRef.current; + if (!controls.object || !controls.target) return; + + // Get current camera position and target + const p = controls.object.position; + const t = controls.target; + const position = [p.x || 0, p.y || 0, p.z || 0]; + const target = [t.x || 0, t.y || 0, t.z || 0]; + + const newPreset = { + id: Math.random().toString(36).substr(2, 9), + name, + fov: cameraProps.fov, + zoom: cameraProps.zoom, + autoRotate: cameraProps.autoRotate, + orthographic: cameraProps.orthographic, + cameraMode: cameraProps.cameraMode, + spherical: { ...cameraProps.spherical }, + position, + target + }; + + setCameraPresets(prev => [...prev, newPreset]); + }; + + const loadCameraPreset = (preset: any) => { + if (!orbitControlsRef.current) return; + const controls = orbitControlsRef.current; + + setCameraProps(prev => ({ + ...prev, + fov: preset.fov, + zoom: preset.zoom, + autoRotate: preset.autoRotate || false, + orthographic: preset.orthographic || false, + cameraMode: preset.cameraMode || "absolute", + spherical: preset.spherical || { distance: 6.326, azimuth: -91.475, inclination: -0.393, twist: 0 }, + target: preset.target || [0, 0, 0], + position: preset.position || [3, 2, 5], + pivot: preset.target || [0, 0, 0], + walkthroughMode: false, + groundGrid: false, + depthOfField: { enabled: false, focusDistance: 0.01, focalLength: 0.02, bokehScale: 2 } + })); + + // We need to set the camera position and the controls target + const p = preset.position; + const t = preset.target; + controls.object.position.set(p[0], p[1], p[2]); + controls.target.set(t[0], t[1], t[2]); + controls.update(); + }; + + // Keyboard Shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Don't intercept if user is typing in an input + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + return; + } + + // Prevent default for common shortcuts + if (e.ctrlKey || e.metaKey) { + switch (e.key.toLowerCase()) { + case 'n': + e.preventDefault(); + newScene(); + break; + case 's': + e.preventDefault(); + openSaveDialog(); + break; + case 'o': + e.preventDefault(); + sceneInputRef.current?.click(); + break; + case 'i': + e.preventDefault(); + fileInputRef.current?.click(); + break; + case 'e': + e.preventDefault(); + setExportRequest(true); + break; + case 'z': + e.preventDefault(); + if (e.shiftKey) { + redo(); + } else { + undo(); + } + break; + case 'y': + e.preventDefault(); + redo(); + break; + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, []); + + const RenderManager = () => { + const { gl, scene, camera } = useThree(); + + useEffect(() => { + if (!renderRequest) return; + const { width, height, includeAlpha, format, name } = renderRequest; + + setIsRendering(true); + + // Use an offscreen render target so we get exactly the requested resolution + // without resizing the visible canvas (which can produce multiple download events) + const renderFrame = () => { + // Save original state + const originalBackground = scene.background; + const originalToneMapping = gl.toneMapping; + const originalToneMappingExposure = gl.toneMappingExposure; + + // Create offscreen render target at exact requested size + const rt = new THREE.WebGLRenderTarget(width, height, { + minFilter: THREE.LinearFilter, + magFilter: THREE.LinearFilter, + format: includeAlpha ? THREE.RGBAFormat : THREE.RGBFormat, + colorSpace: THREE.SRGBColorSpace, + }); + + if (includeAlpha) { + scene.background = null; + } + + // Render scene into the offscreen target + gl.setRenderTarget(rt); + gl.render(scene, camera); + gl.setRenderTarget(null); + + // Read pixels from the render target + const pixels = new Uint8Array(width * height * 4); + gl.readRenderTargetPixels(rt, 0, 0, width, height, pixels); + + // Flip vertically (WebGL reads bottom-to-top) + const flipped = new Uint8Array(width * height * 4); + for (let row = 0; row < height; row++) { + const src = (height - 1 - row) * width * 4; + const dst = row * width * 4; + flipped.set(pixels.subarray(src, src + width * 4), dst); + } + + // Draw into a 2D canvas and export + const offscreen = document.createElement('canvas'); + offscreen.width = width; + offscreen.height = height; + const ctx = offscreen.getContext('2d')!; + const imageData = ctx.createImageData(width, height); + imageData.data.set(flipped); + ctx.putImageData(imageData, 0, 0); + + const mimeType = format === 'jpg' ? 'image/jpeg' : 'image/png'; + const dataUrl = offscreen.toDataURL(mimeType, 0.95); + const link = document.createElement('a'); + link.download = `${name}.${format}`; + link.href = dataUrl; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // Restore + rt.dispose(); + scene.background = originalBackground; + gl.toneMapping = originalToneMapping; + gl.toneMappingExposure = originalToneMappingExposure; + + setRenderRequest(null); + setShowRenderModal(false); + setIsRendering(false); + }; + + // Wait for React to hide UI elements before capturing + setTimeout(renderFrame, 150); + }, [renderRequest, gl, scene, camera]); + + return null; + }; + + const ExportManager = () => { + const { scene } = useThree(); + + useEffect(() => { + if (exportRequest) { + if (exportFormat === 'glb') { + const exporter = new GLTFExporter(); + const exportScene = scene.clone(); + + // Collect things to remove from exportScene + const toRemove: THREE.Object3D[] = []; + exportScene.traverse((child: any) => { + if ( + child instanceof THREE.Camera || + child instanceof THREE.Light || + child instanceof THREE.GridHelper || + child instanceof THREE.PlaneHelper || + child.type === 'GridHelper' || + child.type === 'DirectionalLightHelper' || + child.name === '__background__' || + child.type === 'TransformControls' || + (child.name && child.name.includes('TransformControls')) || + (child.name && child.name.includes('Grid')) || + // Exclude Floor/Shadow plane + (child instanceof THREE.Mesh && child.geometry && child.geometry.type === 'PlaneGeometry') || + // Exclude TransformControls gizmo meshes + (child.parent && child.parent.type === 'TransformControls') + ) { + toRemove.push(child); + } + }); + + toRemove.forEach(c => c.removeFromParent()); + + exporter.parse( + exportScene, + (result) => { + const blob = new Blob([result as ArrayBuffer], { type: 'application/octet-stream' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + const fname = (exportFileName || 'model').replace(/\.glb$/i, ''); + link.download = `${fname}.glb`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + setExportRequest(false); + }, + (error) => { + console.error('GLB export error:', error); + setExportRequest(false); + }, + { binary: true, trs: true, onlyVisible: true, truncateDrawRange: true } + ); + } else if (exportFormat === 'usdz') { + const exporter = new USDZExporter(); + const exportScene = scene.clone(); + const toRemove: THREE.Object3D[] = []; + exportScene.traverse((child: any) => { + if ( + child instanceof THREE.Camera || + child instanceof THREE.Light || + child instanceof THREE.GridHelper || + child instanceof THREE.PlaneHelper || + child.type === 'GridHelper' || + child.type === 'DirectionalLightHelper' || + child.name === '__background__' || + child.type === 'TransformControls' || + (child.name && child.name.includes('TransformControls')) || + (child.name && child.name.includes('Grid')) || + (child instanceof THREE.Mesh && child.geometry && child.geometry.type === 'PlaneGeometry') || + (child.parent && child.parent.type === 'TransformControls') + ) { + toRemove.push(child); + } + }); + toRemove.forEach(c => c.removeFromParent()); + + exporter.parse( + exportScene, + (result) => { + const blob = new Blob([result], { type: 'model/vnd.usdz+zip' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + const fname = (exportFileName || 'model').replace(/\.usdz$/i, ''); + link.download = `${fname}.usdz`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + setExportRequest(false); + }, + (error) => { + console.error('USDZ export error:', error); + setExportRequest(false); + } + ); + } + } + }, [exportRequest, exportFormat, scene]); + + return null; + }; + + const loadBackplate = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (e) => { + const result = e.target?.result; + if (typeof result === 'string') { + setBackplateImage(result); + setBackgroundType("image"); + } + }; + reader.readAsDataURL(file); + }; + + const handleImport = async (event: React.ChangeEvent) => { + const files = event.target.files; + if (!files || files.length === 0) return; + + setIsLoading(true); + setImportProgress(0); + setImportStatus("Preparing files..."); + setImportError(null); + + const fileMap: { [key: string]: string } = {}; + const currentBlobURLs: string[] = []; + let rootFile: { name: string, url: string } | null = null; + + try { + const processFile = (name: string, data: Uint8Array | Blob) => { + const blob = data instanceof Blob ? data : new Blob([data]); + const url = URL.createObjectURL(blob); + // Store both full name and just the filename for better matching + fileMap[name] = url; + const fileName = name.split('/').pop() || name; + fileMap[fileName] = url; + currentBlobURLs.push(url); + + const ext = name.split('.').pop()?.toLowerCase(); + if (!rootFile && ['glb', 'gltf', 'fbx', 'obj', 'usdz', 'usd', 'usda', 'usdc'].includes(ext || '')) { + rootFile = { name, url }; + } + }; + + // Handle Zip or Multiple Files + if (files.length === 1 && files[0].name.endsWith('.zip')) { + setImportStatus("Unzipping archive..."); + const buffer = await files[0].arrayBuffer(); + const unzipped = fflate.unzipSync(new Uint8Array(buffer)); + for (const name in unzipped) { + processFile(name, unzipped[name]); + } + } else { + for (let i = 0; i < files.length; i++) { + const file = files[i]; + processFile(file.name, file); + } + } + + if (!rootFile) { + throw new Error("No supported 3D model file found. Please select a .glb, .gltf, .fbx, .obj, or .usdz file."); + } + + setImportStatus("Parsing 3D data..."); + const extension = rootFile.name.split('.').pop()?.toLowerCase(); + + const manager = new THREE.LoadingManager(); + manager.setURLModifier((url) => { + const fileName = url.split('/').pop() || url; + // Try to find the file in our map + if (fileMap[fileName]) return fileMap[fileName]; + if (fileMap[url]) return fileMap[url]; + + // Handle cases where the path might be slightly different + const decodedUrl = decodeURIComponent(url); + const decodedFileName = decodedUrl.split('/').pop() || decodedUrl; + if (fileMap[decodedFileName]) return fileMap[decodedFileName]; + + return url; + }); + + let object: THREE.Object3D | null = null; + + if (extension === 'glb' || extension === 'gltf') { + const loader = new GLTFLoader(manager); + const dracoLoader = new DRACOLoader(); + dracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.6/'); + loader.setDRACOLoader(dracoLoader); + const gltf = await loader.loadAsync(rootFile.url); + object = gltf.scene; + // Dispose draco loader after use to avoid memory issues + dracoLoader.dispose(); + } else if (extension === 'fbx') { + const loader = new FBXLoader(manager); + object = await loader.loadAsync(rootFile.url); + } else if (extension === 'obj') { + const loader = new OBJLoader(manager); + // Look for companion .mtl file + const mtlFile = Object.keys(fileMap).find(name => name.toLowerCase().endsWith('.mtl')); + if (mtlFile) { + const mtlLoader = new MTLLoader(manager); + try { + const materials = await mtlLoader.loadAsync(fileMap[mtlFile]); + materials.preload(); + loader.setMaterials(materials); + } catch (err) { + console.warn("Failed to load MTL file:", err); + } + } + object = await loader.loadAsync(rootFile.url); + } else if (['usdz', 'usd', 'usda', 'usdc'].includes(extension || '')) { + const loader = new USDLoader(manager); + object = await loader.loadAsync(rootFile.url); + } + + if (object) { + // Save model data for scene persistence + const modelBlob = await fetch(rootFile.url).then(r => r.blob()); + setImportStatus("Uploading to cloud storage..."); + const cloudUrl = await uploadAssetToStorage(modelBlob, 'assets', `models/${user?.id || 'guest'}_${Date.now()}_${rootFile.name}`); + setModelData({ name: rootFile.name, data: cloudUrl, extension: extension || '' }); + + setImportStatus("Optimizing geometry..."); + // 1. Calculate model bounds + const boundingBox = new THREE.Box3().setFromObject(object); + const center = new THREE.Vector3(); + boundingBox.getCenter(center); + const size = new THREE.Vector3(); + boundingBox.getSize(size); + const maxDim = Math.max(size.x || 0, size.y || 0, size.z || 0) || 1; + const radius = maxDim / 2; + const minY = boundingBox.min.y; + + setModelBounds({ center, size, maxDim, minY, radius }); + + // 2. Adjust Directional Light Shadow Camera + if (directionalLightRef.current) { + const light = directionalLightRef.current; + light.shadow.camera.left = -maxDim; + light.shadow.camera.right = maxDim; + light.shadow.camera.top = maxDim; + light.shadow.camera.bottom = -maxDim; + light.shadow.camera.near = 0.1; + light.shadow.camera.far = maxDim * 5; + light.shadow.camera.updateProjectionMatrix(); + light.shadow.mapSize.width = 2048; + light.shadow.mapSize.height = 2048; + } + + // 3. Auto-framing Camera + let cameraZDistance = 0; + if (cameraRef.current) { + const camera = cameraRef.current; + const fovRadians = camera.fov * (Math.PI / 180); + cameraZDistance = radius / Math.sin(fovRadians / 2); + const padding = 1.5; + cameraZDistance *= padding; + + camera.position.set( + center.x, + center.y + (maxDim / 4), + center.z + cameraZDistance + ); + camera.near = radius / 100; + camera.far = radius * 100; + camera.updateProjectionMatrix(); + camera.lookAt(center); + } + + // 4. Update OrbitControls + if (orbitControlsRef.current) { + const controls = orbitControlsRef.current; + if (center) controls.target.copy(center); + controls.minDistance = radius / 2; + controls.maxDistance = (cameraZDistance || radius * 10) * 5; + controls.update(); + } + + const mainModelId = `model-${Date.now()}`; + const newSceneObjects: SceneObject[] = [ + { + id: mainModelId, + name: rootFile.name, + type: 'model', + parentId: null, + visible: true, + transformProps: { + position: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1] + } + } + ]; + + object.visible = true; + let mIdx = 0; + object.traverse((child) => { + if (child instanceof THREE.Mesh) { + child.castShadow = true; + child.receiveShadow = true; + child.frustumCulled = false; + const meshId = `mesh-${mIdx}`; + mIdx++; + + // Extract mesh material properties if available + const material = Array.isArray(child.material) ? child.material[0] : child.material; + const mProps = { ...globalMaterialProps }; + + if (material) { + if (material.color) mProps.color = `#${material.color.getHexString()}`; + if (material.roughness !== undefined) mProps.roughness = material.roughness; + if (material.metalness !== undefined) mProps.metalness = material.metalness; + if (material.opacity !== undefined) mProps.opacity = material.opacity; + + // Standard material props + const m = material as any; + if (m.transmission !== undefined) mProps.transmission = m.transmission; + if (m.ior !== undefined) mProps.ior = m.ior; + if (m.thickness !== undefined) mProps.thickness = m.thickness; + if (m.clearcoat !== undefined) mProps.clearcoat = m.clearcoat; + if (m.sheen !== undefined) mProps.sheen = m.sheen; + if (m.specularIntensity !== undefined) mProps.specularIntensity = m.specularIntensity; + } + + newSceneObjects.push({ + id: meshId, + name: child.name || `Mesh ${meshId.slice(0, 4)}`, + type: 'mesh', + parentId: mainModelId, + visible: true, + transformProps: { + position: [child.position?.x || 0, child.position?.y || 0, child.position?.z || 0], + rotation: [child.rotation?.x || 0, child.rotation?.y || 0, child.rotation?.z || 0], + scale: [child.scale?.x || 1, child.scale?.y || 1, child.scale?.z || 1] + }, + materialProps: mProps + }); + } + }); + + setLoadedModel(object); + setBlobURLs(prev => { + prev.forEach(url => URL.revokeObjectURL(url)); + return currentBlobURLs; + }); + setSceneObjects(prev => [ + ...prev.filter(obj => obj.type !== 'cube'), + ...newSceneObjects + ]); + setSelectedIds([mainModelId]); + + setTimeout(() => { + setIsLoading(false); + setImportProgress(0); + }, 500); + } + } catch (error) { + console.error('Error loading model:', error); + setImportError(error instanceof Error ? error.message : "Failed to load model."); + setIsLoading(false); + setImportProgress(0); + } + }; + + const toggleSelect = (id: string, multi: boolean) => { + if (multi) { + setSelectedIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]); + } else { + setSelectedIds([id]); + } + }; + + const renameObject = (id: string, newName: string) => { + updateSceneObjects(prev => prev.map(obj => obj.id === id ? { ...obj, name: newName } : obj)); + setEditingId(null); + }; + + const groupObjects = () => { + if (selectedIds.length < 2) return; + const groupId = `group-${Date.now()}`; + const groupName = "New Group"; + + updateSceneObjects(prev => [ + ...prev.map(obj => selectedIds.includes(obj.id) ? { ...obj, parentId: groupId } : obj), + { id: groupId, name: groupName, type: 'group', parentId: null, visible: true } + ]); + setSelectedIds([groupId]); + }; + + const deleteObjects = () => { + updateSceneObjects(prev => prev.filter(obj => !selectedIds.includes(obj.id))); + setSelectedIds([]); + }; + + const materialCategories = [ + { + id: "plastics", + name: "Plastics & Polymers", + items: [ + { name: "Matte ABS", color: "#64748b", roughness: 0.8, metalness: 0, specularIntensity: 0.2 }, + { name: "Glossy PVC", color: "#e2e8f0", roughness: 0.05, metalness: 0, specularIntensity: 1 }, + { name: "Polycarbonate", color: "#f8fafc", roughness: 0.05, metalness: 0, transmission: 0.95, ior: 1.58, thickness: 1 }, + { name: "Textured Polypropylene", color: "#334155", roughness: 0.6, metalness: 0, clearcoat: 0.1 }, + { name: "Frosted Acrylic", color: "#ffffff", roughness: 0.5, metalness: 0, transmission: 0.9, ior: 1.49, thickness: 0.5 }, + { name: "Translucent Silicone", color: "#cbd5e1", roughness: 0.7, metalness: 0, transmission: 0.4, ior: 1.4, thickness: 2, sheen: 0.3 }, + { name: "Soft-Touch Elastomer", color: "#1e293b", roughness: 0.9, metalness: 0, specularIntensity: 0.1, sheen: 0.5 }, + { name: "Carbon Fiber (CFRP)", color: "#111111", roughness: 0.3, metalness: 0.4, clearcoat: 1 }, + { name: "PETG", color: "#ffffff", roughness: 0.05, metalness: 0, transmission: 0.98, ior: 1.53, thickness: 0.1 }, + { name: "Melamine Resin", color: "#fef3c7", roughness: 0.1, metalness: 0, transmission: 0.1, ior: 1.5 }, + { name: "Nylon 6/6", color: "#f1f5f9", roughness: 0.5, metalness: 0, sheen: 0.2 }, + { name: "Bakelite", color: "#451a03", roughness: 0.1, metalness: 0, specularIntensity: 0.8 }, + { name: "Iridescent Acrylic", color: "#ffffff", roughness: 0.1, metalness: 0, transmission: 0.8, ior: 1.49, thickness: 0.5, clearcoat: 1, sheen: 1 }, + { name: "Recycled HDPE", color: "#86efac", roughness: 0.7, metalness: 0 }, + { name: "Pearlescent Polystyrene", color: "#fdf4ff", roughness: 0.1, metalness: 0, clearcoat: 1, sheen: 1 }, + { name: "Foamed Polystyrene", color: "#ffffff", roughness: 0.9, metalness: 0 }, + { name: "Clear Epoxy Resin", color: "#ffffff", roughness: 0.02, metalness: 0, transmission: 0.95, ior: 1.55, thickness: 2, clearcoat: 1 }, + { name: "Teflon (PTFE)", color: "#f8fafc", roughness: 0.9, metalness: 0, specularIntensity: 0 }, + { name: "Vinyl", color: "#1a1a1a", roughness: 0.4, metalness: 0, sheen: 0.3 }, + { name: "Polyurethane Foam", color: "#fef08a", roughness: 1.0, metalness: 0, specularIntensity: 0 } + ] + }, + { + id: "metals", + name: "Metals", + items: [ + { name: "Polished Chrome", color: "#ffffff", roughness: 0, metalness: 1 }, + { name: "Brushed Aluminum", color: "#a0a0a0", roughness: 0.4, metalness: 1 }, + { name: "Scratched Stainless Steel", color: "#888888", roughness: 0.3, metalness: 1 }, + { name: "Raw Cast Iron", color: "#222222", roughness: 0.8, metalness: 0.8 }, + { name: "Anodized Aluminum", color: "#991b1b", roughness: 0.3, metalness: 1 }, + { name: "Galvanized Steel", color: "#94a3b8", roughness: 0.5, metalness: 1 }, + { name: "Hammered Copper", color: "#b87333", roughness: 0.3, metalness: 1 }, + { name: "Tarnished Brass", color: "#8a7b31", roughness: 0.4, metalness: 0.9 }, + { name: "Polished Gold", color: "#ffd700", roughness: 0.05, metalness: 1 }, + { name: "Matte Rose Gold", color: "#b76e79", roughness: 0.3, metalness: 1 }, + { name: "Gunmetal", color: "#2a2a2a", roughness: 0.2, metalness: 1 }, + { name: "Titanium", color: "#71717a", roughness: 0.25, metalness: 1 }, + { name: "Rusted Corten Steel", color: "#7c2d12", roughness: 0.9, metalness: 0.2 }, + { name: "Diamond Plate Steel", color: "#cbd5e1", roughness: 0.3, metalness: 1 }, + { name: "Sintered Bronze", color: "#806020", roughness: 0.7, metalness: 0.8 }, + { name: "Wrought Iron", color: "#0a0a0a", roughness: 0.8, metalness: 0.6, specularIntensity: 0.1 }, + { name: "Lead", color: "#3f3f46", roughness: 0.6, metalness: 1 }, + { name: "Polished Silver", color: "#f8fafc", roughness: 0.02, metalness: 1 }, + { name: "Magnesium Alloy", color: "#52525b", roughness: 0.4, metalness: 1 }, + { name: "Bismuth", color: "#a78bfa", roughness: 0.2, metalness: 1, clearcoat: 0.5, sheen: 0.5 } + ] + }, + { + id: "glass", + name: "Glass", + items: [ + { name: "Clear Float Glass", color: "#ffffff", roughness: 0, metalness: 0, transmission: 1, ior: 1.52, thickness: 0.5 }, + { name: "Frosted Glass", color: "#ffffff", roughness: 0.4, metalness: 0, transmission: 0.9, ior: 1.52, thickness: 0.5 }, + { name: "Tinted Bronze Glass", color: "#785b46", roughness: 0, metalness: 0, transmission: 0.8, ior: 1.52, thickness: 1 }, + { name: "Fluted Glass", color: "#ffffff", roughness: 0.1, metalness: 0, transmission: 0.9, ior: 1.52, thickness: 1 }, + { name: "Tempered Glass", color: "#e0f2fe", roughness: 0, metalness: 0, transmission: 0.95, ior: 1.52, thickness: 0.8 }, + { name: "Leaded Crystal", color: "#ffffff", roughness: 0, metalness: 0, transmission: 1, ior: 1.7, thickness: 2 }, + { name: "Dichroic Glass", color: "#fbcfe8", roughness: 0.05, metalness: 0, transmission: 0.9, ior: 1.52, thickness: 0.5, sheen: 1 }, + { name: "Smoked Glass", color: "#1e293b", roughness: 0.02, metalness: 0, transmission: 0.7, ior: 1.52, thickness: 1 }, + { name: "One-Way Mirror", color: "#e2e8f0", roughness: 0, metalness: 0.8, transmission: 0.2, ior: 1.52, thickness: 0.1 }, + { name: "Wired Safety Glass", color: "#e2e8f0", roughness: 0.1, metalness: 0, transmission: 0.8, ior: 1.52, thickness: 1 }, + { name: "Sea Glass", color: "#99f6e4", roughness: 0.6, metalness: 0, transmission: 0.8, ior: 1.52, thickness: 1.5 }, + { name: "Amber Apothecary", color: "#d97706", roughness: 0.05, metalness: 0, transmission: 0.9, ior: 1.52, thickness: 1.2 }, + { name: "Bulletproof Glass", color: "#bbf7d0", roughness: 0, metalness: 0, transmission: 0.85, ior: 1.55, thickness: 3 }, + { name: "Anti-Reflective Coated", color: "#ffffff", roughness: 0, metalness: 0, transmission: 0.99, ior: 1.52, thickness: 0.2, specularIntensity: 0.1 }, + { name: "Obscured Glass", color: "#ffffff", roughness: 0.5, metalness: 0, transmission: 0.7, ior: 1.52, thickness: 1 }, + { name: "Stained Glass", color: "#3b82f6", roughness: 0.1, metalness: 0, transmission: 0.9, ior: 1.52, thickness: 0.3 }, + { name: "Opal Glass", color: "#f8fafc", roughness: 0.1, metalness: 0, transmission: 0.3, ior: 1.52, thickness: 2, sheen: 0.5 }, + { name: "Uranium Glass", color: "#86efac", roughness: 0.05, metalness: 0, transmission: 0.9, ior: 1.52, thickness: 1, emissive: "#22c55e", emissiveIntensity: 0.2 }, + { name: "Shattered Glass", color: "#ffffff", roughness: 0.1, metalness: 0, transmission: 0.9, ior: 1.52, thickness: 0.5 }, + { name: "Smart Glass (Opaque)", color: "#f1f5f9", roughness: 0.8, metalness: 0, transmission: 0.1, ior: 1.52, thickness: 0.2 } + ] + }, + { + id: "liquids", + name: "Liquids", + items: [ + { name: "Clear Water", color: "#ffffff", roughness: 0, metalness: 0, transmission: 1, ior: 1.33, thickness: 2 }, + { name: "Ocean Water", color: "#0284c7", roughness: 0.1, metalness: 0, transmission: 0.9, ior: 1.33, thickness: 5 }, + { name: "Engine Oil", color: "#451a03", roughness: 0.02, metalness: 0, transmission: 0.3, ior: 1.45, thickness: 3 }, + { name: "Milk", color: "#ffffff", roughness: 0.1, metalness: 0, transmission: 0.05, ior: 1.35, thickness: 3, sheen: 0.5 }, + { name: "Orange Juice", color: "#f97316", roughness: 0.05, metalness: 0, transmission: 0.4, ior: 1.35, thickness: 2 }, + { name: "Red Wine", color: "#7f1d1d", roughness: 0, metalness: 0, transmission: 0.8, ior: 1.34, thickness: 1.5 }, + { name: "Honey", color: "#d97706", roughness: 0, metalness: 0, transmission: 0.7, ior: 1.49, thickness: 2 }, + { name: "Liquid Mercury", color: "#e2e8f0", roughness: 0, metalness: 1, transmission: 0, ior: 1 }, + { name: "Coffee", color: "#291304", roughness: 0, metalness: 0, transmission: 0.2, ior: 1.33, thickness: 3 }, + { name: "Carbonated Soda", color: "#ffffff", roughness: 0, metalness: 0, transmission: 0.95, ior: 1.33, thickness: 1.5 }, + { name: "Liquid Nitrogen", color: "#ffffff", roughness: 0, metalness: 0, transmission: 1, ior: 1.2, thickness: 1 }, + { name: "Blood", color: "#7f1d1d", roughness: 0, metalness: 0, transmission: 0.1, ior: 1.35, thickness: 2 }, + { name: "Shampoo", color: "#c084fc", roughness: 0.05, metalness: 0, transmission: 0.8, ior: 1.38, thickness: 1.5, sheen: 0.8 }, + { name: "Beer", color: "#d97706", roughness: 0, metalness: 0, transmission: 0.8, ior: 1.34, thickness: 2 }, + { name: "Glycerin", color: "#ffffff", roughness: 0, metalness: 0, transmission: 1, ior: 1.47, thickness: 2 }, + { name: "Melted Chocolate", color: "#3b1e08", roughness: 0.2, metalness: 0, transmission: 0, ior: 1.5, thickness: 1 }, + { name: "Automotive Coolant", color: "#22c55e", roughness: 0, metalness: 0, transmission: 0.9, ior: 1.33, thickness: 1.5 }, + { name: "Olive Oil", color: "#84cc16", roughness: 0, metalness: 0, transmission: 0.85, ior: 1.47, thickness: 2 }, + { name: "Ink", color: "#000000", roughness: 0, metalness: 0, transmission: 0, ior: 1.33, thickness: 1 }, + { name: "Perfume", color: "#fbcfe8", roughness: 0, metalness: 0, transmission: 0.98, ior: 1.4, thickness: 1.2 } + ] + } + ]; + + const updateSceneObjects = (newObjects: SceneObject[] | ((prev: SceneObject[]) => SceneObject[])) => { + setSceneObjects(prev => { + const next = typeof newObjects === 'function' ? newObjects(prev) : newObjects; + // We'll use a separate effect or a callback to push to history to avoid state update during render + return next; + }); + }; + + useEffect(() => { + if (skipHistoryRef.current) return; + if (!isUndoingRedoing && sceneObjects.length > 0) { + const timer = setTimeout(() => { + pushToHistory(sceneObjects); + }, 500); // Debounce history pushes for performance + return () => clearTimeout(timer); + } + }, [sceneObjects]); + + useEffect(() => { + // Initial history + if (history.length === 0 && sceneObjects.length > 0) { + setHistory([JSON.parse(JSON.stringify(sceneObjects))]); + setHistoryIndex(0); + } + }, []); + + useEffect(() => { + if (theme === 'dark') { + document.body.classList.add('dark'); + } else { + document.body.classList.remove('dark'); + } + }, [theme]); + + const updateObjectMaterial = (id: string, props: Partial) => { + updateSceneObjects(prev => { + const targetObj = prev.find(obj => obj.id === id); + if (!targetObj) return prev; + + // If it's a model, apply material properties to all its child meshes + if (targetObj.type === 'model') { + return prev.map(obj => { + if (obj.id === id || obj.parentId === id) { + return { + ...obj, + materialProps: { ...(obj.materialProps || globalMaterialProps), ...props } + }; + } + return obj; + }); + } + + return prev.map(obj => + obj.id === id ? { ...obj, materialProps: { ...(obj.materialProps || globalMaterialProps), ...props } } : obj + ); + }); + }; + + const updateObjectTransform = (id: string, props: Partial) => { + updateSceneObjects(prev => prev.map(obj => + obj.id === id ? { ...obj, transformProps: { ...obj.transformProps!, ...props } } : obj + )); + }; + + const updateObjectVisibility = (id: string, visible: boolean) => { + updateSceneObjects(prev => prev.map(obj => + obj.id === id ? { ...obj, visible } : obj + )); + }; + + const selectedObject = sceneObjects.find(obj => selectedIds.includes(obj.id)); + const currentCategory = materialCategories.find(c => c.id === selectedCategory) || materialCategories[0]; + + return ( +
+ {/* Background Decorative Blobs */} +
+
+
+
+ + {/* Hidden File Input */} + + + + + + + {/* Top Menu Bar */} +
+
+
+
+ setOpenMenu(openMenu === 'file' ? null : 'file')} + > + File + + {openMenu === 'file' && ( +
+ +
+ +
+ + + + +
+ )} +
+ +
+ setOpenMenu(openMenu === 'edit' ? null : 'edit')} + > + Edit + + {openMenu === 'edit' && ( +
+ + +
+ )} +
+ + Environment + Lighting + Camera + Image + Render +
+ setOpenMenu(openMenu === 'tools' ? null : 'tools')} + > + Tools + + {openMenu === 'tools' && ( +
+ +
+ )} +
+
+ setOpenMenu(openMenu === 'view' ? null : 'view')} + > + View + + {openMenu === 'view' && ( +
+ + +
+ )} +
+ Window + Help +
+
+
+
+ Startup + +
+
+ 100 % + +
+
+
+ + {/* Main Toolbar */} +
+
+ +
+ + +
+ + +
+ + +
+ + +
+
+
+ + +
+ 50.0 FPS +
+
+
+ + {/* Main Studio Area */} +
+ {/* Left Sidebar */} +
+
+ setActiveSidebar("Materials")} /> + setActiveSidebar("Colors")} /> + setActiveSidebar("Textures")} /> + setActiveSidebar("Environ...")} /> + setActiveSidebar("Favorites")} /> + setActiveSidebar("Models")} /> + setShowImageTo3D(true)} /> +
+
+
+ {activeSidebar} +
+ + +
+
+
+
+ + +
+
+
+ {activeSidebar === "Materials" && materialCategories.map(cat => ( + + ))} + {activeSidebar === "Textures" && ["Patterns", "Materials", "Nature", "Abstract"].map(cat => ( + + ))} +
+
+ {activeSidebar === "Materials" && ( +
+ {currentCategory.items.map((mat) => ( + { + if (selectedIds.length > 0) { + selectedIds.forEach(id => { + updateObjectMaterial(id, { + color: mat.color, + roughness: mat.roughness, + metalness: mat.metalness, + clearcoat: mat.clearcoat || 0, + transmission: mat.transmission || 0, + thickness: mat.thickness || 0, + ior: mat.ior || 1.5, + sheen: mat.sheen || 0, + opacity: mat.opacity || 1, + specularIntensity: mat.specularIntensity || 1, + uvScale: mat.uvScale || 1 + }); + }); + } + }} + /> + ))} +
+ )} + + {activeSidebar === "Textures" && ( +
+ {[ + { name: "Carbon Fiber", url: "https://picsum.photos/seed/carbon/200/200" }, + { name: "Wood Grain", url: "https://picsum.photos/seed/wood/200/200" }, + { name: "Brushed Metal", url: "https://picsum.photos/seed/metal/200/200" }, + { name: "Denim Texture", url: "https://picsum.photos/seed/denim/200/200" }, + { name: "Leather", url: "https://picsum.photos/seed/leather/200/200" }, + { name: "Marble", url: "https://picsum.photos/seed/marble/200/200" }, + ].map((tex) => ( + + ))} + +
+ )} + + {activeSidebar === "Colors" && ( +
+ {["#ef4444", "#f97316", "#f59e0b", "#eab308", "#84cc16", "#22c55e", "#10b981", "#06b6d4", "#3b82f6", "#6366f1", "#8b5cf6", "#a855f7", "#d946ef", "#ec4899", "#f43f5e", "#ffffff", "#a1a1aa", "#3f3f46", "#18181b", "#000000"].map((color) => ( +
+ )} + + {activeSidebar === "Environ..." && ( +
+ Environment presets are also available in the right sidebar. +
+ {['studio', 'apartment', 'city', 'dawn', 'forest'].map(preset => ( + + ))} +
+
+ )} +
+
+
+ + {/* Viewport */} +
+ + + + {cameraProps.orthographic ? ( + + ) : ( + + )} + {backgroundType === "color" && } + + + + {sceneObjects.map(obj => { + if (obj.id === 'juicebox-1' && obj.visible) { + return ( + { + e.stopPropagation(); + setSelectedIds([obj.id]); + }} + /> + ); + } + if (obj.type === 'cube' && obj.visible) { + return ( + { + e.stopPropagation(); + setSelectedIds([obj.id]); + }} + > + + 0 || (obj.materialProps?.opacity ?? 1) < 1} + /> + + ); + } + return null; + })} + + + + {(!isRendering || !renderRequest?.includeAlpha) ? ( + customHdri ? ( + + ) : envPreset ? ( + + ) : null + ) : ( + /* During alpha render, provide environment but no background */ + customHdri ? ( + + ) : envPreset ? ( + + ) : null + )} + + {cameraProps.groundGrid && !isRendering && ( + + )} + + {showGrid && !cameraProps.groundGrid && !isRendering && ( + + )} + + {groundProps.showShadow && ( + + )} + + {selectedIds.length === 1 && !isRendering && ( + { + const id = selectedIds[0]; + const obj = sceneObjects.find(o => o.id === id); + if (!obj) return undefined; + + if (id === 'juicebox-1') return juiceBoxRef.current || undefined; + if (obj.type === 'cube') return cubeRef.current || undefined; + if (obj.type === 'model') return modelRef.current || undefined; + if (obj.type === 'mesh' && loadedModel) { + let targetMesh: THREE.Object3D | undefined; + let mIdx = 0; + loadedModel.traverse(child => { + if (child instanceof THREE.Mesh) { + const stableId = `mesh-${mIdx}`; + if (stableId === id) targetMesh = child; + mIdx++; + } + }); + return targetMesh; + } + return undefined; + })()} + onMouseDown={() => { + if (orbitControlsRef.current) orbitControlsRef.current.enabled = false; + }} + onMouseUp={() => { + if (orbitControlsRef.current) orbitControlsRef.current.enabled = true; + const target = transformRef.current?.object; + if (target && target.position && target.rotation && target.scale) { + updateObjectTransform(selectedIds[0], { + position: [target.position.x || 0, target.position.y || 0, target.position.z || 0], + rotation: [target.rotation.x || 0, target.rotation.y || 0, target.rotation.z || 0], + scale: [target.scale.x || 1, target.scale.y || 1, target.scale.z || 1] + }); + } + }} + /> + )} + {/* Background */} + {backgroundType === "image" && backplateImage && (!isRendering || !renderRequest?.includeAlpha) && ( + + )} + + + {/* Primary Light Source */} + + + + {/* Additional Lights */} + {additionalLights.map(light => ( + light.type === 'point' ? ( + + ) : ( + + ) + ))} + + + { + if (orbitControlsRef.current) { + const controls = orbitControlsRef.current; + const target = controls.target; + const position = controls.object?.position; + + if (!target || !position) return; + + // Spherical + const distance = controls.getDistance(); + const azimuth = controls.getAzimuthalAngle() * (180 / Math.PI); + const inclination = (Math.PI / 2 - controls.getPolarAngle()) * (180 / Math.PI); + + setCameraProps(prev => ({ + ...prev, + target: [target.x, target.y, target.z], + position: [position.x, position.y, position.z], + spherical: { + ...prev.spherical, + distance, + azimuth, + inclination + } + })); + } + }} + /> + + {(cameraProps.depthOfField.enabled || cameraProps.bloom.enabled || cameraProps.vignette.enabled || cameraProps.colorGrading.enabled) && ( + + {cameraProps.depthOfField.enabled && ( + + )} + {cameraProps.bloom.enabled && ( + + )} + {cameraProps.vignette.enabled && ( + + )} + {cameraProps.colorGrading.enabled && ( + <> + + + + )} + + )} + + + + {/* Viewport Overlays */} +
+
+
+
+ Real-time Render +
+
+
+ + {/* Loading Overlay */} + + {(isLoading || importError) && ( + +
+ {importError ? ( +
+
+ +
+
+ Import Failed + {importError} +
+ +
+ ) : ( + <> +
+ +
+
+
+ + + {importStatus || "Importing Model"} + +
+ {importProgress}% Complete +
+ + )} +
+
+ )} +
+ + setShowRenderModal(false)} + onRender={(settings) => setRenderRequest(settings)} + /> + + setShowVizAi(false)} + baseImage={vizAiBaseImage} + /> + + setShowApiSettings(false)} + /> + + {/* Export / Save Dialog */} + {exportDialog && ( +
setExportDialog(null)}> + e.stopPropagation()} + className="w-full max-w-sm bg-zinc-900 border border-zinc-700 rounded-2xl shadow-2xl overflow-hidden" + > + {/* Header */} +
+
+ + + {exportDialog.mode === 'scene' ? 'Save Scene' : + exportDialog.mode === 'glb' ? 'Export GLB' : 'Export USDZ'} + +
+ +
+ + {/* Body */} +
+
+ + setExportDialogName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') confirmExportDialog(); if (e.key === 'Escape') setExportDialog(null); }} + placeholder={exportDialog.defaultName} + className="bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-zinc-100 outline-none focus:border-blue-500 transition-colors" + /> +
+
+ +

+ Downloads folder — browser default +

+

File will be saved as: {(exportDialogName || exportDialog.defaultName).trim()}.{exportDialog.mode === 'scene' ? 'json' : exportDialog.mode}

+
+
+ + {/* Footer */} +
+ + +
+
+
+ )} +
+ + {/* Right Sidebar */} +
+
+ + + + + +
+ +
+ {activeRightTab === "Scene" ? ( + <> +
+
+ + Show +
+
+ + +
+
+ +
+
+
+ Hierarchy +
+ + +
+
+ +
+ {sceneObjects.filter(obj => obj.parentId === null).map(obj => ( +
+
toggleSelect(obj.id, e.ctrlKey || e.metaKey)} + onContextMenu={(e) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY, objectId: obj.id }); + }} + className={cn( + "flex items-center gap-2 p-1 rounded text-[10px] cursor-pointer group transition-colors", + selectedIds.includes(obj.id) ? "bg-blue-500/20 text-blue-400" : "hover:bg-zinc-800 text-zinc-300" + )} + > + + {obj.type === 'group' ? : } + + {editingId === obj.id ? ( + renameObject(obj.id, e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') renameObject(obj.id, e.currentTarget.value); + if (e.key === 'Escape') setEditingId(null); + }} + /> + ) : ( + {obj.name} + )} + + {!editingId && selectedIds.includes(obj.id) && ( + + )} +
+ + {/* Render Children */} +
+ {sceneObjects.filter(child => child.parentId === obj.id).map(child => ( +
toggleSelect(child.id, e.ctrlKey || e.metaKey)} + onContextMenu={(e) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY, objectId: child.id }); + }} + className={cn( + "flex items-center gap-2 p-1 rounded text-[10px] cursor-pointer group transition-colors", + selectedIds.includes(child.id) ? "bg-blue-500/20 text-blue-400" : "hover:bg-zinc-800 text-zinc-300" + )} + > + + + + {editingId === child.id ? ( + renameObject(child.id, e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') renameObject(child.id, e.currentTarget.value); + if (e.key === 'Escape') setEditingId(null); + }} + /> + ) : ( + {child.name} + )} + + {!editingId && selectedIds.includes(child.id) && ( + + )} +
+ ))} +
+
+ ))} +
+
+
+ + ) : activeRightTab === "Transform" ? ( +
+
+ Transform +
+
+ + {!selectedObject || !selectedObject.transformProps ? ( +
+ + Select an object to edit its transform +
+ ) : ( + <> + {/* Position */} +
+ Position + {['x', 'y', 'z'].map((axis, i) => ( +
+ {axis} + { + const newPos = [...selectedObject.transformProps!.position] as [number, number, number]; + newPos[i] = parseFloat(e.target.value); + updateObjectTransform(selectedObject.id, { position: newPos }); + }} + className="flex-1 bg-zinc-800 border-none rounded py-1 px-2 text-[10px] text-zinc-300" + /> +
+ ))} +
+ + {/* Rotation */} +
+ Rotation + {['x', 'y', 'z'].map((axis, i) => ( +
+ {axis} + { + const newRot = [...selectedObject.transformProps!.rotation] as [number, number, number]; + newRot[i] = parseFloat(e.target.value); + updateObjectTransform(selectedObject.id, { rotation: newRot }); + }} + className="flex-1 bg-zinc-800 border-none rounded py-1 px-2 text-[10px] text-zinc-300" + /> +
+ ))} +
+ + {/* Scale */} +
+ Scale + {['x', 'y', 'z'].map((axis, i) => ( +
+ {axis} + { + const newScale = [...selectedObject.transformProps!.scale] as [number, number, number]; + newScale[i] = parseFloat(e.target.value); + updateObjectTransform(selectedObject.id, { scale: newScale }); + }} + className="flex-1 bg-zinc-800 border-none rounded py-1 px-2 text-[10px] text-zinc-300" + /> +
+ ))} +
+ + )} +
+ ) : activeRightTab === "Camera" ? ( +
+
+ Position and Orientation +
+
+ + {/* Mode Toggle */} +
+ + +
+ + {/* Spherical Controls */} + {cameraProps.cameraMode === "spherical" && ( +
+
+
+ Distance + {cameraProps.spherical.distance.toFixed(3)} m +
+ { + const dist = parseFloat(e.target.value); + setCameraProps(prev => ({ ...prev, spherical: { ...prev.spherical, distance: dist } })); + // Update camera position based on spherical + if (orbitControlsRef.current) { + const controls = orbitControlsRef.current; + const phi = (90 - cameraProps.spherical.inclination) * (Math.PI / 180); + const theta = cameraProps.spherical.azimuth * (Math.PI / 180); + const x = dist * Math.sin(phi) * Math.cos(theta) + cameraProps.target[0]; + const y = dist * Math.cos(phi) + cameraProps.target[1]; + const z = dist * Math.sin(phi) * Math.sin(theta) + cameraProps.target[2]; + controls.object.position.set(x, y, z); + controls.update(); + } + }} + className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+ +
+
+ Azimuth + {cameraProps.spherical.azimuth.toFixed(3)} ° +
+ { + const az = parseFloat(e.target.value); + setCameraProps(prev => ({ ...prev, spherical: { ...prev.spherical, azimuth: az } })); + if (orbitControlsRef.current) { + const controls = orbitControlsRef.current; + const phi = (90 - cameraProps.spherical.inclination) * (Math.PI / 180); + const theta = az * (Math.PI / 180); + const x = cameraProps.spherical.distance * Math.sin(phi) * Math.cos(theta) + cameraProps.target[0]; + const y = cameraProps.spherical.distance * Math.cos(phi) + cameraProps.target[1]; + const z = cameraProps.spherical.distance * Math.sin(phi) * Math.sin(theta) + cameraProps.target[2]; + controls.object.position.set(x, y, z); + controls.update(); + } + }} + className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+ +
+
+ Inclination + {cameraProps.spherical.inclination.toFixed(3)} ° +
+ { + const inc = parseFloat(e.target.value); + setCameraProps(prev => ({ ...prev, spherical: { ...prev.spherical, inclination: inc } })); + if (orbitControlsRef.current) { + const controls = orbitControlsRef.current; + const phi = (90 - inc) * (Math.PI / 180); + const theta = cameraProps.spherical.azimuth * (Math.PI / 180); + const x = cameraProps.spherical.distance * Math.sin(phi) * Math.cos(theta) + cameraProps.target[0]; + const y = cameraProps.spherical.distance * Math.cos(phi) + cameraProps.target[1]; + const z = cameraProps.spherical.distance * Math.sin(phi) * Math.sin(theta) + cameraProps.target[2]; + controls.object.position.set(x, y, z); + controls.update(); + } + }} + className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+
+ )} + + {/* Absolute Position Controls */} + {cameraProps.cameraMode === "absolute" && ( +
+ Position (Absolute) +
+ {["x", "y", "z"].map((axis, i) => ( +
+ {axis} + { + const newPos = [...cameraProps.position] as [number, number, number]; + newPos[i] = parseFloat(e.target.value); + setCameraProps(prev => ({ ...prev, position: newPos })); + if (orbitControlsRef.current) { + orbitControlsRef.current.object.position.set(...newPos); + orbitControlsRef.current.update(); + } + }} + className="w-full bg-zinc-900 border border-zinc-800 rounded px-1 py-0.5 text-[10px] text-zinc-300" + /> +
+ ))} +
+
+ )} + + {/* Target Controls */} +
+ Target +
+ {["x", "y", "z"].map((axis, i) => ( +
+ {axis} + { + const newTarget = [...cameraProps.target] as [number, number, number]; + newTarget[i] = parseFloat(e.target.value); + setCameraProps(prev => ({ ...prev, target: newTarget })); + if (orbitControlsRef.current) { + orbitControlsRef.current.target.set(...newTarget); + orbitControlsRef.current.update(); + } + }} + className="w-full bg-zinc-900 border border-zinc-800 rounded px-1 py-0.5 text-[10px] text-zinc-300" + /> +
+ ))} +
+
+ + {/* Pivot Controls */} +
+ Pivot +
+ {["x", "y", "z"].map((axis, i) => ( +
+ {axis} + { + const newPivot = [...cameraProps.pivot] as [number, number, number]; + newPivot[i] = parseFloat(e.target.value); + setCameraProps(prev => ({ ...prev, pivot: newPivot })); + }} + className="w-full bg-zinc-900 border border-zinc-800 rounded px-1 py-0.5 text-[10px] text-zinc-300" + /> +
+ ))} +
+
+ + {/* Standard Views */} +
+ Standard Views +
+ {["front", "back", "top", "bottom", "left", "right", "isometric"].map((view) => ( + + ))} +
+
+ +
+ Lens Settings +
+
+ + {/* Camera Type */} +
+ + +
+ + {/* Focal Length / FOV */} + {!cameraProps.orthographic && ( + <> +
+
+ Focal Length + {fovToFocalLength(cameraProps.fov).toFixed(1)} mm +
+ setCameraProps(prev => ({ ...prev, fov: focalLengthToFov(parseInt(e.target.value)) }))} + className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+ +
+
+ Field of View + {cameraProps.fov.toFixed(1)}° +
+ setCameraProps(prev => ({ ...prev, fov: parseInt(e.target.value) }))} + className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+ + )} + + {/* Zoom */} +
+
+ Zoom Level + {cameraProps.zoom.toFixed(2)}x +
+ setCameraProps(prev => ({ ...prev, zoom: parseFloat(e.target.value) }))} + className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+ + {/* Depth of Field Toggle */} +
+
+ Depth of Field + Enable bokeh effect +
+ +
+ + {/* DOF Settings */} + {cameraProps.depthOfField.enabled && ( +
+
+
+ Focus Distance + {cameraProps.depthOfField.focusDistance.toFixed(3)} +
+ setCameraProps(prev => ({ ...prev, depthOfField: { ...prev.depthOfField, focusDistance: parseFloat(e.target.value) } }))} + className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+
+
+ Bokeh Scale + {cameraProps.depthOfField.bokehScale.toFixed(1)} +
+ setCameraProps(prev => ({ ...prev, depthOfField: { ...prev.depthOfField, bokehScale: parseFloat(e.target.value) } }))} + className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+
+ )} + + {/* Post Processing Section */} +
+ Post Processing +
+
+ + {/* Bloom Toggle */} +
+
+ Bloom + Add glow to highlights +
+ +
+ + {/* Bloom Settings */} + {cameraProps.bloom.enabled && ( +
+
+
+ Intensity + {cameraProps.bloom.intensity.toFixed(2)} +
+ setCameraProps(prev => ({ ...prev, bloom: { ...prev.bloom, intensity: parseFloat(e.target.value) } }))} + className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+
+
+ Threshold + {cameraProps.bloom.luminanceThreshold.toFixed(2)} +
+ setCameraProps(prev => ({ ...prev, bloom: { ...prev.bloom, luminanceThreshold: parseFloat(e.target.value) } }))} + className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+
+ )} + + {/* Vignette Toggle */} +
+
+ Vignette + Darken image edges +
+ +
+ + {/* Vignette Settings */} + {cameraProps.vignette.enabled && ( +
+
+
+ Darkness + {cameraProps.vignette.darkness.toFixed(2)} +
+ setCameraProps(prev => ({ ...prev, vignette: { ...prev.vignette, darkness: parseFloat(e.target.value) } }))} + className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+
+ )} + + {/* Color Grading Toggle */} +
+
+ Color Grading + Adjust brightness & color +
+ +
+ + {/* Color Grading Settings */} + {cameraProps.colorGrading.enabled && ( +
+
+
+ Brightness + {cameraProps.colorGrading.brightness.toFixed(2)} +
+ setCameraProps(prev => ({ ...prev, colorGrading: { ...prev.colorGrading, brightness: parseFloat(e.target.value) } }))} + className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+
+
+ Contrast + {cameraProps.colorGrading.contrast.toFixed(2)} +
+ setCameraProps(prev => ({ ...prev, colorGrading: { ...prev.colorGrading, contrast: parseFloat(e.target.value) } }))} + className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+
+
+ Saturation + {cameraProps.colorGrading.saturation.toFixed(2)} +
+ setCameraProps(prev => ({ ...prev, colorGrading: { ...prev.colorGrading, saturation: parseFloat(e.target.value) } }))} + className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+
+ )} + + {/* Grid and Ground Options */} +
+
+
+ Ground Grid + Show grid on floor +
+ +
+ +
+
+ Stay Above Ground + Limit camera height +
+ +
+
+ + {/* Auto Rotate */} +
+
+ Auto-Rotate + Rotate around model +
+ +
+ + {/* Camera Presets */} +
+
+ Camera Presets + +
+
+ +
+ {cameraPresets.map((preset) => ( +
+ +
+ {preset.id !== 'default' && ( + + )} +
+
+ ))} +
+
+ +
+ +
+
+ ) : activeRightTab === "Environ..." ? ( +
+
+ Viewport Background +
+
+ + {/* Background Type Toggle */} +
+ + +
+ + {/* Background Color & Image Selection */} + {(backgroundType === "color" || backgroundType === "image") && ( +
+
+
+ Background Color +
+
+
+
+ +
+
+
+ Backplate Image + {backgroundType === "image" && ( + + )} +
+
+ + {[ + { name: "Studio", url: "https://picsum.photos/seed/studio/1920/1080?blur=4" }, + { name: "Outdoor", url: "https://picsum.photos/seed/outdoor/1920/1080?blur=4" }, + { name: "Interior", url: "https://picsum.photos/seed/interior/1920/1080?blur=4" }, + { name: "Abstract", url: "https://picsum.photos/seed/abstract/1920/1080?blur=4" } + ].map((plate) => ( + + ))} +
+
+
+ Custom Backplate URL +
+ { + if (e.target.value) { + setBackplateImage(e.target.value); + setBackgroundType("image"); + } + }} + /> + +
+
+
+
+ )} + + {/* Environment Rotation Wheel */} +
+
+ Environment Rotation + {envRotation}° +
+
+
{ + const rect = e.currentTarget.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + + const handleMouseMove = (moveEvent: MouseEvent) => { + const angle = Math.atan2(moveEvent.clientY - centerY, moveEvent.clientX - centerX); + let deg = angle * (180 / Math.PI) + 90; + if (deg < 0) deg += 360; + setEnvRotation(Math.round(deg)); + }; + + const handleMouseUp = () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + }} + > + {/* Degree markers */} + {[0, 45, 90, 135, 180, 225, 270, 315].map(deg => ( +
+ ))} + {/* Pointer */} +
+
+
+
+ setEnvRotation(parseInt(e.target.value))} + className="w-full accent-blue-500 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer" + /> +
+ + 180° + 360° +
+
+
+
+ + {/* Grid Toggle */} +
+
+ Viewport Grid + Show spatial reference +
+ +
+ +
+ Primary Light +
+
+ + {/* Light Intensity */} +
+
+ Intensity + {lightProps.intensity.toFixed(2)} +
+ setLightProps(prev => ({ ...prev, intensity: parseFloat(e.target.value) }))} + className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+ + {/* Light Color */} +
+
+ Light Color + setLightProps(prev => ({ ...prev, color: e.target.value }))} + className="w-6 h-6 rounded border border-zinc-700 bg-transparent cursor-pointer" + /> +
+
+ + {/* Advanced Lighting Controls */} +
+
+
+ Shadow Advanced +
+
+
+ Shadow Bias + {lightProps.shadowBias.toFixed(4)} +
+ setLightProps(prev => ({ ...prev, shadowBias: parseFloat(e.target.value) }))} + className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+
+
+ Shadow Radius + {lightProps.shadowRadius.toFixed(1)} +
+ setLightProps(prev => ({ ...prev, shadowRadius: parseFloat(e.target.value) }))} + className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+
+
+ + {/* Additional Lights Section */} +
+
+ Additional Lights +
+ +
+
+
+
+ + {/* Additional Lights List */} +
+ {additionalLights.map((light, index) => ( +
+
+
+ + {light.type} Light {index + 1} +
+ +
+ +
+
+
+ Intensity + {light.intensity.toFixed(1)} +
+ setAdditionalLights(prev => prev.map(l => l.id === light.id ? { ...l, intensity: parseFloat(e.target.value) } : l))} + className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+
+
+ Color + setAdditionalLights(prev => prev.map(l => l.id === light.id ? { ...l, color: e.target.value } : l))} + className="w-4 h-4 rounded border border-zinc-700 bg-transparent cursor-pointer" + /> +
+
+
+ +
+ Position (X, Y, Z) +
+ {[0, 1, 2].map(i => ( + { + const newPos = [...light.position] as [number, number, number]; + newPos[i] = parseFloat(e.target.value); + setAdditionalLights(prev => prev.map(l => l.id === light.id ? { ...l, position: newPos } : l)); + }} + className="bg-zinc-900 border border-zinc-800 rounded px-1.5 py-1 text-[10px] text-zinc-300" + /> + ))} +
+
+
+ ))} + {additionalLights.length === 0 && ( +
+ No additional lights added +
+ )} +
+ +
+ Ground & Shadows +
+
+ + {/* Ground Shadow Toggle */} +
+
+ Ground Shadow + Enable soft shadows +
+ +
+ + {/* Shadow Controls */} + {groundProps.showShadow && ( + <> +
+
+ Shadow Intensity + {groundProps.shadowIntensity.toFixed(2)} +
+ setGroundProps(prev => ({ ...prev, shadowIntensity: parseFloat(e.target.value) }))} + className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+ +
+
+ Shadow Softness + {groundProps.shadowSoftness.toFixed(2)} +
+ setGroundProps(prev => ({ ...prev, shadowSoftness: parseFloat(e.target.value) }))} + className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+ +
+
+ Shadow Length + {groundProps.shadowLength.toFixed(1)} +
+ setGroundProps(prev => ({ ...prev, shadowLength: parseFloat(e.target.value) }))} + className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+ +
+
+ Shadow Rotation + {groundProps.shadowRotation}° +
+
+
+
+
+
+
+ setGroundProps(prev => ({ ...prev, shadowRotation: parseInt(e.target.value) }))} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10" + /> + +
+
+ setGroundProps(prev => ({ ...prev, shadowRotation: parseInt(e.target.value) }))} + className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+ + 180° + 360° +
+
+
+
+ + )} + +
+ Environment Presets +
+
+ +
+ + + {customHdri && ( + + )} + + {[ + { id: 'studio', name: 'Studio', desc: 'Clean, neutral lighting' }, + { id: 'apartment', name: 'Apartment', desc: 'Indoor home lighting' }, + { id: 'city', name: 'City', desc: 'Urban outdoor lighting' }, + { id: 'dawn', name: 'Dawn', desc: 'Soft morning light' }, + { id: 'forest', name: 'Forest', desc: 'Natural outdoor light' }, + { id: 'lobby', name: 'Lobby', desc: 'Commercial indoor light' }, + { id: 'night', name: 'Night', desc: 'Low light, dark environment' }, + { id: 'park', name: 'Park', desc: 'Bright outdoor light' }, + { id: 'sunset', name: 'Sunset', desc: 'Warm evening light' }, + { id: 'warehouse', name: 'Warehouse', desc: 'Industrial lighting' }, + ].map((preset) => ( + + ))} +
+
+ ) : ( +
+
+ Material Properties +
+
+ + {!selectedObject || (selectedObject.type !== 'mesh' && selectedObject.type !== 'model') ? ( +
+ + Select a model or mesh to edit its material properties +
+ ) : ( + <> +
+ {selectedObject.name} + {selectedObject.type === 'model' ? "Bulk Editing Model" : "Mesh Editor"} +
+ + {/* Fill Settings */} +
+
+ Mesh Fill + +
+ + {selectedObject.fillProps?.enabled && ( + <> +
+ + +
+ +
+
+ Height + {Math.round(selectedObject.fillProps.height * 100)}% +
+ { + const newObjects = sceneObjects.map(obj => + obj.id === selectedObject.id ? { ...obj, fillProps: { ...obj.fillProps!, height: parseFloat(e.target.value) } } : obj + ); + setSceneObjects(newObjects); + }} + onMouseUp={() => pushToHistory(sceneObjects)} + className="w-full accent-primary" + /> +
+ +
+ Fill Color +
+
+ { + const newObjects = sceneObjects.map(obj => + obj.id === selectedObject.id ? { ...obj, fillProps: { ...obj.fillProps!, color: e.target.value } } : obj + ); + setSceneObjects(newObjects); + }} + onBlur={() => pushToHistory(sceneObjects)} + className="opacity-0 w-5 h-5 cursor-pointer absolute right-0" + /> +
+
+ + )} +
+ + {/* Color Picker */} +
+
+ Diffuse Color +
+
+ updateObjectMaterial(selectedObject.id, { color: e.target.value })} + className="w-full h-8 bg-zinc-800 border-none rounded cursor-pointer" + /> +
+ + {/* Texture Mapping */} +
+
+ Texture Map + {selectedObject.materialProps?.map ? ( + + ) : ( + None + )} +
+ +
textureInputRef.current?.click()} + className={cn( + "w-full h-24 rounded border-2 border-dashed flex flex-col items-center justify-center gap-2 cursor-pointer transition-all", + selectedObject.materialProps?.map + ? "border-blue-500/50 bg-blue-500/5 overflow-hidden" + : "border-zinc-800 hover:border-zinc-700 bg-zinc-800/30" + )} + > + {selectedObject.materialProps?.map ? ( + Texture Preview + ) : ( + <> + + Upload Texture + + )} +
+ +
+ + {/* UV Scale & Mapping */} +
+
+ Mapping +
+
+ +
+ +
+ + {/* UV Projection Dropdown */} +
+ UV Projection + +
+ + {/* UV Offset */} +
+ Offset (X, Y) +
+ updateObjectMaterial(selectedObject.id, { uvOffset: [parseFloat(e.target.value), selectedObject.materialProps?.uvOffset?.[1] ?? 0] })} + className="flex-1 bg-zinc-800 border-none rounded py-1.5 px-2 text-[10px] text-zinc-300" /> + updateObjectMaterial(selectedObject.id, { uvOffset: [selectedObject.materialProps?.uvOffset?.[0] ?? 0, parseFloat(e.target.value)] })} + className="flex-1 bg-zinc-800 border-none rounded py-1.5 px-2 text-[10px] text-zinc-300" /> +
+
+ + {/* UV Scale / Tiling */} +
+ Scale / Tiling (X, Y) +
+ updateObjectMaterial(selectedObject.id, { uvTiling: [parseFloat(e.target.value), selectedObject.materialProps?.uvTiling?.[1] ?? 1] })} + className="flex-1 bg-zinc-800 border-none rounded py-1.5 px-2 text-[10px] text-zinc-300" /> + updateObjectMaterial(selectedObject.id, { uvTiling: [selectedObject.materialProps?.uvTiling?.[0] ?? 1, parseFloat(e.target.value)] })} + className="flex-1 bg-zinc-800 border-none rounded py-1.5 px-2 text-[10px] text-zinc-300" /> +
+
+ + {/* UV Rotation */} +
+
+ Rotation + {(selectedObject.materialProps?.uvRotation ?? 0).toFixed(0)}° +
+ updateObjectMaterial(selectedObject.id, { uvRotation: parseFloat(e.target.value) })} + className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" /> +
+
+ + {/* Roughness */} +
+
+ Roughness + {(selectedObject.materialProps?.roughness ?? 0.5).toFixed(2)} +
+ updateObjectMaterial(selectedObject.id, { roughness: parseFloat(e.target.value) })} + className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+ + {/* Metalness */} +
+
+ Metalness + {(selectedObject.materialProps?.metalness ?? 0.5).toFixed(2)} +
+ updateObjectMaterial(selectedObject.id, { metalness: parseFloat(e.target.value) })} + className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+ + {/* Clearcoat */} +
+
+ Clearcoat + {(selectedObject.materialProps?.clearcoat ?? 0).toFixed(2)} +
+ updateObjectMaterial(selectedObject.id, { clearcoat: parseFloat(e.target.value) })} + className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+ + {/* Transmission */} +
+
+ Transmission + {(selectedObject.materialProps?.transmission ?? 0).toFixed(2)} +
+ updateObjectMaterial(selectedObject.id, { transmission: parseFloat(e.target.value) })} + className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+ + {/* IOR */} +
+
+ Index of Refraction + {(selectedObject.materialProps?.ior ?? 1.5).toFixed(2)} +
+ updateObjectMaterial(selectedObject.id, { ior: parseFloat(e.target.value) })} + className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+ + {/* Thickness */} +
+
+ Thickness + {(selectedObject.materialProps?.thickness ?? 0).toFixed(2)} +
+ updateObjectMaterial(selectedObject.id, { thickness: parseFloat(e.target.value) })} + className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+ + {/* Sheen */} +
+
+ Sheen + {(selectedObject.materialProps?.sheen ?? 0).toFixed(2)} +
+ updateObjectMaterial(selectedObject.id, { sheen: parseFloat(e.target.value) })} + className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+ + {/* Opacity */} +
+
+ Opacity + {(selectedObject.materialProps?.opacity ?? 1).toFixed(2)} +
+ updateObjectMaterial(selectedObject.id, { opacity: parseFloat(e.target.value) })} + className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+ + {/* Specular Intensity */} +
+
+ Specular Intensity + {(selectedObject.materialProps?.specularIntensity ?? 1).toFixed(2)} +
+ updateObjectMaterial(selectedObject.id, { specularIntensity: parseFloat(e.target.value) })} + className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+ + {/* Emissive */} +
+
+ Emissive Color +
+
+ updateObjectMaterial(selectedObject.id, { emissive: e.target.value })} + className="w-full h-8 bg-zinc-800 border-none rounded cursor-pointer" + /> +
+ +
+
+ Emissive Intensity + {(selectedObject.materialProps?.emissiveIntensity ?? 0).toFixed(2)} +
+ updateObjectMaterial(selectedObject.id, { emissiveIntensity: parseFloat(e.target.value) })} + className="w-full h-1 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+ + {/* Advanced Maps */} +
+ Advanced Maps + {/* Normal Map */} +
+
+ Normal Map + {selectedObject.materialProps?.normalMap && ( + + )} +
+
normalInputRef.current?.click()} + className={cn( + "w-full h-12 rounded border border-dashed flex items-center justify-center gap-2 cursor-pointer transition-all", + selectedObject.materialProps?.normalMap ? "border-blue-500/50 bg-blue-500/5" : "border-zinc-800 hover:border-zinc-700 bg-zinc-800/30" + )} + > + {selectedObject.materialProps?.normalMap ? "Normal Map Loaded" : "Upload Normal Map"} +
+ handleTextureUpload(e, 'normalMap')} accept="image/*" className="hidden" /> +
+ + {/* Specular Map */} +
+
+ Specular Map + {selectedObject.materialProps?.specularMap && ( + + )} +
+
specularInputRef.current?.click()} + className={cn( + "w-full h-12 rounded border border-dashed flex items-center justify-center gap-2 cursor-pointer transition-all", + selectedObject.materialProps?.specularMap ? "border-blue-500/50 bg-blue-500/5" : "border-zinc-800 hover:border-zinc-700 bg-zinc-800/30" + )} + > + {selectedObject.materialProps?.specularMap ? "Specular Map Loaded" : "Upload Specular Map"} +
+ handleTextureUpload(e, 'specularMap')} accept="image/*" className="hidden" /> +
+ + {/* Alpha Map */} +
+
+ Alpha Map + {selectedObject.materialProps?.alphaMap && ( + + )} +
+
alphaInputRef.current?.click()} + className={cn( + "w-full h-12 rounded border border-dashed flex items-center justify-center gap-2 cursor-pointer transition-all", + selectedObject.materialProps?.alphaMap ? "border-blue-500/50 bg-blue-500/5" : "border-zinc-800 hover:border-zinc-700 bg-zinc-800/30" + )} + > + {selectedObject.materialProps?.alphaMap ? "Alpha Map Loaded" : "Upload Alpha Map"} +
+ handleTextureUpload(e, 'alphaMap')} accept="image/*" className="hidden" /> +
+
+ +
+ +
+ + )} +
+ )} + +
+
+ Scene Information + +
+
+
+ Triangles: + {loadedModel ? "Dynamic" : "12,452"} +
+
+ Vertices: + {loadedModel ? "Dynamic" : "8,210"} +
+
+ Objects: + 1 +
+
+
+
+
+
+ + {/* Bottom Toolbar */} +
+
+ + + + + + + + + +
+
+
+ Cloud Library | Titan3d Hub +
+ +
+
+ + {/* Context Menu */} + + {contextMenu && ( + <> +
setContextMenu(null)} + onContextMenu={(e) => { e.preventDefault(); setContextMenu(null); }} + /> + + + + +
+ + + + + + )} + + + {/* UV Editor Overlay */} + {showUVEditor && selectedObject && ( + setShowUVEditor(false)} + onSave={(dataUrl, mappingType, layers, bgColor, bgTransparent) => { + updateObjectMaterial(selectedObject.id, { map: dataUrl, mappingType: (mappingType as 'uv'|'box'|'planar'|'cylinder'|'sphere') || 'uv', uvLayers: layers, uvBackground: bgColor, uvTransparent: bgTransparent }); + }} + targetObject={selectedObject} + loadedModel={loadedModel as THREE.Group} + sceneObjects={sceneObjects} + /> + )} + + setShowImageTo3D(false)} + onPushToApp={async (modelUrl) => { + setIsLoading(true); + setImportStatus("Importing generated model..."); + try { + const manager = new THREE.LoadingManager(); + const loader = new GLTFLoader(manager); + const dracoLoader = new DRACOLoader(); + dracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.6/'); + loader.setDRACOLoader(dracoLoader); + + const gltf = await loader.loadAsync(modelUrl); + const object = gltf.scene; + dracoLoader.dispose(); + + const mainModelId = `model-${Date.now()}`; + const newSceneObjects: SceneObject[] = [ + { + id: mainModelId, + name: 'Generated 3D Model', + type: 'model', + parentId: null, + visible: true, + transformProps: { position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] } + } + ]; + + object.visible = true; + let mIdx = 0; + object.traverse((child) => { + if (child instanceof THREE.Mesh) { + child.castShadow = true; + child.receiveShadow = true; + child.frustumCulled = false; + const meshId = `mesh-${mIdx}`; + mIdx++; + + const material = Array.isArray(child.material) ? child.material[0] : child.material; + const mProps = { ...globalMaterialProps }; + + if (material) { + if (material.color) mProps.color = `#${material.color.getHexString()}`; + if (material.roughness !== undefined) mProps.roughness = material.roughness; + if (material.metalness !== undefined) mProps.metalness = material.metalness; + if (material.opacity !== undefined) mProps.opacity = material.opacity; + } + + newSceneObjects.push({ + id: meshId, + name: child.name || `Mesh ${meshId.slice(0, 4)}`, + type: 'mesh', + parentId: mainModelId, + visible: true, + transformProps: { + position: [child.position?.x || 0, child.position?.y || 0, child.position?.z || 0], + rotation: [child.rotation?.x || 0, child.rotation?.y || 0, child.rotation?.z || 0], + scale: [child.scale?.x || 1, child.scale?.y || 1, child.scale?.z || 1] + }, + materialProps: mProps + }); + } + }); + + // Save model data for scene persistence + const modelBlob = await fetch(modelUrl).then(r => r.blob()); + const modelBase64 = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.readAsDataURL(modelBlob); + }); + setModelData({ name: "Generated_Model.glb", data: modelBase64, extension: 'glb' }); + + const boundingBox = new THREE.Box3().setFromObject(object); + const center = new THREE.Vector3(); + boundingBox.getCenter(center); + const size = new THREE.Vector3(); + boundingBox.getSize(size); + const maxDim = Math.max(size.x || 0, size.y || 0, size.z || 0) || 1; + const radius = maxDim / 2; + const minY = boundingBox.min.y; + + setModelBounds({ center, size, maxDim, minY, radius }); + + if (directionalLightRef.current) { + const light = directionalLightRef.current; + light.shadow.camera.left = -maxDim; + light.shadow.camera.right = maxDim; + light.shadow.camera.top = maxDim; + light.shadow.camera.bottom = -maxDim; + light.shadow.camera.near = 0.1; + light.shadow.camera.far = maxDim * 5; + light.shadow.camera.updateProjectionMatrix(); + light.shadow.mapSize.width = 2048; + light.shadow.mapSize.height = 2048; + } + + let cameraZDistance = 0; + if (cameraRef.current) { + const camera = cameraRef.current; + const fovRadians = camera.fov * (Math.PI / 180); + cameraZDistance = radius / Math.sin(fovRadians / 2); + cameraZDistance *= 1.5; + + camera.position.set(center.x, center.y + (maxDim / 4), center.z + cameraZDistance); + camera.near = radius / 100; + camera.far = radius * 100; + camera.updateProjectionMatrix(); + camera.lookAt(center); + } + + if (orbitControlsRef.current) { + const controls = orbitControlsRef.current; + if (center) controls.target.copy(center); + controls.minDistance = radius / 2; + controls.maxDistance = (cameraZDistance || radius * 10) * 5; + controls.update(); + } + + setLoadedModel(object); + setBlobURLs(prev => [...prev, modelUrl]); + setSceneObjects(prev => [ + ...prev.filter(obj => obj.type !== 'cube'), + ...newSceneObjects + ]); + setSelectedIds([mainModelId]); + setShowImageTo3D(false); + + setTimeout(() => { + setIsLoading(false); + setImportStatus(""); + }, 500); + + } catch (error) { + console.error('Error loading generated model:', error); + setIsLoading(false); + setImportStatus(""); + } + }} + /> +
+ ); +} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx new file mode 100644 index 0000000..b3e4dca --- /dev/null +++ b/src/pages/Home.tsx @@ -0,0 +1,45 @@ +import { Link } from 'react-router-dom'; +import { useAuthStore } from '../store/authStore'; +import { Box } from 'lucide-react'; + +export default function Home() { + const { user } = useAuthStore(); + + return ( +
+
+ +
+

+ Welcome to Titan3d +

+

+ The ultimate cloud-based 3D product configurator. Build, edit, and visualize 3D assets entirely in your browser with the power of AI. +

+ + {user ? ( + + Go to Dashboard + + ) : ( +
+ + Sign In + + + Create Account + +
+ )} +
+ ); +} diff --git a/src/store/authStore.ts b/src/store/authStore.ts new file mode 100644 index 0000000..8398be3 --- /dev/null +++ b/src/store/authStore.ts @@ -0,0 +1,64 @@ +import { create } from 'zustand'; +import { api } from '../lib/api'; + +interface UserProfile { + id: string; + email: string; + role: string; + api_credits: number; + storage_limit_mb: number; +} + +interface AuthState { + user: { id: string, email: string } | null; + profile: UserProfile | null; + isLoading: boolean; + initialize: () => Promise; + setSession: (token: string, profile: UserProfile) => void; + signOut: () => void; + setProfile: (profile: UserProfile) => void; +} + +export const useAuthStore = create((set) => ({ + user: null, + profile: null, + isLoading: true, + + initialize: async () => { + try { + const token = localStorage.getItem('auth_token'); + if (!token) { + set({ user: null, profile: null, isLoading: false }); + return; + } + + const { profile } = await api.getMe(); + if (profile) { + set({ + user: { id: profile.id, email: profile.email }, + profile, + isLoading: false + }); + } else { + localStorage.removeItem('auth_token'); + set({ user: null, profile: null, isLoading: false }); + } + } catch (error) { + console.error('Failed to initialize session:', error); + localStorage.removeItem('auth_token'); + set({ user: null, profile: null, isLoading: false }); + } + }, + + setSession: (token, profile) => { + localStorage.setItem('auth_token', token); + set({ user: { id: profile.id, email: profile.email }, profile }); + }, + + signOut: () => { + localStorage.removeItem('auth_token'); + set({ user: null, profile: null }); + }, + + setProfile: (profile) => set({ profile }), +})); diff --git a/tsconfig.json b/tsconfig.json index d88f175..c2e7e1e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "DOM", "DOM.Iterable" ], + "types": ["vite/client"], "skipLibCheck": true, "moduleResolution": "bundler", "isolatedModules": true, diff --git a/vite.config.ts b/vite.config.ts index d3743d8..a9a9066 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -22,6 +22,8 @@ export default defineConfig(({mode}) => { include: ['react', 'react-dom', 'three', '@react-three/fiber', '@react-three/drei'], }, server: { + host: true, + port: 3000, // HMR is disabled in AI Studio via DISABLE_HMR env var. // Do not modify—file watching is disabled to prevent flickering during agent edits. hmr: process.env.DISABLE_HMR !== 'true',