Spaces:
Sleeping
Sleeping
| import React, { Suspense, useRef, useEffect } from 'react'; | |
| import { Canvas, useThree } from '@react-three/fiber'; | |
| import { | |
| OrbitControls, Grid, GizmoHelper, | |
| GizmoViewport, Sky, Stars, TransformControls, | |
| } from '@react-three/drei'; | |
| import * as THREE from 'three'; | |
| import { useStudioStore } from '../store/useStudioStore'; | |
| import { Model, ModelHandle } from './Model'; | |
| import './Viewport.css'; | |
| // โโ One model + its optional TransformControls โโโโโโโโโโโโโโโโ | |
| const ModelWithGizmo: React.FC<{ | |
| obj: any; | |
| isSelected: boolean; | |
| gizmoMode: 'translate' | 'rotate' | 'scale'; | |
| onOrbitEnable: (v: boolean) => void; | |
| }> = ({ obj, isSelected, gizmoMode, onOrbitEnable }) => { | |
| const modelRef = useRef<ModelHandle>(null); | |
| const { updateObject, setSelectedId } = useStudioStore(); | |
| const syncTransform = () => { | |
| const g = modelRef.current?.group; | |
| if (!g) return; | |
| updateObject(obj.id, { | |
| position: [+g.position.x.toFixed(3), +g.position.y.toFixed(3), +g.position.z.toFixed(3)], | |
| rotation: [ | |
| +THREE.MathUtils.radToDeg(g.rotation.x).toFixed(2), | |
| +THREE.MathUtils.radToDeg(g.rotation.y).toFixed(2), | |
| +THREE.MathUtils.radToDeg(g.rotation.z).toFixed(2), | |
| ], | |
| scale: [+g.scale.x.toFixed(3), +g.scale.y.toFixed(3), +g.scale.z.toFixed(3)], | |
| }); | |
| }; | |
| return ( | |
| <> | |
| <Model | |
| ref={modelRef} | |
| obj={obj} | |
| isSelected={isSelected} | |
| onClick={() => setSelectedId(obj.id)} | |
| /> | |
| {isSelected && modelRef.current?.group && ( | |
| <TransformControls | |
| object={modelRef.current.group} | |
| mode={gizmoMode} | |
| size={0.9} | |
| onMouseDown={() => onOrbitEnable(false)} | |
| onMouseUp={() => { onOrbitEnable(true); syncTransform(); }} | |
| onChange={syncTransform} | |
| /> | |
| )} | |
| </> | |
| ); | |
| }; | |
| // โโ HDR / equirectangular skybox โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| const SkyboxHDR: React.FC<{ url: string }> = ({ url }) => { | |
| const { scene } = useThree(); | |
| useEffect(() => { | |
| new THREE.TextureLoader().load(url, (tex) => { | |
| tex.mapping = THREE.EquirectangularReflectionMapping; | |
| scene.background = tex; | |
| scene.environment = tex; | |
| }); | |
| }, [url, scene]); | |
| return null; | |
| }; | |
| // โโ Main scene content โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| const SceneContent: React.FC = () => { | |
| const { | |
| objects, selectedId, | |
| ambientIntensity, directionalIntensity, | |
| showGrid, showAxes, | |
| skyboxType, skyboxUrl, bgColor, | |
| mode, gizmoMode, | |
| } = useStudioStore(); | |
| const { scene } = useThree(); | |
| const orbitRef = useRef<any>(null); | |
| useEffect(() => { | |
| if (skyboxType !== 'uploaded') | |
| scene.background = new THREE.Color(bgColor); | |
| }, [bgColor, skyboxType, scene]); | |
| const setOrbit = (v: boolean) => { | |
| if (orbitRef.current) orbitRef.current.enabled = v; | |
| }; | |
| return ( | |
| <> | |
| <ambientLight intensity={ambientIntensity} /> | |
| <directionalLight | |
| position={[5, 10, 5]} | |
| intensity={directionalIntensity} | |
| castShadow | |
| shadow-mapSize={[2048, 2048]} | |
| /> | |
| <directionalLight position={[-5, 5, -5]} intensity={directionalIntensity * 0.3} /> | |
| {skyboxType === 'gradient' && ( | |
| <Sky sunPosition={[100, 20, 100]} turbidity={8} rayleigh={2} /> | |
| )} | |
| {skyboxType === 'uploaded' && skyboxUrl && <SkyboxHDR url={skyboxUrl} />} | |
| {mode === 'render' && ( | |
| <Stars radius={100} depth={50} count={3000} factor={4} /> | |
| )} | |
| <Suspense fallback={null}> | |
| {objects.map((obj) => ( | |
| <ModelWithGizmo | |
| key={obj.id} | |
| obj={obj} | |
| isSelected={selectedId === obj.id} | |
| gizmoMode={gizmoMode} | |
| onOrbitEnable={setOrbit} | |
| /> | |
| ))} | |
| </Suspense> | |
| {showGrid && ( | |
| <Grid | |
| position={[0, -0.01, 0]} | |
| args={[20, 20]} | |
| cellSize={1} | |
| cellThickness={0.5} | |
| cellColor="#444466" | |
| sectionSize={5} | |
| sectionThickness={1} | |
| sectionColor="#6666aa" | |
| fadeDistance={30} | |
| infiniteGrid | |
| /> | |
| )} | |
| {showAxes && <axesHelper args={[5]} />} | |
| <OrbitControls ref={orbitRef} makeDefault /> | |
| <GizmoHelper alignment="bottom-right" margin={[80, 80]}> | |
| <GizmoViewport | |
| axisColors={['#f06292', '#66bb6a', '#42a5f5']} | |
| labelColor="white" | |
| /> | |
| </GizmoHelper> | |
| </> | |
| ); | |
| }; | |
| // โโ Viewport root โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| const Viewport: React.FC = () => { | |
| const { gizmoMode, setGizmoMode, mode } = useStudioStore(); | |
| return ( | |
| <div className="viewport"> | |
| {/* Floating gizmo mode toolbar โ only in MODEL mode */} | |
| {mode === 'model' && ( | |
| <div className="gizmo-toolbar"> | |
| {([ | |
| { id: 'translate', icon: 'โ', label: 'Move', key: 'G' }, | |
| { id: 'rotate', icon: 'โป', label: 'Rotate', key: 'R' }, | |
| { id: 'scale', icon: 'โคข', label: 'Scale', key: 'S' }, | |
| ] as const).map((g) => ( | |
| <button | |
| key={g.id} | |
| className={`gizmo-btn ${gizmoMode === g.id ? 'active' : ''}`} | |
| onClick={() => setGizmoMode(g.id)} | |
| title={`${g.label} (${g.key})`} | |
| > | |
| <span className="gizmo-icon">{g.icon}</span> | |
| <span>{g.label}</span> | |
| <span className="gizmo-key">{g.key}</span> | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| <Canvas | |
| shadows | |
| camera={{ position: [5, 5, 5], fov: 50 }} | |
| gl={{ | |
| antialias: true, | |
| toneMapping: THREE.ACESFilmicToneMapping, | |
| toneMappingExposure: 1, | |
| preserveDrawingBuffer: true, // required for video recording | |
| }} | |
| onPointerMissed={() => useStudioStore.getState().setSelectedId(null)} | |
| > | |
| <SceneContent /> | |
| </Canvas> | |
| </div> | |
| ); | |
| }; | |
| export default Viewport; |