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"; 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 App() { const [activeSidebar, setActiveSidebar] = useState("Materials"); const [theme, setTheme] = useState<"light" | "dark">("light"); 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; }); }; 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 | 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(""); } }} />
); }