"use client"; import { useEffect, useRef, useCallback, useState } from "react"; import * as THREE from "three"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { RotateCcw } from "lucide-react"; import { cn } from "@/lib/utils"; // 相机状态接口 export interface CameraState { azimuth: number; // 方位角 0-360 elevation: number; // 仰角 -30 到 60 distance: number; // 距离 0-10 } interface QwenCameraWidgetProps { initialState?: Partial; imageUrl?: string | null; onStateChange?: (state: CameraState) => void; className?: string; } // 下拉选项定义 const AZIMUTH_OPTIONS = [ { label: "正前方", value: 0 }, { label: "右前方", value: 45 }, { label: "正右方", value: 90 }, { label: "右后方", value: 135 }, { label: "正后方", value: 180 }, { label: "左后方", value: 225 }, { label: "正左方", value: 270 }, { label: "左前方", value: 315 }, ]; const ELEVATION_OPTIONS = [ { label: "低角度", value: -30 }, { label: "平视", value: 0 }, { label: "微俯视", value: 30 }, { label: "高角度", value: 60 }, ]; const DISTANCE_OPTIONS = [ { label: "远景", value: 1 }, { label: "中景", value: 5 }, { label: "近景", value: 8 }, ]; export function QwenCameraWidget({ initialState, imageUrl, onStateChange, className, }: QwenCameraWidgetProps) { const containerRef = useRef(null); const canvasRef = useRef(null); const sceneRef = useRef(null); const cameraRef = useRef(null); const rendererRef = useRef(null); const animationIdRef = useRef(null); // 控制对象引用 const azimuthHandleRef = useRef(null); const elevationHandleRef = useRef(null); const distanceHandleRef = useRef(null); const cameraIndicatorRef = useRef(null); const azimuthRingRef = useRef(null); const elevationArcRef = useRef(null); const imageCardRef = useRef(null); // 状态 const [state, setState] = useState({ azimuth: initialState?.azimuth ?? 45, elevation: initialState?.elevation ?? 0, distance: initialState?.distance ?? 5, }); // 交互状态 const isDraggingRef = useRef(false); const dragTargetRef = useRef(null); const raycasterRef = useRef(new THREE.Raycaster()); const mouseRef = useRef(new THREE.Vector2()); // 常量 const CENTER = new THREE.Vector3(0, 0.5, 0); const AZIMUTH_RADIUS = 1.8; const ELEVATION_RADIUS = 1.4; const ELEV_ARC_X = -0.8; // 生成提示词 const generatePrompt = useCallback((s: CameraState) => { const hAngle = s.azimuth % 360; let hDirection: string; if (hAngle < 22.5 || hAngle >= 337.5) hDirection = "front view"; else if (hAngle < 67.5) hDirection = "front-right quarter view"; else if (hAngle < 112.5) hDirection = "right side view"; else if (hAngle < 157.5) hDirection = "back-right quarter view"; else if (hAngle < 202.5) hDirection = "back view"; else if (hAngle < 247.5) hDirection = "back-left quarter view"; else if (hAngle < 292.5) hDirection = "left side view"; else hDirection = "front-left quarter view"; let vDirection: string; if (s.elevation < -15) vDirection = "low-angle shot"; else if (s.elevation < 15) vDirection = "eye-level shot"; else if (s.elevation < 45) vDirection = "elevated shot"; else vDirection = "high-angle shot"; let distance: string; if (s.distance < 3) distance = "wide shot"; else if (s.distance < 7) distance = "medium shot"; else distance = "close-up"; return ` ${hDirection} ${vDirection} ${distance}`; }, []); // 更新场景中的对象位置 const updateVisuals = useCallback((s: CameraState) => { const azRad = (s.azimuth * Math.PI) / 180; const elRad = (s.elevation * Math.PI) / 180; const visualDist = 2.6 - (s.distance / 10) * 2.0; // 相机指示器位置 if (cameraIndicatorRef.current) { const camX = visualDist * Math.sin(azRad) * Math.cos(elRad); const camY = CENTER.y + visualDist * Math.sin(elRad); const camZ = visualDist * Math.cos(azRad) * Math.cos(elRad); cameraIndicatorRef.current.position.set(camX, camY, camZ); cameraIndicatorRef.current.lookAt(CENTER); cameraIndicatorRef.current.rotateX(Math.PI / 2); } // 方位角手柄位置 if (azimuthHandleRef.current) { const azX = AZIMUTH_RADIUS * Math.sin(azRad); const azZ = AZIMUTH_RADIUS * Math.cos(azRad); azimuthHandleRef.current.position.set(azX, 0.16, azZ); } // 仰角手柄位置 if (elevationHandleRef.current) { const elY = CENTER.y + ELEVATION_RADIUS * Math.sin(elRad); const elZ = ELEVATION_RADIUS * Math.cos(elRad); elevationHandleRef.current.position.set(ELEV_ARC_X, elY, elZ); } // 距离手柄位置 if (distanceHandleRef.current && cameraIndicatorRef.current) { const distT = 0.15 + ((10 - s.distance) / 10) * 0.7; distanceHandleRef.current.position.lerpVectors( CENTER, cameraIndicatorRef.current.position, distT ); } }, []); // 初始化 Three.js 场景 useEffect(() => { if (!canvasRef.current || !containerRef.current) return; const container = containerRef.current; const width = container.clientWidth || 400; const height = container.clientHeight || 400; // 场景 const scene = new THREE.Scene(); // 移除背景色,使用透明背景 // scene.background = new THREE.Color(0x0a0a0f); sceneRef.current = scene; // 相机 const camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 1000); // 调整相机位置和注视点 // position: 继续拉近距离,让主体显示得更大 (从 4.8 改为 3.8) // lookAt Y: 稍微上调注视点,避免底部被切掉 camera.position.set(3.8, 3.8, 3.8); camera.lookAt(0, -1.2, 0); cameraRef.current = camera; // 渲染器 const renderer = new THREE.WebGLRenderer({ canvas: canvasRef.current, antialias: true, alpha: true, // 启用透明 }); renderer.setSize(width, height); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.outputColorSpace = THREE.SRGBColorSpace; rendererRef.current = renderer; // 光照 const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); scene.add(ambientLight); const mainLight = new THREE.DirectionalLight(0xffffff, 1.0); mainLight.position.set(5, 10, 5); scene.add(mainLight); const fillLight = new THREE.DirectionalLight(0xE93D82, 0.4); fillLight.position.set(-5, 5, -5); scene.add(fillLight); // 网格 - 调整颜色以适应浅色/深色主题 // 使用更中性的颜色 const gridHelper = new THREE.GridHelper(5, 20, 0x888888, 0x444444); gridHelper.position.y = -0.01; (gridHelper.material as THREE.Material).transparent = true; (gridHelper.material as THREE.Material).opacity = 0.2; scene.add(gridHelper); // 创建图片卡片 const cardGeo = new THREE.BoxGeometry(1.2, 1.2, 0.02); const cardMat = new THREE.MeshBasicMaterial({ color: 0xcccccc }); const imageCard = new THREE.Mesh(cardGeo, cardMat); imageCard.position.copy(CENTER); scene.add(imageCard); imageCardRef.current = imageCard; // 卡片边框 const frameGeo = new THREE.EdgesGeometry(cardGeo); const frameMat = new THREE.LineBasicMaterial({ color: 0xE93D82 }); const frame = new THREE.LineSegments(frameGeo, frameMat); frame.position.copy(CENTER); scene.add(frame); // 方位角圆环 (粉色) const azRingGeo = new THREE.TorusGeometry(AZIMUTH_RADIUS, 0.04, 16, 100); const azRingMat = new THREE.MeshBasicMaterial({ color: 0xE93D82, transparent: true, opacity: 0.7, }); const azimuthRing = new THREE.Mesh(azRingGeo, azRingMat); azimuthRing.rotation.x = Math.PI / 2; azimuthRing.position.y = 0.02; scene.add(azimuthRing); azimuthRingRef.current = azimuthRing; // 方位角手柄 (粉色球) const azHandleGeo = new THREE.SphereGeometry(0.16, 32, 32); const azHandleMat = new THREE.MeshStandardMaterial({ color: 0xE93D82, emissive: 0xE93D82, emissiveIntensity: 0.6, metalness: 0.3, roughness: 0.4, }); const azimuthHandle = new THREE.Mesh(azHandleGeo, azHandleMat); azimuthHandle.userData.type = "azimuth"; scene.add(azimuthHandle); azimuthHandleRef.current = azimuthHandle; // 仰角轨道背景 (灰色全圆环,表示垂直旋转的轨道) const elTrackGeo = new THREE.TorusGeometry(ELEVATION_RADIUS, 0.02, 16, 100); const elTrackMat = new THREE.MeshBasicMaterial({ color: 0x444444, transparent: true, opacity: 0.3, }); const elevationTrack = new THREE.Mesh(elTrackGeo, elTrackMat); elevationTrack.rotation.y = Math.PI / 2; // 垂直于 X 轴 elevationTrack.position.x = ELEV_ARC_X; elevationTrack.position.y = CENTER.y; scene.add(elevationTrack); // 仰角弧线 (青色,表示有效范围) const arcPoints: THREE.Vector3[] = []; for (let i = 0; i <= 32; i++) { const angle = (-30 + (90 * i / 32)) * Math.PI / 180; arcPoints.push(new THREE.Vector3( ELEV_ARC_X, ELEVATION_RADIUS * Math.sin(angle) + CENTER.y, ELEVATION_RADIUS * Math.cos(angle) )); } const arcCurve = new THREE.CatmullRomCurve3(arcPoints); const elArcGeo = new THREE.TubeGeometry(arcCurve, 32, 0.04, 8, false); const elArcMat = new THREE.MeshBasicMaterial({ color: 0x00FFD0, transparent: true, opacity: 0.8, }); const elevationArc = new THREE.Mesh(elArcGeo, elArcMat); scene.add(elevationArc); elevationArcRef.current = elevationArc; // 仰角手柄 (青色球) const elHandleGeo = new THREE.SphereGeometry(0.16, 32, 32); const elHandleMat = new THREE.MeshStandardMaterial({ color: 0x00FFD0, emissive: 0x00FFD0, emissiveIntensity: 0.6, metalness: 0.3, roughness: 0.4, }); const elevationHandle = new THREE.Mesh(elHandleGeo, elHandleMat); elevationHandle.userData.type = "elevation"; scene.add(elevationHandle); elevationHandleRef.current = elevationHandle; // 距离手柄 (金色球) const distHandleGeo = new THREE.SphereGeometry(0.15, 32, 32); const distHandleMat = new THREE.MeshStandardMaterial({ color: 0xFFB800, emissive: 0xFFB800, emissiveIntensity: 0.7, metalness: 0.5, roughness: 0.3, }); const distanceHandle = new THREE.Mesh(distHandleGeo, distHandleMat); distanceHandle.userData.type = "distance"; scene.add(distanceHandle); distanceHandleRef.current = distanceHandle; // 相机指示器 (粉色锥体) const camGeo = new THREE.ConeGeometry(0.15, 0.4, 4); const camMat = new THREE.MeshStandardMaterial({ color: 0xE93D82, emissive: 0xE93D82, emissiveIntensity: 0.5, metalness: 0.8, roughness: 0.2, }); const cameraIndicator = new THREE.Mesh(camGeo, camMat); scene.add(cameraIndicator); cameraIndicatorRef.current = cameraIndicator; // 初始化位置 updateVisuals(state); // 动画循环 let time = 0; const animate = () => { animationIdRef.current = requestAnimationFrame(animate); time += 0.01; renderer.render(scene, camera); }; animate(); // 监听容器大小变化 const resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { const { width, height } = entry.contentRect; if (width && height && cameraRef.current && rendererRef.current) { cameraRef.current.aspect = width / height; cameraRef.current.updateProjectionMatrix(); rendererRef.current.setSize(width, height); } } }); resizeObserver.observe(container); // 清理 return () => { if (animationIdRef.current) { cancelAnimationFrame(animationIdRef.current); } resizeObserver.disconnect(); renderer.dispose(); scene.clear(); }; }, []); // 更新图片 useEffect(() => { if (!imageCardRef.current || !imageUrl) return; const loader = new THREE.TextureLoader(); loader.load( imageUrl, (texture) => { texture.colorSpace = THREE.SRGBColorSpace; const mat = imageCardRef.current!.material as THREE.MeshBasicMaterial; mat.map = texture; mat.color.set(0xffffff); mat.needsUpdate = true; // 调整卡片比例 const img = texture.image as HTMLImageElement; if (img) { const ar = img.width / img.height; const maxSize = 1.5; let scaleX: number, scaleY: number; if (ar > 1) { scaleX = maxSize; scaleY = maxSize / ar; } else { scaleY = maxSize; scaleX = maxSize * ar; } imageCardRef.current!.scale.set(scaleX, scaleY, 1); } }, undefined, () => { console.error("Failed to load image texture"); } ); }, [imageUrl]); // 更新视觉效果当状态变化 useEffect(() => { updateVisuals(state); }, [state, updateVisuals]); // 鼠标/触摸事件处理 const getMousePos = useCallback((event: MouseEvent | Touch, canvas: HTMLCanvasElement) => { const rect = canvas.getBoundingClientRect(); mouseRef.current.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; mouseRef.current.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; }, []); const handlePointerDown = useCallback((event: React.MouseEvent | React.TouchEvent) => { if (!canvasRef.current || !cameraRef.current) return; const clientX = "touches" in event ? event.touches[0].clientX : event.clientX; const clientY = "touches" in event ? event.touches[0].clientY : event.clientY; getMousePos({ clientX, clientY } as MouseEvent, canvasRef.current); raycasterRef.current.setFromCamera(mouseRef.current, cameraRef.current); const handles = [ azimuthHandleRef.current, elevationHandleRef.current, distanceHandleRef.current, ].filter(Boolean) as THREE.Mesh[]; const intersects = raycasterRef.current.intersectObjects(handles); if (intersects.length > 0) { isDraggingRef.current = true; dragTargetRef.current = intersects[0].object.userData.type; if (canvasRef.current) { canvasRef.current.style.cursor = "grabbing"; } } }, [getMousePos]); const handlePointerMove = useCallback((event: React.MouseEvent | React.TouchEvent) => { if (!canvasRef.current || !cameraRef.current || !isDraggingRef.current) return; const clientX = "touches" in event ? event.touches[0].clientX : event.clientX; const clientY = "touches" in event ? event.touches[0].clientY : event.clientY; getMousePos({ clientX, clientY } as MouseEvent, canvasRef.current); raycasterRef.current.setFromCamera(mouseRef.current, cameraRef.current); const plane = new THREE.Plane(); const intersect = new THREE.Vector3(); if (dragTargetRef.current === "azimuth") { plane.setFromNormalAndCoplanarPoint( new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, 0, 0) ); if (raycasterRef.current.ray.intersectPlane(plane, intersect)) { let angle = Math.atan2(intersect.x, intersect.z) * (180 / Math.PI); if (angle < 0) angle += 360; const newAzimuth = Math.max(0, Math.min(360, angle)); setState(prev => { const newState = { ...prev, azimuth: Math.round(newAzimuth) }; onStateChange?.(newState); return newState; }); } } else if (dragTargetRef.current === "elevation") { const elevPlane = new THREE.Plane(new THREE.Vector3(1, 0, 0), -ELEV_ARC_X); if (raycasterRef.current.ray.intersectPlane(elevPlane, intersect)) { const relY = intersect.y - CENTER.y; const relZ = intersect.z; let angle = Math.atan2(relY, relZ) * (180 / Math.PI); angle = Math.max(-30, Math.min(60, angle)); setState(prev => { const newState = { ...prev, elevation: Math.round(angle) }; onStateChange?.(newState); return newState; }); } } else if (dragTargetRef.current === "distance") { const newDist = 5 - mouseRef.current.y * 5; const clampedDist = Math.max(0, Math.min(10, newDist)); setState(prev => { const newState = { ...prev, distance: Math.round(clampedDist * 10) / 10 }; onStateChange?.(newState); return newState; }); } }, [getMousePos, onStateChange]); const handlePointerUp = useCallback(() => { isDraggingRef.current = false; dragTargetRef.current = null; if (canvasRef.current) { canvasRef.current.style.cursor = "default"; } }, []); // 下拉选择器变化处理 const handleAzimuthChange = useCallback((value: string) => { const val = Number(value); setState(prev => { const newState = { ...prev, azimuth: val }; onStateChange?.(newState); return newState; }); }, [onStateChange]); const handleElevationChange = useCallback((value: string) => { const val = Number(value); setState(prev => { const newState = { ...prev, elevation: val }; onStateChange?.(newState); return newState; }); }, [onStateChange]); const handleDistanceChange = useCallback((value: string) => { const val = Number(value); setState(prev => { const newState = { ...prev, distance: val }; onStateChange?.(newState); return newState; }); }, [onStateChange]); // 重置 const handleReset = useCallback(() => { const defaultState = { azimuth: 0, elevation: 0, distance: 5 }; setState(defaultState); onStateChange?.(defaultState); }, [onStateChange]); // 找到最接近的选项 const findClosestAzimuth = (value: number) => { let closest = AZIMUTH_OPTIONS[0].value; let minDiff = 360; for (const opt of AZIMUTH_OPTIONS) { const diff = Math.min( Math.abs(value - opt.value), Math.abs(value - opt.value - 360), Math.abs(value - opt.value + 360) ); if (diff < minDiff) { minDiff = diff; closest = opt.value; } } return closest.toString(); }; const findClosestElevation = (value: number) => { let closest = ELEVATION_OPTIONS[0].value; let minDiff = 100; for (const opt of ELEVATION_OPTIONS) { const diff = Math.abs(value - opt.value); if (diff < minDiff) { minDiff = diff; closest = opt.value; } } return closest.toString(); }; const findClosestDistance = (value: number) => { if (value < 3) return "1"; if (value < 7) return "5"; return "8"; }; return (
{/* 3D 画布 - 占据剩余空间 */}
{/* 提示词浮层 - 放在底部 */}
{generatePrompt(state)}
{/* 控制面板 - 紧凑布局 */}
{Math.round(state.azimuth)}°
{Math.round(state.elevation)}°
{state.distance.toFixed(1)}
); } export default QwenCameraWidget;