4883 lines
231 KiB
TypeScript
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 — 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>
|
|
);
|
|
}
|