Files
titan_react_node_sqllite/src/App.tsx
T
2026-06-16 16:19:50 +05:30

4883 lines
231 KiB
TypeScript

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 }) => (
<button
onClick={onClick}
className={cn(
"flex flex-col items-center justify-center gap-1 p-2 transition-all rounded-xl",
active ? "text-primary bg-primary/10 border border-primary/20 shadow-sm" : "text-zinc-500 hover:bg-white/40 dark:hover:bg-white/5"
)}
>
<Icon size={20} />
<span className="text-[10px] font-medium">{label}</span>
</button>
);
const ToolbarButton = ({ icon: Icon, label, active }: { icon: any, label?: string, active?: boolean }) => (
<button className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg transition-all duration-200",
active ? "bg-primary text-white shadow-lg shadow-primary/20" : "text-zinc-500 hover:bg-white/40 dark:hover:bg-white/5"
)}>
<Icon size={16} />
{label && <span className="text-xs font-medium">{label}</span>}
</button>
);
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
}) => (
<button
onClick={onClick}
className={cn(
"group flex flex-col gap-1 p-1.5 rounded-xl glass-card transition-all duration-300",
active && "ring-2 ring-primary bg-white/80 dark:bg-white/20 shadow-lg"
)}
>
<div
className="w-full aspect-square rounded-lg shadow-inner relative overflow-hidden"
style={{
backgroundColor: color,
backgroundImage: `radial-gradient(circle at 30% 30%, rgba(255,255,255,${0.6 * (1 - roughness)}), transparent)`,
boxShadow: metalness > 0.5 ? "inset 0 0 15px rgba(0,0,0,0.8)" : "inset 0 0 10px rgba(0,0,0,0.3)",
opacity: transmission > 0 ? 0.7 : 1
}}
>
{transmission > 0 && (
<div className="absolute inset-0 bg-gradient-to-tr from-white/10 to-transparent pointer-events-none" />
)}
</div>
<span className="text-[9px] text-zinc-500 dark:text-zinc-400 truncate text-center font-medium group-hover:text-primary transition-colors">{name}</span>
</button>
);
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 (
<mesh
rotation={[-Math.PI / 2, 0, (rotation * Math.PI) / 180]}
position={[0, posY, 0]}
receiveShadow
>
<planeGeometry args={[planeSize, planeSize]} />
<shadowMaterial
{...({
transparent: true,
opacity: opacity,
alphaMap: alphaTexture,
color: "#000000"
} as any)}
/>
</mesh>
);
};
export default function App() {
const [activeSidebar, setActiveSidebar] = useState("Materials");
const [theme, setTheme] = useState<"light" | "dark">("light");
const [loadedModel, setLoadedModel] = useState<THREE.Object3D | null>(null);
const [blobURLs, setBlobURLs] = useState<string[]>([]);
// 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<SceneObject[]>([]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [editingId, setEditingId] = useState<string | null>(null);
const [activeRightTab, setActiveRightTab] = useState("Scene");
const [showUVEditor, setShowUVEditor] = useState(false);
const [showImageTo3D, setShowImageTo3D] = useState(false);
// History State
const [history, setHistory] = useState<SceneObject[][]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const historyRef = useRef<SceneObject[][]>([]);
const historyIndexRef = useRef<number>(-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<MaterialProps>({
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<AdditionalLight[]>([]);
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<string>("studio");
const [envRotation, setEnvRotation] = useState(0);
const [customHdri, setCustomHdri] = useState<string | null>(null);
const [backplateImage, setBackplateImage] = useState<string | null>(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<string | null>(null);
const [renderRequest, setRenderRequest] = useState<RenderSettings | null>(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<string | null>(null);
const [importError, setImportError] = useState<string | null>(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<any[]>([
{ id: 'default', name: 'Default View', fov: 35, zoom: 1, position: [3, 2, 5], target: [0, 0, 0] }
]);
const orbitControlsRef = useRef<any>(null);
const [modelBounds, setModelBounds] = useState<{
center: THREE.Vector3;
size: THREE.Vector3;
maxDim: number;
minY: number;
radius: number;
} | null>(null);
const controlsRef = useRef<any>(null);
const directionalLightRef = useRef<THREE.DirectionalLight>(null);
const cameraRef = useRef<THREE.PerspectiveCamera>(null);
const juiceBoxRef = useRef<THREE.Group>(null);
const cubeRef = useRef<THREE.Mesh>(null);
const modelRef = useRef<THREE.Group>(null);
const transformRef = useRef<any>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const hdriInputRef = useRef<HTMLInputElement>(null);
const backplateInputRef = useRef<HTMLInputElement>(null);
const textureInputRef = useRef<HTMLInputElement>(null);
const normalInputRef = useRef<HTMLInputElement>(null);
const specularInputRef = useRef<HTMLInputElement>(null);
const alphaInputRef = useRef<HTMLInputElement>(null);
const [openMenu, setOpenMenu] = useState<string | null>(null);
const sceneInputRef = useRef<HTMLInputElement>(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<string>('model');
const loadScene = (event: React.ChangeEvent<HTMLInputElement>) => {
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<HTMLInputElement>, 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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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<string>((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<MaterialProps>) => {
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<TransformProps>) => {
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 (
<div className={cn(
"flex flex-col h-screen font-sans select-none overflow-hidden transition-colors duration-500",
theme === 'dark' ? "bg-zinc-950 text-zinc-200" : "bg-[#F6F9FF] text-gray-900"
)}>
{/* Background Decorative Blobs */}
<div className="fixed inset-0 pointer-events-none overflow-hidden z-0">
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-primary/10 rounded-full blur-[120px] animate-float" />
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-accent/10 rounded-full blur-[120px] animate-float-delayed" />
</div>
{/* Hidden File Input */}
<input
type="file"
ref={fileInputRef}
onChange={handleImport}
accept=".glb,.gltf,.fbx,.obj,.mtl,.usdz,.usd,.usda,.usdc,.zip"
multiple
className="hidden"
/>
<input
type="file"
ref={sceneInputRef}
accept=".json"
onChange={loadScene}
className="hidden"
/>
<input
type="file"
ref={backplateInputRef}
onChange={loadBackplate}
accept="image/png, image/jpeg"
className="hidden"
/>
{/* Top Menu Bar */}
<div className="flex items-center justify-between h-8 px-2 glass-panel border-b border-white/20 text-xs z-50">
<div className="flex items-center gap-4">
<div className="flex items-center gap-3 px-2 relative">
<div className="relative">
<span
className="hover:text-primary cursor-pointer px-1 py-0.5 rounded hover:bg-white/40 dark:hover:bg-white/5 transition-colors"
onClick={() => setOpenMenu(openMenu === 'file' ? null : 'file')}
>
File
</span>
{openMenu === 'file' && (
<div className="absolute top-full left-0 mt-1 w-56 glass-panel border border-white/20 rounded-xl shadow-2xl z-50 py-1 overflow-hidden">
<button
onClick={newScene}
className="w-full text-left px-4 py-2 hover:bg-primary hover:text-white flex items-center justify-between transition-colors"
>
<div className="flex items-center gap-2">
<Plus size={14} />
<span>New Scene</span>
</div>
<span className="text-[10px] opacity-50">Ctrl+N</span>
</button>
<div className="h-px bg-white/10 my-1" />
<button
onClick={() => { fileInputRef.current?.click(); setOpenMenu(null); }}
className="w-full text-left px-4 py-2 hover:bg-primary hover:text-white flex items-center justify-between transition-colors"
>
<div className="flex items-center gap-2">
<Import size={14} />
<span>Import Model...</span>
</div>
<span className="text-[10px] opacity-50">Ctrl+I</span>
</button>
<div className="h-px bg-white/10 my-1" />
<button
onClick={openSaveDialog}
className="w-full text-left px-4 py-2 hover:bg-primary hover:text-white flex items-center justify-between transition-colors"
>
<div className="flex items-center gap-2">
<Save size={14} />
<span>Save Scene...</span>
</div>
<span className="text-[10px] opacity-50">Ctrl+S</span>
</button>
<button
onClick={() => { sceneInputRef.current?.click(); }}
className="w-full text-left px-4 py-2 hover:bg-primary hover:text-white flex items-center justify-between transition-colors"
>
<div className="flex items-center gap-2">
<FolderOpen size={14} />
<span>Load Scene...</span>
</div>
<span className="text-[10px] opacity-50">Ctrl+O</span>
</button>
<button
onClick={() => openExportDialog('glb')}
className="w-full text-left px-4 py-2 hover:bg-primary hover:text-white flex items-center justify-between transition-colors"
>
<div className="flex items-center gap-2">
<Share2 size={14} />
<span>Export Scene (GLB)...</span>
</div>
<span className="text-[10px] opacity-50">Ctrl+E</span>
</button>
<button
onClick={() => openExportDialog('usdz')}
className="w-full text-left px-4 py-2 hover:bg-primary hover:text-white flex items-center justify-between transition-colors"
>
<div className="flex items-center gap-2">
<Share2 size={14} />
<span>Export Scene (USDZ)...</span>
</div>
</button>
</div>
)}
</div>
<div className="relative">
<span
className="hover:text-primary cursor-pointer px-1 py-0.5 rounded hover:bg-white/40 dark:hover:bg-white/5 transition-colors"
onClick={() => setOpenMenu(openMenu === 'edit' ? null : 'edit')}
>
Edit
</span>
{openMenu === 'edit' && (
<div className="absolute top-full left-0 mt-1 w-48 glass-panel border border-white/20 rounded-xl shadow-2xl z-50 py-1 overflow-hidden">
<button
onClick={() => { undo(); setOpenMenu(null); }}
disabled={historyIndex <= 0}
className="w-full text-left px-4 py-2 hover:bg-primary hover:text-white flex items-center justify-between disabled:opacity-30 transition-colors"
>
<div className="flex items-center gap-2">
<Undo2 size={14} />
<span>Undo</span>
</div>
<span className="text-[10px] opacity-50">Ctrl+Z</span>
</button>
<button
onClick={() => { redo(); setOpenMenu(null); }}
disabled={historyIndex >= history.length - 1}
className="w-full text-left px-4 py-2 hover:bg-primary hover:text-white flex items-center justify-between disabled:opacity-30 transition-colors"
>
<div className="flex items-center gap-2">
<Redo2 size={14} />
<span>Redo</span>
</div>
<span className="text-[10px] opacity-50">Ctrl+Y</span>
</button>
</div>
)}
</div>
<span className="hover:text-white cursor-pointer">Environment</span>
<span className="hover:text-white cursor-pointer">Lighting</span>
<span className="hover:text-white cursor-pointer">Camera</span>
<span className="hover:text-white cursor-pointer">Image</span>
<span className="hover:text-white cursor-pointer">Render</span>
<div className="relative">
<span
className="hover:text-primary cursor-pointer px-1 py-0.5 rounded hover:bg-white/40 dark:hover:bg-white/5 transition-colors"
onClick={() => setOpenMenu(openMenu === 'tools' ? null : 'tools')}
>
Tools
</span>
{openMenu === 'tools' && (
<div className="absolute top-full left-0 mt-1 w-48 bg-zinc-900 border border-zinc-800 rounded-lg shadow-xl py-1 z-50">
<button
className="w-full flex items-center gap-2 px-4 py-2 text-xs text-zinc-300 hover:bg-zinc-800 hover:text-white"
onClick={() => {
setShowApiSettings(true);
setOpenMenu(null);
}}
>
<Settings size={14} />
<span>API Settings</span>
</button>
</div>
)}
</div>
<div className="relative">
<span
className="hover:text-primary cursor-pointer px-1 py-0.5 rounded hover:bg-white/40 dark:hover:bg-white/5 transition-colors"
onClick={() => setOpenMenu(openMenu === 'view' ? null : 'view')}
>
View
</span>
{openMenu === 'view' && (
<div className="absolute top-full left-0 mt-1 w-48 glass-panel border border-white/20 rounded-xl shadow-2xl z-50 py-1 overflow-hidden">
<button
onClick={() => { setTheme('light'); setOpenMenu(null); }}
className="w-full text-left px-4 py-2 hover:bg-primary hover:text-white flex items-center justify-between transition-colors"
>
<div className="flex items-center gap-2">
<Sun size={14} />
<span>Light Theme (Glassy)</span>
</div>
{theme === 'light' && <div className="w-1.5 h-1.5 rounded-full bg-blue-400" />}
</button>
<button
onClick={() => { setTheme('dark'); setOpenMenu(null); }}
className="w-full text-left px-4 py-2 hover:bg-primary hover:text-white flex items-center justify-between transition-colors"
>
<div className="flex items-center gap-2">
<ImageIcon size={14} />
<span>Dark Theme</span>
</div>
{theme === 'dark' && <div className="w-1.5 h-1.5 rounded-full bg-blue-400" />}
</button>
</div>
)}
</div>
<span className="hover:text-white cursor-pointer">Window</span>
<span className="hover:text-white cursor-pointer">Help</span>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 bg-zinc-800 px-2 py-0.5 rounded">
<span>Startup</span>
<ChevronDown size={12} />
</div>
<div className="flex items-center gap-1 bg-zinc-800 px-2 py-0.5 rounded">
<span>100 %</span>
<ChevronDown size={12} />
</div>
</div>
</div>
{/* Main Toolbar */}
<div className="flex items-center justify-between h-12 px-4 glass-panel border-b border-white/20 z-40">
<div className="flex items-center gap-1">
<ToolbarButton icon={Layers} label="Workspaces" />
<div className="w-px h-6 bg-white/10 mx-2" />
<button
onClick={undo}
disabled={historyIndex <= 0}
className="p-2 text-zinc-500 hover:text-primary disabled:opacity-30 transition-colors"
title="Undo (Ctrl+Z)"
>
<Undo2 size={18} />
</button>
<button
onClick={redo}
disabled={historyIndex >= history.length - 1}
className="p-2 text-zinc-500 hover:text-primary disabled:opacity-30 transition-colors"
title="Redo (Ctrl+Y)"
>
<Redo2 size={18} />
</button>
<div className="w-px h-6 bg-white/10 mx-2" />
<ToolbarButton icon={Cpu} label="CPU Usage" />
<ToolbarButton icon={Wrench} label="Performance Mode" />
<div className="w-px h-6 bg-white/10 mx-2" />
<ToolbarButton icon={BoxSelect} label="GPU" />
<ToolbarButton icon={ImageIcon} label="Denoise" />
<div className="w-px h-6 bg-white/10 mx-2" />
<ToolbarButton icon={Box} label="Perspective" />
<ToolbarButton icon={Settings} label="Tools" />
</div>
<div className="flex items-center gap-4 pr-4">
<div className="flex items-center gap-2 text-xs text-zinc-500">
<Smartphone size={14} />
<Monitor size={14} />
<div className="w-px h-4 bg-white/10" />
<span className="font-mono">50.0 FPS</span>
</div>
</div>
</div>
{/* Main Studio Area */}
<div className="flex-1 flex overflow-hidden">
{/* Left Sidebar */}
<div className="w-72 flex glass-panel border-r border-white/20 m-2 rounded-2xl overflow-hidden z-30">
<div className="w-16 flex flex-col border-r border-white/10 bg-white/20 dark:bg-black/20">
<SidebarItem icon={Palette} label="Materials" active={activeSidebar === "Materials"} onClick={() => setActiveSidebar("Materials")} />
<SidebarItem icon={ImageIcon} label="Colors" active={activeSidebar === "Colors"} onClick={() => setActiveSidebar("Colors")} />
<SidebarItem icon={TextureIcon} label="Textures" active={activeSidebar === "Textures"} onClick={() => setActiveSidebar("Textures")} />
<SidebarItem icon={Sun} label="Environ..." active={activeSidebar === "Environ..."} onClick={() => setActiveSidebar("Environ...")} />
<SidebarItem icon={ImageIcon} label="Favorites" active={activeSidebar === "Favorites"} onClick={() => setActiveSidebar("Favorites")} />
<SidebarItem icon={Box} label="Models" active={activeSidebar === "Models"} onClick={() => setActiveSidebar("Models")} />
<SidebarItem icon={Box} label="Image to 3D" onClick={() => setShowImageTo3D(true)} />
</div>
<div className="flex-1 flex flex-col overflow-hidden">
<div className="p-3 border-b border-white/10 flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-500">{activeSidebar}</span>
<div className="flex gap-2">
<Maximize2 size={12} className="text-zinc-400 hover:text-primary cursor-pointer transition-colors" />
<Menu size={12} className="text-zinc-400 hover:text-primary cursor-pointer transition-colors" />
</div>
</div>
<div className="p-3">
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
<input
type="text"
placeholder="Search assets..."
className="w-full bg-white/40 dark:bg-black/20 border border-white/20 rounded-xl py-2 pl-9 pr-3 text-xs focus:outline-none focus:ring-2 focus:ring-primary/30 transition-all"
/>
</div>
</div>
<div className="px-3 pb-3 flex gap-2 overflow-x-auto no-scrollbar border-b border-white/10">
{activeSidebar === "Materials" && materialCategories.map(cat => (
<button
key={cat.id}
onClick={() => setSelectedCategory(cat.id)}
className={cn(
"px-3 py-1.5 rounded-lg text-[10px] font-bold whitespace-nowrap transition-all",
selectedCategory === cat.id ? "bg-primary text-white shadow-lg shadow-primary/20" : "text-zinc-500 hover:text-primary hover:bg-white/40 dark:hover:bg-white/5"
)}
>
{cat.name}
</button>
))}
{activeSidebar === "Textures" && ["Patterns", "Materials", "Nature", "Abstract"].map(cat => (
<button
key={cat}
className="px-3 py-1.5 rounded-lg text-[10px] font-bold whitespace-nowrap text-zinc-500 hover:text-primary hover:bg-white/40 dark:hover:bg-white/5 transition-all"
>
{cat}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-3 custom-scrollbar">
{activeSidebar === "Materials" && (
<div className="grid grid-cols-3 gap-2">
{currentCategory.items.map((mat) => (
<MaterialCard
key={mat.name}
{...mat}
active={selectedObject?.materialProps?.color === mat.color && selectedObject?.materialProps?.roughness === mat.roughness}
onClick={() => {
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
});
});
}
}}
/>
))}
</div>
)}
{activeSidebar === "Textures" && (
<div className="grid grid-cols-2 gap-2">
{[
{ 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) => (
<button
key={tex.name}
onClick={() => {
if (selectedIds.length > 0) {
selectedIds.forEach(id => {
updateObjectMaterial(id, { map: tex.url, uvScale: 1 });
});
}
}}
className="group relative aspect-square rounded overflow-hidden border border-zinc-800 hover:border-blue-500 transition-all"
>
<img src={tex.url} alt={tex.name} className="w-full h-full object-cover" referrerPolicy="no-referrer" />
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center p-2 text-center">
<span className="text-[8px] text-white font-medium">{tex.name}</span>
</div>
</button>
))}
<button
onClick={() => textureInputRef.current?.click()}
className="aspect-square rounded border-2 border-dashed border-zinc-800 hover:border-zinc-700 flex flex-col items-center justify-center gap-1 text-zinc-500 hover:text-zinc-400 transition-all"
>
<FolderPlus size={16} />
<span className="text-[8px]">Upload</span>
</button>
</div>
)}
{activeSidebar === "Colors" && (
<div className="grid grid-cols-4 gap-2">
{["#ef4444", "#f97316", "#f59e0b", "#eab308", "#84cc16", "#22c55e", "#10b981", "#06b6d4", "#3b82f6", "#6366f1", "#8b5cf6", "#a855f7", "#d946ef", "#ec4899", "#f43f5e", "#ffffff", "#a1a1aa", "#3f3f46", "#18181b", "#000000"].map((color) => (
<button
key={color}
onClick={() => {
if (selectedIds.length > 0) {
selectedIds.forEach(id => {
updateObjectMaterial(id, { color });
});
}
}}
className="aspect-square rounded border border-zinc-800 hover:scale-110 transition-transform"
style={{ backgroundColor: color }}
/>
))}
</div>
)}
{activeSidebar === "Environ..." && (
<div className="flex flex-col gap-4">
<span className="text-[10px] text-zinc-500 italic">Environment presets are also available in the right sidebar.</span>
<div className="grid grid-cols-1 gap-2">
{['studio', 'apartment', 'city', 'dawn', 'forest'].map(preset => (
<button
key={preset}
onClick={() => setEnvPreset(preset)}
className={cn(
"p-2 rounded border text-left transition-all",
envPreset === preset ? "bg-blue-500/10 border-blue-500/50 text-blue-400" : "bg-zinc-800/50 border-zinc-700 text-zinc-400"
)}
>
<span className="text-[10px] font-bold capitalize">{preset}</span>
</button>
))}
</div>
</div>
)}
</div>
</div>
</div>
{/* Viewport */}
<div className="flex-1 relative bg-zinc-950">
<ErrorBoundary>
<Canvas
shadows
dpr={[1, 2]}
gl={{ preserveDrawingBuffer: true, alpha: true }}
performance={{ min: 0.5 }}
>
<SceneSetup />
{cameraProps.orthographic ? (
<OrthographicCamera makeDefault position={cameraProps.position} zoom={cameraProps.zoom * 50} />
) : (
<PerspectiveCamera
makeDefault
ref={cameraRef}
position={cameraProps.position}
fov={cameraProps.fov}
zoom={cameraProps.zoom}
/>
)}
{backgroundType === "color" && <color attach="background" args={[backgroundColor]} />}
<RenderManager />
<ExportManager />
<Suspense fallback={null}>
{sceneObjects.map(obj => {
if (obj.id === 'juicebox-1' && obj.visible) {
return (
<JuiceBox
key={obj.id}
ref={juiceBoxRef}
position={obj.transformProps?.position}
rotation={obj.transformProps?.rotation}
scale={obj.transformProps?.scale}
color={obj.materialProps?.color}
roughness={obj.materialProps?.roughness}
metalness={obj.materialProps?.metalness}
clearcoat={obj.materialProps?.clearcoat}
transmission={obj.materialProps?.transmission}
thickness={obj.materialProps?.thickness}
ior={obj.materialProps?.ior}
sheen={obj.materialProps?.sheen}
map={obj.materialProps?.map}
uvScale={obj.materialProps?.uvScale}
onClick={(e: any) => {
e.stopPropagation();
setSelectedIds([obj.id]);
}}
/>
);
}
if (obj.type === 'cube' && obj.visible) {
return (
<mesh
key={obj.id}
ref={cubeRef}
position={obj.transformProps?.position}
rotation={obj.transformProps?.rotation}
scale={obj.transformProps?.scale}
castShadow
receiveShadow
onClick={(e) => {
e.stopPropagation();
setSelectedIds([obj.id]);
}}
>
<boxGeometry args={[1, 1, 1]} />
<meshPhysicalMaterial
color={obj.materialProps?.color ?? "#ffffff"}
roughness={obj.materialProps?.roughness ?? 0.5}
metalness={obj.materialProps?.metalness ?? 0}
clearcoat={obj.materialProps?.clearcoat ?? 0}
transmission={obj.materialProps?.transmission ?? 0}
thickness={obj.materialProps?.thickness ?? 0}
ior={obj.materialProps?.ior ?? 1.5}
sheen={obj.materialProps?.sheen ?? 0}
opacity={obj.materialProps?.opacity ?? 1}
attenuationDistance={obj.materialProps?.attenuationDistance ?? Infinity}
attenuationColor={obj.materialProps?.attenuationColor ?? obj.materialProps?.color ?? "#ffffff"}
transparent={(obj.materialProps?.transmission ?? 0) > 0 || (obj.materialProps?.opacity ?? 1) < 1}
/>
</mesh>
);
}
return null;
})}
<ModelViewer ref={modelRef} object={loadedModel} sceneObjects={sceneObjects} />
{(!isRendering || !renderRequest?.includeAlpha) ? (
customHdri ? (
<Environment
{...({
files: customHdri,
background: backgroundType === "hdri",
backgroundRotation: [0, (envRotation * Math.PI) / 180, 0],
environmentRotation: [0, (envRotation * Math.PI) / 180, 0]
} as any)}
/>
) : envPreset ? (
<Environment
{...({
preset: envPreset as any,
background: backgroundType === "hdri",
backgroundRotation: [0, (envRotation * Math.PI) / 180, 0],
environmentRotation: [0, (envRotation * Math.PI) / 180, 0]
} as any)}
/>
) : null
) : (
/* During alpha render, provide environment but no background */
customHdri ? (
<Environment
{...({
files: customHdri,
background: false,
environmentRotation: [0, (envRotation * Math.PI) / 180, 0]
} as any)}
/>
) : envPreset ? (
<Environment
{...({
preset: envPreset as any,
background: false,
environmentRotation: [0, (envRotation * Math.PI) / 180, 0]
} as any)}
/>
) : null
)}
{cameraProps.groundGrid && !isRendering && (
<Grid
infiniteGrid
fadeDistance={50}
fadeStrength={5}
cellSize={0.5}
sectionSize={2.5}
sectionThickness={1}
sectionColor="#333333"
cellColor="#222222"
position={[0, (modelBounds?.minY ?? -0.5) - 0.005, 0]}
/>
)}
{showGrid && !cameraProps.groundGrid && !isRendering && (
<Grid
infiniteGrid
fadeDistance={50}
fadeStrength={5}
cellSize={0.5}
sectionSize={2.5}
sectionThickness={1}
sectionColor="#333333"
cellColor="#222222"
position={[0, (modelBounds?.minY ?? -0.5) - 0.005, 0]}
/>
)}
{groundProps.showShadow && (
<ShadowFloor
bounds={modelBounds}
opacity={groundProps.shadowIntensity}
rotation={groundProps.shadowRotation}
softness={groundProps.shadowSoftness}
/>
)}
{selectedIds.length === 1 && !isRendering && (
<TransformControls
ref={transformRef}
object={(() => {
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) && (
<FixedBackplate url={backplateImage} />
)}
</Suspense>
{/* Primary Light Source */}
<directionalLight
ref={directionalLightRef}
position={[
groundProps.shadowLength * Math.cos((groundProps.shadowRotation * Math.PI) / 180),
10,
groundProps.shadowLength * Math.sin((groundProps.shadowRotation * Math.PI) / 180)
]}
intensity={lightProps.intensity}
color={lightProps.color}
castShadow
shadow-mapSize={[2048, 2048]}
shadow-bias={lightProps.shadowBias}
shadow-radius={lightProps.shadowRadius}
/>
<ambientLight intensity={0.4} />
{/* Additional Lights */}
{additionalLights.map(light => (
light.type === 'point' ? (
<pointLight
key={light.id}
position={light.position}
intensity={light.intensity}
color={light.color}
distance={light.distance}
decay={light.decay}
castShadow={light.castShadow}
/>
) : (
<spotLight
key={light.id}
position={light.position}
intensity={light.intensity}
color={light.color}
distance={light.distance}
decay={light.decay}
angle={light.angle}
penumbra={light.penumbra}
castShadow={light.castShadow}
/>
)
))}
<OrbitControls
ref={orbitControlsRef}
makeDefault
minPolarAngle={0}
maxPolarAngle={cameraProps.walkthroughMode ? Math.PI / 2 : Math.PI / 1.75}
autoRotate={cameraProps.autoRotate}
autoRotateSpeed={2}
onEnd={() => {
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) && (
<EffectComposer enableNormalPass={false}>
{cameraProps.depthOfField.enabled && (
<DepthOfField
focusDistance={cameraProps.depthOfField.focusDistance}
focalLength={cameraProps.depthOfField.focalLength}
bokehScale={cameraProps.depthOfField.bokehScale}
height={480}
/>
)}
{cameraProps.bloom.enabled && (
<Bloom
intensity={cameraProps.bloom.intensity}
luminanceThreshold={cameraProps.bloom.luminanceThreshold}
luminanceSmoothing={cameraProps.bloom.luminanceSmoothing}
mipmapBlur={cameraProps.bloom.mipmapBlur}
/>
)}
{cameraProps.vignette.enabled && (
<Vignette
offset={cameraProps.vignette.offset}
darkness={cameraProps.vignette.darkness}
/>
)}
{cameraProps.colorGrading.enabled && (
<>
<BrightnessContrast
brightness={cameraProps.colorGrading.brightness}
contrast={cameraProps.colorGrading.contrast}
/>
<HueSaturation
hue={cameraProps.colorGrading.hue}
saturation={cameraProps.colorGrading.saturation}
/>
</>
)}
</EffectComposer>
)}
</Canvas>
</ErrorBoundary>
{/* Viewport Overlays */}
<div className="absolute bottom-4 left-4 flex flex-col gap-2">
<div className="bg-zinc-900/80 backdrop-blur-md p-2 rounded-lg border border-zinc-800 shadow-2xl flex items-center gap-3">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest">Real-time Render</span>
</div>
</div>
</div>
{/* Loading Overlay */}
<AnimatePresence>
{(isLoading || importError) && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-zinc-950/80 backdrop-blur-md flex items-center justify-center z-50"
>
<div className="flex flex-col items-center gap-6 w-80">
{importError ? (
<div className="flex flex-col items-center gap-4 p-6 bg-zinc-900 rounded-xl border border-red-500/30 shadow-2xl">
<div className="w-12 h-12 rounded-full bg-red-500/10 flex items-center justify-center text-red-500">
<Trash2 size={24} />
</div>
<div className="flex flex-col items-center gap-1 text-center">
<span className="text-sm font-bold text-white">Import Failed</span>
<span className="text-xs text-zinc-400">{importError}</span>
</div>
<button
onClick={() => setImportError(null)}
className="mt-2 px-6 py-2 bg-zinc-800 hover:bg-zinc-700 rounded-lg text-xs font-bold transition-all"
>
Dismiss
</button>
</div>
) : (
<>
<div className="relative w-full h-1.5 bg-zinc-800 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${importProgress}%` }}
className="absolute top-0 left-0 h-full bg-blue-500 shadow-[0_0_10px_rgba(59,130,246,0.5)]"
/>
</div>
<div className="flex flex-col items-center gap-2">
<div className="flex items-center gap-2">
<Loader2 className="w-3 h-3 text-blue-400 animate-spin" />
<span className="text-xs font-bold text-white tracking-widest uppercase">
{importStatus || "Importing Model"}
</span>
</div>
<span className="text-[10px] font-mono text-zinc-500">{importProgress}% Complete</span>
</div>
</>
)}
</div>
</motion.div>
)}
</AnimatePresence>
<RenderModal
isOpen={showRenderModal}
onClose={() => setShowRenderModal(false)}
onRender={(settings) => setRenderRequest(settings)}
/>
<VizAiModal
isOpen={showVizAi}
onClose={() => setShowVizAi(false)}
baseImage={vizAiBaseImage}
/>
<ApiSettingsModal
isOpen={showApiSettings}
onClose={() => setShowApiSettings(false)}
/>
{/* Export / Save Dialog */}
{exportDialog && (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/60 backdrop-blur-sm"
onClick={() => setExportDialog(null)}>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
onClick={e => e.stopPropagation()}
className="w-full max-w-sm bg-zinc-900 border border-zinc-700 rounded-2xl shadow-2xl overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-zinc-800">
<div className="flex items-center gap-2">
<Save size={15} className="text-blue-400" />
<span className="text-sm font-bold text-zinc-100">
{exportDialog.mode === 'scene' ? 'Save Scene' :
exportDialog.mode === 'glb' ? 'Export GLB' : 'Export USDZ'}
</span>
</div>
<button onClick={() => setExportDialog(null)}
className="p-1.5 hover:bg-zinc-700 rounded-lg text-zinc-500 hover:text-zinc-200 transition-colors">
<svg width={14} height={14} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
{/* Body */}
<div className="p-5 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">File Name</label>
<input
autoFocus
type="text"
value={exportDialogName}
onChange={e => 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"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">Save Location</label>
<p className="text-xs text-zinc-500 bg-zinc-800/60 rounded-lg px-3 py-2 border border-zinc-800">
Downloads folder &mdash; browser default
</p>
<p className="text-[10px] text-zinc-600 mt-0.5">File will be saved as: <span className="text-zinc-400 font-mono">{(exportDialogName || exportDialog.defaultName).trim()}.{exportDialog.mode === 'scene' ? 'json' : exportDialog.mode}</span></p>
</div>
</div>
{/* Footer */}
<div className="flex gap-3 px-5 pb-5">
<button onClick={() => setExportDialog(null)}
className="flex-1 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm font-semibold transition-colors">
Cancel
</button>
<button onClick={confirmExportDialog}
className="flex-1 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm font-bold shadow-lg shadow-blue-900/30 transition-colors">
{exportDialog.mode === 'scene' ? 'Save' : 'Export'}
</button>
</div>
</motion.div>
</div>
)}
</div>
{/* Right Sidebar */}
<div className="w-72 flex flex-col glass-panel border-l border-white/20 m-2 rounded-2xl overflow-hidden z-30">
<div className="flex h-12 border-b border-white/10 bg-white/20 dark:bg-black/20">
<button
onClick={() => setActiveRightTab("Scene")}
className={cn(
"flex-1 flex flex-col items-center justify-center transition-all",
activeRightTab === "Scene" ? "text-primary bg-white/40 dark:bg-white/5 border-b-2 border-primary" : "text-zinc-500 hover:text-primary"
)}
>
<Layout size={16} />
<span className="text-[9px] font-bold mt-1 uppercase tracking-wider">Scene</span>
</button>
<button
onClick={() => setActiveRightTab("Material")}
className={cn(
"flex-1 flex flex-col items-center justify-center transition-all",
activeRightTab === "Material" ? "text-primary bg-white/40 dark:bg-white/5 border-b-2 border-primary" : "text-zinc-500 hover:text-primary"
)}
>
<Palette size={16} />
<span className="text-[9px] font-bold mt-1 uppercase tracking-wider">Material</span>
</button>
<button
onClick={() => setActiveRightTab("Transform")}
className={cn(
"flex-1 flex flex-col items-center justify-center transition-all",
activeRightTab === "Transform" ? "text-primary bg-white/40 dark:bg-white/5 border-b-2 border-primary" : "text-zinc-500 hover:text-primary"
)}
>
<Maximize2 size={16} />
<span className="text-[9px] font-bold mt-1 uppercase tracking-wider">Transform</span>
</button>
<button
onClick={() => setActiveRightTab("Camera")}
className={cn(
"flex-1 flex flex-col items-center justify-center transition-all",
activeRightTab === "Camera" ? "text-primary bg-white/40 dark:bg-white/5 border-b-2 border-primary" : "text-zinc-500 hover:text-primary"
)}
>
<Camera size={16} />
<span className="text-[9px] font-bold mt-1 uppercase tracking-wider">Camera</span>
</button>
<button
onClick={() => setActiveRightTab("Environ...")}
className={cn(
"flex-1 flex flex-col items-center justify-center transition-all",
activeRightTab === "Environ..." ? "text-primary bg-white/40 dark:bg-white/5 border-b-2 border-primary" : "text-zinc-500 hover:text-primary"
)}
>
<Sun size={16} />
<span className="text-[9px] font-bold mt-1 uppercase tracking-wider">Environ...</span>
</button>
</div>
<div className="flex-1 flex flex-col overflow-hidden custom-scrollbar">
{activeRightTab === "Scene" ? (
<>
<div className="p-2 border-b border-zinc-800 flex items-center justify-between">
<div className="flex items-center gap-2">
<ChevronDown size={14} className="text-zinc-500" />
<span className="text-xs font-bold text-zinc-400">Show</span>
</div>
<div className="relative flex-1 mx-2">
<Search size={10} className="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-500" />
<input
type="text"
placeholder="Search All"
className="w-full bg-zinc-800 border border-zinc-700 rounded py-0.5 px-2 text-[9px] focus:outline-none"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div className="p-1">
<div className="flex items-center justify-between px-2 py-1 mb-1">
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-wider">Hierarchy</span>
<div className="flex gap-2">
<button
onClick={groupObjects}
disabled={selectedIds.length < 2}
className="text-zinc-500 hover:text-blue-400 disabled:opacity-30"
title="Group Selected"
>
<FolderPlus size={14} />
</button>
<button
onClick={deleteObjects}
disabled={selectedIds.length === 0}
className="text-zinc-500 hover:text-red-400 disabled:opacity-30"
title="Delete Selected"
>
<Trash2 size={14} />
</button>
</div>
</div>
<div className="flex flex-col gap-0.5">
{sceneObjects.filter(obj => obj.parentId === null).map(obj => (
<div key={obj.id}>
<div
onClick={(e) => 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"
)}
>
<button
onClick={(e) => { e.stopPropagation(); updateObjectVisibility(obj.id, !obj.visible); }}
className="hover:text-white"
>
{obj.visible ? <Eye size={12} /> : <EyeOff size={12} className="text-zinc-700" />}
</button>
{obj.type === 'group' ? <FolderPlus size={12} /> : <Box size={12} />}
{editingId === obj.id ? (
<input
autoFocus
className="bg-zinc-800 border border-blue-500 rounded px-1 outline-none w-full"
defaultValue={obj.name}
onBlur={(e) => renameObject(obj.id, e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') renameObject(obj.id, e.currentTarget.value);
if (e.key === 'Escape') setEditingId(null);
}}
/>
) : (
<span className="flex-1 truncate">{obj.name}</span>
)}
{!editingId && selectedIds.includes(obj.id) && (
<button
onClick={(e) => { e.stopPropagation(); setEditingId(obj.id); }}
className="opacity-0 group-hover:opacity-100 p-0.5 hover:text-white"
>
<Edit2 size={10} />
</button>
)}
</div>
{/* Render Children */}
<div className="pl-4 flex flex-col gap-0.5 mt-0.5">
{sceneObjects.filter(child => child.parentId === obj.id).map(child => (
<div
key={child.id}
onClick={(e) => 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"
)}
>
<button
onClick={(e) => { e.stopPropagation(); updateObjectVisibility(child.id, !child.visible); }}
className="hover:text-white"
>
{child.visible ? <Eye size={12} /> : <EyeOff size={12} className="text-zinc-700" />}
</button>
<Box size={12} />
{editingId === child.id ? (
<input
autoFocus
className="bg-zinc-800 border border-blue-500 rounded px-1 outline-none w-full"
defaultValue={child.name}
onBlur={(e) => renameObject(child.id, e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') renameObject(child.id, e.currentTarget.value);
if (e.key === 'Escape') setEditingId(null);
}}
/>
) : (
<span className="flex-1 truncate">{child.name}</span>
)}
{!editingId && selectedIds.includes(child.id) && (
<button
onClick={(e) => { e.stopPropagation(); setEditingId(child.id); }}
className="opacity-0 group-hover:opacity-100 p-0.5 hover:text-white"
>
<Edit2 size={10} />
</button>
)}
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
</>
) : activeRightTab === "Transform" ? (
<div className="flex-1 flex flex-col p-4 gap-6 overflow-y-auto">
<div className="flex flex-col gap-2">
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">Transform</span>
<div className="h-px bg-zinc-800 w-full" />
</div>
{!selectedObject || !selectedObject.transformProps ? (
<div className="flex flex-col items-center justify-center p-8 text-center gap-2">
<Maximize2 size={24} className="text-zinc-700" />
<span className="text-xs text-zinc-500">Select an object to edit its transform</span>
</div>
) : (
<>
{/* Position */}
<div className="flex flex-col gap-3">
<span className="text-[10px] text-zinc-500 font-bold uppercase">Position</span>
{['x', 'y', 'z'].map((axis, i) => (
<div key={axis} className="flex items-center gap-2">
<span className="text-[10px] text-zinc-500 w-4 uppercase">{axis}</span>
<input
type="number"
step="0.1"
value={selectedObject.transformProps!.position[i]}
onChange={(e) => {
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"
/>
</div>
))}
</div>
{/* Rotation */}
<div className="flex flex-col gap-3">
<span className="text-[10px] text-zinc-500 font-bold uppercase">Rotation</span>
{['x', 'y', 'z'].map((axis, i) => (
<div key={axis} className="flex items-center gap-2">
<span className="text-[10px] text-zinc-500 w-4 uppercase">{axis}</span>
<input
type="number"
step="0.1"
value={selectedObject.transformProps!.rotation[i]}
onChange={(e) => {
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"
/>
</div>
))}
</div>
{/* Scale */}
<div className="flex flex-col gap-3">
<span className="text-[10px] text-zinc-500 font-bold uppercase">Scale</span>
{['x', 'y', 'z'].map((axis, i) => (
<div key={axis} className="flex items-center gap-2">
<span className="text-[10px] text-zinc-500 w-4 uppercase">{axis}</span>
<input
type="number"
step="0.1"
value={selectedObject.transformProps!.scale[i]}
onChange={(e) => {
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"
/>
</div>
))}
</div>
</>
)}
</div>
) : activeRightTab === "Camera" ? (
<div className="flex-1 flex flex-col p-4 gap-6 overflow-y-auto">
<div className="flex flex-col gap-2">
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">Position and Orientation</span>
<div className="h-px bg-zinc-800 w-full" />
</div>
{/* Mode Toggle */}
<div className="flex p-1 bg-zinc-900 rounded-lg border border-zinc-800">
<button
onClick={() => setCameraProps(prev => ({ ...prev, cameraMode: "spherical" }))}
className={cn(
"flex-1 py-1 text-[10px] font-bold rounded transition-all",
cameraProps.cameraMode === "spherical" ? "bg-zinc-800 text-blue-400" : "text-zinc-500 hover:text-zinc-300"
)}
>
Spherical
</button>
<button
onClick={() => setCameraProps(prev => ({ ...prev, cameraMode: "absolute" }))}
className={cn(
"flex-1 py-1 text-[10px] font-bold rounded transition-all",
cameraProps.cameraMode === "absolute" ? "bg-zinc-800 text-blue-400" : "text-zinc-500 hover:text-zinc-300"
)}
>
Absolute
</button>
</div>
{/* Spherical Controls */}
{cameraProps.cameraMode === "spherical" && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Distance</span>
<span className="text-[10px] font-mono text-blue-400">{cameraProps.spherical.distance.toFixed(3)} m</span>
</div>
<input
type="range"
min="0.1"
max="20"
step="0.001"
value={cameraProps.spherical.distance}
onChange={(e) => {
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"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Azimuth</span>
<span className="text-[10px] font-mono text-blue-400">{cameraProps.spherical.azimuth.toFixed(3)} °</span>
</div>
<input
type="range"
min="-180"
max="180"
step="0.001"
value={cameraProps.spherical.azimuth}
onChange={(e) => {
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"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Inclination</span>
<span className="text-[10px] font-mono text-blue-400">{cameraProps.spherical.inclination.toFixed(3)} °</span>
</div>
<input
type="range"
min="-90"
max="90"
step="0.001"
value={cameraProps.spherical.inclination}
onChange={(e) => {
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"
/>
</div>
</div>
)}
{/* Absolute Position Controls */}
{cameraProps.cameraMode === "absolute" && (
<div className="flex flex-col gap-2">
<span className="text-xs text-zinc-400">Position (Absolute)</span>
<div className="grid grid-cols-3 gap-2">
{["x", "y", "z"].map((axis, i) => (
<div key={axis} className="flex flex-col gap-1">
<span className="text-[8px] text-zinc-500 uppercase">{axis}</span>
<input
type="number"
step="0.1"
value={cameraProps.position[i].toFixed(2)}
onChange={(e) => {
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"
/>
</div>
))}
</div>
</div>
)}
{/* Target Controls */}
<div className="flex flex-col gap-2">
<span className="text-xs text-zinc-400">Target</span>
<div className="grid grid-cols-3 gap-2">
{["x", "y", "z"].map((axis, i) => (
<div key={axis} className="flex flex-col gap-1">
<span className="text-[8px] text-zinc-500 uppercase">{axis}</span>
<input
type="number"
step="0.1"
value={cameraProps.target[i].toFixed(2)}
onChange={(e) => {
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"
/>
</div>
))}
</div>
</div>
{/* Pivot Controls */}
<div className="flex flex-col gap-2">
<span className="text-xs text-zinc-400">Pivot</span>
<div className="grid grid-cols-3 gap-2">
{["x", "y", "z"].map((axis, i) => (
<div key={axis} className="flex flex-col gap-1">
<span className="text-[8px] text-zinc-500 uppercase">{axis}</span>
<input
type="number"
step="0.1"
value={cameraProps.pivot[i].toFixed(2)}
onChange={(e) => {
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"
/>
</div>
))}
</div>
</div>
{/* Standard Views */}
<div className="flex flex-col gap-2">
<span className="text-xs text-zinc-400">Standard Views</span>
<div className="grid grid-cols-4 gap-1">
{["front", "back", "top", "bottom", "left", "right", "isometric"].map((view) => (
<button
key={view}
onClick={() => setStandardView(view)}
className="py-1 text-[8px] font-bold bg-zinc-800 hover:bg-zinc-700 rounded border border-zinc-700 text-zinc-400 capitalize"
>
{view}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">Lens Settings</span>
<div className="h-px bg-zinc-800 w-full" />
</div>
{/* Camera Type */}
<div className="flex p-1 bg-zinc-900 rounded-lg border border-zinc-800">
<button
onClick={() => setCameraProps(prev => ({ ...prev, orthographic: false }))}
className={cn(
"flex-1 py-1 text-[10px] font-bold rounded transition-all",
!cameraProps.orthographic ? "bg-zinc-800 text-blue-400" : "text-zinc-500 hover:text-zinc-300"
)}
>
Perspective
</button>
<button
onClick={() => setCameraProps(prev => ({ ...prev, orthographic: true }))}
className={cn(
"flex-1 py-1 text-[10px] font-bold rounded transition-all",
cameraProps.orthographic ? "bg-zinc-800 text-blue-400" : "text-zinc-500 hover:text-zinc-300"
)}
>
Orthographic
</button>
</div>
{/* Focal Length / FOV */}
{!cameraProps.orthographic && (
<>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Focal Length</span>
<span className="text-[10px] font-mono text-blue-400">{fovToFocalLength(cameraProps.fov).toFixed(1)} mm</span>
</div>
<input
type="range"
min="10"
max="200"
step="1"
value={fovToFocalLength(cameraProps.fov)}
onChange={(e) => 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"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Field of View</span>
<span className="text-[10px] font-mono text-blue-400">{cameraProps.fov.toFixed(1)}°</span>
</div>
<input
type="range"
min="10"
max="120"
step="1"
value={cameraProps.fov}
onChange={(e) => 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"
/>
</div>
</>
)}
{/* Zoom */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Zoom Level</span>
<span className="text-[10px] font-mono text-blue-400">{cameraProps.zoom.toFixed(2)}x</span>
</div>
<input
type="range"
min="0.1"
max="5"
step="0.1"
value={cameraProps.zoom}
onChange={(e) => 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"
/>
</div>
{/* Depth of Field Toggle */}
<div className="flex items-center justify-between p-3 bg-zinc-800/50 rounded border border-zinc-800">
<div className="flex flex-col">
<span className="text-xs font-bold text-zinc-300">Depth of Field</span>
<span className="text-[10px] text-zinc-500">Enable bokeh effect</span>
</div>
<button
onClick={() => setCameraProps(prev => ({ ...prev, depthOfField: { ...prev.depthOfField, enabled: !prev.depthOfField.enabled } }))}
className={cn(
"w-10 h-5 rounded-full transition-colors relative",
cameraProps.depthOfField.enabled ? "bg-blue-500" : "bg-zinc-700"
)}
>
<div className={cn(
"absolute top-1 w-3 h-3 bg-white rounded-full transition-all",
cameraProps.depthOfField.enabled ? "right-1" : "left-1"
)} />
</button>
</div>
{/* DOF Settings */}
{cameraProps.depthOfField.enabled && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Focus Distance</span>
<span className="text-[10px] font-mono text-blue-400">{cameraProps.depthOfField.focusDistance.toFixed(3)}</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.001"
value={cameraProps.depthOfField.focusDistance}
onChange={(e) => 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"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Bokeh Scale</span>
<span className="text-[10px] font-mono text-blue-400">{cameraProps.depthOfField.bokehScale.toFixed(1)}</span>
</div>
<input
type="range"
min="0"
max="10"
step="0.1"
value={cameraProps.depthOfField.bokehScale}
onChange={(e) => 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"
/>
</div>
</div>
)}
{/* Post Processing Section */}
<div className="flex flex-col gap-2 pt-4">
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">Post Processing</span>
<div className="h-px bg-zinc-800 w-full" />
</div>
{/* Bloom Toggle */}
<div className="flex items-center justify-between p-3 bg-zinc-800/50 rounded border border-zinc-800">
<div className="flex flex-col">
<span className="text-xs font-bold text-zinc-300">Bloom</span>
<span className="text-[10px] text-zinc-500">Add glow to highlights</span>
</div>
<button
onClick={() => setCameraProps(prev => ({ ...prev, bloom: { ...prev.bloom, enabled: !prev.bloom.enabled } }))}
className={cn(
"w-10 h-5 rounded-full transition-colors relative",
cameraProps.bloom.enabled ? "bg-blue-500" : "bg-zinc-700"
)}
>
<div className={cn(
"absolute top-1 w-3 h-3 bg-white rounded-full transition-all",
cameraProps.bloom.enabled ? "right-1" : "left-1"
)} />
</button>
</div>
{/* Bloom Settings */}
{cameraProps.bloom.enabled && (
<div className="flex flex-col gap-4 pl-2 border-l border-zinc-800">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Intensity</span>
<span className="text-[10px] font-mono text-blue-400">{cameraProps.bloom.intensity.toFixed(2)}</span>
</div>
<input
type="range"
min="0"
max="5"
step="0.01"
value={cameraProps.bloom.intensity}
onChange={(e) => 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"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Threshold</span>
<span className="text-[10px] font-mono text-blue-400">{cameraProps.bloom.luminanceThreshold.toFixed(2)}</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.01"
value={cameraProps.bloom.luminanceThreshold}
onChange={(e) => 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"
/>
</div>
</div>
)}
{/* Vignette Toggle */}
<div className="flex items-center justify-between p-3 bg-zinc-800/50 rounded border border-zinc-800">
<div className="flex flex-col">
<span className="text-xs font-bold text-zinc-300">Vignette</span>
<span className="text-[10px] text-zinc-500">Darken image edges</span>
</div>
<button
onClick={() => setCameraProps(prev => ({ ...prev, vignette: { ...prev.vignette, enabled: !prev.vignette.enabled } }))}
className={cn(
"w-10 h-5 rounded-full transition-colors relative",
cameraProps.vignette.enabled ? "bg-blue-500" : "bg-zinc-700"
)}
>
<div className={cn(
"absolute top-1 w-3 h-3 bg-white rounded-full transition-all",
cameraProps.vignette.enabled ? "right-1" : "left-1"
)} />
</button>
</div>
{/* Vignette Settings */}
{cameraProps.vignette.enabled && (
<div className="flex flex-col gap-4 pl-2 border-l border-zinc-800">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Darkness</span>
<span className="text-[10px] font-mono text-blue-400">{cameraProps.vignette.darkness.toFixed(2)}</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.01"
value={cameraProps.vignette.darkness}
onChange={(e) => 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"
/>
</div>
</div>
)}
{/* Color Grading Toggle */}
<div className="flex items-center justify-between p-3 bg-zinc-800/50 rounded border border-zinc-800">
<div className="flex flex-col">
<span className="text-xs font-bold text-zinc-300">Color Grading</span>
<span className="text-[10px] text-zinc-500">Adjust brightness & color</span>
</div>
<button
onClick={() => setCameraProps(prev => ({ ...prev, colorGrading: { ...prev.colorGrading, enabled: !prev.colorGrading.enabled } }))}
className={cn(
"w-10 h-5 rounded-full transition-colors relative",
cameraProps.colorGrading.enabled ? "bg-blue-500" : "bg-zinc-700"
)}
>
<div className={cn(
"absolute top-1 w-3 h-3 bg-white rounded-full transition-all",
cameraProps.colorGrading.enabled ? "right-1" : "left-1"
)} />
</button>
</div>
{/* Color Grading Settings */}
{cameraProps.colorGrading.enabled && (
<div className="flex flex-col gap-4 pl-2 border-l border-zinc-800">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Brightness</span>
<span className="text-[10px] font-mono text-blue-400">{cameraProps.colorGrading.brightness.toFixed(2)}</span>
</div>
<input
type="range"
min="-1"
max="1"
step="0.01"
value={cameraProps.colorGrading.brightness}
onChange={(e) => 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"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Contrast</span>
<span className="text-[10px] font-mono text-blue-400">{cameraProps.colorGrading.contrast.toFixed(2)}</span>
</div>
<input
type="range"
min="-1"
max="1"
step="0.01"
value={cameraProps.colorGrading.contrast}
onChange={(e) => 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"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Saturation</span>
<span className="text-[10px] font-mono text-blue-400">{cameraProps.colorGrading.saturation.toFixed(2)}</span>
</div>
<input
type="range"
min="-1"
max="1"
step="0.01"
value={cameraProps.colorGrading.saturation}
onChange={(e) => 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"
/>
</div>
</div>
)}
{/* Grid and Ground Options */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between p-3 bg-zinc-800/50 rounded border border-zinc-800">
<div className="flex flex-col">
<span className="text-xs font-bold text-zinc-300">Ground Grid</span>
<span className="text-[10px] text-zinc-500">Show grid on floor</span>
</div>
<button
onClick={() => setCameraProps(prev => ({ ...prev, groundGrid: !prev.groundGrid }))}
className={cn(
"w-10 h-5 rounded-full transition-colors relative",
cameraProps.groundGrid ? "bg-blue-500" : "bg-zinc-700"
)}
>
<div className={cn(
"absolute top-1 w-3 h-3 bg-white rounded-full transition-all",
cameraProps.groundGrid ? "right-1" : "left-1"
)} />
</button>
</div>
<div className="flex items-center justify-between p-3 bg-zinc-800/50 rounded border border-zinc-800">
<div className="flex flex-col">
<span className="text-xs font-bold text-zinc-300">Stay Above Ground</span>
<span className="text-[10px] text-zinc-500">Limit camera height</span>
</div>
<button
onClick={() => {
if (orbitControlsRef.current) {
orbitControlsRef.current.minPolarAngle = cameraProps.walkthroughMode ? 0 : (cameraProps.groundGrid ? 0 : 0);
// This is just a placeholder for now, we'll use minPolarAngle
}
setCameraProps(prev => ({ ...prev, walkthroughMode: !prev.walkthroughMode }))
}}
className={cn(
"w-10 h-5 rounded-full transition-colors relative",
cameraProps.walkthroughMode ? "bg-blue-500" : "bg-zinc-700"
)}
>
<div className={cn(
"absolute top-1 w-3 h-3 bg-white rounded-full transition-all",
cameraProps.walkthroughMode ? "right-1" : "left-1"
)} />
</button>
</div>
</div>
{/* Auto Rotate */}
<div className="flex items-center justify-between p-3 bg-zinc-800/50 rounded border border-zinc-800">
<div className="flex flex-col">
<span className="text-xs font-bold text-zinc-300">Auto-Rotate</span>
<span className="text-[10px] text-zinc-500">Rotate around model</span>
</div>
<button
onClick={() => setCameraProps(prev => ({ ...prev, autoRotate: !prev.autoRotate }))}
className={cn(
"w-10 h-5 rounded-full transition-colors relative",
cameraProps.autoRotate ? "bg-blue-500" : "bg-zinc-700"
)}
>
<div className={cn(
"absolute top-1 w-3 h-3 bg-white rounded-full transition-all",
cameraProps.autoRotate ? "right-1" : "left-1"
)} />
</button>
</div>
{/* Camera Presets */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">Camera Presets</span>
<button
onClick={() => {
const name = `View ${cameraPresets.length}`;
saveCameraPreset(name);
}}
className="p-1 hover:bg-zinc-800 rounded text-blue-400 transition-colors"
title="Save Current View"
>
<Plus size={14} />
</button>
</div>
<div className="h-px bg-zinc-800 w-full" />
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto pr-1">
{cameraPresets.map((preset) => (
<div key={preset.id} className="group flex items-center justify-between p-2 rounded bg-zinc-800/30 hover:bg-zinc-800/60 transition-colors border border-transparent hover:border-zinc-700">
<button
onClick={() => loadCameraPreset(preset)}
className="flex-1 text-left text-[10px] text-zinc-300 font-medium truncate"
>
{preset.name}
</button>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{preset.id !== 'default' && (
<button
onClick={() => setCameraPresets(prev => prev.filter(p => p.id !== preset.id))}
className="p-1 hover:bg-zinc-700 rounded text-zinc-500 hover:text-red-400"
>
<Trash2 size={12} />
</button>
)}
</div>
</div>
))}
</div>
</div>
<div className="mt-auto pt-4 flex flex-col gap-2">
<button
onClick={() => {
setCameraProps(prev => ({
...prev,
fov: 35,
zoom: 1,
autoRotate: false,
orthographic: false,
target: [0, 0, 0],
position: [3, 2, 5],
pivot: [0, 0, 0],
cameraMode: "absolute",
spherical: {
distance: 6.16,
azimuth: 30.96,
inclination: 18.92,
twist: 0
},
walkthroughMode: false,
groundGrid: false,
depthOfField: {
enabled: false,
focusDistance: 0.1,
focalLength: 0.1,
bokehScale: 2.0
}
}));
if (orbitControlsRef.current) {
orbitControlsRef.current.reset();
orbitControlsRef.current.target.set(0, 0, 0);
orbitControlsRef.current.object.position.set(3, 2, 5);
orbitControlsRef.current.update();
}
}}
className="w-full py-1.5 bg-zinc-800 hover:bg-zinc-700 rounded text-[10px] font-medium transition-colors"
>
Reset Camera View
</button>
</div>
</div>
) : activeRightTab === "Environ..." ? (
<div className="flex-1 flex flex-col p-4 gap-6 overflow-y-auto">
<div className="flex flex-col gap-2">
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">Viewport Background</span>
<div className="h-px bg-zinc-800 w-full" />
</div>
{/* Background Type Toggle */}
<div className="flex p-1 bg-zinc-900 rounded-lg border border-zinc-800">
<button
onClick={() => setBackgroundType("color")}
className={cn(
"flex-1 py-1.5 text-[10px] font-bold rounded transition-all",
backgroundType === "color" || backgroundType === "image" ? "bg-zinc-800 text-blue-400" : "text-zinc-500 hover:text-zinc-300"
)}
>
Solid / Image
</button>
<button
onClick={() => setBackgroundType("hdri")}
className={cn(
"flex-1 py-1.5 text-[10px] font-bold rounded transition-all",
backgroundType === "hdri" ? "bg-zinc-800 text-blue-400" : "text-zinc-500 hover:text-zinc-300"
)}
>
HDRI
</button>
</div>
{/* Background Color & Image Selection */}
{(backgroundType === "color" || backgroundType === "image") && (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Background Color</span>
<div className="flex items-center gap-2">
<button
onClick={() => setBackgroundType("color")}
className={cn(
"w-4 h-4 rounded-full border border-zinc-700",
backgroundType === "color" ? "ring-2 ring-blue-500 ring-offset-2 ring-offset-zinc-950" : ""
)}
style={{ backgroundColor: backgroundColor }}
/>
<input
type="color"
value={backgroundColor}
onChange={(e) => {
setBackgroundColor(e.target.value);
setBackgroundType("color");
}}
className="w-6 h-6 rounded border border-zinc-700 bg-transparent cursor-pointer"
/>
</div>
</div>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Backplate Image</span>
{backgroundType === "image" && (
<button
onClick={() => {
setBackgroundType("color");
setBackplateImage(null);
}}
className="text-[9px] text-red-400 hover:text-red-300 uppercase font-bold"
>
Clear
</button>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => backplateInputRef.current?.click()}
className={cn(
"relative aspect-video rounded overflow-hidden border-2 border-dashed transition-all flex flex-col items-center justify-center gap-1 bg-zinc-900/50",
backgroundType === "image" && !backplateImage ? "border-blue-500" : "border-zinc-800 hover:border-zinc-700"
)}
>
<FolderPlus size={16} className="text-zinc-500" />
<span className="text-[9px] font-bold text-zinc-500 uppercase">Upload</span>
</button>
{[
{ 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) => (
<button
key={plate.name}
onClick={() => {
setBackplateImage(plate.url);
setBackgroundType("image");
}}
className={cn(
"relative aspect-video rounded overflow-hidden border-2 transition-all",
backgroundType === "image" && backplateImage === plate.url ? "border-blue-500" : "border-transparent hover:border-zinc-700"
)}
>
<img src={plate.url} alt={plate.name} className="w-full h-full object-cover" referrerPolicy="no-referrer" />
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity">
<span className="text-[10px] font-bold text-white">{plate.name}</span>
</div>
</button>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-[10px] text-zinc-500">Custom Backplate URL</span>
<div className="flex gap-2">
<input
type="text"
placeholder="https://..."
className="flex-1 bg-zinc-900 border border-zinc-800 rounded px-2 py-1.5 text-[10px] text-zinc-300"
onBlur={(e) => {
if (e.target.value) {
setBackplateImage(e.target.value);
setBackgroundType("image");
}
}}
/>
<button className="p-1.5 bg-zinc-800 hover:bg-zinc-700 rounded text-zinc-400">
<ImageIcon size={14} />
</button>
</div>
</div>
</div>
</div>
)}
{/* Environment Rotation Wheel */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">Environment Rotation</span>
<span className="text-[10px] font-mono text-blue-400">{envRotation}°</span>
</div>
<div className="flex items-center gap-4 bg-zinc-800/30 p-3 rounded-lg border border-zinc-800">
<div
className="w-16 h-16 rounded-full border-2 border-zinc-700 relative cursor-pointer bg-zinc-900 shadow-inner flex items-center justify-center group"
onMouseDown={(e) => {
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 => (
<div
key={deg}
className="absolute w-0.5 h-1 bg-zinc-700"
style={{ transform: `rotate(${deg}deg) translateY(-28px)` }}
/>
))}
{/* Pointer */}
<div
className="absolute w-1 h-8 bg-blue-500 rounded-full origin-bottom -translate-y-4 shadow-[0_0_8px_rgba(59,130,246,0.5)]"
style={{ transform: `rotate(${envRotation}deg)` }}
/>
<div className="w-2 h-2 rounded-full bg-zinc-800 z-10" />
</div>
<div className="flex-1 flex flex-col gap-2">
<input
type="range"
min="0"
max="360"
value={envRotation}
onChange={(e) => setEnvRotation(parseInt(e.target.value))}
className="w-full accent-blue-500 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer"
/>
<div className="flex justify-between text-[8px] text-zinc-500 font-mono">
<span>0°</span>
<span>180°</span>
<span>360°</span>
</div>
</div>
</div>
</div>
{/* Grid Toggle */}
<div className="flex items-center justify-between p-3 bg-zinc-800/50 rounded border border-zinc-800">
<div className="flex flex-col">
<span className="text-xs font-bold text-zinc-300">Viewport Grid</span>
<span className="text-[10px] text-zinc-500">Show spatial reference</span>
</div>
<button
onClick={() => setShowGrid(!showGrid)}
className={cn(
"w-10 h-5 rounded-full transition-colors relative",
showGrid ? "bg-blue-500" : "bg-zinc-700"
)}
>
<div className={cn(
"absolute top-1 w-3 h-3 bg-white rounded-full transition-all",
showGrid ? "right-1" : "left-1"
)} />
</button>
</div>
<div className="flex flex-col gap-2 pt-4">
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">Primary Light</span>
<div className="h-px bg-zinc-800 w-full" />
</div>
{/* Light Intensity */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Intensity</span>
<span className="text-[10px] font-mono text-blue-400">{lightProps.intensity.toFixed(2)}</span>
</div>
<input
type="range"
min="0"
max="10"
step="0.1"
value={lightProps.intensity}
onChange={(e) => 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"
/>
</div>
{/* Light Color */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Light Color</span>
<input
type="color"
value={lightProps.color}
onChange={(e) => setLightProps(prev => ({ ...prev, color: e.target.value }))}
className="w-6 h-6 rounded border border-zinc-700 bg-transparent cursor-pointer"
/>
</div>
</div>
{/* Advanced Lighting Controls */}
<div className="flex flex-col gap-4 pl-2 border-l border-zinc-800 mt-2">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-500 uppercase tracking-widest">Shadow Advanced</span>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-400">Shadow Bias</span>
<span className="text-[10px] font-mono text-blue-400">{lightProps.shadowBias.toFixed(4)}</span>
</div>
<input
type="range"
min="-0.01"
max="0.01"
step="0.0001"
value={lightProps.shadowBias}
onChange={(e) => 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"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-400">Shadow Radius</span>
<span className="text-[10px] font-mono text-blue-400">{lightProps.shadowRadius.toFixed(1)}</span>
</div>
<input
type="range"
min="0"
max="100"
step="0.1"
value={lightProps.shadowRadius}
onChange={(e) => 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"
/>
</div>
</div>
</div>
{/* Additional Lights Section */}
<div className="flex flex-col gap-2 pt-4">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">Additional Lights</span>
<div className="flex gap-1">
<button
onClick={() => {
const newLight: AdditionalLight = {
id: `light-${Date.now()}`,
type: 'point',
position: [2, 2, 2],
intensity: 1,
color: "#ffffff",
distance: 10,
decay: 2,
castShadow: false
};
setAdditionalLights(prev => [...prev, newLight]);
}}
className="p-1 hover:bg-zinc-800 rounded text-blue-400 transition-colors"
title="Add Point Light"
>
<Plus size={14} />
</button>
</div>
</div>
<div className="h-px bg-zinc-800 w-full" />
</div>
{/* Additional Lights List */}
<div className="flex flex-col gap-3">
{additionalLights.map((light, index) => (
<div key={light.id} className="flex flex-col gap-3 p-3 bg-zinc-800/30 rounded border border-zinc-800">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Sun size={12} className="text-blue-400" />
<span className="text-[10px] font-bold text-zinc-300 uppercase">{light.type} Light {index + 1}</span>
</div>
<button
onClick={() => setAdditionalLights(prev => prev.filter(l => l.id !== light.id))}
className="p-1 hover:bg-zinc-700 rounded text-zinc-500 hover:text-red-400"
>
<Trash2 size={12} />
</button>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-400">Intensity</span>
<span className="text-[10px] font-mono text-blue-400">{light.intensity.toFixed(1)}</span>
</div>
<input
type="range"
min="0"
max="10"
step="0.1"
value={light.intensity}
onChange={(e) => 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"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-400">Color</span>
<input
type="color"
value={light.color}
onChange={(e) => 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"
/>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-[10px] text-zinc-400">Position (X, Y, Z)</span>
<div className="grid grid-cols-3 gap-2">
{[0, 1, 2].map(i => (
<input
key={i}
type="number"
value={light.position[i]}
onChange={(e) => {
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"
/>
))}
</div>
</div>
</div>
))}
{additionalLights.length === 0 && (
<div className="py-4 flex flex-col items-center justify-center gap-2 border border-dashed border-zinc-800 rounded-lg">
<span className="text-[10px] text-zinc-600 italic">No additional lights added</span>
</div>
)}
</div>
<div className="flex flex-col gap-2 pt-4">
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">Ground & Shadows</span>
<div className="h-px bg-zinc-800 w-full" />
</div>
{/* Ground Shadow Toggle */}
<div className="flex items-center justify-between p-3 bg-zinc-800/50 rounded border border-zinc-800">
<div className="flex flex-col">
<span className="text-xs font-bold text-zinc-300">Ground Shadow</span>
<span className="text-[10px] text-zinc-500">Enable soft shadows</span>
</div>
<button
onClick={() => setGroundProps(prev => ({ ...prev, showShadow: !prev.showShadow }))}
className={cn(
"w-10 h-5 rounded-full transition-colors relative",
groundProps.showShadow ? "bg-blue-500" : "bg-zinc-700"
)}
>
<div className={cn(
"absolute top-1 w-3 h-3 bg-white rounded-full transition-all",
groundProps.showShadow ? "right-1" : "left-1"
)} />
</button>
</div>
{/* Shadow Controls */}
{groundProps.showShadow && (
<>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Shadow Intensity</span>
<span className="text-[10px] font-mono text-blue-400">{groundProps.shadowIntensity.toFixed(2)}</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.01"
value={groundProps.shadowIntensity}
onChange={(e) => 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"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Shadow Softness</span>
<span className="text-[10px] font-mono text-blue-400">{groundProps.shadowSoftness.toFixed(2)}</span>
</div>
<input
type="range"
min="0"
max="25"
step="0.01"
value={groundProps.shadowSoftness}
onChange={(e) => 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"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Shadow Length</span>
<span className="text-[10px] font-mono text-blue-400">{groundProps.shadowLength.toFixed(1)}</span>
</div>
<input
type="range"
min="0"
max="100"
step="0.5"
value={groundProps.shadowLength}
onChange={(e) => 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"
/>
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Shadow Rotation</span>
<span className="text-[10px] font-mono text-blue-400">{groundProps.shadowRotation}°</span>
</div>
<div className="flex items-center gap-4">
<div className="relative w-16 h-16 flex items-center justify-center">
<div className="absolute inset-0 rounded-full border-2 border-zinc-800" />
<div
className="absolute inset-0 rounded-full border-2 border-blue-500/30 transition-all"
style={{ transform: `rotate(${groundProps.shadowRotation}deg)` }}
>
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-1 h-3 bg-blue-500 rounded-full" />
</div>
<input
type="range"
min="0"
max="360"
value={groundProps.shadowRotation}
onChange={(e) => setGroundProps(prev => ({ ...prev, shadowRotation: parseInt(e.target.value) }))}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
/>
<RotateCw size={14} className="text-zinc-600" />
</div>
<div className="flex-1 flex flex-col gap-1">
<input
type="range"
min="0"
max="360"
value={groundProps.shadowRotation}
onChange={(e) => 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"
/>
<div className="flex justify-between text-[8px] text-zinc-500 font-mono">
<span>0°</span>
<span>180°</span>
<span>360°</span>
</div>
</div>
</div>
</div>
</>
)}
<div className="flex flex-col gap-2 pt-4">
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">Environment Presets</span>
<div className="h-px bg-zinc-800 w-full" />
</div>
<div className="grid grid-cols-1 gap-2">
<button
onClick={() => hdriInputRef.current?.click()}
className="flex flex-col items-start p-3 rounded border border-dashed border-zinc-700 hover:border-blue-500/50 hover:bg-blue-500/5 transition-all text-left group"
>
<div className="flex items-center gap-2 mb-1">
<FolderPlus size={14} className="text-zinc-500 group-hover:text-blue-400" />
<span className="text-xs font-bold text-zinc-400 group-hover:text-blue-400">Custom HDRI</span>
</div>
<span className="text-[10px] text-zinc-500">Upload .hdr or .exr file</span>
<input
type="file"
ref={hdriInputRef}
className="hidden"
accept=".hdr,.exr,.jpg,.png"
onChange={handleHdriUpload}
/>
</button>
{customHdri && (
<button
onClick={() => {
setCustomHdri(null);
setEnvPreset('studio');
}}
className="w-full py-2 bg-red-500/10 hover:bg-red-500/20 text-red-500 text-[10px] font-bold rounded border border-red-500/20 transition-all"
>
Clear Custom HDRI
</button>
)}
{[
{ 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) => (
<button
key={preset.id}
onClick={() => setEnvPreset(preset.id)}
className={cn(
"flex flex-col items-start p-3 rounded-xl border transition-all text-left glass-card",
envPreset === preset.id
? "bg-primary/10 border-primary/50 text-primary shadow-lg shadow-primary/10"
: "text-zinc-500 hover:text-primary"
)}
>
<span className="text-xs font-bold">{preset.name}</span>
<span className="text-[10px] opacity-60">{preset.desc}</span>
</button>
))}
</div>
</div>
) : (
<div className="flex-1 flex flex-col p-4 gap-6 overflow-y-auto">
<div className="flex flex-col gap-2">
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">Material Properties</span>
<div className="h-px bg-zinc-800 w-full" />
</div>
{!selectedObject || (selectedObject.type !== 'mesh' && selectedObject.type !== 'model') ? (
<div className="flex flex-col items-center justify-center p-8 text-center gap-2">
<Palette size={24} className="text-zinc-700" />
<span className="text-xs text-zinc-500">Select a model or mesh to edit its material properties</span>
</div>
) : (
<>
<div className="flex flex-col gap-1 mb-4">
<span className="text-xs font-bold text-zinc-200">{selectedObject.name}</span>
<span className="text-[10px] text-primary">{selectedObject.type === 'model' ? "Bulk Editing Model" : "Mesh Editor"}</span>
</div>
{/* Fill Settings */}
<div className="flex flex-col gap-3 mb-6 p-3 bg-zinc-800/50 rounded-xl border border-zinc-800">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-zinc-300 uppercase tracking-widest">Mesh Fill</span>
<button
onClick={() => {
const newFillProps = selectedObject.fillProps?.enabled ? undefined : {
enabled: true,
type: 'liquid',
height: 0.5,
color: '#3b82f6'
};
const newObjects = sceneObjects.map(obj =>
obj.id === selectedObject.id ? { ...obj, fillProps: newFillProps as any } : obj
);
setSceneObjects(newObjects);
pushToHistory(newObjects);
}}
className={cn(
"w-8 h-4 rounded-full transition-colors relative",
selectedObject.fillProps?.enabled ? "bg-primary" : "bg-zinc-700"
)}
>
<div className={cn(
"absolute top-0.5 left-0.5 w-3 h-3 bg-white rounded-full transition-transform",
selectedObject.fillProps?.enabled ? "translate-x-4" : "translate-x-0"
)} />
</button>
</div>
{selectedObject.fillProps?.enabled && (
<>
<div className="flex items-center gap-2 mt-2">
<button
onClick={() => {
const newObjects = sceneObjects.map(obj =>
obj.id === selectedObject.id ? { ...obj, fillProps: { ...obj.fillProps!, type: 'solid' as 'solid' } } : obj
);
setSceneObjects(newObjects);
pushToHistory(newObjects);
}}
className={cn(
"flex-1 py-1 text-[10px] rounded border transition-colors",
selectedObject.fillProps.type === 'solid' ? "bg-primary/20 border-primary text-primary" : "border-zinc-700 text-zinc-500"
)}
>
Solid
</button>
<button
onClick={() => {
const newObjects = sceneObjects.map(obj =>
obj.id === selectedObject.id ? { ...obj, fillProps: { ...obj.fillProps!, type: 'liquid' as 'liquid' } } : obj
);
setSceneObjects(newObjects);
pushToHistory(newObjects);
}}
className={cn(
"flex-1 py-1 text-[10px] rounded border transition-colors",
selectedObject.fillProps.type === 'liquid' ? "bg-primary/20 border-primary text-primary" : "border-zinc-700 text-zinc-500"
)}
>
Liquid
</button>
</div>
<div className="flex flex-col gap-1 mt-2">
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-400">Height</span>
<span className="text-[10px] text-primary">{Math.round(selectedObject.fillProps.height * 100)}%</span>
</div>
<input
type="range"
min="0" max="1" step="0.01"
value={selectedObject.fillProps.height}
onChange={(e) => {
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"
/>
</div>
<div className="flex items-center justify-between mt-2">
<span className="text-[10px] text-zinc-400">Fill Color</span>
<div className="relative w-5 h-5">
<div
className="w-5 h-5 rounded border border-zinc-600 pointer-events-none absolute right-0"
style={{ backgroundColor: selectedObject.fillProps.color }}
/>
<input
type="color"
value={selectedObject.fillProps.color}
onChange={(e) => {
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"
/>
</div>
</div>
</>
)}
</div>
{/* Color Picker */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Diffuse Color</span>
<div
className="w-6 h-6 rounded border border-zinc-700"
style={{ backgroundColor: selectedObject.materialProps?.color || '#ffffff' }}
/>
</div>
<input
type="color"
value={selectedObject.materialProps?.color || '#ffffff'}
onChange={(e) => updateObjectMaterial(selectedObject.id, { color: e.target.value })}
className="w-full h-8 bg-zinc-800 border-none rounded cursor-pointer"
/>
</div>
{/* Texture Mapping */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Texture Map</span>
{selectedObject.materialProps?.map ? (
<button
onClick={removeTexture}
className="text-[9px] text-red-400 hover:text-red-300 transition-colors"
>
Remove
</button>
) : (
<span className="text-[9px] text-zinc-600 italic">None</span>
)}
</div>
<div
onClick={() => 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 ? (
<img
src={selectedObject.materialProps.map}
alt="Texture Preview"
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
) : (
<>
<ImageIcon size={20} className="text-zinc-600" />
<span className="text-[10px] text-zinc-500">Upload Texture</span>
</>
)}
</div>
<input
type="file"
ref={textureInputRef}
onChange={handleTextureUpload}
accept="image/*"
className="hidden"
/>
</div>
{/* UV Scale & Mapping */}
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">Mapping</span>
<div className="h-px bg-zinc-800 w-full" />
</div>
<div className="flex flex-col gap-2">
<button
onClick={() => setShowUVEditor(true)}
className="w-full py-2 bg-blue-500 hover:bg-blue-600 text-white font-bold rounded shadow shadow-blue-500/20 transition-colors flex items-center justify-center gap-2"
>
<ImageIcon size={14} />
<span>UV Map</span>
</button>
</div>
{/* UV Projection Dropdown */}
<div className="flex flex-col gap-2">
<span className="text-[10px] text-zinc-400">UV Projection</span>
<select
value={selectedObject.materialProps?.mappingType || 'uv'}
onChange={e => updateObjectMaterial(selectedObject.id, { mappingType: e.target.value as any })}
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg py-2 px-3 text-xs text-zinc-300 outline-none focus:border-blue-500 transition-colors"
>
<option value="uv">UV (Default)</option>
<option value="planar">Planar</option>
<option value="box">Box</option>
<option value="cylinder">Cylinder</option>
<option value="triplanar">Triplanar</option>
</select>
</div>
{/* UV Offset */}
<div className="flex flex-col gap-2">
<span className="text-[10px] text-zinc-400">Offset (X, Y)</span>
<div className="flex gap-2">
<input type="number" step="0.1" value={selectedObject.materialProps?.uvOffset?.[0] ?? 0}
onChange={e => 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" />
<input type="number" step="0.1" value={selectedObject.materialProps?.uvOffset?.[1] ?? 0}
onChange={e => 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" />
</div>
</div>
{/* UV Scale / Tiling */}
<div className="flex flex-col gap-2">
<span className="text-[10px] text-zinc-400">Scale / Tiling (X, Y)</span>
<div className="flex gap-2">
<input type="number" step="0.1" value={selectedObject.materialProps?.uvTiling?.[0] ?? 1}
onChange={e => 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" />
<input type="number" step="0.1" value={selectedObject.materialProps?.uvTiling?.[1] ?? 1}
onChange={e => 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" />
</div>
</div>
{/* UV Rotation */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Rotation</span>
<span className="text-[10px] font-mono text-blue-400">{(selectedObject.materialProps?.uvRotation ?? 0).toFixed(0)}°</span>
</div>
<input type="range" min="0" max="360" step="1" value={selectedObject.materialProps?.uvRotation ?? 0}
onChange={e => 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" />
</div>
</div>
{/* Roughness */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Roughness</span>
<span className="text-[10px] font-mono text-blue-400">{(selectedObject.materialProps?.roughness ?? 0.5).toFixed(2)}</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.01"
value={selectedObject.materialProps?.roughness ?? 0.5}
onChange={(e) => 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"
/>
</div>
{/* Metalness */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Metalness</span>
<span className="text-[10px] font-mono text-blue-400">{(selectedObject.materialProps?.metalness ?? 0.5).toFixed(2)}</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.01"
value={selectedObject.materialProps?.metalness ?? 0.5}
onChange={(e) => 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"
/>
</div>
{/* Clearcoat */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Clearcoat</span>
<span className="text-[10px] font-mono text-blue-400">{(selectedObject.materialProps?.clearcoat ?? 0).toFixed(2)}</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.01"
value={selectedObject.materialProps?.clearcoat ?? 0}
onChange={(e) => 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"
/>
</div>
{/* Transmission */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Transmission</span>
<span className="text-[10px] font-mono text-blue-400">{(selectedObject.materialProps?.transmission ?? 0).toFixed(2)}</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.01"
value={selectedObject.materialProps?.transmission ?? 0}
onChange={(e) => 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"
/>
</div>
{/* IOR */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Index of Refraction</span>
<span className="text-[10px] font-mono text-blue-400">{(selectedObject.materialProps?.ior ?? 1.5).toFixed(2)}</span>
</div>
<input
type="range"
min="1"
max="2.3"
step="0.01"
value={selectedObject.materialProps?.ior ?? 1.5}
onChange={(e) => 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"
/>
</div>
{/* Thickness */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Thickness</span>
<span className="text-[10px] font-mono text-blue-400">{(selectedObject.materialProps?.thickness ?? 0).toFixed(2)}</span>
</div>
<input
type="range"
min="0"
max="5"
step="0.1"
value={selectedObject.materialProps?.thickness ?? 0}
onChange={(e) => 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"
/>
</div>
{/* Sheen */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Sheen</span>
<span className="text-[10px] font-mono text-blue-400">{(selectedObject.materialProps?.sheen ?? 0).toFixed(2)}</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.01"
value={selectedObject.materialProps?.sheen ?? 0}
onChange={(e) => 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"
/>
</div>
{/* Opacity */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Opacity</span>
<span className="text-[10px] font-mono text-blue-400">{(selectedObject.materialProps?.opacity ?? 1).toFixed(2)}</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.01"
value={selectedObject.materialProps?.opacity ?? 1}
onChange={(e) => 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"
/>
</div>
{/* Specular Intensity */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Specular Intensity</span>
<span className="text-[10px] font-mono text-blue-400">{(selectedObject.materialProps?.specularIntensity ?? 1).toFixed(2)}</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.01"
value={selectedObject.materialProps?.specularIntensity ?? 1}
onChange={(e) => 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"
/>
</div>
{/* Emissive */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Emissive Color</span>
<div
className="w-6 h-6 rounded border border-zinc-700"
style={{ backgroundColor: selectedObject.materialProps?.emissive || '#000000' }}
/>
</div>
<input
type="color"
value={selectedObject.materialProps?.emissive || '#000000'}
onChange={(e) => updateObjectMaterial(selectedObject.id, { emissive: e.target.value })}
className="w-full h-8 bg-zinc-800 border-none rounded cursor-pointer"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-400">Emissive Intensity</span>
<span className="text-[10px] font-mono text-blue-400">{(selectedObject.materialProps?.emissiveIntensity ?? 0).toFixed(2)}</span>
</div>
<input
type="range"
min="0"
max="10"
step="0.01"
value={selectedObject.materialProps?.emissiveIntensity ?? 0}
onChange={(e) => 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"
/>
</div>
{/* Advanced Maps */}
<div className="flex flex-col gap-4 pt-2">
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">Advanced Maps</span>
{/* Normal Map */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-400">Normal Map</span>
{selectedObject.materialProps?.normalMap && (
<button onClick={removeNormalMap} className="text-[9px] text-red-400">Remove</button>
)}
</div>
<div
onClick={() => 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"
)}
>
<span className="text-[9px] text-zinc-500">{selectedObject.materialProps?.normalMap ? "Normal Map Loaded" : "Upload Normal Map"}</span>
</div>
<input type="file" ref={normalInputRef} onChange={(e) => handleTextureUpload(e, 'normalMap')} accept="image/*" className="hidden" />
</div>
{/* Specular Map */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-400">Specular Map</span>
{selectedObject.materialProps?.specularMap && (
<button onClick={removeSpecularMap} className="text-[9px] text-red-400">Remove</button>
)}
</div>
<div
onClick={() => 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"
)}
>
<span className="text-[9px] text-zinc-500">{selectedObject.materialProps?.specularMap ? "Specular Map Loaded" : "Upload Specular Map"}</span>
</div>
<input type="file" ref={specularInputRef} onChange={(e) => handleTextureUpload(e, 'specularMap')} accept="image/*" className="hidden" />
</div>
{/* Alpha Map */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-400">Alpha Map</span>
{selectedObject.materialProps?.alphaMap && (
<button onClick={removeAlphaMap} className="text-[9px] text-red-400">Remove</button>
)}
</div>
<div
onClick={() => 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"
)}
>
<span className="text-[9px] text-zinc-500">{selectedObject.materialProps?.alphaMap ? "Alpha Map Loaded" : "Upload Alpha Map"}</span>
</div>
<input type="file" ref={alphaInputRef} onChange={(e) => handleTextureUpload(e, 'alphaMap')} accept="image/*" className="hidden" />
</div>
</div>
<div className="mt-auto pt-4 flex flex-col gap-2">
<button
onClick={() => updateObjectMaterial(selectedObject.id, globalMaterialProps)}
className="w-full py-1.5 bg-zinc-800 hover:bg-zinc-700 rounded text-[10px] font-medium transition-colors"
>
Reset to Default
</button>
</div>
</>
)}
</div>
)}
<div className="h-32 border-t border-zinc-800 flex flex-col">
<div className="flex items-center justify-between p-2 border-b border-zinc-800">
<span className="text-[10px] font-bold text-zinc-400">Scene Information</span>
<ChevronDown size={12} className="text-zinc-500" />
</div>
<div className="flex-1 p-2 text-[9px] text-zinc-500 flex flex-col gap-1">
<div className="flex justify-between">
<span>Triangles:</span>
<span>{loadedModel ? "Dynamic" : "12,452"}</span>
</div>
<div className="flex justify-between">
<span>Vertices:</span>
<span>{loadedModel ? "Dynamic" : "8,210"}</span>
</div>
<div className="flex justify-between">
<span>Objects:</span>
<span>1</span>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Bottom Toolbar */}
<div className="h-12 bg-zinc-900 border-t border-zinc-800 flex items-center justify-between px-4">
<div className="flex items-center gap-6">
<button
onClick={() => fileInputRef.current?.click()}
className="flex flex-col items-center gap-0.5 text-zinc-500 hover:text-zinc-300 transition-colors"
>
<Import size={18} />
<span className="text-[9px]">Import</span>
</button>
<button className="flex flex-col items-center gap-0.5 text-blue-400">
<Library size={18} />
<span className="text-[9px]">Library</span>
</button>
<button className="flex flex-col items-center gap-0.5 text-zinc-500 hover:text-zinc-300">
<Layout size={18} />
<span className="text-[9px]">Project</span>
</button>
<button className="flex flex-col items-center gap-0.5 text-zinc-500 hover:text-zinc-300">
<Video size={18} />
<span className="text-[9px]">Animation</span>
</button>
<button className="flex flex-col items-center gap-0.5 text-zinc-500 hover:text-zinc-300">
<Share2 size={18} />
<span className="text-[9px]">Export</span>
</button>
<button className="flex flex-col items-center gap-0.5 text-zinc-500 hover:text-zinc-300">
<Box size={18} />
<span className="text-[9px]">VIZ3D XR</span>
</button>
<button className="flex flex-col items-center gap-0.5 text-zinc-500 hover:text-zinc-300">
<Smartphone size={18} />
<span className="text-[9px]">VR</span>
</button>
<button
onClick={() => setShowRenderModal(true)}
className="flex flex-col items-center gap-0.5 text-zinc-500 hover:text-zinc-300 transition-colors"
>
<ImageIcon size={18} />
<span className="text-[9px]">Render</span>
</button>
<button
onClick={() => {
const canvas = document.querySelector('canvas');
if (canvas) {
setVizAiBaseImage(canvas.toDataURL("image/png"));
setShowVizAi(true);
}
}}
className="flex flex-col items-center gap-0.5 text-blue-500 hover:text-blue-400 transition-colors"
>
<Sparkles size={18} />
<span className="text-[9px]">Viz AI</span>
</button>
</div>
<div className="flex items-center gap-2">
<div className="text-[10px] text-zinc-500 mr-4">
Cloud Library | VIZ3D Hub
</div>
<button className="p-1 hover:bg-zinc-800 rounded text-zinc-500">
<Maximize2 size={16} />
</button>
</div>
</div>
{/* Context Menu */}
<AnimatePresence>
{contextMenu && (
<>
<div
className="fixed inset-0 z-[100]"
onClick={() => setContextMenu(null)}
onContextMenu={(e) => { e.preventDefault(); setContextMenu(null); }}
/>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="fixed z-[101] bg-zinc-900 border border-zinc-800 rounded-lg shadow-2xl py-1 min-w-[160px]"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-[10px] text-zinc-300 hover:bg-blue-500 hover:text-white transition-colors"
onClick={() => {
const obj = sceneObjects.find(o => o.id === contextMenu.objectId);
if (obj) {
setClipboard({
materialProps: obj.materialProps ? { ...obj.materialProps } : undefined,
transformProps: obj.transformProps ? { ...obj.transformProps } : undefined
});
}
setContextMenu(null);
}}
>
<Copy size={12} />
<span>Copy All</span>
</button>
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-[10px] text-zinc-300 hover:bg-blue-500 hover:text-white transition-colors"
onClick={() => {
const obj = sceneObjects.find(o => o.id === contextMenu.objectId);
if (obj && obj.materialProps) {
setClipboard({ materialProps: { ...obj.materialProps } });
}
setContextMenu(null);
}}
>
<Palette size={12} />
<span>Copy Material</span>
</button>
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-[10px] text-zinc-300 hover:bg-blue-500 hover:text-white transition-colors"
onClick={() => {
const obj = sceneObjects.find(o => o.id === contextMenu.objectId);
if (obj && obj.transformProps) {
setClipboard({ transformProps: { ...obj.transformProps } });
}
setContextMenu(null);
}}
>
<Maximize2 size={12} />
<span>Copy Transform</span>
</button>
<div className="h-px bg-zinc-800 my-1" />
<button
disabled={!clipboard}
className="w-full flex items-center gap-2 px-3 py-1.5 text-[10px] text-zinc-300 hover:bg-blue-500 hover:text-white disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-zinc-500 transition-colors"
onClick={() => {
if (clipboard) {
if (clipboard.materialProps) updateObjectMaterial(contextMenu.objectId, clipboard.materialProps);
if (clipboard.transformProps) updateObjectTransform(contextMenu.objectId, clipboard.transformProps);
}
setContextMenu(null);
}}
>
<Clipboard size={12} />
<span>Paste All</span>
</button>
<button
disabled={!clipboard?.materialProps}
className="w-full flex items-center gap-2 px-3 py-1.5 text-[10px] text-zinc-300 hover:bg-blue-500 hover:text-white disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-zinc-500 transition-colors"
onClick={() => {
if (clipboard?.materialProps) updateObjectMaterial(contextMenu.objectId, clipboard.materialProps);
setContextMenu(null);
}}
>
<Palette size={12} />
<span>Paste Material</span>
</button>
<button
disabled={!clipboard?.transformProps}
className="w-full flex items-center gap-2 px-3 py-1.5 text-[10px] text-zinc-300 hover:bg-blue-500 hover:text-white disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-zinc-500 transition-colors"
onClick={() => {
if (clipboard?.transformProps) updateObjectTransform(contextMenu.objectId, clipboard.transformProps);
setContextMenu(null);
}}
>
<Maximize2 size={12} />
<span>Paste Transform</span>
</button>
</motion.div>
</>
)}
</AnimatePresence>
{/* UV Editor Overlay */}
{showUVEditor && selectedObject && (
<UVEditor
onClose={() => 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}
/>
)}
<ImageTo3DModal
isOpen={showImageTo3D}
onClose={() => 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<string>((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("");
}
}}
/>
</div>
);
}