Spaces:
Sleeping
Sleeping
| import React, { useRef } from 'react'; | |
| import { useStudioStore } from '../store/useStudioStore'; | |
| import './RightPanel.css'; | |
| const SliderRow: React.FC<{ | |
| label: string; | |
| value: number; | |
| min: number; | |
| max: number; | |
| step?: number; | |
| onChange: (v: number) => void; | |
| }> = ({ label, value, min, max, step = 0.01, onChange }) => ( | |
| <div className="prop-row"> | |
| <label>{label}</label> | |
| <input | |
| type="range" | |
| min={min} | |
| max={max} | |
| step={step} | |
| value={value} | |
| onChange={(e) => onChange(parseFloat(e.target.value))} | |
| /> | |
| <span className="prop-val">{value.toFixed(2)}</span> | |
| </div> | |
| ); | |
| const Vec3Row: React.FC<{ | |
| label: string; | |
| value: [number, number, number]; | |
| step?: number; | |
| onChange: (v: [number, number, number]) => void; | |
| }> = ({ label, value, step = 0.1, onChange }) => ( | |
| <div className="vec3-row"> | |
| <label className="vec3-label">{label}</label> | |
| <div className="vec3-inputs"> | |
| {(['X', 'Y', 'Z'] as const).map((axis, i) => ( | |
| <div key={axis} className="vec3-item"> | |
| <span className={`vec3-axis axis-${axis.toLowerCase()}`}>{axis}</span> | |
| <input | |
| type="number" | |
| step={step} | |
| value={value[i].toFixed(2)} | |
| onChange={(e) => { | |
| const v: [number, number, number] = [...value] as any; | |
| v[i] = parseFloat(e.target.value) || 0; | |
| onChange(v); | |
| }} | |
| /> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| ); | |
| const RightPanel: React.FC = () => { | |
| const { | |
| mode, objects, selectedId, updateObject, | |
| ambientIntensity, directionalIntensity, bgColor, showGrid, showAxes, | |
| bloomIntensity, postProcessing, | |
| skyboxType, skyboxUrl, | |
| setAmbientIntensity, setDirectionalIntensity, setBgColor, | |
| setShowGrid, setShowAxes, setBloomIntensity, setPostProcessing, | |
| setSkybox, | |
| } = useStudioStore(); | |
| const skyFileRef = useRef<HTMLInputElement>(null); | |
| const texFileRef = useRef<HTMLInputElement>(null); | |
| const selectedObj = objects.find(o => o.id === selectedId); | |
| const update = (patch: any) => { | |
| if (selectedId) updateObject(selectedId, patch); | |
| }; | |
| return ( | |
| <div className="right-panel"> | |
| {/* Object Properties */} | |
| {selectedObj && ( | |
| <div className="prop-section"> | |
| <div className="section-title"> | |
| <span className="section-icon">โ</span> | |
| {selectedObj.name} | |
| </div> | |
| <Vec3Row | |
| label="Position" | |
| value={selectedObj.position} | |
| onChange={(v) => update({ position: v })} | |
| /> | |
| <Vec3Row | |
| label="Rotation" | |
| value={selectedObj.rotation} | |
| step={1} | |
| onChange={(v) => update({ rotation: v })} | |
| /> | |
| <Vec3Row | |
| label="Scale" | |
| value={selectedObj.scale} | |
| step={0.05} | |
| onChange={(v) => update({ scale: v })} | |
| /> | |
| <div className="prop-divider" /> | |
| <div className="prop-row"> | |
| <label>Color</label> | |
| <input | |
| type="color" | |
| value={selectedObj.color} | |
| onChange={(e) => update({ color: e.target.value })} | |
| className="color-input" | |
| /> | |
| <span className="prop-val">{selectedObj.color}</span> | |
| </div> | |
| <SliderRow label="Metalness" value={selectedObj.metalness} min={0} max={1} onChange={(v) => update({ metalness: v })} /> | |
| <SliderRow label="Roughness" value={selectedObj.roughness} min={0} max={1} onChange={(v) => update({ roughness: v })} /> | |
| <SliderRow label="Reflection" value={selectedObj.envMapIntensity} min={0} max={3} onChange={(v) => update({ envMapIntensity: v })} /> | |
| <div className="prop-divider" /> | |
| <div className="texture-section"> | |
| <label className="section-sublabel">TEXTURE MAP</label> | |
| <button | |
| className="btn-small" | |
| onClick={() => texFileRef.current?.click()} | |
| > | |
| {selectedObj.textureUrl ? 'โ Texture Loaded' : '+ Upload Texture'} | |
| </button> | |
| {selectedObj.textureUrl && ( | |
| <button className="btn-small btn-danger" onClick={() => update({ textureUrl: undefined })}> | |
| Remove | |
| </button> | |
| )} | |
| <input | |
| ref={texFileRef} | |
| type="file" | |
| accept="image/*" | |
| style={{ display: 'none' }} | |
| onChange={(e) => { | |
| const file = e.target.files?.[0]; | |
| if (file) update({ textureUrl: URL.createObjectURL(file) }); | |
| }} | |
| /> | |
| </div> | |
| </div> | |
| )} | |
| {/* Scene / Rendering */} | |
| <div className="prop-section"> | |
| <div className="section-title"> | |
| <span className="section-icon">โ</span> | |
| LIGHTING & SCENE | |
| </div> | |
| <SliderRow label="Ambient" value={ambientIntensity} min={0} max={3} onChange={setAmbientIntensity} /> | |
| <SliderRow label="Directional" value={directionalIntensity} min={0} max={5} onChange={setDirectionalIntensity} /> | |
| <div className="prop-row"> | |
| <label>BG Color</label> | |
| <input type="color" value={bgColor} onChange={(e) => setBgColor(e.target.value)} className="color-input" /> | |
| </div> | |
| <div className="toggle-row"> | |
| <label>Grid</label> | |
| <button className={`toggle-btn ${showGrid ? 'on' : ''}`} onClick={() => setShowGrid(!showGrid)}> | |
| {showGrid ? 'ON' : 'OFF'} | |
| </button> | |
| </div> | |
| <div className="toggle-row"> | |
| <label>Axes</label> | |
| <button className={`toggle-btn ${showAxes ? 'on' : ''}`} onClick={() => setShowAxes(!showAxes)}> | |
| {showAxes ? 'ON' : 'OFF'} | |
| </button> | |
| </div> | |
| <div className="toggle-row"> | |
| <label>Bloom</label> | |
| <button className={`toggle-btn ${postProcessing ? 'on' : ''}`} onClick={() => setPostProcessing(!postProcessing)}> | |
| {postProcessing ? 'ON' : 'OFF'} | |
| </button> | |
| </div> | |
| {postProcessing && ( | |
| <SliderRow label="Bloom Int." value={bloomIntensity} min={0} max={3} onChange={setBloomIntensity} /> | |
| )} | |
| </div> | |
| {/* Skybox */} | |
| <div className="prop-section"> | |
| <div className="section-title"> | |
| <span className="section-icon">โฌก</span> | |
| SKYBOX | |
| </div> | |
| <div className="skybox-btns"> | |
| {(['none', 'gradient', 'uploaded'] as const).map((t) => ( | |
| <button | |
| key={t} | |
| className={`sky-btn ${skyboxType === t ? 'active' : ''}`} | |
| onClick={() => setSkybox(t, t !== 'uploaded' ? undefined : skyboxUrl || undefined)} | |
| > | |
| {t.toUpperCase()} | |
| </button> | |
| ))} | |
| </div> | |
| {skyboxType === 'uploaded' && ( | |
| <button className="btn-small" onClick={() => skyFileRef.current?.click()}> | |
| {skyboxUrl ? 'โ HDR/JPG Loaded' : '+ Upload HDR/JPG'} | |
| </button> | |
| )} | |
| <input | |
| ref={skyFileRef} | |
| type="file" | |
| accept=".hdr,.jpg,.jpeg,.png" | |
| style={{ display: 'none' }} | |
| onChange={(e) => { | |
| const file = e.target.files?.[0]; | |
| if (file) setSkybox('uploaded', URL.createObjectURL(file)); | |
| }} | |
| /> | |
| </div> | |
| {/* Animation panel */} | |
| {selectedObj && selectedObj.animations.length > 0 && mode === 'animate' && ( | |
| <div className="prop-section"> | |
| <div className="section-title"> | |
| <span className="section-icon">โท</span> | |
| ANIMATION | |
| </div> | |
| <div className="anim-list"> | |
| {selectedObj.animations.map((anim, i) => ( | |
| <div | |
| key={i} | |
| className={`anim-item ${selectedObj.selectedAnimIndex === i ? 'active' : ''}`} | |
| onClick={() => update({ selectedAnimIndex: i })} | |
| > | |
| <span className="anim-name">{anim.name}</span> | |
| <span className="anim-dur">{anim.duration.toFixed(1)}s</span> | |
| </div> | |
| ))} | |
| </div> | |
| <div className="anim-controls"> | |
| <button | |
| className={`play-btn ${selectedObj.animPlaying ? 'playing' : ''}`} | |
| onClick={() => update({ animPlaying: !selectedObj.animPlaying })} | |
| > | |
| {selectedObj.animPlaying ? 'โธ Pause' : 'โถ Play'} | |
| </button> | |
| </div> | |
| <SliderRow label="Speed" value={selectedObj.animSpeed} min={0.1} max={3} step={0.1} | |
| onChange={(v) => update({ animSpeed: v })} /> | |
| <div className="toggle-row"> | |
| <label>Loop</label> | |
| <button | |
| className={`toggle-btn ${selectedObj.animLoop ? 'on' : ''}`} | |
| onClick={() => update({ animLoop: !selectedObj.animLoop })} | |
| > | |
| {selectedObj.animLoop ? 'ON' : 'OFF'} | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| export default RightPanel; | |